diff --git a/.codex b/.codex new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.env.example b/.env.example index 90efa8b412c..9e104245d24 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,7 @@ 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 +FILE_SIZE_LIMIT=104857600 # GPT settings OPENAI_API_BASE="https://api.openai.com/v1" # deprecated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000000..6710e3949f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: Kanavio CI + +on: + pull_request: + branches: + - dev + - main + push: + branches: + - dev + - main + +permissions: + contents: read + +concurrency: + group: kanavio-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + web-quality: + name: Web lint and format + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + TURBO_SCM_HEAD: ${{ github.sha }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.12.1 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22.18.0 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check workflow formatting + run: pnpm exec prettier --check ".github/workflows/ci.yml" ".github/workflows/publish-images.yml" + + - name: Check affected workspace formatting + run: pnpm turbo run check:format --affected + + - name: Lint affected workspaces + run: pnpm turbo run check:lint --affected + + api-quality: + name: API lint and format + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Ruff + run: python -m pip install ruff + + - name: Check API formatting + run: ruff format --check apps/api + + - name: Lint API + run: ruff check apps/api diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml new file mode 100644 index 00000000000..34abf2e29df --- /dev/null +++ b/.github/workflows/publish-images.yml @@ -0,0 +1,161 @@ +name: Publish Kanavio Plane Images + +on: + push: + branches: + - dev + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: publish-kanavio-plane-${{ github.ref }} + cancel-in-progress: true + +env: + NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }} + NEXT_PUBLIC_ADMIN_BASE_URL: ${{ vars.NEXT_PUBLIC_ADMIN_BASE_URL }} + NEXT_PUBLIC_ADMIN_BASE_PATH: ${{ vars.NEXT_PUBLIC_ADMIN_BASE_PATH }} + NEXT_PUBLIC_LIVE_BASE_URL: ${{ vars.NEXT_PUBLIC_LIVE_BASE_URL }} + NEXT_PUBLIC_LIVE_BASE_PATH: ${{ vars.NEXT_PUBLIC_LIVE_BASE_PATH }} + NEXT_PUBLIC_SPACE_BASE_URL: ${{ vars.NEXT_PUBLIC_SPACE_BASE_URL }} + NEXT_PUBLIC_SPACE_BASE_PATH: ${{ vars.NEXT_PUBLIC_SPACE_BASE_PATH }} + NEXT_PUBLIC_WEB_BASE_URL: ${{ vars.NEXT_PUBLIC_WEB_BASE_URL }} + NEXT_PUBLIC_CP_SERVER_URL: ${{ vars.NEXT_PUBLIC_CP_SERVER_URL }} + +jobs: + publish-frontend-images: + name: Publish ${{ matrix.service }} + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + include: + - service: web + image_name: drakesoftware/plane-web-amd64 + dockerfile: ./apps/web/Dockerfile.web + - service: admin + image_name: drakesoftware/plane-admin-amd64 + dockerfile: ./apps/admin/Dockerfile.admin + - service: space + image_name: drakesoftware/plane-space-amd64 + dockerfile: ./apps/space/Dockerfile.space + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate frontend build variables + run: | + missing=0 + for name in \ + NEXT_PUBLIC_API_BASE_URL \ + NEXT_PUBLIC_ADMIN_BASE_URL \ + NEXT_PUBLIC_ADMIN_BASE_PATH \ + NEXT_PUBLIC_LIVE_BASE_URL \ + NEXT_PUBLIC_LIVE_BASE_PATH \ + NEXT_PUBLIC_SPACE_BASE_URL \ + NEXT_PUBLIC_SPACE_BASE_PATH \ + NEXT_PUBLIC_WEB_BASE_URL \ + NEXT_PUBLIC_CP_SERVER_URL + do + if [ -z "${!name}" ]; then + echo "::error::$name repository variable is required for Plane frontend image builds" + missing=1 + fi + done + exit "$missing" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Resolve image tags + id: image-tags + run: | + channel="${GITHUB_REF_NAME//\//-}" + { + echo "tags<> "$GITHUB_OUTPUT" + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + push: true + tags: ${{ steps.image-tags.outputs.tags }} + build-args: | + NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }} + NEXT_PUBLIC_ADMIN_BASE_URL=${{ env.NEXT_PUBLIC_ADMIN_BASE_URL }} + NEXT_PUBLIC_ADMIN_BASE_PATH=${{ env.NEXT_PUBLIC_ADMIN_BASE_PATH }} + NEXT_PUBLIC_LIVE_BASE_URL=${{ env.NEXT_PUBLIC_LIVE_BASE_URL }} + NEXT_PUBLIC_LIVE_BASE_PATH=${{ env.NEXT_PUBLIC_LIVE_BASE_PATH }} + NEXT_PUBLIC_SPACE_BASE_URL=${{ env.NEXT_PUBLIC_SPACE_BASE_URL }} + NEXT_PUBLIC_SPACE_BASE_PATH=${{ env.NEXT_PUBLIC_SPACE_BASE_PATH }} + NEXT_PUBLIC_WEB_BASE_URL=${{ env.NEXT_PUBLIC_WEB_BASE_URL }} + NEXT_PUBLIC_CP_SERVER_URL=${{ env.NEXT_PUBLIC_CP_SERVER_URL }} + + publish-service-images: + name: Publish ${{ matrix.service }} + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + include: + - service: api + image_name: drakesoftware/plane-api-amd64 + context: ./apps/api + dockerfile: ./apps/api/Dockerfile.api + - service: live + image_name: drakesoftware/plane-live-amd64 + context: . + dockerfile: ./apps/live/Dockerfile.live + - service: proxy + image_name: drakesoftware/plane-proxy-amd64 + context: ./apps/proxy + dockerfile: ./apps/proxy/Dockerfile.ce + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Resolve image tags + id: image-tags + run: | + channel="${GITHUB_REF_NAME//\//-}" + { + echo "tags<> "$GITHUB_OUTPUT" + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + push: true + tags: ${{ steps.image-tags.outputs.tags }} diff --git a/.gitignore b/.gitignore index 0edc47dccb4..5ad5aa9597b 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ package-lock.json .secrets tmp/ +apps/api/plane/media-library/ ## packages dist diff --git a/apps/admin/Dockerfile.admin b/apps/admin/Dockerfile.admin index 6bfa0765f6e..ef09499b843 100644 --- a/apps/admin/Dockerfile.admin +++ b/apps/admin/Dockerfile.admin @@ -3,7 +3,7 @@ FROM node:22-alpine AS base # Setup pnpm package manager with corepack and configure global bin directory for caching ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" +ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" RUN corepack enable # ***************************************************************************** diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx index cf479119015..34d73b327bd 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx @@ -15,7 +15,7 @@ import { cn } from "@plane/utils"; import { useTheme } from "@/hooks/store"; // assets // eslint-disable-next-line import/order -import packageJson from "package.json"; +import packageJson from "../../../package.json"; const helpOptions = [ { diff --git a/apps/admin/ee/components/authentication/authentication-modes.tsx b/apps/admin/ee/components/authentication/authentication-modes.tsx index 4e3b05a5228..cc0b37648b7 100644 --- a/apps/admin/ee/components/authentication/authentication-modes.tsx +++ b/apps/admin/ee/components/authentication/authentication-modes.tsx @@ -1 +1 @@ -export * from "ce/components/authentication/authentication-modes"; +export * from "@/plane-admin/components/authentication/authentication-modes"; diff --git a/apps/admin/ee/components/common/index.ts b/apps/admin/ee/components/common/index.ts index 60441ee25be..4fa9c6a8cae 100644 --- a/apps/admin/ee/components/common/index.ts +++ b/apps/admin/ee/components/common/index.ts @@ -1 +1 @@ -export * from "ce/components/common"; +export * from "@/plane-admin/components/common"; diff --git a/apps/admin/ee/store/root.store.ts b/apps/admin/ee/store/root.store.ts index c514c4c25f7..30b9549e22e 100644 --- a/apps/admin/ee/store/root.store.ts +++ b/apps/admin/ee/store/root.store.ts @@ -1 +1 @@ -export * from "ce/store/root.store"; +export * from "@/plane-admin/store/root.store"; diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index c848e0b9255..5d12639f33c 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -19,7 +19,6 @@ const nextConfig = { "@plane/propel", "@plane/services", "@plane/shared-state", - "@plane/types", "@plane/ui", "@plane/utils", ], diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json index d85abf2cc9a..f6f27d00f90 100644 --- a/apps/admin/tsconfig.json +++ b/apps/admin/tsconfig.json @@ -6,13 +6,12 @@ "name": "next" } ], - "baseUrl": ".", "paths": { - "@/app/*": ["app/*"], - "@/*": ["core/*"], - "@/public/*": ["public/*"], - "@/plane-admin/*": ["ce/*"], - "@/styles/*": ["styles/*"] + "@/app/*": ["./app/*"], + "@/*": ["./core/*"], + "@/public/*": ["./public/*"], + "@/plane-admin/*": ["./ce/*"], + "@/styles/*": ["./styles/*"] }, "strictNullChecks": true }, diff --git a/apps/api/.env.example b/apps/api/.env.example index f158e3d7cc9..ce170a7ea61 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -28,10 +28,13 @@ AWS_REGION="" AWS_ACCESS_KEY_ID="access-key" AWS_SECRET_ACCESS_KEY="secret-key" AWS_S3_ENDPOINT_URL="http://localhost:9000" +AWS_S3_INTERNAL_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 +FILE_SIZE_LIMIT=104857600 +# Maximum media library thumbnail size (bytes) +MEDIA_LIBRARY_THUMBNAIL_MAX_BYTES=51200 # Settings related to Docker DOCKERIZED=1 # deprecated @@ -70,3 +73,13 @@ MINIO_ENDPOINT_SSL=0 # API key rate limit API_KEY_RATE_LIMIT="60/minute" + +# Service Gateway webhook sync (Plane -> service-gateway) +SERVICE_GATEWAY_WEBHOOK_ENABLED=0 +SERVICE_GATEWAY_EVENT_API="http://service-gateway:1437/api/event" +SERVICE_GATEWAY_EVENT_SEND_API="http://service-gateway:1437/api/event/send" # optional, defaults to SERVICE_GATEWAY_EVENT_API + "/send" +SERVICE_GATEWAY_SCHEDULED_EVENT_API="http://service-gateway:1437/api/scheduled-event" +SERVICE_GATEWAY_WEBHOOK_TIMEOUT=30 +SERVICE_GATEWAY_TIMEZONE="Asia/Kolkata" # this will be a dynamic value in future, currently set to IST as most of our users are in India +# Optional: set to a valid service-gateway team id so synced events appear in month-wise/fullstack views. +SERVICE_GATEWAY_DEFAULT_TEAM_ID=0 diff --git a/apps/api/Dockerfile.api b/apps/api/Dockerfile.api index 132514811c2..06512375814 100644 --- a/apps/api/Dockerfile.api +++ b/apps/api/Dockerfile.api @@ -16,7 +16,8 @@ RUN apk add --no-cache --upgrade \ "libxslt" \ "xmlsec" \ "ca-certificates" \ - "openssl" + "openssl" \ + "ffmpeg" COPY requirements.txt ./ COPY requirements ./requirements @@ -55,4 +56,4 @@ RUN chmod -R 777 /code # Expose container port and run entry point script EXPOSE 8000 -CMD ["./bin/docker-entrypoint-api.sh"] \ No newline at end of file +CMD ["./bin/docker-entrypoint-api.sh"] diff --git a/apps/api/Dockerfile.dev b/apps/api/Dockerfile.dev index 3ec8c6340ac..916f2552628 100644 --- a/apps/api/Dockerfile.dev +++ b/apps/api/Dockerfile.dev @@ -12,6 +12,7 @@ RUN apk --no-cache add \ "libxslt" \ "nodejs-current" \ "xmlsec" \ + "ffmpeg" \ "libffi-dev" \ "bash~=5.2" \ "g++" \ diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 7596915eb40..141e5a2da76 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -53,3 +53,4 @@ GenericAssetUpdateSerializer, FileAssetSerializer, ) +from .media_library import MediaPackageCreateSerializer diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py index d7fc3e911dc..7c34d1857e7 100644 --- a/apps/api/plane/api/serializers/issue.py +++ b/apps/api/plane/api/serializers/issue.py @@ -18,6 +18,7 @@ IssueLink, Label, ProjectMember, + Project, State, User, EstimatePoint, @@ -26,6 +27,8 @@ validate_html_content, validate_binary_data, ) +from plane.utils.issue_datetime import is_issue_start_datetime_in_past +from plane.utils.opposition_team import normalize_opposition_team from .base import BaseSerializer from .cycle import CycleLiteSerializer, CycleSerializer @@ -61,13 +64,31 @@ class IssueSerializer(BaseSerializer): type_id = serializers.PrimaryKeyRelatedField( source="type", queryset=IssueType.objects.all(), required=False, allow_null=True ) + opposition_team = serializers.JSONField(required=False, allow_null=True) class Meta: model = Issue - read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"] + read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at", "sg_event_id"] exclude = ["description", "description_stripped"] def validate(self, data): + project_sport = ( + Project.objects.filter(pk=self.context.get("project_id")).values_list("sport", flat=True).first() + ) + if isinstance(project_sport, str): + project_sport = project_sport.strip() or None + if project_sport: + data["sport"] = project_sport + + should_validate_start_datetime = self.instance is None or "start_date" in data or "start_time" in data + start_date = data.get("start_date", getattr(self.instance, "start_date", None)) + start_time = data.get("start_time", getattr(self.instance, "start_time", None)) + + if should_validate_start_datetime and is_issue_start_datetime_in_past(start_date, start_time): + raise serializers.ValidationError( + {"start_time": "Event date and time cannot be earlier than the current time."} + ) + if ( data.get("start_date", None) is not None and data.get("target_date", None) is not None @@ -98,6 +119,12 @@ def validate(self, data): if not is_valid: raise serializers.ValidationError({"description_binary": "Invalid binary data"}) + if "opposition_team" in data: + try: + data["opposition_team"] = normalize_opposition_team(data["opposition_team"]) + except ValueError as exc: + raise serializers.ValidationError({"opposition_team": str(exc)}) + # Validate assignees are from project if data.get("assignees", []): data["assignees"] = ProjectMember.objects.filter( @@ -658,6 +685,7 @@ class Meta: "updated_by", "created_at", "updated_at", + "sg_event_id", ] diff --git a/apps/api/plane/api/serializers/media_library.py b/apps/api/plane/api/serializers/media_library.py new file mode 100644 index 00000000000..c7f6d4c2821 --- /dev/null +++ b/apps/api/plane/api/serializers/media_library.py @@ -0,0 +1,106 @@ +# Django imports +from django.utils.dateparse import parse_datetime + +# Third party imports +from rest_framework import serializers + +from plane.utils.media_library import normalize_metadata_ref +MEDIA_LIBRARY_FORMAT_CHOICES = ( + "mov", + "webm", + "avi", + "mkv", + "mpeg", + "mpg", + "m4v", + "mp4", + "m3u8", + "json", + "csv", + "pdf", + "docx", + "xlsx", + "pptx", + "jpg", + "jpeg", + "png", + "svg", + "webp", + "gif", + "bmp", + "tif", + "tiff", + "avif", + "heic", + "heif", + "thumbnail", + "txt", +) + +MEDIA_LIBRARY_ACTION_CHOICES = ( + "play", + "stream", + "view", + "download", + "preview", + "edit", + "navigate", + "play_hls", + "open_mp4", + "open_pdf", + "attach_captions", +) + + +def _validate_iso_datetime(value: str) -> str: + if not isinstance(value, str) or parse_datetime(value) is None: + raise serializers.ValidationError("Invalid ISO-8601 datetime.") + return value + + +class MediaArtifactSerializer(serializers.Serializer): + name = serializers.CharField() + title = serializers.CharField() + description = serializers.CharField(required=False, allow_blank=True, allow_null=True) + format = serializers.ChoiceField(choices=MEDIA_LIBRARY_FORMAT_CHOICES) + path = serializers.CharField() + link = serializers.CharField(allow_null=True) + action = serializers.ChoiceField(choices=MEDIA_LIBRARY_ACTION_CHOICES) + metadata_ref = serializers.CharField(required=False, allow_blank=True, allow_null=True) + meta = serializers.JSONField(required=False, allow_null=True) + work_item_id = serializers.CharField(required=False, allow_null=True, allow_blank=True) + created_at = serializers.CharField() + updated_at = serializers.CharField() + + def validate_meta(self, value): + if value is None: + return {} + if not isinstance(value, dict): + raise serializers.ValidationError("Meta must be an object.") + return value + + def validate_metadata_ref(self, value): + if value in (None, ""): + return value + if not normalize_metadata_ref(value): + raise serializers.ValidationError("Invalid metadata_ref.") + return value + + def validate_created_at(self, value): + return _validate_iso_datetime(value) + + def validate_updated_at(self, value): + return _validate_iso_datetime(value) + + +class MediaPackageCreateSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=False) + package_id = serializers.CharField(required=False, allow_blank=False, write_only=True) + name = serializers.CharField() + title = serializers.CharField() + artifacts = MediaArtifactSerializer(many=True, required=False) + + def validate(self, attrs): + if not attrs.get("id") and attrs.get("package_id"): + attrs["id"] = attrs["package_id"] + return attrs diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py index 3228c5ad91d..6c1d08a3f0c 100644 --- a/apps/api/plane/api/serializers/project.py +++ b/apps/api/plane/api/serializers/project.py @@ -29,6 +29,7 @@ class Meta: fields = [ "name", "description", + "sport", "project_lead", "default_assignee", "identifier", @@ -60,6 +61,20 @@ class Meta: ] def validate(self, data): + if "sport" in data and isinstance(data["sport"], str): + data["sport"] = data["sport"].strip() or None + + current_sport = getattr(self.instance, "sport", None) + if isinstance(current_sport, str): + current_sport = current_sport.strip() or None + + next_sport = data.get("sport", current_sport) + if isinstance(next_sport, str): + next_sport = next_sport.strip() or None + + if current_sport and next_sport != current_sport: + raise serializers.ValidationError({"sport": "Project sport cannot be changed once set."}) + if data.get("project_lead", None) is not None: # Check if the project lead is a member of the workspace if not WorkspaceMember.objects.filter( @@ -158,6 +173,20 @@ class Meta: ] def validate(self, data): + if "sport" in data and isinstance(data["sport"], str): + data["sport"] = data["sport"].strip() or None + + current_sport = getattr(self.instance, "sport", None) + if isinstance(current_sport, str): + current_sport = current_sport.strip() or None + + next_sport = data.get("sport", current_sport) + if isinstance(next_sport, str): + next_sport = next_sport.strip() or None + + if current_sport and next_sport != current_sport: + raise serializers.ValidationError({"sport": "Project sport cannot be changed once set."}) + # Check project lead should be a member of the workspace if ( data.get("project_lead", None) is not None @@ -223,6 +252,7 @@ class Meta: "id", "identifier", "name", + "sport", "cover_image", "icon_prop", "emoji", diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index 10cad2068e3..72463d15b71 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -4,6 +4,7 @@ from .label import urlpatterns as label_patterns from .member import urlpatterns as member_patterns from .module import urlpatterns as module_patterns +from .media_library import urlpatterns as media_library_patterns from .project import urlpatterns as project_patterns from .state import urlpatterns as state_patterns from .user import urlpatterns as user_patterns @@ -16,6 +17,7 @@ *label_patterns, *member_patterns, *module_patterns, + *media_library_patterns, *project_patterns, *state_patterns, *user_patterns, diff --git a/apps/api/plane/api/urls/media_library.py b/apps/api/plane/api/urls/media_library.py new file mode 100644 index 00000000000..660796c183c --- /dev/null +++ b/apps/api/plane/api/urls/media_library.py @@ -0,0 +1,37 @@ +from django.urls import path + +from plane.api.views import ( + MediaArtifactDetailAPIEndpoint, + MediaArtifactsListAPIEndpoint, + MediaLibraryInitAPIEndpoint, + MediaManifestDetailAPIEndpoint, + MediaPackageCreateAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//media-library/", + MediaLibraryInitAPIEndpoint.as_view(http_method_names=["post"]), + name="media-library-init", + ), + path( + "workspaces//projects//media-library/packages/", + MediaPackageCreateAPIEndpoint.as_view(http_method_names=["post"]), + name="media-library-packages", + ), + path( + "workspaces//projects//media-library/packages//manifest/", + MediaManifestDetailAPIEndpoint.as_view(http_method_names=["get", "patch"]), + name="media-library-manifest", + ), + path( + "workspaces//projects//media-library/packages//artifacts/", + MediaArtifactsListAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="media-library-artifacts", + ), + path( + "workspaces//projects//media-library/packages//artifacts//", + MediaArtifactDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]), + name="media-library-artifact-detail", + ), +] diff --git a/apps/api/plane/api/urls/work_item.py b/apps/api/plane/api/urls/work_item.py index 7207df9579f..59eea9fdeb1 100644 --- a/apps/api/plane/api/urls/work_item.py +++ b/apps/api/plane/api/urls/work_item.py @@ -12,6 +12,7 @@ IssueAttachmentListCreateAPIEndpoint, IssueAttachmentDetailAPIEndpoint, WorkspaceIssueAPIEndpoint, + WorkspaceIssueCreatePackAPIEndpoint, IssueSearchEndpoint, ) @@ -91,6 +92,11 @@ WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]), name="work-item-by-identifier", ), + path( + "workspaces//work-items/-/create-package/", + WorkspaceIssueCreatePackAPIEndpoint.as_view(http_method_names=["post"]), + name="work-item-create-package", + ), path( "workspaces//projects//work-items/", IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 8535d4858bc..a85f12f925d 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -11,6 +11,7 @@ from .issue import ( WorkspaceIssueAPIEndpoint, + WorkspaceIssueCreatePackAPIEndpoint, IssueListCreateAPIEndpoint, IssueDetailAPIEndpoint, LabelListCreateAPIEndpoint, @@ -53,3 +54,11 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint from .user import UserEndpoint + +from .media_library import ( + MediaArtifactDetailAPIEndpoint, + MediaArtifactsListAPIEndpoint, + MediaLibraryInitAPIEndpoint, + MediaManifestDetailAPIEndpoint, + MediaPackageCreateAPIEndpoint, +) diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index d3686ceea50..910032f92b8 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -49,6 +49,7 @@ IssueLinkCreateSerializer, IssueLinkUpdateSerializer, LabelCreateUpdateSerializer, + MediaPackageCreateSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, @@ -56,6 +57,7 @@ ProjectMemberPermission, ) from plane.bgtasks.issue_activities_task import issue_activity +from plane.bgtasks.service_gateway_webhook_task import service_gateway_event_sync from plane.db.models import ( Issue, IssueActivity, @@ -72,7 +74,14 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from .base import BaseAPIView from plane.utils.host import base_host -from plane.bgtasks.webhook_task import model_activity +from plane.utils.media_library import ( + create_manifest, + manifest_path, + package_root, + validate_segment, + write_manifest_atomic, +) +from plane.bgtasks.webhook_task import model_activity, webhook_activity from plane.app.permissions import ROLE from plane.utils.openapi import ( work_item_docs, @@ -235,6 +244,120 @@ def get(self, request, slug, project_identifier=None, issue_identifier=None): ) +class WorkspaceIssueCreatePackAPIEndpoint(BaseAPIView): + """ + Create a media library package scoped to a work item identifier route. + """ + + permission_classes = [ProjectEntityPermission] + + @property + def project_identifier(self): + return self.kwargs.get("project_identifier", None) + + @property + def project_id(self): + project_id = super().project_id + if project_id: + return project_id + project_identifier = self.project_identifier + if not project_identifier: + return None + return ( + Project.objects.filter( + workspace__slug=self.workspace_slug, + identifier__iexact=project_identifier, + ) + .values_list("id", flat=True) + .first() + ) + + def post(self, request, slug, project_identifier=None, issue_identifier=None): + try: + issue_sequence_id = int(issue_identifier) + except (TypeError, ValueError): + return Response( + {"success": False, "error": "Invalid issue identifier."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + project = ( + Project.objects.filter( + workspace__slug=slug, + identifier__iexact=project_identifier, + ) + .only("id", "identifier") + .first() + ) + if not project: + return Response( + {"success": False, "error": "Project not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + issue = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project_id=project.id, + sequence_id=issue_sequence_id, + ) + .only("id") + .first() + ) + if not issue: + return Response( + {"success": False, "error": "Work item not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = MediaPackageCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + project_id_str = str(project.id) + package_id = serializer.validated_data.get("id") or uuid.uuid4().hex + + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + + root = package_root(project_id_str, package_id) + manifest_file = manifest_path(project_id_str, package_id) + + if root.exists() or manifest_file.exists(): + return Response( + {"success": False, "error": "Package already exists."}, + status=status.HTTP_409_CONFLICT, + ) + + (root / "artifacts").mkdir(parents=True, exist_ok=False) + (root / "attachment").mkdir(parents=True, exist_ok=False) + + artifacts = serializer.validated_data.get("artifacts") or [] + artifacts_with_work_item = [] + for artifact in artifacts: + artifact_data = dict(artifact) + if not artifact_data.get("work_item_id"): + artifact_data["work_item_id"] = str(issue.id) + artifacts_with_work_item.append(artifact_data) + + manifest = create_manifest( + project_id=project_id_str, + package_id=package_id, + name=serializer.validated_data["name"], + title=serializer.validated_data["title"], + artifacts=artifacts_with_work_item, + ) + write_manifest_atomic(manifest_file, manifest) + + return Response( + { + "success": True, + "message": "Package created successfully.", + "data": manifest, + }, + status=status.HTTP_201_CREATED, + ) + + class IssueListCreateAPIEndpoint(BaseAPIView): """ This viewset provides `list` and `create` on issue level @@ -455,6 +578,27 @@ def post(self, request, slug, project_id): issue.created_at = request.data.get("created_at", timezone.now()) issue.created_by_id = request.data.get("created_by", request.user.id) issue.save(update_fields=["created_at", "created_by"]) + service_gateway_event_sync( + event="issue", + verb="created", + event_data=Issue.issue_objects.filter(pk=serializer.data["id"]) + .values( + "id", + "workspace_id", + "project_id", + "name", + "start_time", + "start_date", + "target_date", + "level", + "sport", + "program", + "year", + "category", + "sg_event_id", + ) + .first(), + ) # Track the issue issue_activity.delay( @@ -477,7 +621,8 @@ def post(self, request, slug, project_id): slug=slug, origin=base_host(request=request, is_app=True), ) - return Response(serializer.data, status=status.HTTP_201_CREATED) + issue.refresh_from_db(fields=["sg_event_id"]) + return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -616,6 +761,27 @@ def put(self, request, slug, project_id): # If the serializer is valid, save the issue and dispatch # the update issue activity worker event. serializer.save() + service_gateway_event_sync( + event="issue", + verb="updated", + event_data=Issue.issue_objects.filter(pk=issue.id) + .values( + "id", + "workspace_id", + "project_id", + "name", + "start_time", + "start_date", + "target_date", + "level", + "sport", + "program", + "year", + "category", + "sg_event_id", + ) + .first(), + ) issue_activity.delay( type="issue.activity.updated", requested_data=requested_data, @@ -625,7 +791,8 @@ def put(self, request, slug, project_id): current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) - return Response(serializer.data, status=status.HTTP_200_OK) + issue.refresh_from_db(fields=["sg_event_id"]) + return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) return Response( # If the serializer is not valid, respond with 400 bad # request @@ -663,6 +830,27 @@ def put(self, request, slug, project_id): issue.created_at = request.data.get("created_at", timezone.now()) issue.created_by_id = request.data.get("created_by", request.user.id) issue.save(update_fields=["created_at", "created_by"]) + service_gateway_event_sync( + event="issue", + verb="created", + event_data=Issue.issue_objects.filter(pk=serializer.data["id"]) + .values( + "id", + "workspace_id", + "project_id", + "name", + "start_time", + "start_date", + "target_date", + "level", + "sport", + "program", + "year", + "category", + "sg_event_id", + ) + .first(), + ) issue_activity.delay( type="issue.activity.created", @@ -673,7 +861,8 @@ def put(self, request, slug, project_id): current_instance=None, epoch=int(timezone.now().timestamp()), ) - return Response(serializer.data, status=status.HTTP_201_CREATED) + issue.refresh_from_db(fields=["sg_event_id"]) + return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: return Response( @@ -739,6 +928,27 @@ def patch(self, request, slug, project_id, pk): ) serializer.save() + service_gateway_event_sync( + event="issue", + verb="updated", + event_data=Issue.issue_objects.filter(pk=pk) + .values( + "id", + "workspace_id", + "project_id", + "name", + "start_time", + "start_date", + "target_date", + "level", + "sport", + "program", + "year", + "category", + "sg_event_id", + ) + .first(), + ) issue_activity.delay( type="issue.activity.updated", requested_data=requested_data, @@ -748,7 +958,8 @@ def patch(self, request, slug, project_id, pk): current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) - return Response(serializer.data, status=status.HTTP_200_OK) + issue.refresh_from_db(fields=["sg_event_id"]) + return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @work_item_docs( @@ -785,7 +996,25 @@ def delete(self, request, slug, project_id, pk): status=status.HTTP_403_FORBIDDEN, ) current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder) + deleted_issue_event_data = {"id": issue.id, "sg_event_id": issue.sg_event_id} issue.delete() + service_gateway_event_sync(event="issue", verb="deleted", event_data=deleted_issue_event_data) + # delete workitems using service gateway for proper cascade delete and webhook trigger + webhook_activity.delay( + event="issue", + verb="deleted", + field=None, + old_value=None, + new_value=None, + actor_id=request.user.id, + slug=slug, + current_site=base_host(request=request, is_app=True), + event_id=issue.id, + old_identifier=None, + new_identifier=None, + event_data=deleted_issue_event_data, + skip_service_gateway=True, + ) issue_activity.delay( type="issue.activity.deleted", requested_data=json.dumps({"issue_id": str(pk)}), @@ -2041,8 +2270,22 @@ def get(self, request, slug, project_id, issue_id, pk): status=status.HTTP_403_FORBIDDEN, ) - # Get the asset - asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id) + asset_filters = { + "id": pk, + "workspace__slug": slug, + "project_id": project_id, + "issue_id": issue_id, + "entity_type": FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + } + asset = FileAsset.objects.filter(**asset_filters).first() + if not asset and request.query_params.get("response") == "json": + # Media library can still resolve soft-deleted attachments to MinIO signed URLs. + asset = FileAsset.all_objects.filter(**asset_filters, is_deleted=True).first() + if not asset: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) # Check if the asset is uploaded if not asset.is_uploaded: @@ -2057,6 +2300,8 @@ def get(self, request, slug, project_id, issue_id, pk): disposition="attachment", filename=asset.attributes.get("name"), ) + if request.query_params.get("response") == "json": + return Response({"url": presigned_url}, status=status.HTTP_200_OK) return HttpResponseRedirect(presigned_url) @issue_attachment_docs( diff --git a/apps/api/plane/api/views/media_library.py b/apps/api/plane/api/views/media_library.py new file mode 100644 index 00000000000..34a1f8f8386 --- /dev/null +++ b/apps/api/plane/api/views/media_library.py @@ -0,0 +1,1184 @@ +# Python imports +import json +import math +import logging +import os +import shutil +from urllib.parse import urlparse +from pathlib import Path +from uuid import UUID, uuid4 + +# Third party imports +from rest_framework import status +from rest_framework.exceptions import NotFound +from rest_framework.response import Response +from django.conf import settings + +# Module imports +from plane.api.serializers.media_library import MediaArtifactSerializer, MediaPackageCreateSerializer +from plane.api.views.base import BaseAPIView +from plane.app.permissions import ProjectLitePermission +from plane.db.models import FileAsset +from plane.settings.storage import S3Storage +from plane.utils.exception_logger import log_exception +from plane.utils.media_library import ( + _now_iso, + create_manifest, + ensure_project_library, + filter_media_library_artifacts, + generate_thumbnail, + get_document_icon_source, + get_document_thumbnail_hint, + hydrate_artifacts_with_meta, + manifest_path, + media_library_root, + manifest_write_lock, + normalize_manifest_metadata, + normalize_metadata_ref, + update_manifest_artifact_fields, + update_manifest_event_meta, + package_root, + read_manifest, + MediaLibraryTranscodeError, + transcode_mp4_to_hls, + validate_segment, + write_manifest_atomic, +) +from plane.utils.paginator import BadPaginationError, Cursor, CursorResult + +_IMAGE_FORMATS = { + "jpg", + "jpeg", + "png", + "svg", + "webp", + "gif", + "bmp", + "tif", + "tiff", + "avif", + "heic", + "heif", + "thumbnail", +} +_VIDEO_FORMATS = {"mp4", "m3u8", "mov", "webm", "avi", "mkv", "mpeg", "mpg", "m4v"} +logger = logging.getLogger(__name__) + + +def _default_artifact_description(title: str) -> str: + title_value = (title or "Uploaded file").strip() or "Uploaded file" + return ( + "

This asset was uploaded to the media library and is ready for use.
" + "It can be previewed, downloaded, or used in projects as needed.
" + f"File name: {title_value}

" + ) + + +class ListPaginator: + def __init__(self, items): + self.items = items + + def get_result(self, limit=1000, cursor=None): + if cursor is None: + cursor = Cursor(limit, 0, 0) + + if limit <= 0: + raise BadPaginationError("Pagination limit must be positive") + + total_count = len(self.items) + page = cursor.offset + if page < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + offset = page * limit + stop = offset + limit + 1 + page_items = self.items[offset:stop] + has_next = len(page_items) > limit + + results = page_items[:limit] + next_cursor = Cursor(limit, page + 1, False, has_next) + prev_cursor = Cursor(limit, page - 1, True, page > 0) + max_hits = math.ceil(total_count / limit) if limit else 0 + + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=total_count, + max_hits=max_hits, + ) + + +def _create_video_thumbnail(source_path: Path, thumbnail_path: Path) -> bool: + return generate_thumbnail(source_path, thumbnail_path, seek="00:00:00.000") + + +def _create_video_thumbnail_from_source(source: str, thumbnail_path: Path) -> bool: + if not source: + return False + return generate_thumbnail(source, thumbnail_path, seek="00:00:00.000") + + +def _extract_asset_id_from_url(value: str) -> str | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = urlparse(value) + path = parsed.path or "" + except ValueError: + path = value + if not path or ("/api/assets/" not in path and "/assets/v2/" not in path): + return None + segments = [segment for segment in path.split("/") if segment] + if not segments: + return None + candidate = segments[-1] + try: + return str(UUID(candidate)) + except ValueError: + return None + + +def _resolve_external_video_sources(path: str, request, project_id: str) -> list[str]: + if not isinstance(path, str) or not path: + return [] + source = path + if source.startswith("/"): + try: + source = request.build_absolute_uri(source) + except Exception: + source = path + candidates: list[str] = [] + asset_id = _extract_asset_id_from_url(source) + if asset_id: + asset = FileAsset.objects.filter(id=asset_id, project_id=project_id, is_deleted=False).first() + if asset and asset.is_uploaded: + if request is not None: + storage = S3Storage(request=request) + candidates.append( + storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="inline", + filename=asset.attributes.get("name"), + ) + ) + storage_internal = S3Storage() + candidates.append( + storage_internal.generate_presigned_url( + object_name=asset.asset.name, + disposition="inline", + filename=asset.attributes.get("name"), + ) + ) + if source.startswith(("http://", "https://")): + candidates.append(source) + deduped: list[str] = [] + for candidate in candidates: + if candidate and candidate not in deduped: + deduped.append(candidate) + return deduped + + +def _resolve_artifact_disk_path(artifact: dict, base_root: Path) -> Path | None: + raw_path = artifact.get("path") or "" + if not raw_path: + return None + if isinstance(raw_path, str) and raw_path.lower().startswith(("http://", "https://")): + return None + candidate = Path(raw_path) + if not candidate.is_absolute(): + candidate = (base_root / candidate).resolve(strict=False) + else: + candidate = candidate.resolve(strict=False) + if os.path.commonpath([str(base_root), str(candidate)]) != str(base_root): + return None + return candidate + + +def _delete_artifact_disk_path(path: Path, artifact_name: str | None = None) -> None: + if not path.exists(): + return + if path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + return + if artifact_name: + try: + parent = path.parent + if parent.name == artifact_name and parent.parent.name == "artifacts": + shutil.rmtree(parent, ignore_errors=True) + return + except OSError: + return + try: + path.unlink() + except FileNotFoundError: + return + except OSError: + return + + +class MediaPackageCreateAPIEndpoint(BaseAPIView): + permission_classes = [ProjectLitePermission] + + def post(self, request, slug, project_id): + serializer = MediaPackageCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + project_id_str = str(project_id) + package_id = serializer.validated_data.get("id") or uuid4().hex + + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + + root = package_root(project_id_str, package_id) + manifest_file = manifest_path(project_id_str, package_id) + + if root.exists() or manifest_file.exists(): + return Response({"error": "Package already exists."}, status=status.HTTP_409_CONFLICT) + + (root / "artifacts").mkdir(parents=True, exist_ok=False) + (root / "attachment").mkdir(parents=True, exist_ok=False) + + manifest = create_manifest( + project_id=project_id_str, + package_id=package_id, + name=serializer.validated_data["name"], + title=serializer.validated_data["title"], + artifacts=serializer.validated_data.get("artifacts"), + ) + write_manifest_atomic(manifest_file, manifest) + + return Response(manifest, status=status.HTTP_201_CREATED) + + +class MediaLibraryInitAPIEndpoint(BaseAPIView): + permission_classes = [ProjectLitePermission] + + def post(self, request, slug, project_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + packages_root = ensure_project_library(project_id_str) + + package_dirs = [path for path in packages_root.iterdir() if path.is_dir()] + if package_dirs: + for package_dir in sorted(package_dirs, key=lambda path: path.name): + manifest_file = package_dir / "manifest.json" + if manifest_file.exists(): + try: + manifest = read_manifest(manifest_file) + except Exception as exc: + log_exception(exc) + manifest = create_manifest( + project_id=project_id_str, + package_id=package_dir.name, + name=package_dir.name, + title="Media Library Package", + ) + write_manifest_atomic(manifest_file, manifest) + return Response(manifest, status=status.HTTP_200_OK) + manifest = create_manifest( + project_id=project_id_str, + package_id=package_dir.name, + name=package_dir.name, + title="Media Library Package", + ) + write_manifest_atomic(manifest_file, manifest) + return Response(manifest, status=status.HTTP_201_CREATED) + return Response(status=status.HTTP_204_NO_CONTENT) + + package_id = f"package-{uuid4().hex[:8]}" + root = package_root(project_id_str, package_id) + (root / "artifacts").mkdir(parents=True, exist_ok=False) + (root / "attachment").mkdir(parents=True, exist_ok=False) + manifest = create_manifest( + project_id=project_id_str, + package_id=package_id, + name=package_id, + title="Media Library Package", + ) + write_manifest_atomic(manifest_path(project_id_str, package_id), manifest) + return Response(manifest, status=status.HTTP_201_CREATED) + + +class MediaManifestDetailAPIEndpoint(BaseAPIView): + permission_classes = [ProjectLitePermission] + + def get(self, request, slug, project_id, package_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + manifest = read_manifest(manifest_file) + return Response(manifest, status=status.HTTP_200_OK) + + def patch(self, request, slug, project_id, package_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + + payload = request.data or {} + work_item_id = payload.get("work_item_id") or payload.get("workItemId") or "" + artifact_id = payload.get("artifact_id") or payload.get("artifactId") or "" + meta = payload.get("meta") if "meta" in payload else None + artifact_fields = payload.get("artifact") if "artifact" in payload else payload.get("artifact_fields") + if meta is None and artifact_fields is None: + return Response({"error": "meta or artifact fields are required."}, status=status.HTTP_400_BAD_REQUEST) + if meta is not None and not work_item_id: + return Response({"error": "work_item_id is required for meta updates."}, status=status.HTTP_400_BAD_REQUEST) + if meta is not None and not isinstance(meta, dict): + return Response({"error": "meta must be an object."}, status=status.HTTP_400_BAD_REQUEST) + if artifact_fields is not None and not artifact_id: + return Response({"error": "artifact_id is required for artifact updates."}, status=status.HTTP_400_BAD_REQUEST) + if artifact_fields is not None and not isinstance(artifact_fields, dict): + return Response({"error": "artifact fields must be an object."}, status=status.HTTP_400_BAD_REQUEST) + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + with manifest_write_lock(manifest_file): + manifest = read_manifest(manifest_file) + updated_count = 0 + if meta is not None: + updated_count += update_manifest_event_meta(manifest, work_item_id, meta) + if artifact_fields is not None: + updated_count += update_manifest_artifact_fields(manifest, artifact_fields, artifact_id=artifact_id) + if updated_count <= 0: + return Response({"updated": 0}, status=status.HTTP_200_OK) + manifest["updatedAt"] = _now_iso() + normalize_manifest_metadata(manifest) + write_manifest_atomic(manifest_file, manifest) + + return Response({"updated": updated_count}, status=status.HTTP_200_OK) + + +class MediaArtifactDetailAPIEndpoint(BaseAPIView): + permission_classes = [ProjectLitePermission] + + def get(self, request, slug, project_id, package_id, artifact_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + validate_segment(artifact_id, "artifactId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + manifest = read_manifest(manifest_file) + artifacts = manifest.get("artifacts") or [] + if not artifacts: + raise NotFound("Artifact not found.") + + target = None + related = [] + for artifact in artifacts: + name = artifact.get("name") + if name == artifact_id: + target = artifact + link = artifact.get("link") + if link == artifact_id: + format_value = (artifact.get("format") or "").lower() + action_value = (artifact.get("action") or "").lower() + if format_value == "thumbnail" or action_value == "preview": + related.append(artifact) + + if not target: + raise NotFound("Artifact not found.") + + metadata = manifest.get("metadata") if isinstance(manifest, dict) else {} + payload = hydrate_artifacts_with_meta([target, *related], metadata) + return Response(payload, status=status.HTTP_200_OK) + + def delete(self, request, slug, project_id, package_id, artifact_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + validate_segment(artifact_id, "artifactId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + removed_artifacts: list[dict] = [] + with manifest_write_lock(manifest_file): + manifest = read_manifest(manifest_file) + artifacts = manifest.get("artifacts") or [] + if not artifacts: + raise NotFound("Artifact not found.") + + related_names = {artifact_id} + for artifact in artifacts: + if artifact.get("link") == artifact_id and artifact.get("format") == "thumbnail": + name = artifact.get("name") + if name: + related_names.add(name) + + remaining_artifacts = [] + for artifact in artifacts: + if artifact.get("name") in related_names: + removed_artifacts.append(artifact) + else: + remaining_artifacts.append(artifact) + + if not removed_artifacts: + raise NotFound("Artifact not found.") + + manifest["artifacts"] = remaining_artifacts + manifest["updatedAt"] = _now_iso() + + metadata = manifest.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + used_refs: set[str] = set() + for artifact in remaining_artifacts: + metadata_ref = normalize_metadata_ref(artifact.get("metadata_ref")) or normalize_metadata_ref( + artifact.get("name") + ) + if metadata_ref: + used_refs.add(metadata_ref) + manifest["metadata"] = {key: value for key, value in metadata.items() if key in used_refs} + normalize_manifest_metadata(manifest) + write_manifest_atomic(manifest_file, manifest) + + base_root = media_library_root().resolve(strict=False) + for artifact in removed_artifacts: + resolved_path = _resolve_artifact_disk_path(artifact, base_root) + if resolved_path: + _delete_artifact_disk_path(resolved_path, artifact.get("name")) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class MediaArtifactsListAPIEndpoint(BaseAPIView): + permission_classes = [ProjectLitePermission] + + def get(self, request, slug, project_id, package_id): + try: + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + manifest = read_manifest(manifest_file) + artifacts = manifest.get("artifacts", []) + metadata = manifest.get("metadata") if isinstance(manifest, dict) else {} + query = request.query_params.get("q") or "" + section = request.query_params.get("section") or "" + format_values = request.query_params.getlist("formats") + if not format_values: + format_param = request.query_params.get("formats") or "" + format_values = [entry.strip() for entry in format_param.split(",") if entry.strip()] + filters_raw = request.query_params.get("filters") + filters = None + if filters_raw: + try: + filters = json.loads(filters_raw) + except json.JSONDecodeError: + return Response( + {"error": "Filters must be valid JSON."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + artifacts = filter_media_library_artifacts( + artifacts, + query=query, + filters=filters, + section=section, + formats=format_values, + metadata=metadata, + ) + except Exception as exc: + log_exception(exc) + if "cursor" in request.query_params or "per_page" in request.query_params: + hydrated = hydrate_artifacts_with_meta(artifacts, metadata) + return self.paginate(request=request, paginator=ListPaginator(hydrated)) + return Response(hydrate_artifacts_with_meta(artifacts, metadata), status=status.HTTP_200_OK) + except Exception as exc: + log_exception(exc) + message = str(exc) if settings.DEBUG else "Something went wrong please try again later" + return Response({"error": message}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def post(self, request, slug, project_id, package_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + payload = request.data + file_obj = request.FILES.get("file") + if file_obj: + media_library_file_size_limit = getattr(settings, "MEDIA_LIBRARY_FILE_SIZE_LIMIT", 0) + if media_library_file_size_limit and file_obj.size > media_library_file_size_limit: + return Response( + { + "error": "File exceeds media library upload size limit.", + "code": "MEDIA_LIBRARY_FILE_TOO_LARGE", + "limit": media_library_file_size_limit, + "size": file_obj.size, + }, + status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + ) + + is_bulk = isinstance(payload, list) or (isinstance(payload, dict) and "artifacts" in payload) + artifacts_payload = [] + file_path = None + artifact_dir = None + should_transcode = False + thumbnail_name = None + thumbnail_path = None + thumbnail_relative_path = None + doc_thumbnail_name = None + doc_thumbnail_file_name = None + doc_thumbnail_path = None + doc_thumbnail_relative_path = None + doc_thumbnail_source = None + doc_thumbnail_action = None + image_thumbnail_name = None + image_thumbnail_file_name = None + image_thumbnail_path = None + image_thumbnail_relative_path = None + image_thumbnail_action = None + video_thumbnail_name = None + video_thumbnail_path = None + video_thumbnail_relative_path = None + video_thumbnail_action = None + timestamp = _now_iso() + + if file_obj: + raw_name = file_obj.name or "artifact" + base_name = Path(raw_name).stem or "artifact" + extension = Path(raw_name).suffix.lstrip(".").lower() + if not extension: + return Response( + {"error": "File extension is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + format_value = (request.data.get("format") or extension).lower() + artifact_name = request.data.get("name") or base_name + title = request.data.get("title") or base_name + primary_artifact_name = artifact_name + primary_title = title + link = request.data.get("link") + if isinstance(link, str) and link.strip().lower() in {"", "null"}: + link = None + work_item_id = request.data.get("work_item_id") + if isinstance(work_item_id, str) and not work_item_id.strip(): + work_item_id = None + + meta = request.data.get("meta") or {} + raw_metadata_ref = request.data.get("metadata_ref") or request.data.get("metadataRef") + metadata_ref = normalize_metadata_ref(raw_metadata_ref) + if raw_metadata_ref and not metadata_ref: + return Response( + {"error": "metadata_ref must be a valid identifier."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(meta, str): + try: + meta = json.loads(meta) + except json.JSONDecodeError: + return Response( + {"error": "Meta must be valid JSON."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if meta is None: + meta = {} + + created_at = request.data.get("created_at") or timestamp + updated_at = request.data.get("updated_at") or created_at + primary_created_at = created_at + primary_updated_at = updated_at + artifacts_root = package_root(project_id_str, package_id) / "artifacts" + attachment_root = package_root(project_id_str, package_id) / "attachment" + is_video_upload = format_value in _VIDEO_FORMATS or extension in _VIDEO_FORMATS + # Keep uploaded videos in their original format; do not auto-transcode to HLS on upload. + should_transcode = False + if should_transcode: + if shutil.which("ffmpeg") is None: + return Response( + {"error": "ffmpeg is not installed. Install ffmpeg or upload a non-video file."}, + status=status.HTTP_400_BAD_REQUEST, + ) + artifact_dir = artifacts_root / primary_artifact_name + artifact_file_name = "index.m3u8" + file_path = artifact_dir / artifact_file_name + relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{primary_artifact_name}/{artifact_file_name}" + ) + thumbnail_name = f"{primary_artifact_name}-thumbnail" + thumbnail_path = artifact_dir / "thumbnail.webp" + thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{primary_artifact_name}/thumbnail.webp" + ) + meta.setdefault("source_format", extension) + meta.setdefault("hls", True) + else: + artifact_file_name = f"{artifact_name}.{extension}" + file_path = artifacts_root / artifact_file_name + relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{artifact_file_name}" + ) + if format_value in _VIDEO_FORMATS and format_value != "m3u8": + video_thumbnail_name = f"{primary_artifact_name}-thumbnail" + video_thumbnail_file_name = f"{primary_artifact_name}-thumbnail.webp" + video_thumbnail_path = artifacts_root / video_thumbnail_file_name + video_thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{video_thumbnail_file_name}" + ) + video_thumbnail_action = "preview" + if format_value in _IMAGE_FORMATS and format_value != "thumbnail": + image_thumbnail_name = f"{primary_artifact_name}-thumbnail" + image_thumbnail_file_name = f"{primary_artifact_name}-thumbnail.webp" + image_thumbnail_path = artifacts_root / image_thumbnail_file_name + image_thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{image_thumbnail_file_name}" + ) + image_thumbnail_action = "view" + if format_value not in _VIDEO_FORMATS and format_value not in _IMAGE_FORMATS: + thumbnail_hint = get_document_thumbnail_hint(format_value, meta) + doc_thumbnail_source = get_document_icon_source(format_value, thumbnail_hint) + if doc_thumbnail_source: + doc_thumbnail_name = f"{primary_artifact_name}-thumb" + doc_thumbnail_file_name = None + if isinstance(thumbnail_hint, str): + hint_name = Path(thumbnail_hint).name + if hint_name: + doc_thumbnail_file_name = f"{Path(hint_name).stem}.webp" + if not doc_thumbnail_file_name: + doc_thumbnail_file_name = f"{primary_artifact_name}-thumbnail.webp" + doc_thumbnail_path = attachment_root / doc_thumbnail_file_name + doc_thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/attachment/{doc_thumbnail_file_name}" + ) + + action = request.data.get("action") + if not action: + if format_value in _VIDEO_FORMATS: + action = "play" + elif format_value in _IMAGE_FORMATS: + action = "view" + else: + action = "download" + if doc_thumbnail_name: + doc_thumbnail_action = "open_pdf" if format_value == "pdf" else action + primary_metadata_ref = metadata_ref or artifact_name + primary_entry = { + "name": artifact_name, + "title": title, + "description": _default_artifact_description(title), + "format": format_value, + "path": relative_path, + "link": link, + "action": action, + "metadata_ref": primary_metadata_ref, + "meta": meta, + "created_at": created_at, + "updated_at": updated_at, + } + if work_item_id is not None: + primary_entry["work_item_id"] = work_item_id + artifacts_payload = [primary_entry] + is_bulk = False + elif isinstance(payload, list): + artifacts_payload = payload + elif isinstance(payload, dict) and "artifacts" in payload: + artifacts_payload = payload.get("artifacts") or [] + elif isinstance(payload, dict): + artifacts_payload = [payload] + + if not artifacts_payload: + return Response({"error": "Artifacts payload required."}, status=status.HTTP_400_BAD_REQUEST) + + prepared_payload = [] + for artifact in artifacts_payload: + if not isinstance(artifact, dict): + return Response({"error": "Each artifact must be an object."}, status=status.HTTP_400_BAD_REQUEST) + entry = artifact.copy() + if "metadata_ref" not in entry and "metadataRef" in entry: + entry["metadata_ref"] = entry.pop("metadataRef") + if entry.get("format") == "thumbnail": + entry.pop("description", None) + elif not entry.get("description"): + title_value = entry.get("title") or "Untitled file" + entry["description"] = _default_artifact_description(title_value) + if not entry.get("created_at"): + entry["created_at"] = timestamp + if not entry.get("updated_at"): + entry["updated_at"] = entry["created_at"] + if not entry.get("metadata_ref") and entry.get("format") == "thumbnail": + link_ref = normalize_metadata_ref(entry.get("link")) + if link_ref: + entry["metadata_ref"] = link_ref + prepared_payload.append(entry) + + serializer = MediaArtifactSerializer(data=prepared_payload, many=True) + serializer.is_valid(raise_exception=True) + validated_artifacts = serializer.validated_data + for artifact in validated_artifacts: + if not artifact.get("metadata_ref"): + artifact["metadata_ref"] = artifact.get("name") + + manifest = read_manifest(manifest_file) + existing_artifacts = manifest.get("artifacts") or [] + existing_names = {artifact.get("name") for artifact in existing_artifacts if artifact.get("name")} + incoming_names = set() + repair_existing_document_artifact = False + for artifact in validated_artifacts: + artifact_payload_name = artifact.get("name") + validate_segment(artifact_payload_name, "artifactId") + if artifact_payload_name in existing_names: + if ( + file_obj + and doc_thumbnail_name + and doc_thumbnail_source + and doc_thumbnail_path + and doc_thumbnail_relative_path + and artifact_payload_name == artifact_name + ): + repair_existing_document_artifact = True + continue + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + if artifact_payload_name in incoming_names: + return Response( + {"error": "Duplicate artifact name in request."}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming_names.add(artifact_payload_name) + + if repair_existing_document_artifact: + repaired_artifact = None + with manifest_write_lock(manifest_file): + manifest = read_manifest(manifest_file) + artifacts = manifest.get("artifacts") or [] + existing_artifact = next( + ( + item + for item in artifacts + if isinstance(item, dict) and item.get("name") == artifact_name + ), + None, + ) + if not existing_artifact: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + + primary_update = validated_artifacts[0] + for field in ( + "title", + "description", + "format", + "link", + "action", + "metadata_ref", + "meta", + "work_item_id", + ): + if field in primary_update: + existing_artifact[field] = primary_update.get(field) + existing_artifact["updated_at"] = primary_update.get("updated_at") or timestamp + + attachment_root.mkdir(parents=True, exist_ok=True) + thumbnail_created = generate_thumbnail(doc_thumbnail_source, doc_thumbnail_path, seek=None) + if thumbnail_created: + thumbnail_entry = next( + ( + item + for item in artifacts + if isinstance(item, dict) and item.get("name") == doc_thumbnail_name + ), + None, + ) + if thumbnail_entry is None: + thumbnail_entry = { + "name": doc_thumbnail_name, + "created_at": primary_update.get("created_at") or timestamp, + } + artifacts.append(thumbnail_entry) + thumbnail_entry.update( + { + "title": primary_update.get("title") or "Document thumbnail", + "format": "thumbnail", + "path": doc_thumbnail_relative_path, + "link": artifact_name, + "action": doc_thumbnail_action or primary_update.get("action") or "download", + "metadata_ref": primary_update.get("metadata_ref") or artifact_name, + "updated_at": primary_update.get("updated_at") + or primary_update.get("created_at") + or timestamp, + } + ) + work_item_id = primary_update.get("work_item_id") + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + + manifest["artifacts"] = artifacts + manifest["updatedAt"] = _now_iso() + normalize_manifest_metadata(manifest) + write_manifest_atomic(manifest_file, manifest) + repaired_artifact = hydrate_artifacts_with_meta( + [existing_artifact], + manifest.get("metadata") if isinstance(manifest, dict) else {}, + )[0] + + return Response(repaired_artifact, status=status.HTTP_200_OK) + + if thumbnail_name: + validate_segment(thumbnail_name, "artifactId") + if thumbnail_name in existing_names: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + if thumbnail_name in incoming_names: + return Response( + {"error": "Duplicate artifact name in request."}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming_names.add(thumbnail_name) + if doc_thumbnail_name: + validate_segment(doc_thumbnail_name, "artifactId") + if doc_thumbnail_name in existing_names: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + if doc_thumbnail_name in incoming_names: + return Response( + {"error": "Duplicate artifact name in request."}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming_names.add(doc_thumbnail_name) + if image_thumbnail_name: + validate_segment(image_thumbnail_name, "artifactId") + if image_thumbnail_name in existing_names: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + if image_thumbnail_name in incoming_names: + return Response( + {"error": "Duplicate artifact name in request."}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming_names.add(image_thumbnail_name) + if video_thumbnail_name: + validate_segment(video_thumbnail_name, "artifactId") + if video_thumbnail_name in existing_names: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + if video_thumbnail_name in incoming_names: + return Response( + {"error": "Duplicate artifact name in request."}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming_names.add(video_thumbnail_name) + + if file_obj and file_path: + if should_transcode: + if not artifact_dir: + return Response({"error": "Artifact directory missing."}, status=status.HTTP_400_BAD_REQUEST) + if artifact_dir.exists(): + return Response({"error": "Artifact file already exists."}, status=status.HTTP_409_CONFLICT) + try: + _, created_thumbnail = transcode_mp4_to_hls( + file_obj, + artifact_dir, + thumbnail_path=thumbnail_path, + ) + except FileExistsError: + return Response({"error": "Artifact file already exists."}, status=status.HTTP_409_CONFLICT) + except MediaLibraryTranscodeError as exc: + return Response( + {"error": str(exc)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + if created_thumbnail and thumbnail_relative_path and thumbnail_name: + thumbnail_entry = { + "name": thumbnail_name, + "title": f"{primary_title}", + "format": "thumbnail", + "path": thumbnail_relative_path, + "link": primary_artifact_name, + "action": "preview", + "metadata_ref": primary_metadata_ref, + "created_at": primary_created_at, + "updated_at": primary_updated_at, + } + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + else: + if file_path.exists(): + return Response({"error": "Artifact file already exists."}, status=status.HTTP_409_CONFLICT) + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, "wb") as handle: + for chunk in file_obj.chunks(): + handle.write(chunk) + if video_thumbnail_name and video_thumbnail_path and video_thumbnail_relative_path: + if shutil.which("ffmpeg") is None: + logger.error("ffmpeg is not installed. Skipping video thumbnail for %s.", primary_artifact_name) + else: + created_thumbnail = _create_video_thumbnail(file_path, video_thumbnail_path) + if created_thumbnail: + thumbnail_entry = { + "name": video_thumbnail_name, + "title": f"{primary_title}", + "format": "thumbnail", + "path": video_thumbnail_relative_path, + "link": primary_artifact_name, + "action": video_thumbnail_action or "preview", + "metadata_ref": primary_metadata_ref, + "created_at": primary_created_at, + "updated_at": primary_updated_at, + } + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + if image_thumbnail_name and image_thumbnail_relative_path: + max_bytes = getattr(settings, "MEDIA_LIBRARY_THUMBNAIL_MAX_BYTES", 51200) + thumbnail_relative_path = image_thumbnail_relative_path + use_existing = False + try: + if file_path.suffix.lower() == ".webp" and file_path.stat().st_size <= max_bytes: + thumbnail_relative_path = relative_path + use_existing = True + except OSError: + pass + if not use_existing: + if not (image_thumbnail_path and generate_thumbnail(file_path, image_thumbnail_path, seek=None)): + thumbnail_relative_path = None + if thumbnail_relative_path: + thumbnail_entry = { + "name": image_thumbnail_name, + "title": f"{primary_title}", + "format": "thumbnail", + "path": thumbnail_relative_path, + "link": primary_artifact_name, + "action": image_thumbnail_action or "view", + "metadata_ref": primary_metadata_ref, + "created_at": primary_created_at, + "updated_at": primary_updated_at, + } + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + if doc_thumbnail_name and doc_thumbnail_relative_path and doc_thumbnail_source and doc_thumbnail_path: + if generate_thumbnail(doc_thumbnail_source, doc_thumbnail_path, seek=None): + thumbnail_entry = { + "name": doc_thumbnail_name, + "title": f"{primary_title}", + "format": "thumbnail", + "path": doc_thumbnail_relative_path, + "link": primary_artifact_name, + "action": doc_thumbnail_action or "download", + "metadata_ref": primary_metadata_ref, + "created_at": primary_created_at, + "updated_at": primary_updated_at, + } + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + + if not file_obj: + artifacts_root = package_root(project_id_str, package_id) / "artifacts" + attachment_root = package_root(project_id_str, package_id) / "attachment" + for artifact in list(validated_artifacts): + format_value = str(artifact.get("format") or "").lower() + if format_value == "thumbnail": + continue + action_value = str(artifact.get("action") or "").lower() + is_video = ( + format_value in _VIDEO_FORMATS + or format_value == "stream" + or action_value in {"play_streaming", "play_hls", "play", "open_mp4"} + ) + raw_path = artifact.get("path") or "" + + if is_video: + if not isinstance(raw_path, str) or not raw_path.startswith(("http://", "https://", "/")): + continue + thumbnail_name = f"{artifact.get('name')}-thumbnail" + validate_segment(thumbnail_name, "artifactId") + if thumbnail_name in existing_names or thumbnail_name in incoming_names: + continue + if shutil.which("ffmpeg") is None: + logger.error("ffmpeg is not installed. Skipping video thumbnail for %s.", artifact.get("name")) + continue + source_urls = _resolve_external_video_sources(raw_path, request, project_id_str) + if not source_urls: + continue + artifacts_root.mkdir(parents=True, exist_ok=True) + thumbnail_file_name = f"{artifact.get('name')}-thumbnail.webp" + thumbnail_path = artifacts_root / thumbnail_file_name + created_thumbnail = False + for source_url in source_urls: + try: + if thumbnail_path.exists(): + thumbnail_path.unlink() + except OSError: + pass + if _create_video_thumbnail_from_source(source_url, thumbnail_path): + created_thumbnail = True + break + if not created_thumbnail: + continue + thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{thumbnail_file_name}" + ) + thumbnail_entry = { + "name": thumbnail_name, + "title": artifact.get("title") or "Video thumbnail", + "format": "thumbnail", + "path": thumbnail_relative_path, + "link": artifact.get("name"), + "action": "preview", + "metadata_ref": artifact.get("metadata_ref") or artifact.get("name"), + "created_at": artifact.get("created_at") or timestamp, + "updated_at": artifact.get("updated_at") or artifact.get("created_at") or timestamp, + } + work_item_id = artifact.get("work_item_id") + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + incoming_names.add(thumbnail_name) + continue + + if format_value in _IMAGE_FORMATS: + raw_path = artifact.get("path") or "" + if not isinstance(raw_path, str) or not raw_path: + continue + thumbnail_name = f"{artifact.get('name')}-thumbnail" + validate_segment(thumbnail_name, "artifactId") + if thumbnail_name in existing_names or thumbnail_name in incoming_names: + continue + artifacts_root.mkdir(parents=True, exist_ok=True) + thumbnail_file_name = f"{artifact.get('name')}-thumbnail.webp" + thumbnail_path = artifacts_root / thumbnail_file_name + max_bytes = getattr(settings, "MEDIA_LIBRARY_THUMBNAIL_MAX_BYTES", 51200) + + resolved_path = _resolve_artifact_disk_path(artifact, media_library_root()) + created_thumbnail = False + thumbnail_relative_path = None + + if resolved_path and resolved_path.exists(): + try: + if ( + resolved_path.suffix.lower() == ".webp" + and resolved_path.stat().st_size <= max_bytes + ): + thumbnail_relative_path = raw_path + created_thumbnail = True + except OSError: + pass + if not created_thumbnail: + if generate_thumbnail(resolved_path, thumbnail_path, seek=None): + created_thumbnail = True + else: + source_urls = _resolve_external_video_sources(raw_path, request, project_id_str) + for source_url in source_urls: + try: + if thumbnail_path.exists(): + thumbnail_path.unlink() + except OSError: + pass + if generate_thumbnail(source_url, thumbnail_path, seek=None): + created_thumbnail = True + break + + if not created_thumbnail: + continue + if not thumbnail_relative_path: + thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{thumbnail_file_name}" + ) + thumbnail_entry = { + "name": thumbnail_name, + "title": artifact.get("title") or "Image thumbnail", + "format": "thumbnail", + "path": thumbnail_relative_path, + "link": artifact.get("name"), + "action": "view", + "metadata_ref": artifact.get("metadata_ref") or artifact.get("name"), + "created_at": artifact.get("created_at") or timestamp, + "updated_at": artifact.get("updated_at") or artifact.get("created_at") or timestamp, + } + work_item_id = artifact.get("work_item_id") + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + incoming_names.add(thumbnail_name) + continue + + thumbnail_name = f"{artifact.get('name')}-thumb" + validate_segment(thumbnail_name, "artifactId") + if thumbnail_name in existing_names or thumbnail_name in incoming_names: + continue + meta_value = artifact.get("meta") + thumbnail_hint = get_document_thumbnail_hint(format_value, meta_value) + doc_thumbnail_source = get_document_icon_source(format_value, thumbnail_hint) + if not doc_thumbnail_source: + continue + doc_thumbnail_file_name = None + if isinstance(thumbnail_hint, str): + hint_name = Path(thumbnail_hint).name + if hint_name: + doc_thumbnail_file_name = f"{Path(hint_name).stem}.webp" + if not doc_thumbnail_file_name: + doc_thumbnail_file_name = f"{artifact.get('name')}-thumbnail.webp" + doc_thumbnail_path = attachment_root / doc_thumbnail_file_name + if not generate_thumbnail(doc_thumbnail_source, doc_thumbnail_path, seek=None): + continue + doc_thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/attachment/{doc_thumbnail_file_name}" + ) + thumbnail_entry = { + "name": thumbnail_name, + "title": artifact.get("title") or "Document thumbnail", + "format": "thumbnail", + "path": doc_thumbnail_relative_path, + "link": artifact.get("name"), + "action": "open_pdf" if format_value == "pdf" else action_value or "download", + "metadata_ref": artifact.get("metadata_ref") or artifact.get("name"), + "created_at": artifact.get("created_at") or timestamp, + "updated_at": artifact.get("updated_at") or artifact.get("created_at") or timestamp, + } + work_item_id = artifact.get("work_item_id") + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + incoming_names.add(thumbnail_name) + + with manifest_write_lock(manifest_file): + manifest = read_manifest(manifest_file) + existing_artifacts = manifest.get("artifacts") or [] + existing_names = {artifact.get("name") for artifact in existing_artifacts if artifact.get("name")} + for artifact in validated_artifacts: + artifact_name = artifact.get("name") + if artifact_name in existing_names: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + existing_names.add(artifact_name) + artifacts_for_manifest = [artifact.copy() for artifact in validated_artifacts] + existing_artifacts.extend(artifacts_for_manifest) + manifest["artifacts"] = existing_artifacts + manifest["updatedAt"] = _now_iso() + normalize_manifest_metadata(manifest) + write_manifest_atomic(manifest_file, manifest) + + response_payload = validated_artifacts if is_bulk else validated_artifacts[0] + return Response(response_payload, status=status.HTTP_201_CREATED) diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py index 131932bf228..c3240cff0fb 100644 --- a/apps/api/plane/api/views/project.py +++ b/apps/api/plane/api/views/project.py @@ -30,6 +30,8 @@ from plane.bgtasks.webhook_task import model_activity, webhook_activity from .base import BaseAPIView from plane.utils.host import base_host +from plane.utils.exception_logger import log_exception +from plane.utils.media_library import delete_project_library from plane.api.serializers import ( ProjectSerializer, ProjectCreateSerializer, @@ -517,6 +519,10 @@ def delete(self, request, slug, pk): # Delete the user favorite cycle UserFavorite.objects.filter(entity_type="project", entity_identifier=pk, project_id=pk).delete() project.delete() + try: + delete_project_library(str(pk)) + except Exception as exc: + log_exception(exc) webhook_activity.delay( event="project", verb="deleted", diff --git a/apps/api/plane/app/management/__init__.py b/apps/api/plane/app/management/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api/plane/app/management/commands/__init__.py b/apps/api/plane/app/management/commands/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api/plane/app/management/commands/backfill_media_library_thumbnails.py b/apps/api/plane/app/management/commands/backfill_media_library_thumbnails.py new file mode 100644 index 00000000000..28f775a4d54 --- /dev/null +++ b/apps/api/plane/app/management/commands/backfill_media_library_thumbnails.py @@ -0,0 +1,185 @@ +# Python imports +import shutil +from pathlib import Path + +# Django imports +from django.core.management.base import BaseCommand + +# Module imports +from plane.utils.media_library import ( + _now_iso, + ensure_manifest_metadata, + get_document_icon_source, + media_library_root, + normalize_metadata_ref, + package_root, + read_manifest, + resolve_artifact_metadata, + write_manifest_atomic, +) + +_IMAGE_FORMATS = {"jpg", "jpeg", "png", "svg", "thumbnail"} +_VIDEO_FORMATS = {"mp4", "m3u8"} + + +def _is_document(format_value: str) -> bool: + value = (format_value or "").lower() + return value and value not in _IMAGE_FORMATS and value not in _VIDEO_FORMATS and value != "thumbnail" + + +def _parse_project_package_ids(manifest_file: Path) -> tuple[str, str] | None: + try: + relative = manifest_file.relative_to(media_library_root()) + except ValueError: + return None + if len(relative.parts) < 5: + return None + if relative.parts[0] != "projects" or relative.parts[2] != "packages": + return None + return relative.parts[1], relative.parts[3] + + +class Command(BaseCommand): + help = "Backfill document thumbnail artifacts for media library packages." + + def add_arguments(self, parser): + parser.add_argument("--project", dest="project_id", help="Filter to a specific project id") + parser.add_argument("--package", dest="package_id", help="Filter to a specific package id") + parser.add_argument("--dry-run", action="store_true", help="Report changes without writing") + + def handle(self, *args, **options): + project_filter = options.get("project_id") + package_filter = options.get("package_id") + dry_run = bool(options.get("dry_run")) + + root = media_library_root() / "projects" + if not root.exists(): + self.stdout.write("Media library root not found.") + return + + created_count = 0 + scanned = 0 + + for manifest_file in sorted(root.glob("*/packages/*/manifest.json")): + ids = _parse_project_package_ids(manifest_file) + if not ids: + continue + project_id, package_id = ids + if project_filter and project_filter != project_id: + continue + if package_filter and package_filter != package_id: + continue + + manifest = read_manifest(manifest_file) + artifacts = manifest.get("artifacts") or [] + metadata = ensure_manifest_metadata(manifest) + existing_names = {item.get("name") for item in artifacts if item.get("name")} + existing_for = set() + existing_thumbnails = {} + for item in artifacts: + if item.get("format") == "thumbnail": + link = item.get("link") + if link: + existing_for.add(link) + existing_thumbnails.setdefault(link, item) + meta = resolve_artifact_metadata(item, metadata) + if isinstance(meta, dict) and meta.get("for"): + existing_for.add(meta.get("for")) + existing_thumbnails.setdefault(meta.get("for"), item) + + updated = False + for item in list(artifacts): + format_value = item.get("format") + if not _is_document(format_value): + continue + name = item.get("name") + if not name: + continue + thumb_name = f"{name}-thumb" + + meta = resolve_artifact_metadata(item, metadata) + thumbnail_hint = meta.get("thumbnail") if isinstance(meta, dict) else None + icon_source = get_document_icon_source(format_value, thumbnail_hint) + existing_thumb = existing_thumbnails.get(name) + if existing_thumb: + if icon_source: + raw_path = existing_thumb.get("path") or "" + dest_path = None + if raw_path: + candidate = Path(raw_path) + if not candidate.is_absolute(): + candidate = (media_library_root() / candidate).resolve(strict=False) + else: + candidate = candidate.resolve(strict=False) + dest_path = candidate + if dest_path and not dest_path.exists(): + dest_path.parent.mkdir(parents=True, exist_ok=True) + if not dry_run: + try: + shutil.copyfile(icon_source, dest_path) + except OSError: + self.stdout.write( + f"Failed to copy icon for {project_id}/{package_id}/{name}." + ) + continue + + if name in existing_for or thumb_name in existing_names: + continue + if not icon_source: + continue + + file_name = None + if isinstance(thumbnail_hint, str): + hint_name = Path(thumbnail_hint).name + if hint_name: + file_name = hint_name + if not file_name: + file_name = f"{name}-thumbnail{icon_source.suffix}" + dest_path = package_root(project_id, package_id) / "attachment" / file_name + dest_path.parent.mkdir(parents=True, exist_ok=True) + if not dest_path.exists() and not dry_run: + try: + shutil.copyfile(icon_source, dest_path) + except OSError: + self.stdout.write( + f"Failed to copy icon for {project_id}/{package_id}/{name}." + ) + continue + + created_at = item.get("created_at") or _now_iso() + updated_at = item.get("updated_at") or created_at + action = "open_pdf" if (format_value or "").lower() == "pdf" else item.get("action") or "download" + thumbnail_metadata_ref = normalize_metadata_ref(item.get("metadata_ref")) or normalize_metadata_ref(name) + if thumbnail_metadata_ref and thumbnail_metadata_ref not in metadata: + metadata[thumbnail_metadata_ref] = {} + thumbnail_entry = { + "name": thumb_name, + "title": f"{item.get('title') or name} Thumbnail", + "format": "thumbnail", + "path": f"projects/{project_id}/packages/{package_id}/attachment/{file_name}", + "link": name, + "action": action, + "metadata_ref": thumbnail_metadata_ref, + "created_at": created_at, + "updated_at": updated_at, + } + + artifacts.append(thumbnail_entry) + existing_names.add(thumb_name) + existing_for.add(name) + updated = True + created_count += 1 + + if updated: + manifest["artifacts"] = artifacts + manifest["metadata"] = metadata + manifest["updatedAt"] = _now_iso() + if not dry_run: + write_manifest_atomic(manifest_file, manifest) + + scanned += 1 + + self.stdout.write( + f"Scanned {scanned} manifests, created {created_count} thumbnail entries." + + (" (dry-run)" if dry_run else "") + ) diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py index 18be363cda5..8b0c948b72d 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -85,6 +85,7 @@ ModuleLinkSerializer, ModuleUserPropertiesSerializer, ) +from .roster import RosterPlayerSerializer, RosterPlayerImportSerializer from .api import APITokenSerializer, APITokenReadSerializer @@ -128,3 +129,5 @@ DraftIssueSerializer, DraftIssueDetailSerializer, ) +from .media_library import MediaLibraryPackageCreateSerializer +from .custom_playlist import CustomPlaylistSerializer diff --git a/apps/api/plane/app/serializers/custom_playlist.py b/apps/api/plane/app/serializers/custom_playlist.py new file mode 100644 index 00000000000..6d7bf07ffbb --- /dev/null +++ b/apps/api/plane/app/serializers/custom_playlist.py @@ -0,0 +1,161 @@ +from urllib.parse import unquote, urlparse + +from django.core.exceptions import ValidationError as DjangoValidationError +from rest_framework import serializers +from rest_framework.exceptions import NotFound, PermissionDenied + +from plane.db.models import CustomPlaylist, Issue, Project, ProjectMember + +from .base import BaseSerializer + + +def user_can_access_event(user, event): + if user.is_anonymous: + return False + + return ProjectMember.objects.filter( + project_id=event.project_id, + workspace_id=event.workspace_id, + member=user, + is_active=True, + ).exists() + + +def user_can_access_project(user, project_id, workspace_slug=None): + if user.is_anonymous or not project_id: + return False + + try: + projects = Project.objects.filter(pk=project_id) + except (DjangoValidationError, TypeError, ValueError): + return False + + if workspace_slug: + projects = projects.filter(workspace__slug=workspace_slug) + + project = projects.select_related("workspace").first() + if not project: + return False + + return ProjectMember.objects.filter( + project_id=project.id, + workspace_id=project.workspace_id, + member=user, + is_active=True, + ).exists() + + +def user_can_access_custom_playlist_event(user, event_id, project_id=None, workspace_slug=None): + events = Issue.issue_objects.filter(sg_event_id=event_id).select_related("project", "workspace") + + if events.exists(): + if any(user_can_access_event(user, event) for event in events): + return True + raise PermissionDenied("You do not have access to this event.") + + if user_can_access_project(user, project_id, workspace_slug): + return True + + raise NotFound("Event does not exist.") + + +CUSTOM_PLAYLIST_CLIP_EXCLUDED_KEYS = {"durationSeconds", "fallbackTimestamp", "timecode"} + + +def strip_custom_playlist_clip_fields(clips): + return [ + {key: value for key, value in clip.items() if key not in CUSTOM_PLAYLIST_CLIP_EXCLUDED_KEYS} + if isinstance(clip, dict) + else clip + for clip in clips + ] + + +class CustomPlaylistSerializer(BaseSerializer): + subtitle = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=255) + url = serializers.CharField(required=True, max_length=2048) + thumbnail = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=2048) + clip = serializers.IntegerField(required=False, min_value=0, default=0) + clips = serializers.ListField(child=serializers.JSONField(), required=False) + project_id = serializers.UUIDField(required=False, write_only=True) + workspace_slug = serializers.CharField(required=False, allow_blank=True, write_only=True) + + class Meta: + model = CustomPlaylist + fields = [ + "id", + "event_id", + "name", + "subtitle", + "url", + "thumbnail", + "clip", + "clips", + "project_id", + "workspace_slug", + ] + read_only_fields = ["id"] + + def validate_name(self, value): + if not value or not value.strip(): + raise serializers.ValidationError("Name is required.") + return value.strip() + + def validate_subtitle(self, value): + if value is None: + return None + return value.strip() or None + + def _normalize_file_name(self, value): + normalized_value = (value or "").strip() + if not normalized_value: + return "" + + parsed_value = urlparse(normalized_value) + path_value = parsed_value.path if parsed_value.scheme or parsed_value.netloc else normalized_value + file_name = unquote(path_value.replace("\\", "/").rstrip("/").split("/")[-1]).strip() + + if not file_name or "/" in file_name or "\\" in file_name: + raise serializers.ValidationError("Enter a valid file name.") + + if len(file_name) > 255: + raise serializers.ValidationError("File name must be 255 characters or fewer.") + + return file_name + + def validate_url(self, value): + file_name = self._normalize_file_name(value) + if not file_name: + raise serializers.ValidationError("URL is required.") + return file_name + + def validate_thumbnail(self, value): + if value is None: + return None + return self._normalize_file_name(value) or None + + def validate_clips(self, value): + return strip_custom_playlist_clip_fields(value) + + def validate_event_id(self, value): + request = self.context["request"] + user_can_access_custom_playlist_event( + request.user, + value, + self.initial_data.get("project_id"), + self.initial_data.get("workspace_slug"), + ) + return value + + def validate(self, attrs): + if self.instance is None and "event_id" not in attrs: + raise serializers.ValidationError({"event_id": "This field is required."}) + attrs.pop("project_id", None) + attrs.pop("workspace_slug", None) + return attrs + + def to_representation(self, instance): + data = super().to_representation(instance) + if isinstance(data.get("clips"), list): + data["clips"] = strip_custom_playlist_clip_fields(data["clips"]) + return data diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 583b62fd682..fa34ebe1dd7 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -37,12 +37,15 @@ IssueVersion, IssueDescriptionVersion, ProjectMember, + Project, EstimatePoint, ) from plane.utils.content_validator import ( validate_html_content, validate_binary_data, ) +from plane.utils.issue_datetime import is_issue_start_datetime_in_past +from plane.utils.opposition_team import normalize_opposition_team class IssueFlatSerializer(BaseSerializer): @@ -56,14 +59,21 @@ class Meta: "description", "description_html", "priority", + "start_time", "start_date", "target_date", "sequence_id", "sort_order", "is_draft", + "level", # sport app Field + "sport", # sport app Field + "program", # sport app Field + "year", # sport app Field + "category", # sport app Field + "opposition_team", + "sg_event_id", ] - class IssueProjectLiteSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(source="project", read_only=True) @@ -93,6 +103,7 @@ class IssueCreateSerializer(BaseSerializer): write_only=True, required=False, ) + opposition_team = serializers.JSONField(required=False, allow_null=True) project_id = serializers.UUIDField(source="project.id", read_only=True) workspace_id = serializers.UUIDField(source="workspace.id", read_only=True) @@ -106,6 +117,7 @@ class Meta: "updated_by", "created_at", "updated_at", + "sg_event_id", ] def to_representation(self, instance): @@ -117,6 +129,23 @@ def to_representation(self, instance): return data def validate(self, attrs): + project_sport = ( + Project.objects.filter(pk=self.context.get("project_id")).values_list("sport", flat=True).first() + ) + if isinstance(project_sport, str): + project_sport = project_sport.strip() or None + if project_sport: + attrs["sport"] = project_sport + + should_validate_start_datetime = self.instance is None or "start_date" in attrs or "start_time" in attrs + start_date = attrs.get("start_date", getattr(self.instance, "start_date", None)) + start_time = attrs.get("start_time", getattr(self.instance, "start_time", None)) + + if should_validate_start_datetime and is_issue_start_datetime_in_past(start_date, start_time): + raise serializers.ValidationError( + {"start_time": "Event date and time cannot be earlier than the current time."} + ) + if ( attrs.get("start_date", None) is not None and attrs.get("target_date", None) is not None @@ -138,6 +167,12 @@ def validate(self, attrs): if not is_valid: raise serializers.ValidationError({"description_binary": "Invalid binary data"}) + if "opposition_team" in attrs: + try: + attrs["opposition_team"] = normalize_opposition_team(attrs["opposition_team"]) + except ValueError as exc: + raise serializers.ValidationError({"opposition_team": str(exc)}) + # Validate assignees are from project if attrs.get("assignee_ids", []): attrs["assignee_ids"] = ProjectMember.objects.filter( @@ -761,6 +796,7 @@ class Meta: "completed_at", "estimate_point", "priority", + "start_time", "start_date", "target_date", "sequence_id", @@ -779,6 +815,13 @@ class Meta: "link_count", "is_draft", "archived_at", + "level", # sport app Field + "sport", # sport app Field + "program", # sport app Field + "year", # sport app Field + "category", # sport app Field + "opposition_team", + "sg_event_id", ] read_only_fields = fields @@ -810,6 +853,7 @@ def to_representation(self, instance): "completed_at": instance.completed_at, "estimate_point": instance.estimate_point_id, "priority": instance.priority, + "start_time": instance.start_time, "start_date": instance.start_date, "target_date": instance.target_date, "sequence_id": instance.sequence_id, @@ -829,6 +873,14 @@ def to_representation(self, instance): "sub_issues_count": instance.sub_issues_count, "attachment_count": instance.attachment_count, "link_count": instance.link_count, + # sport app fields + "level": instance.level, + "sport": instance.sport, + "program": instance.program, + "year": instance.year, + "category": instance.category, + "opposition_team": instance.opposition_team, + "sg_event_id": instance.sg_event_id, } # Handle expanded fields only when requested - using direct field access diff --git a/apps/api/plane/app/serializers/media_library.py b/apps/api/plane/app/serializers/media_library.py new file mode 100644 index 00000000000..e8d59ebd695 --- /dev/null +++ b/apps/api/plane/app/serializers/media_library.py @@ -0,0 +1,106 @@ +# Django imports +from django.utils.dateparse import parse_datetime + +# Third party imports +from rest_framework import serializers + +from plane.utils.media_library import normalize_metadata_ref +MEDIA_LIBRARY_FORMAT_CHOICES = ( + "mov", + "webm", + "avi", + "mkv", + "mpeg", + "mpg", + "m4v", + "mp4", + "m3u8", + "json", + "csv", + "pdf", + "docx", + "xlsx", + "pptx", + "jpg", + "jpeg", + "png", + "svg", + "webp", + "gif", + "bmp", + "tif", + "tiff", + "avif", + "heic", + "heif", + "thumbnail", + "txt", +) + +MEDIA_LIBRARY_ACTION_CHOICES = ( + "play", + "stream", + "view", + "download", + "preview", + "edit", + "navigate", + "play_hls", + "open_mp4", + "open_pdf", + "attach_captions", +) + + +def _validate_iso_datetime(value: str) -> str: + if not isinstance(value, str) or parse_datetime(value) is None: + raise serializers.ValidationError("Invalid ISO-8601 datetime.") + return value + + +class MediaArtifactSerializer(serializers.Serializer): + name = serializers.CharField() + title = serializers.CharField() + description = serializers.CharField(required=False, allow_blank=True, allow_null=True) + format = serializers.ChoiceField(choices=MEDIA_LIBRARY_FORMAT_CHOICES) + path = serializers.CharField() + link = serializers.CharField(allow_null=True) + action = serializers.ChoiceField(choices=MEDIA_LIBRARY_ACTION_CHOICES) + metadata_ref = serializers.CharField(required=False, allow_blank=True, allow_null=True) + meta = serializers.JSONField(required=False, allow_null=True) + work_item_id = serializers.CharField(required=False, allow_null=True, allow_blank=True) + created_at = serializers.CharField() + updated_at = serializers.CharField() + + def validate_meta(self, value): + if value is None: + return {} + if not isinstance(value, dict): + raise serializers.ValidationError("Meta must be an object.") + return value + + def validate_metadata_ref(self, value): + if value in (None, ""): + return value + if not normalize_metadata_ref(value): + raise serializers.ValidationError("Invalid metadata_ref.") + return value + + def validate_created_at(self, value): + return _validate_iso_datetime(value) + + def validate_updated_at(self, value): + return _validate_iso_datetime(value) + + +class MediaLibraryPackageCreateSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=False) + package_id = serializers.CharField(required=False, allow_blank=False, write_only=True) + name = serializers.CharField() + title = serializers.CharField() + artifacts = MediaArtifactSerializer(many=True, required=False) + + def validate(self, attrs): + if not attrs.get("id") and attrs.get("package_id"): + attrs["id"] = attrs["package_id"] + return attrs diff --git a/apps/api/plane/app/serializers/project.py b/apps/api/plane/app/serializers/project.py index c709093adcc..c5d33291720 100644 --- a/apps/api/plane/app/serializers/project.py +++ b/apps/api/plane/app/serializers/project.py @@ -60,6 +60,20 @@ def validate_identifier(self, identifier): return identifier def validate(self, data): + if "sport" in data and isinstance(data["sport"], str): + data["sport"] = data["sport"].strip() or None + + current_sport = getattr(self.instance, "sport", None) + if isinstance(current_sport, str): + current_sport = current_sport.strip() or None + + next_sport = data.get("sport", current_sport) + if isinstance(next_sport, str): + next_sport = next_sport.strip() or None + + if current_sport and next_sport != current_sport: + raise serializers.ValidationError({"sport": ["PROJECT_SPORT_ALREADY_LOCKED"]}) + # Validate description content for security if "description_html" in data and data["description_html"]: is_valid, error_msg, sanitized_html = validate_html_content(str(data["description_html"])) @@ -89,6 +103,7 @@ class Meta: "id", "identifier", "name", + "sport", "cover_image", "cover_image_url", "logo_props", diff --git a/apps/api/plane/app/serializers/roster.py b/apps/api/plane/app/serializers/roster.py new file mode 100644 index 00000000000..ad89b74737d --- /dev/null +++ b/apps/api/plane/app/serializers/roster.py @@ -0,0 +1,138 @@ +# Python imports +from typing import Any + +# Django imports +from django.db import transaction + +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from plane.db.models import RosterPlayer, RosterPlayerStatus + + +class RosterPlayerSerializer(BaseSerializer): + program_id = serializers.UUIDField(source="project_id", read_only=True) + + class Meta: + model = RosterPlayer + fields = [ + "id", + "program_id", + "player_name", + "jersey_number", + "position", + "height", + "weight", + "class_year", + "status", + "notes", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "program_id", "created_at", "updated_at"] + + def validate_status(self, value): + if not value: + return RosterPlayerStatus.ACTIVE + return value + + def validate_player_name(self, value): + if not value or not value.strip(): + raise serializers.ValidationError("Player name is required.") + return value.strip() + + def validate_jersey_number(self, value): + if value is None: + return None + value = value.strip() + return value or None + + def validate(self, attrs): + project = self.context["project"] + jersey_number = attrs.get("jersey_number") + if jersey_number: + queryset = RosterPlayer.objects.filter(project=project, jersey_number=jersey_number) + if self.instance: + queryset = queryset.exclude(pk=self.instance.pk) + if queryset.exists(): + raise serializers.ValidationError( + {"jersey_number": "Jersey number must be unique within this program."} + ) + return attrs + + def create(self, validated_data): + project = self.context["project"] + return RosterPlayer.objects.create(project=project, **validated_data) + + +class RosterPlayerImportSerializer(serializers.Serializer): + players = serializers.ListField(child=serializers.DictField(), allow_empty=False) + + def _get_first_error_message(self, errors: Any) -> str: + if isinstance(errors, dict): + first_error = next(iter(errors.values()), "Please provide valid detail.") + return self._get_first_error_message(first_error) + if isinstance(errors, list): + first_error = errors[0] if errors else "Please provide valid detail." + return self._get_first_error_message(first_error) + return str(errors) + + def validate_players(self, players): + if not players: + raise serializers.ValidationError("At least one player is required.") + + project = self.context["project"] + validated_rows = [] + row_errors = [] + imported_jersey_numbers = {} + + for row_number, payload in enumerate(players, start=1): + serializer = RosterPlayerSerializer(data=payload, context=self.context) + if not serializer.is_valid(): + row_errors.append(f"Row {row_number}: {self._get_first_error_message(serializer.errors)}") + continue + + validated_row = dict(serializer.validated_data) + jersey_number = validated_row.get("jersey_number") + + if jersey_number: + duplicate_row = imported_jersey_numbers.get(jersey_number) + if duplicate_row: + row_errors.append( + f"Row {row_number}: Jersey number {jersey_number} is duplicated in the import file." + ) + continue + imported_jersey_numbers[jersey_number] = row_number + + validated_rows.append((row_number, validated_row)) + + existing_jersey_numbers = set( + RosterPlayer.objects.filter( + project=project, + jersey_number__in=list(imported_jersey_numbers.keys()), + ).values_list("jersey_number", flat=True) + ) + + for row_number, validated_row in validated_rows: + jersey_number = validated_row.get("jersey_number") + if jersey_number and jersey_number in existing_jersey_numbers: + row_errors.append( + f"Row {row_number}: Jersey number {jersey_number} already exists in this program." + ) + + if row_errors: + raise serializers.ValidationError(row_errors) + + return [validated_row for _, validated_row in validated_rows] + + def create(self, validated_data): + project = self.context["project"] + players = [] + + with transaction.atomic(): + for player_data in validated_data["players"]: + players.append(RosterPlayer.objects.create(project=project, **player_data)) + + return players diff --git a/apps/api/plane/app/serializers/view.py b/apps/api/plane/app/serializers/view.py index bf7ff9727c6..bec23bd080f 100644 --- a/apps/api/plane/app/serializers/view.py +++ b/apps/api/plane/app/serializers/view.py @@ -26,8 +26,10 @@ def to_representation(self, instance): "completed_at": instance.completed_at, "estimate_point": instance.estimate_point_id, "priority": instance.priority, + "start_time": instance.start_time, "start_date": instance.start_date, "target_date": instance.target_date, + "sg_event_id": instance.sg_event_id, "sequence_id": instance.sequence_id, "project_id": instance.project_id, "parent_id": instance.parent_id, diff --git a/apps/api/plane/app/urls/__init__.py b/apps/api/plane/app/urls/__init__.py index 3feab4cb548..7a5efb95e13 100644 --- a/apps/api/plane/app/urls/__init__.py +++ b/apps/api/plane/app/urls/__init__.py @@ -2,6 +2,7 @@ from .api import urlpatterns as api_urls from .asset import urlpatterns as asset_urls from .cycle import urlpatterns as cycle_urls +from .custom_playlist import urlpatterns as custom_playlist_urls from .estimate import urlpatterns as estimate_urls from .external import urlpatterns as external_urls from .intake import urlpatterns as intake_urls @@ -10,6 +11,8 @@ from .notification import urlpatterns as notification_urls from .page import urlpatterns as page_urls from .project import urlpatterns as project_urls +from .roster import urlpatterns as roster_urls +from .media_library import urlpatterns as media_library_urls from .search import urlpatterns as search_urls from .state import urlpatterns as state_urls from .user import urlpatterns as user_urls @@ -23,6 +26,7 @@ *analytic_urls, *asset_urls, *cycle_urls, + *custom_playlist_urls, *estimate_urls, *external_urls, *intake_urls, @@ -31,6 +35,8 @@ *notification_urls, *page_urls, *project_urls, + *roster_urls, + *media_library_urls, *search_urls, *state_urls, *user_urls, diff --git a/apps/api/plane/app/urls/custom_playlist.py b/apps/api/plane/app/urls/custom_playlist.py new file mode 100644 index 00000000000..b8c9897aa80 --- /dev/null +++ b/apps/api/plane/app/urls/custom_playlist.py @@ -0,0 +1,17 @@ +from django.urls import path + +from plane.app.views import CustomPlaylistViewSet + + +urlpatterns = [ + path( + "custom-playlists/", + CustomPlaylistViewSet.as_view({"get": "list", "post": "create"}), + name="custom-playlists", + ), + path( + "custom-playlists//", + CustomPlaylistViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), + name="custom-playlists-detail", + ), +] diff --git a/apps/api/plane/app/urls/media_library.py b/apps/api/plane/app/urls/media_library.py new file mode 100644 index 00000000000..0b4bb470f94 --- /dev/null +++ b/apps/api/plane/app/urls/media_library.py @@ -0,0 +1,84 @@ +from django.urls import path + +from plane.app.views.media_library import ( + MediaArtifactDetailAPIView, + MediaArtifactFileAPIView, + MediaArtifactTranscodeAPIView, + MediaArtifactTranscodeJobAPIView, + MediaArtifactTranscodeJobCancelAPIView, + MediaArtifactTranscodeJobRetryAPIView, + MediaArtifactsListAPIView, + MediaLibraryInitAPIView, + MediaTranscodeCallbackAPIView, + MediaManifestDetailAPIView, + MediaPackageCreateAPIView, + MediaWorkItemSyncAPIView, +) + +urlpatterns = [ + path( + "workspaces//projects//media-library/", + MediaLibraryInitAPIView.as_view(), + name="media-library-init", + ), + path( + "workspaces//projects//media-library/work-items/webhook/", + MediaWorkItemSyncAPIView.as_view(), + name="media-library-work-item-webhook", + ), + path( + "workspaces//projects//media-library/packages/", + MediaPackageCreateAPIView.as_view(), + name="media-library-packages", + ), + path( + "workspaces//projects//media-library/packages//manifest/", + MediaManifestDetailAPIView.as_view(), + name="media-library-manifest", + ), + path( + "workspaces//projects//media-library/packages//artifacts/", + MediaArtifactsListAPIView.as_view(), + name="media-library-artifacts", + ), + path( + "workspaces//projects//media-library/packages//artifacts//", + MediaArtifactDetailAPIView.as_view(), + name="media-library-artifact-detail", + ), + path( + "workspaces//projects//media-library/packages//artifacts//transcode/", + MediaArtifactTranscodeAPIView.as_view(), + name="media-library-artifact-transcode", + ), + path( + "workspaces//projects//media-library/packages//artifacts//transcode/jobs//", + MediaArtifactTranscodeJobAPIView.as_view(), + name="media-library-artifact-transcode-job", + ), + path( + "workspaces//projects//media-library/packages//artifacts//transcode/jobs//retry/", + MediaArtifactTranscodeJobRetryAPIView.as_view(), + name="media-library-artifact-transcode-job-retry", + ), + path( + "workspaces//projects//media-library/packages//artifacts//transcode/jobs//cancel/", + MediaArtifactTranscodeJobCancelAPIView.as_view(), + name="media-library-artifact-transcode-job-cancel", + ), + path( + "media-library/transcode/callback/", + MediaTranscodeCallbackAPIView.as_view(), + name="media-library-transcode-callback", + ), + path( + "workspaces//projects//media-library/packages//artifacts//file//", + MediaArtifactFileAPIView.as_view(), + name="media-library-artifact-file-path", + ), + path( + "workspaces//projects//media-library/packages//artifacts//file/", + MediaArtifactFileAPIView.as_view(), + name="media-library-artifact-file", + ), +] diff --git a/apps/api/plane/app/urls/roster.py b/apps/api/plane/app/urls/roster.py new file mode 100644 index 00000000000..602940d6415 --- /dev/null +++ b/apps/api/plane/app/urls/roster.py @@ -0,0 +1,28 @@ +from django.urls import path + +from plane.app.views import RosterPlayerViewSet + + +urlpatterns = [ + path( + "workspaces//projects//roster/import/", + RosterPlayerViewSet.as_view({"post": "import_players"}), + name="project-roster-import", + ), + path( + "workspaces//projects//roster/", + RosterPlayerViewSet.as_view({"get": "list", "post": "create"}), + name="project-roster", + ), + path( + "workspaces//projects//roster//", + RosterPlayerViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-roster-detail", + ), +] diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 9d81754e295..3e4bcf6cc91 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -159,6 +159,7 @@ from .module.issue import ModuleIssueViewSet from .module.archive import ModuleArchiveUnarchiveEndpoint +from .roster import RosterPlayerViewSet from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint @@ -217,6 +218,7 @@ UnreadNotificationEndpoint, UserNotificationPreferenceEndpoint, ) +from .custom_playlist import CustomPlaylistViewSet from .exporter.base import ExportIssuesEndpoint diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index 610c5335f90..8d2f9236756 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -196,6 +196,15 @@ def delete(self, request, asset_id): class WorkspaceFileAssetEndpoint(BaseAPIView): """This endpoint is used to upload cover images/logos etc for workspace, projects and users.""" + def _resolve_disposition(self, request): + download = request.query_params.get("download") + disposition = request.query_params.get("disposition") + if disposition: + return "attachment" if str(disposition).lower() == "attachment" else "inline" + if download is None: + return "inline" + return "attachment" if str(download).lower() in {"1", "true", "yes"} else "inline" + def get_entity_id_field(self, entity_type, entity_id): # Workspace Logo if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: @@ -417,7 +426,7 @@ def get(self, request, slug, asset_id): # Generate a presigned URL to share an S3 object signed_url = storage.generate_presigned_url( object_name=asset.asset.name, - disposition="attachment", + disposition=self._resolve_disposition(request), filename=asset.attributes.get("name"), ) # Redirect to the signed URL @@ -504,6 +513,15 @@ def get_entity_id_field(self, entity_type, entity_id): return {"draft_issue_id": entity_id} return {} + def _resolve_disposition(self, request): + download = request.query_params.get("download") + disposition = request.query_params.get("disposition") + if disposition: + return "attachment" if str(disposition).lower() == "attachment" else "inline" + if download is None: + return "inline" + return "attachment" if str(download).lower() in {"1", "true", "yes"} else "inline" + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def post(self, request, slug, project_id): name = request.data.get("name") @@ -545,6 +563,12 @@ def post(self, request, slug, project_id): # asset key asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + entity_fields = self.get_entity_id_field(entity_type, entity_identifier) + # Keep project scoping for this endpoint while avoiding duplicate kwargs + # when entity_type already maps to project_id (e.g. PROJECT_COVER). + if "project_id" not in entity_fields or not entity_fields["project_id"]: + entity_fields["project_id"] = project_id + # Create a File Asset asset = FileAsset.objects.create( attributes={"name": name, "type": type, "size": size_limit}, @@ -553,8 +577,7 @@ def post(self, request, slug, project_id): workspace=workspace, created_by=request.user, entity_type=entity_type, - project_id=project_id, - **self.get_entity_id_field(entity_type, entity_identifier), + **entity_fields, ) # Get the presigned URL @@ -600,8 +623,20 @@ def delete(self, request, slug, project_id, pk): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, pk): - # get the asset id - asset = FileAsset.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + asset = FileAsset.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk).first() + if not asset and request.query_params.get("response") == "json": + # Allow media-library resolution for soft-deleted assets still present in MinIO. + asset = FileAsset.all_objects.filter( + workspace__slug=slug, + project_id=project_id, + pk=pk, + is_deleted=True, + ).first() + if not asset: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) # Check if the asset is uploaded if not asset.is_uploaded: @@ -615,9 +650,11 @@ def get(self, request, slug, project_id, pk): # Generate a presigned URL to share an S3 object signed_url = storage.generate_presigned_url( object_name=asset.asset.name, - disposition="attachment", + disposition=self._resolve_disposition(request), filename=asset.attributes.get("name"), ) + if request.query_params.get("response") == "json": + return Response({"url": signed_url}, status=status.HTTP_200_OK) # Redirect to the signed URL return HttpResponseRedirect(signed_url) diff --git a/apps/api/plane/app/views/custom_playlist.py b/apps/api/plane/app/views/custom_playlist.py new file mode 100644 index 00000000000..08cae168cd2 --- /dev/null +++ b/apps/api/plane/app/views/custom_playlist.py @@ -0,0 +1,80 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response + +from plane.app.serializers import CustomPlaylistSerializer +from plane.app.serializers.custom_playlist import user_can_access_custom_playlist_event +from plane.db.models import CustomPlaylist, Issue, ProjectMember + +from .base import BaseViewSet + + +class CustomPlaylistViewSet(BaseViewSet): + model = CustomPlaylist + serializer_class = CustomPlaylistSerializer + + def _accessible_event_ids(self): + project_ids = ProjectMember.objects.filter(member=self.request.user, is_active=True).values("project_id") + return Issue.issue_objects.filter(project_id__in=project_ids, sg_event_id__isnull=False).values("sg_event_id") + + def _parse_event_id(self, event_id): + try: + parsed_event_id = int(str(event_id)) + except (TypeError, ValueError): + raise ValidationError({"event_id": "Enter a valid service gateway event id."}) + + if parsed_event_id <= 0: + raise ValidationError({"event_id": "Enter a valid service gateway event id."}) + + return parsed_event_id + + def _authorize_event(self, event_id): + return user_can_access_custom_playlist_event( + self.request.user, + event_id, + self.request.query_params.get("project_id") or self.request.data.get("project_id"), + self.request.query_params.get("workspace_slug") or self.request.data.get("workspace_slug"), + ) + + def get_queryset(self): + return CustomPlaylist.objects.filter(event_id__in=self._accessible_event_ids()).order_by("-created_at") + + def get_object(self): + playlist = get_object_or_404(CustomPlaylist.objects.all(), pk=self.kwargs.get("pk")) + self._authorize_event(playlist.event_id) + return playlist + + def list(self, request): + event_id = request.GET.get("event_id") + if event_id: + parsed_event_id = self._parse_event_id(event_id) + self._authorize_event(parsed_event_id) + queryset = CustomPlaylist.objects.filter(event_id=parsed_event_id).order_by("-created_at") + else: + queryset = self.get_queryset() + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def create(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def retrieve(self, request, pk): + serializer = self.get_serializer(self.get_object()) + return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, pk): + serializer = self.get_serializer(self.get_object(), data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, pk): + playlist = get_object_or_404(CustomPlaylist.all_objects.all(), pk=pk) + self._authorize_event(playlist.event_id) + playlist.delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/issue/attachment.py b/apps/api/plane/app/views/issue/attachment.py index 7b7ecf378b5..884aba7ed95 100644 --- a/apps/api/plane/app/views/issue/attachment.py +++ b/apps/api/plane/app/views/issue/attachment.py @@ -84,6 +84,15 @@ class IssueAttachmentV2Endpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer model = FileAsset + def _resolve_disposition(self, request): + download = request.query_params.get("download") + disposition = request.query_params.get("disposition") + if disposition: + return "attachment" if str(disposition).lower() == "attachment" else "inline" + if download is None: + return "inline" + return "attachment" if str(download).lower() in {"1", "true", "yes"} else "inline" + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def post(self, request, slug, project_id, issue_id): name = request.data.get("name") @@ -158,8 +167,22 @@ def delete(self, request, slug, project_id, issue_id, pk): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, issue_id, pk=None): if pk: - # Get the asset - asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id) + asset_filters = { + "id": pk, + "workspace__slug": slug, + "project_id": project_id, + "issue_id": issue_id, + "entity_type": FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + } + asset = FileAsset.objects.filter(**asset_filters).first() + if not asset and request.query_params.get("response") == "json": + # Media library can still resolve soft-deleted attachments to MinIO signed URLs. + asset = FileAsset.all_objects.filter(**asset_filters, is_deleted=True).first() + if not asset: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) # Check if the asset is uploaded if not asset.is_uploaded: @@ -171,9 +194,11 @@ def get(self, request, slug, project_id, issue_id, pk=None): storage = S3Storage(request=request) presigned_url = storage.generate_presigned_url( object_name=asset.asset.name, - disposition="attachment", + disposition=self._resolve_disposition(request), filename=asset.attributes.get("name"), ) + if request.query_params.get("response") == "json": + return Response({"url": presigned_url}, status=status.HTTP_200_OK) return HttpResponseRedirect(presigned_url) # Get all the attachments diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index c24db616980..525596dcf90 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -39,7 +39,8 @@ from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_description_version_task import issue_description_version_task from plane.bgtasks.recent_visited_task import recent_visited_task -from plane.bgtasks.webhook_task import model_activity +from plane.bgtasks.service_gateway_webhook_task import service_gateway_event_sync +from plane.bgtasks.webhook_task import model_activity, webhook_activity from plane.db.models import ( CycleIssue, FileAsset, @@ -163,6 +164,7 @@ def get(self, request, slug, project_id): "completed_at", "estimate_point", "priority", + "start_time", "start_date", "target_date", "sequence_id", @@ -182,6 +184,12 @@ def get(self, request, slug, project_id): "is_draft", "archived_at", "deleted_at", + "level", + "sport", + "program", + "year", + "category", + "sg_event_id", ) datetime_fields = ["created_at", "updated_at"] issues = user_timezone_converter(issues, datetime_fields, request.user.user_timezone) @@ -399,6 +407,27 @@ def create(self, request, slug, project_id): if serializer.is_valid(): serializer.save() + service_gateway_event_sync( + event="issue", + verb="created", + event_data=Issue.issue_objects.filter(pk=serializer.data["id"]) + .values( + "id", + "workspace_id", + "project_id", + "name", + "start_time", + "start_date", + "target_date", + "level", + "sport", + "program", + "year", + "category", + "sg_event_id", + ) + .first(), + ) # Track the issue issue_activity.delay( @@ -428,6 +457,7 @@ def create(self, request, slug, project_id): "completed_at", "estimate_point", "priority", + "start_time", "start_date", "target_date", "sequence_id", @@ -447,6 +477,12 @@ def create(self, request, slug, project_id): "is_draft", "archived_at", "deleted_at", + "level", + "sport", + "program", + "year", + "category", + "sg_event_id", ) .first() ) @@ -659,6 +695,27 @@ def partial_update(self, request, slug, project_id, pk=None): serializer = IssueCreateSerializer(issue, data=request.data, partial=True, context={"project_id": project_id}) if serializer.is_valid(): serializer.save() + service_gateway_event_sync( + event="issue", + verb="updated", + event_data=Issue.issue_objects.filter(pk=serializer.data["id"]) + .values( + "id", + "workspace_id", + "project_id", + "name", + "start_time", + "start_date", + "target_date", + "level", + "sport", + "program", + "year", + "category", + "sg_event_id", + ) + .first(), + ) issue_activity.delay( type="issue.activity.updated", requested_data=requested_data, @@ -691,8 +748,28 @@ def partial_update(self, request, slug, project_id, pk=None): @allow_permission([ROLE.ADMIN], creator=True, model=Issue) def destroy(self, request, slug, project_id, pk=None): issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + # delete workitems using service gateway for proper cascade delete and webhook trigger + + deleted_issue_id = issue.id + deleted_issue_event_data = {"id": deleted_issue_id, "sg_event_id": issue.sg_event_id} issue.delete() + service_gateway_event_sync(event="issue", verb="deleted", event_data=deleted_issue_event_data) + webhook_activity.delay( + event="issue", + verb="deleted", + field=None, + old_value=None, + new_value=None, + actor_id=request.user.id, + slug=slug, + current_site=base_host(request=request, is_app=True), + event_id=deleted_issue_id, + old_identifier=None, + new_identifier=None, + event_data=deleted_issue_event_data, + skip_service_gateway=True, + ) # delete the issue from recent visits UserRecentVisit.objects.filter( project_id=project_id, @@ -744,8 +821,8 @@ def delete(self, request, slug, project_id): return Response({"error": "Issue IDs are required"}, status=status.HTTP_400_BAD_REQUEST) issues = Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issue_ids) - - total_issues = len(issues) + deleted_issue_payloads = list(issues.values("id", "sg_event_id")) + total_issues = len(deleted_issue_payloads) # First, delete all related cycle issues CycleIssue.objects.filter(issue_id__in=issue_ids).delete() @@ -756,6 +833,24 @@ def delete(self, request, slug, project_id): # Finally, delete the issues themselves issues.delete() + for deleted_issue_payload in deleted_issue_payloads: + service_gateway_event_sync(event="issue", verb="deleted", event_data=deleted_issue_payload) + webhook_activity.delay( + event="issue", + verb="deleted", + field=None, + old_value=None, + new_value=None, + actor_id=request.user.id, + slug=slug, + current_site=base_host(request=request, is_app=True), + event_id=deleted_issue_payload["id"], + old_identifier=None, + new_identifier=None, + event_data=deleted_issue_payload, + skip_service_gateway=True, + ) + return Response( {"message": f"{total_issues} issues were deleted"}, status=status.HTTP_200_OK, diff --git a/apps/api/plane/app/views/media_library.py b/apps/api/plane/app/views/media_library.py new file mode 100644 index 00000000000..2e41775d4f8 --- /dev/null +++ b/apps/api/plane/app/views/media_library.py @@ -0,0 +1,3452 @@ +# Python imports +import json +import math +import logging +import shutil +import mimetypes +import os +import re +import time +from hashlib import sha1 +from urllib import error as urlerror, request as urlrequest +from urllib.parse import urlparse +from pathlib import Path +from uuid import UUID, uuid4 +from html import unescape +from types import SimpleNamespace + +# Third party imports +from django.http import FileResponse, HttpResponse, StreamingHttpResponse +from rest_framework import status +from rest_framework.exceptions import NotFound +from rest_framework.response import Response +from django.conf import settings + +# Module imports +from plane.app.permissions import allow_permission, ROLE +from plane.app.serializers.media_library import MediaArtifactSerializer, MediaLibraryPackageCreateSerializer +from plane.app.views.base import BaseAPIView +from plane.api.middleware.api_authentication import APIKeyAuthentication +from plane.authentication.session import BaseSessionAuthentication +from plane.db.models import FileAsset, Issue +from plane.settings.storage import S3Storage +from plane.utils.exception_logger import log_exception +from plane.utils.media_library import ( + _now_iso, + create_manifest, + ensure_project_library, + filter_media_library_artifacts, + generate_thumbnail, + get_document_icon_source, + get_document_thumbnail_hint, + hydrate_artifacts_with_meta, + manifest_path, + media_library_root, + manifest_write_lock, + normalize_manifest_metadata, + normalize_metadata_ref, + update_manifest_artifact_fields, + update_manifest_event_meta, + MediaLibraryTranscodeError, + package_root, + read_manifest, + resolve_artifact_metadata, + transcode_video_to_mp4, + transcode_mp4_to_hls, + validate_segment, + write_manifest_atomic, +) +from plane.utils.paginator import BadPaginationError, Cursor, CursorResult + +_IMAGE_FORMATS = { + "jpg", + "jpeg", + "png", + "svg", + "webp", + "gif", + "bmp", + "tif", + "tiff", + "avif", + "heic", + "heif", + "thumbnail", +} +_VIDEO_FORMATS = {"mp4", "m3u8", "mov", "webm", "avi", "mkv", "mpeg", "mpg", "m4v"} +_MP4_FASTSTART_FORMATS = {".mp4", ".m4v"} +_TRANSCODE_SOURCE_FORMATS = {"mp4"} +_TRANSCODE_TERMINAL_STATUSES = {"COMPLETED", "FAILED", "CANCELLED"} +logger = logging.getLogger(__name__) + +_UPLOAD_LOG_SAFE_FIELD_NAMES = { + "artifact_count", + "artifact_name", + "bytes_written", + "content_length", + "duration_ms", + "elapsed_ms", + "error", + "error_code", + "file_name", + "file_size", + "file_type", + "format", + "handler_status", + "hls_pending", + "is_bulk", + "metadata_ref", + "package_id", + "project_id", + "request_content_type", + "request_id", + "source_file_name", + "status_code", + "transcode_asset_id", + "transcode_job_id", + "transcode_profile", + "transcode_status", + "upload_client", + "upload_id", + "upstream_status", + "work_item_id", + "workspace_slug", +} + + +def _safe_upload_log_context(fields: dict) -> dict: + safe = {} + for key, value in fields.items(): + if key not in _UPLOAD_LOG_SAFE_FIELD_NAMES: + continue + if value is None: + safe[key] = None + continue + if isinstance(value, (str, UUID)): + text = str(value).strip() + if text: + safe[key] = text[:500] + continue + if isinstance(value, bool): + safe[key] = value + continue + if isinstance(value, (int, float)): + safe[key] = value + return safe + + +def _extract_upload_trace_fields(payload: dict | None) -> dict: + if not isinstance(payload, dict): + return {} + aliases = { + "upload_id": ("upload_id", "uploadId"), + "request_id": ("request_id", "requestId"), + "upload_client": ("upload_client", "uploadClient"), + } + fields = {} + for field_name, keys in aliases.items(): + for key in keys: + value = payload.get(key) + if isinstance(value, str) and value.strip(): + fields[field_name] = value.strip()[:500] + break + return fields + + +def _get_upload_trace_fields(request, meta: dict | None = None) -> dict: + trace = _extract_upload_trace_fields(meta) + headers = getattr(request, "headers", {}) or {} + header_trace = _extract_upload_trace_fields( + { + "upload_id": headers.get("X-Upload-ID"), + "request_id": headers.get("X-Request-ID"), + } + ) + trace.update(header_trace) + if trace.get("upload_id") and not trace.get("request_id"): + trace["request_id"] = trace["upload_id"] + return trace + + +def _log_media_upload_event(level: int, event: str, trace_fields: dict | None = None, **fields) -> None: + payload = { + "event": f"media_library_upload_{event}", + "ts": _now_iso(), + **_safe_upload_log_context(trace_fields or {}), + **_safe_upload_log_context(fields), + } + logger.log(level, json.dumps(payload, separators=(",", ":"), sort_keys=True)) + + +def _default_artifact_description(title: str) -> str: + title_value = (title or "Uploaded file").strip() or "Uploaded file" + return ( + "

This asset was uploaded to the media library and is ready for use.
" + "It can be previewed, downloaded, or used in projects as needed.
" + f"File name: {title_value}

" + ) + + +class ListPaginator: + def __init__(self, items): + self.items = items + + def get_result(self, limit=1000, cursor=None): + if cursor is None: + cursor = Cursor(limit, 0, 0) + + if limit <= 0: + raise BadPaginationError("Pagination limit must be positive") + + total_count = len(self.items) + page = cursor.offset + if page < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + offset = page * limit + stop = offset + limit + 1 + page_items = self.items[offset:stop] + has_next = len(page_items) > limit + + results = page_items[:limit] + next_cursor = Cursor(limit, page + 1, False, has_next) + prev_cursor = Cursor(limit, page - 1, True, page > 0) + max_hits = math.ceil(total_count / limit) if limit else 0 + + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=total_count, + max_hits=max_hits, + ) + + +def _create_video_thumbnail(source_path: Path, thumbnail_path: Path) -> bool: + return generate_thumbnail(source_path, thumbnail_path, seek="00:00:00.000") + + +def _create_video_thumbnail_from_source(source: str, thumbnail_path: Path) -> bool: + if not source: + return False + return generate_thumbnail(source, thumbnail_path, seek="00:00:00.000") + + +def _extract_asset_id_from_url(value: str) -> str | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = urlparse(value) + path = parsed.path or "" + except ValueError: + path = value + if not path or ("/api/assets/" not in path and "/assets/v2/" not in path): + return None + segments = [segment for segment in path.split("/") if segment] + if not segments: + return None + candidate = segments[-1] + try: + return str(UUID(candidate)) + except ValueError: + return None + + +def _resolve_external_video_sources(path: str, request, project_id: str) -> list[str]: + if not isinstance(path, str) or not path: + return [] + source = path + if source.startswith("/"): + try: + source = request.build_absolute_uri(source) + except Exception: + source = path + candidates: list[str] = [] + asset_id = _extract_asset_id_from_url(source) + if asset_id: + asset = FileAsset.objects.filter(id=asset_id, project_id=project_id, is_deleted=False).first() + if asset and asset.is_uploaded: + if request is not None: + storage = S3Storage(request=request) + candidates.append( + storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="inline", + filename=asset.attributes.get("name"), + ) + ) + storage_internal = S3Storage() + candidates.append( + storage_internal.generate_presigned_url( + object_name=asset.asset.name, + disposition="inline", + filename=asset.attributes.get("name"), + ) + ) + if source.startswith(("http://", "https://")): + candidates.append(source) + deduped: list[str] = [] + for candidate in candidates: + if candidate and candidate not in deduped: + deduped.append(candidate) + return deduped + + +def _pick_transcode_source(sources: list[str]) -> str | None: + if not sources: + return None + internal_endpoint = os.environ.get("AWS_S3_INTERNAL_ENDPOINT_URL") or os.environ.get("MINIO_INTERNAL_ENDPOINT_URL") + if internal_endpoint: + try: + internal_host = urlparse(internal_endpoint).netloc + except ValueError: + internal_host = "" + if internal_host: + for source in sources: + try: + if urlparse(source).netloc == internal_host: + return source + except ValueError: + continue + return sources[0] + + +def _resolve_artifact_disk_path(artifact: dict, base_root: Path) -> Path | None: + raw_path = artifact.get("path") or "" + if not raw_path: + return None + if isinstance(raw_path, str) and raw_path.lower().startswith(("http://", "https://")): + return None + candidate = Path(raw_path) + if not candidate.is_absolute(): + candidate = (base_root / candidate).resolve(strict=False) + else: + candidate = candidate.resolve(strict=False) + if os.path.commonpath([str(base_root), str(candidate)]) != str(base_root): + return None + return candidate + + +def _transcode_source_root() -> Path: + return Path(getattr(settings, "MEDIA_TRANSCODE_SOURCE_ROOT", "") or "").resolve(strict=False) + + +def _transcode_source_storage_prefix() -> str: + return str(getattr(settings, "MEDIA_TRANSCODE_SOURCE_STORAGE_PREFIX", "transcode-sources") or "").strip("/") + + +def _build_transcode_source_paths( + asset_id: str, + extension: str, +) -> tuple[str, Path]: + relative_suffix = f"{asset_id}.{extension}" + prefix = _transcode_source_storage_prefix() + storage_path = f"{prefix}/{relative_suffix}" if prefix else relative_suffix + return storage_path, _transcode_source_root() / relative_suffix + + +def _resolve_transcode_source_disk_path(source_path: str | None) -> Path | None: + if not isinstance(source_path, str) or not source_path.strip(): + return None + value = source_path.strip() + if value.startswith(("http://", "https://")): + return None + prefix = _transcode_source_storage_prefix() + if prefix and value.startswith(f"{prefix}/"): + value = value[len(prefix) + 1 :] + candidate = Path(value) + source_root = _transcode_source_root() + if candidate.is_absolute(): + candidate = candidate.resolve(strict=False) + else: + candidate = (source_root / candidate).resolve(strict=False) + try: + common = os.path.commonpath([str(source_root), str(candidate)]) + except ValueError: + return None + if common != str(source_root): + return None + return candidate + + +def _delete_transcode_source(source_path: str | None) -> None: + disk_path = _resolve_transcode_source_disk_path(source_path) + if not disk_path: + return + try: + if disk_path.is_dir(): + shutil.rmtree(disk_path, ignore_errors=True) + else: + disk_path.unlink(missing_ok=True) + parent = disk_path.parent + source_root = _transcode_source_root() + while parent != source_root and parent.exists(): + try: + parent.rmdir() + except OSError: + break + parent = parent.parent + except OSError as exc: + logger.warning("Could not remove transcode source file: %s", exc) + + +def _delete_artifact_disk_path(path: Path, artifact_name: str | None = None) -> None: + if not path.exists(): + return + if path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + return + if artifact_name: + try: + parent = path.parent + if parent.name == artifact_name and parent.parent.name == "artifacts": + shutil.rmtree(parent, ignore_errors=True) + return + except OSError: + return + try: + path.unlink() + except FileNotFoundError: + return + except OSError: + return + + +def _should_download_as_attachment(request) -> bool: + value = request.query_params.get("download") + if value is None: + return False + if isinstance(value, str) and value.strip().lower() in {"0", "false", "no"}: + return False + return True + + +def _should_stream_in_chunks(request) -> bool: + value = request.query_params.get("stream") + if value is None: + return False + if isinstance(value, str) and value.strip().lower() in {"0", "false", "no"}: + return False + return True + + +def _parse_http_range(value: str, file_size: int) -> tuple[int, int] | None: + if not value: + return None + raw = value.strip().lower() + if not raw.startswith("bytes="): + return None + raw = raw[len("bytes=") :] + if "," in raw: + raw = raw.split(",", 1)[0] + raw = raw.strip() + if "-" not in raw: + return None + start_str, end_str = raw.split("-", 1) + if not start_str and not end_str: + return None + if not start_str: + try: + length = int(end_str) + except (TypeError, ValueError): + return None + if length <= 0: + return None + if length > file_size: + length = file_size + start = max(file_size - length, 0) + end = file_size - 1 + return (start, end) + try: + start = int(start_str) + except (TypeError, ValueError): + return None + if start < 0: + return None + if end_str: + try: + end = int(end_str) + except (TypeError, ValueError): + return None + else: + end = file_size - 1 + if start >= file_size: + return None + if end < start: + return None + if end >= file_size: + end = file_size - 1 + return (start, end) + + +def _iter_file_range(path: Path, start: int, end: int, chunk_size: int = 8192): + with open(path, "rb") as handle: + handle.seek(start) + remaining = end - start + 1 + while remaining > 0: + chunk = handle.read(min(chunk_size, remaining)) + if not chunk: + break + yield chunk + remaining -= len(chunk) + + +def _is_mp4_faststart(path: Path) -> bool: + """ + Check top-level atom order. Faststart MP4 has `moov` before the first `mdat`. + """ + if path.suffix.lower() not in _MP4_FASTSTART_FORMATS or not path.exists() or not path.is_file(): + return False + + file_size = path.stat().st_size + offset = 0 + moov_offset = None + mdat_offset = None + + with open(path, "rb") as handle: + while offset + 8 <= file_size: + handle.seek(offset) + header = handle.read(8) + if len(header) < 8: + break + + atom_size = int.from_bytes(header[:4], byteorder="big", signed=False) + atom_type = header[4:8] + header_size = 8 + + if atom_size == 1: + ext_size = handle.read(8) + if len(ext_size) < 8: + break + atom_size = int.from_bytes(ext_size, byteorder="big", signed=False) + header_size = 16 + elif atom_size == 0: + atom_size = file_size - offset + + if atom_size < header_size: + break + + if atom_type == b"moov" and moov_offset is None: + moov_offset = offset + elif atom_type == b"mdat" and mdat_offset is None: + mdat_offset = offset + + if moov_offset is not None and mdat_offset is not None: + return moov_offset < mdat_offset + if mdat_offset is not None and moov_offset is None: + return False + + offset += atom_size + + if moov_offset is None: + return False + if mdat_offset is None: + return True + return moov_offset < mdat_offset + + +def _ensure_mp4_faststart(path: Path, artifact_name: str | None = None, force: bool = False) -> None: + if path.suffix.lower() not in _MP4_FASTSTART_FORMATS or not path.exists() or not path.is_file(): + return + + label = artifact_name or path.name + if not force: + try: + if _is_mp4_faststart(path): + return + except OSError as exc: + logger.warning("Failed to inspect MP4 atom order for %s: %s", label, exc) + return + + if shutil.which("ffmpeg") is None: + logger.warning("ffmpeg is not installed. Skipping MP4 faststart for %s.", label) + return + + try: + transcode_video_to_mp4(path, path) + except MediaLibraryTranscodeError as exc: + logger.warning("Failed to optimize MP4 for streaming (%s): %s", label, exc) + + +def _transcode_service_url(path: str) -> str: + base_url = (getattr(settings, "MEDIA_TRANSCODE_SERVICE_URL", "") or "http://thumbnail-service:5000").rstrip("/") + return f"{base_url}/{path.lstrip('/')}" + + +def _transcode_service_headers() -> dict[str, str]: + headers = {"Content-Type": "application/json"} + token = (getattr(settings, "MEDIA_TRANSCODE_INTERNAL_API_TOKEN", "") or "").strip() + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _call_transcode_service(method: str, path: str, payload: dict | None = None) -> tuple[int, dict]: + body = None + if payload is not None: + body = json.dumps(payload).encode("utf-8") + request = urlrequest.Request( + _transcode_service_url(path), + data=body, + headers=_transcode_service_headers(), + method=method, + ) + timeout = getattr(settings, "MEDIA_TRANSCODE_REQUEST_TIMEOUT", 10) + try: + with urlrequest.urlopen(request, timeout=timeout) as response: + raw_body = response.read(1024 * 1024) + if not raw_body: + return response.status, {} + try: + return response.status, json.loads(raw_body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return response.status, {"error": "Transcoding service returned an invalid response."} + except urlerror.HTTPError as exc: + raw_body = exc.read(1024 * 1024) + try: + data = json.loads(raw_body.decode("utf-8")) if raw_body else {} + except (UnicodeDecodeError, json.JSONDecodeError): + data = {"error": "Transcoding service returned an invalid error response."} + return exc.code, data + except (OSError, TimeoutError, urlerror.URLError) as exc: + logger.warning("Transcoding service request failed for %s %s: %s", method, path, exc) + return 502, {"error": {"code": "TRANSCODE_SERVICE_UNAVAILABLE", "message": "Transcoding service unavailable."}} + + +def _find_manifest_artifact(manifest: dict, artifact_id: str) -> dict | None: + artifacts = manifest.get("artifacts") or [] + if not isinstance(artifacts, list): + return None + return next((artifact for artifact in artifacts if isinstance(artifact, dict) and artifact.get("name") == artifact_id), None) + + +def _hydrate_manifest_artifact(manifest: dict, artifact: dict) -> dict: + metadata = manifest.get("metadata") if isinstance(manifest, dict) else {} + meta = resolve_artifact_metadata(artifact, metadata if isinstance(metadata, dict) else {}) + if not meta: + return artifact + hydrated = artifact.copy() + hydrated["meta"] = meta + return hydrated + + +def _transcode_asset_id(project_id: str, package_id: str, artifact_id: str) -> str: + digest = sha1(f"{project_id}:{package_id}:{artifact_id}".encode("utf-8")).hexdigest() + return f"media-{digest}" + + +def _artifact_transcode_format(artifact: dict) -> str: + meta = artifact.get("meta") if isinstance(artifact.get("meta"), dict) else {} + source_path = str(meta.get("transcode_source_path") or "").strip() + source_format = str(meta.get("source_format") or "").lower().lstrip(".") + if source_path and source_format: + return source_format + format_value = str(artifact.get("format") or "").lower().lstrip(".") + if format_value: + return format_value + raw_path = str(artifact.get("path") or "") + return Path(raw_path.split("?", 1)[0].split("#", 1)[0]).suffix.lower().lstrip(".") + + +def _build_transcode_input_path(artifact: dict) -> str: + meta = artifact.get("meta") if isinstance(artifact.get("meta"), dict) else {} + source_path = str(meta.get("transcode_source_path") or "").strip() + if source_path: + return source_path + raw_path = str(artifact.get("path") or "").strip() + if raw_path.startswith(("http://", "https://")): + return raw_path + return f"media-library/{raw_path.lstrip('/')}" + + +def _normalize_transcode_output_url(value: str | None) -> str: + if not value: + return "" + output_base_url = (getattr(settings, "MEDIA_TRANSCODE_OUTPUT_BASE_URL", "") or "").rstrip("/") + if not output_base_url: + return str(value) + output_path = str(value).strip() + marker = "/data/media/" + if marker in output_path: + output_path = output_path.split(marker, 1)[1] + transcoded_marker = "/data/transcoded/" + if transcoded_marker in output_path: + output_path = output_path.split(transcoded_marker, 1)[1] + output_path = output_path.lstrip("/") + if output_path.startswith("transcoded/"): + output_path = output_path[len("transcoded/") :] + if output_path.startswith("media/"): + output_path = output_path[len("media/") :] + return f"{output_base_url}/{output_path.lstrip('/')}" + + +def _build_transcode_output_asset_url(playable_url: str, relative_path: str | None) -> str: + if not playable_url or not relative_path: + return "" + if "/hls/" not in playable_url: + asset_base = playable_url.rsplit("/", 1)[0].rstrip("/") + else: + asset_base = playable_url.split("/hls/", 1)[0].rstrip("/") + return f"{asset_base}/{str(relative_path).lstrip('/')}" + + +def _upsert_manifest_thumbnail_artifact( + manifest: dict, + artifact: dict, + artifact_id: str, + thumbnail_path: str | None, + *, + action: str = "preview", + timestamp: str | None = None, +) -> int: + thumbnail_path_value = str(thumbnail_path or "").strip() + if not thumbnail_path_value: + return 0 + + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list): + artifacts = [] + manifest["artifacts"] = artifacts + + thumbnail_name = f"{artifact_id}-thumbnail" + validate_segment(thumbnail_name, "artifactId") + updated_at = timestamp or _now_iso() + metadata_ref = normalize_metadata_ref(artifact.get("metadata_ref")) or normalize_metadata_ref(artifact.get("name")) + if not metadata_ref: + metadata_ref = artifact_id + + thumbnail_entry = next( + ( + item + for item in artifacts + if isinstance(item, dict) and item.get("format") == "thumbnail" and item.get("link") == artifact_id + ), + None, + ) + if thumbnail_entry is None: + thumbnail_entry = next( + ( + item + for item in artifacts + if isinstance(item, dict) and item.get("name") == thumbnail_name + ), + None, + ) + + created_at = ( + thumbnail_entry.get("created_at") + if isinstance(thumbnail_entry, dict) and thumbnail_entry.get("created_at") + else artifact.get("created_at") or updated_at + ) + next_thumbnail = { + "name": thumbnail_entry.get("name") if isinstance(thumbnail_entry, dict) else thumbnail_name, + "title": artifact.get("title") or "Video thumbnail", + "format": "thumbnail", + "path": thumbnail_path_value, + "link": artifact_id, + "action": action or "preview", + "metadata_ref": metadata_ref, + "created_at": created_at, + "updated_at": updated_at, + } + work_item_id = artifact.get("work_item_id") + if work_item_id is not None: + next_thumbnail["work_item_id"] = work_item_id + + serializer = MediaArtifactSerializer(data=next_thumbnail) + serializer.is_valid(raise_exception=True) + validated_thumbnail = serializer.validated_data + + if thumbnail_entry is None: + artifacts.append(validated_thumbnail) + return 1 + + changed = False + for key, value in validated_thumbnail.items(): + if thumbnail_entry.get(key) != value: + thumbnail_entry[key] = value + changed = True + return 1 if changed else 0 + + +def _update_artifact_transcode_meta( + project_id: str, + package_id: str, + artifact_id: str, + updates: dict, + artifact_updates: dict | None = None, +) -> int: + manifest_file = manifest_path(project_id, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + with manifest_write_lock(manifest_file): + manifest = read_manifest(manifest_file) + artifact = _find_manifest_artifact(manifest, artifact_id) + if not artifact: + raise NotFound("Artifact not found.") + metadata = manifest.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + manifest["metadata"] = metadata + current_meta = resolve_artifact_metadata(artifact, metadata) + next_meta = {**current_meta, **updates} + if next_meta.get("transcode_status") == "COMPLETED": + _delete_transcode_source(next_meta.get("transcode_source_path") or current_meta.get("transcode_source_path")) + if next_meta.get("transcode_status") in {"COMPLETED", "CANCELLED"}: + next_meta.pop("transcode_source_path", None) + metadata_ref = normalize_metadata_ref(artifact.get("metadata_ref")) or normalize_metadata_ref(artifact.get("name")) + updated_count = 0 + if metadata_ref: + artifact["metadata_ref"] = metadata_ref + if metadata.get(metadata_ref) != next_meta: + metadata[metadata_ref] = next_meta + updated_count += 1 + if "meta" in artifact: + artifact.pop("meta", None) + updated_count += 1 + next_artifact_updates = artifact_updates or {} + else: + next_artifact_updates = {"meta": next_meta} + if artifact_updates: + next_artifact_updates.update(artifact_updates) + if next_artifact_updates: + updated_count += update_manifest_artifact_fields( + manifest, + next_artifact_updates, + artifact_id=artifact_id, + ) + if next_meta.get("transcode_status") == "COMPLETED": + updated_count += _upsert_manifest_thumbnail_artifact( + manifest, + artifact, + artifact_id, + str(next_meta.get("poster_url") or next_meta.get("thumbnail") or "").strip(), + action="preview", + ) + if updated_count > 0: + manifest["updatedAt"] = _now_iso() + normalize_manifest_metadata(manifest) + write_manifest_atomic(manifest_file, manifest) + return updated_count + + +mimetypes.add_type("application/vnd.apple.mpegurl", ".m3u8") +mimetypes.add_type("application/x-mpegURL", ".m3u8") +mimetypes.add_type("video/mp2t", ".ts") + + +class MediaPackageCreateAPIView(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="PROJECT") + def post(self, request, slug, project_id): + serializer = MediaLibraryPackageCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + project_id_str = str(project_id) + package_id = serializer.validated_data.get("id") or uuid4().hex + + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + + root = package_root(project_id_str, package_id) + manifest_file = manifest_path(project_id_str, package_id) + + if root.exists() or manifest_file.exists(): + return Response({"error": "Package already exists."}, status=status.HTTP_409_CONFLICT) + + (root / "artifacts").mkdir(parents=True, exist_ok=False) + (root / "attachment").mkdir(parents=True, exist_ok=False) + + manifest = create_manifest( + project_id=project_id_str, + package_id=package_id, + name=serializer.validated_data["name"], + title=serializer.validated_data["title"], + artifacts=serializer.validated_data.get("artifacts"), + ) + write_manifest_atomic(manifest_file, manifest) + + return Response(manifest, status=status.HTTP_201_CREATED) + + +class MediaLibraryInitAPIView(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT") + def post(self, request, slug, project_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + packages_root = ensure_project_library(project_id_str) + + package_dirs = [path for path in packages_root.iterdir() if path.is_dir()] + if package_dirs: + for package_dir in sorted(package_dirs, key=lambda path: path.name): + manifest_file = package_dir / "manifest.json" + if manifest_file.exists(): + try: + manifest = read_manifest(manifest_file) + except Exception as exc: + log_exception(exc) + manifest = create_manifest( + project_id=project_id_str, + package_id=package_dir.name, + name=package_dir.name, + title="Media Library Package", + ) + write_manifest_atomic(manifest_file, manifest) + return Response(manifest, status=status.HTTP_200_OK) + manifest = create_manifest( + project_id=project_id_str, + package_id=package_dir.name, + name=package_dir.name, + title="Media Library Package", + ) + write_manifest_atomic(manifest_file, manifest) + return Response(manifest, status=status.HTTP_201_CREATED) + return Response(status=status.HTTP_204_NO_CONTENT) + + package_id = f"package-{uuid4().hex[:8]}" + root = package_root(project_id_str, package_id) + (root / "artifacts").mkdir(parents=True, exist_ok=False) + (root / "attachment").mkdir(parents=True, exist_ok=False) + manifest = create_manifest( + project_id=project_id_str, + package_id=package_id, + name=package_id, + title="Media Library Package", + ) + write_manifest_atomic(manifest_path(project_id_str, package_id), manifest) + return Response(manifest, status=status.HTTP_201_CREATED) + + +class MediaManifestDetailAPIView(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT") + def get(self, request, slug, project_id, package_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + manifest = read_manifest(manifest_file) + return Response(manifest, status=status.HTTP_200_OK) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="PROJECT") + def patch(self, request, slug, project_id, package_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + + payload = request.data or {} + work_item_id = payload.get("work_item_id") or payload.get("workItemId") or "" + artifact_id = payload.get("artifact_id") or payload.get("artifactId") or "" + meta = payload.get("meta") if "meta" in payload else None + artifact_fields = payload.get("artifact") if "artifact" in payload else payload.get("artifact_fields") + if meta is None and artifact_fields is None: + return Response({"error": "meta or artifact fields are required."}, status=status.HTTP_400_BAD_REQUEST) + if meta is not None and not work_item_id: + return Response({"error": "work_item_id is required for meta updates."}, status=status.HTTP_400_BAD_REQUEST) + if meta is not None and not isinstance(meta, dict): + return Response({"error": "meta must be an object."}, status=status.HTTP_400_BAD_REQUEST) + if artifact_fields is not None and not artifact_id: + return Response({"error": "artifact_id is required for artifact updates."}, status=status.HTTP_400_BAD_REQUEST) + if artifact_fields is not None and not isinstance(artifact_fields, dict): + return Response({"error": "artifact fields must be an object."}, status=status.HTTP_400_BAD_REQUEST) + if artifact_fields is not None: + description_value = artifact_fields.get("description") + if isinstance(description_value, str) and description_value.strip(): + artifact_fields = dict(artifact_fields) + artifact_fields["description"] = _ensure_description_image_sources( + description_value, + slug=slug, + project_id=project_id_str, + ) + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + with manifest_write_lock(manifest_file): + manifest = read_manifest(manifest_file) + updated_count = 0 + if meta is not None: + updated_count += update_manifest_event_meta(manifest, work_item_id, meta) + if artifact_fields is not None: + updated_count += update_manifest_artifact_fields(manifest, artifact_fields, artifact_id=artifact_id) + if updated_count <= 0: + return Response({"updated": 0}, status=status.HTTP_200_OK) + manifest["updatedAt"] = _now_iso() + normalize_manifest_metadata(manifest) + write_manifest_atomic(manifest_file, manifest) + + return Response({"updated": updated_count}, status=status.HTTP_200_OK) + + +class MediaArtifactFileAPIView(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT") + def get(self, request, slug, project_id, package_id, artifact_id, artifact_path=None): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + validate_segment(artifact_id, "artifactId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + manifest = read_manifest(manifest_file) + artifacts = manifest.get("artifacts") or [] + artifact = next( + (entry for entry in artifacts if entry.get("name") == artifact_id), + None, + ) + if not artifact: + raise NotFound("Artifact not found.") + + base_root = media_library_root().resolve(strict=False) + file_path = None + raw_path = artifact.get("path") or "" + external_url = None + if isinstance(raw_path, str) and raw_path.startswith(("http://", "https://")): + external_url = raw_path + elif raw_path: + candidate = Path(raw_path) + if not candidate.is_absolute(): + candidate = (base_root / candidate).resolve(strict=False) + else: + candidate = candidate.resolve(strict=False) + if os.path.commonpath([str(base_root), str(candidate)]) == str(base_root) and candidate.exists(): + file_path = candidate + + if artifact_path: + if not file_path: + artifacts_root = package_root(project_id_str, package_id) / "artifacts" + candidate_dir = artifacts_root / artifact_id + if candidate_dir.exists(): + file_path = candidate_dir.resolve(strict=False) + else: + raise NotFound("Artifact file not found.") + base_dir = file_path if file_path.is_dir() else file_path.parent + relative_path = Path(artifact_path) + if relative_path.is_absolute() or ".." in relative_path.parts: + raise NotFound("Artifact file not found.") + resolved_path = (base_dir / relative_path).resolve(strict=False) + base_dir_resolved = base_dir.resolve(strict=False) + if os.path.commonpath([str(base_dir_resolved), str(resolved_path)]) != str(base_dir_resolved): + raise NotFound("Artifact file not found.") + file_path = resolved_path + elif not file_path: + artifacts_root = package_root(project_id_str, package_id) / "artifacts" + if artifacts_root.exists(): + matches = list(artifacts_root.glob(f"{artifact_id}.*")) + if matches: + file_path = matches[0].resolve(strict=False) + + download_requested = _should_download_as_attachment(request) + stream_requested = _should_stream_in_chunks(request) and not download_requested + format_value = str(artifact.get("format") or "").lower() + action_value = str(artifact.get("action") or "").lower() + is_video = ( + format_value in _VIDEO_FORMATS + or format_value == "stream" + or action_value in {"play_streaming", "play_hls", "play", "open_mp4"} + ) + if not file_path or not file_path.exists(): + if external_url and download_requested and artifact_path is None and is_video: + mp4_path = package_root(project_id_str, package_id) / "artifacts" / f"{artifact_id}.mp4" + if not mp4_path.exists(): + try: + source_url = external_url + resolved_sources = _resolve_external_video_sources(external_url, request, project_id_str) + picked_source = _pick_transcode_source(resolved_sources) + if picked_source: + source_url = picked_source + transcode_video_to_mp4(source_url, mp4_path) + except MediaLibraryTranscodeError as exc: + return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + file_path = mp4_path + else: + raise NotFound("Artifact file not found.") + + if download_requested and artifact_path is None and is_video: + mp4_path = file_path + if file_path.suffix.lower() != ".mp4": + mp4_path = file_path.with_suffix(".mp4") + if not mp4_path.exists(): + try: + transcode_video_to_mp4(file_path, mp4_path) + except MediaLibraryTranscodeError as exc: + return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + file_path = mp4_path + + if is_video and artifact_path is None: + _ensure_mp4_faststart(file_path, artifact.get("name")) + + content_type, _ = mimetypes.guess_type(str(file_path)) + content_type = content_type or "application/octet-stream" + range_header = request.headers.get("range") or request.META.get("HTTP_RANGE") + if range_header and file_path.is_file(): + file_size = file_path.stat().st_size + parsed = _parse_http_range(range_header, file_size) + if not parsed: + response = HttpResponse(status=416) + response["Content-Range"] = f"bytes */{file_size}" + response["Accept-Ranges"] = "bytes" + return response + start, end = parsed + if stream_requested and is_video: + max_bytes = getattr(settings, "MEDIA_LIBRARY_STREAM_CHUNK_BYTES", 0) or 0 + if max_bytes > 0 and end - start + 1 > max_bytes: + end = min(file_size - 1, start + max_bytes - 1) + response = StreamingHttpResponse( + _iter_file_range(file_path, start, end), + status=206, + content_type=content_type, + ) + response["Content-Range"] = f"bytes {start}-{end}/{file_size}" + response["Content-Length"] = str(end - start + 1) + response["Accept-Ranges"] = "bytes" + else: + response = FileResponse(open(file_path, "rb"), content_type=content_type) + response["Accept-Ranges"] = "bytes" + if download_requested and artifact_path is None: + download_name = artifact.get("name") or "media" + suffix = ".mp4" if is_video else (Path(file_path).suffix or "") + response["Content-Disposition"] = f'attachment; filename="{download_name}{suffix}"' + return response + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="PROJECT") + def patch(self, request, slug, project_id, package_id, artifact_id, artifact_path=None): + if artifact_path: + return Response( + {"error": "Nested artifact files cannot be updated through this endpoint."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + payload = request.data if isinstance(request.data, dict) else {} + annotations = payload.get("annotations") + if not isinstance(annotations, list): + return Response({"error": "annotations must be an array."}, status=status.HTTP_400_BAD_REQUEST) + + def text_value(value): + return str(value).strip() if value is not None else "" + + device_id = text_value(payload.get("device_id") or payload.get("deviceId")) + stream_id = text_value(payload.get("stream_id") or payload.get("streamId")) + stream_name = text_value(payload.get("stream_name") or payload.get("streamName") or payload.get("stream")) + view_key = text_value(payload.get("view_key") or payload.get("viewKey")) + + if not any([device_id, stream_id, stream_name, view_key]): + return Response( + {"error": "device_id, stream_id, stream_name, or view_key is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + validate_segment(artifact_id, "artifactId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + with manifest_write_lock(manifest_file): + manifest = read_manifest(manifest_file) + artifacts = manifest.get("artifacts") or [] + artifact = next((entry for entry in artifacts if entry.get("name") == artifact_id), None) + if not artifact: + raise NotFound("Artifact not found.") + + format_value = str(artifact.get("format") or "").lower() + base_root = media_library_root().resolve(strict=False) + file_path = _resolve_artifact_disk_path(artifact, base_root) + if not file_path or not file_path.exists() or not file_path.is_file(): + raise NotFound("Artifact file not found.") + if format_value != "json" and file_path.suffix.lower() != ".json": + return Response( + {"error": "Only JSON event artifacts can store view annotations."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + event_payload = json.loads(file_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return Response( + {"error": "Artifact file must contain valid JSON."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not isinstance(event_payload, dict): + return Response( + {"error": "Artifact JSON must be an object."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def build_reference_view_keys(reference): + reference_stream_name = text_value(reference.get("streamName") or reference.get("stream_name")) + reference_stream_id = text_value(reference.get("streamId") or reference.get("stream_id")) + reference_device_id = text_value( + reference.get("deviceId") or reference.get("device_id") or reference.get("activeDeviceId") + ) + keys = [] + if reference_stream_name: + keys.append(f"stream:{reference_stream_name}") + if reference_stream_id: + keys.append(f"stream-id:{reference_stream_id}") + if reference_device_id: + keys.append(f"device:{reference_device_id}") + existing_key = text_value(reference.get("annotationViewKey")) + if existing_key: + keys.append(existing_key) + return keys + + def match_score(reference): + score = 0 + reference_stream_id = text_value(reference.get("streamId") or reference.get("stream_id")) + reference_stream_name = text_value(reference.get("streamName") or reference.get("stream_name")) + reference_device_id = text_value( + reference.get("deviceId") or reference.get("device_id") or reference.get("activeDeviceId") + ) + if view_key and view_key in build_reference_view_keys(reference): + score += 16 + if stream_id and reference_stream_id == stream_id: + score += 8 + if stream_name and reference_stream_name == stream_name: + score += 6 + if device_id and reference_device_id == device_id: + score += 4 + return score + + best_key = None + best_index = -1 + best_score = 0 + for collection_key in ("mediaReferences", "media_references", "devices"): + collection = event_payload.get(collection_key) + if not isinstance(collection, list): + continue + for index, reference in enumerate(collection): + if not isinstance(reference, dict): + continue + score = match_score(reference) + if score > best_score: + best_key = collection_key + best_index = index + best_score = score + + if best_key is None or best_index < 0: + raise NotFound("Matching media reference view not found.") + + references = event_payload[best_key] + media_reference = dict(references[best_index]) + media_reference["annotations"] = annotations + media_reference["annotationsUpdatedAt"] = _now_iso() + if view_key: + media_reference["annotationViewKey"] = view_key + references[best_index] = media_reference + + temporary_path = file_path.with_name(f".{file_path.name}.{uuid4().hex}.tmp") + temporary_path.write_text(json.dumps(event_payload, indent=2), encoding="utf-8") + os.replace(temporary_path, file_path) + + return Response( + { + "annotations": annotations, + "eventPayload": event_payload, + "mediaReference": media_reference, + "updated": 1, + }, + status=status.HTTP_200_OK, + ) + + +class MediaArtifactDetailAPIView(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT") + def get(self, request, slug, project_id, package_id, artifact_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + validate_segment(artifact_id, "artifactId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + manifest = read_manifest(manifest_file) + artifacts = manifest.get("artifacts") or [] + if not artifacts: + raise NotFound("Artifact not found.") + + target = None + related = [] + for artifact in artifacts: + name = artifact.get("name") + if name == artifact_id: + target = artifact + link = artifact.get("link") + if link == artifact_id: + format_value = (artifact.get("format") or "").lower() + action_value = (artifact.get("action") or "").lower() + if format_value == "thumbnail" or action_value == "preview": + related.append(artifact) + + if not target: + raise NotFound("Artifact not found.") + + metadata = manifest.get("metadata") if isinstance(manifest, dict) else {} + payload = hydrate_artifacts_with_meta([target, *related], metadata) + return Response(payload, status=status.HTTP_200_OK) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="PROJECT") + def delete(self, request, slug, project_id, package_id, artifact_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + validate_segment(artifact_id, "artifactId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + removed_artifacts: list[dict] = [] + with manifest_write_lock(manifest_file): + manifest = read_manifest(manifest_file) + artifacts = manifest.get("artifacts") or [] + if not artifacts: + raise NotFound("Artifact not found.") + + related_names = {artifact_id} + for artifact in artifacts: + if artifact.get("link") == artifact_id and artifact.get("format") == "thumbnail": + name = artifact.get("name") + if name: + related_names.add(name) + + remaining_artifacts = [] + for artifact in artifacts: + if artifact.get("name") in related_names: + removed_artifacts.append(artifact) + else: + remaining_artifacts.append(artifact) + + if not removed_artifacts: + raise NotFound("Artifact not found.") + + manifest["artifacts"] = remaining_artifacts + manifest["updatedAt"] = _now_iso() + + metadata = manifest.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + used_refs: set[str] = set() + for artifact in remaining_artifacts: + metadata_ref = normalize_metadata_ref(artifact.get("metadata_ref")) or normalize_metadata_ref( + artifact.get("name") + ) + if metadata_ref: + used_refs.add(metadata_ref) + manifest["metadata"] = {key: value for key, value in metadata.items() if key in used_refs} + normalize_manifest_metadata(manifest) + write_manifest_atomic(manifest_file, manifest) + + base_root = media_library_root().resolve(strict=False) + for artifact in removed_artifacts: + resolved_path = _resolve_artifact_disk_path(artifact, base_root) + if resolved_path: + _delete_artifact_disk_path(resolved_path, artifact.get("name")) + meta = artifact.get("meta") if isinstance(artifact.get("meta"), dict) else {} + _delete_transcode_source(meta.get("transcode_source_path")) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class MediaArtifactTranscodeAPIView(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="PROJECT") + def post(self, request, slug, project_id, package_id, artifact_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + validate_segment(artifact_id, "artifactId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + manifest = read_manifest(manifest_file) + artifact = _find_manifest_artifact(manifest, artifact_id) + if not artifact: + raise NotFound("Artifact not found.") + artifact = _hydrate_manifest_artifact(manifest, artifact) + + artifact_format = _artifact_transcode_format(artifact) + if artifact_format not in _TRANSCODE_SOURCE_FORMATS: + return Response( + {"error": {"code": "SOURCE_UNSUPPORTED", "message": "Only MP4 uploads can be transcoded."}}, + status=status.HTTP_400_BAD_REQUEST, + ) + + existing_meta = artifact.get("meta") if isinstance(artifact.get("meta"), dict) else {} + existing_job_id = str(existing_meta.get("transcode_job_id") or "").strip() + if existing_job_id: + upstream_status, upstream_payload = _call_transcode_service("GET", f"/transcoding/jobs/{existing_job_id}") + if upstream_status < 400 and str(upstream_payload.get("status") or "") not in _TRANSCODE_TERMINAL_STATUSES: + return Response(upstream_payload, status=status.HTTP_200_OK) + + encoding_profile = str(request.data.get("encoding_profile") or "adaptive-1080p").strip() + generate_thumbnails = bool(request.data.get("generate_thumbnails", True)) + asset_id = _transcode_asset_id(project_id_str, package_id, artifact_id) + payload = { + "asset_id": asset_id, + "input_path": _build_transcode_input_path(artifact), + "encoding_profile": encoding_profile, + "generate_thumbnails": generate_thumbnails, + "workspace_slug": slug, + "project_id": project_id_str, + "package_id": package_id, + "artifact_id": artifact_id, + } + + upstream_status, upstream_payload = _call_transcode_service("POST", "/transcoding/jobs", payload) + if upstream_status >= 400: + return Response(upstream_payload, status=upstream_status) + + job_id = str(upstream_payload.get("job_id") or "").strip() + if job_id: + _update_artifact_transcode_meta( + project_id_str, + package_id, + artifact_id, + { + "transcode_asset_id": asset_id, + "transcode_job_id": job_id, + "transcode_status": upstream_payload.get("status") or "QUEUED", + "transcode_profile": encoding_profile, + }, + ) + return Response(upstream_payload, status=status.HTTP_201_CREATED) + + +class MediaArtifactTranscodeJobAPIView(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT") + def get(self, request, slug, project_id, package_id, artifact_id, job_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + validate_segment(artifact_id, "artifactId") + + upstream_status, upstream_payload = _call_transcode_service("GET", f"/transcoding/jobs/{job_id}") + if upstream_status >= 400: + return Response(upstream_payload, status=upstream_status) + + job_status = str(upstream_payload.get("status") or "") + meta_updates = { + "transcode_job_id": job_id, + "transcode_status": job_status, + "transcode_progress": upstream_payload.get("progress"), + } + artifact_updates = None + if job_status == "COMPLETED": + output = upstream_payload.get("output") if isinstance(upstream_payload.get("output"), dict) else {} + output_location = output.get("public_or_internal_url") or output.get("master_playlist_location") + playable_url = _normalize_transcode_output_url(output_location) + thumbnails = output.get("thumbnails") if isinstance(output.get("thumbnails"), dict) else {} + poster_url = _build_transcode_output_asset_url(playable_url, thumbnails.get("poster")) + meta_updates.update( + { + "transcode_completed_at": upstream_payload.get("completed_at"), + "hls_master_playlist": playable_url or output_location, + "hls_renditions": output.get("renditions"), + "hls_pending": False, + "poster": output.get("thumbnails"), + "poster_url": poster_url, + "thumbnail": poster_url, + "thumbnail_artifact_id": f"{artifact_id}-thumbnail" if poster_url else None, + "thumbnail_artifact_path": poster_url or None, + } + ) + if playable_url: + artifact_updates = {"path": playable_url, "format": "m3u8", "action": "play_hls"} + upstream_payload["playable_url"] = playable_url + elif job_status in {"FAILED", "CANCELLED"}: + error = upstream_payload.get("error") + if isinstance(error, dict): + meta_updates["transcode_error"] = error.get("message") or error.get("code") + + _update_artifact_transcode_meta(project_id_str, package_id, artifact_id, meta_updates, artifact_updates) + return Response(upstream_payload, status=status.HTTP_200_OK) + + +class MediaArtifactTranscodeJobRetryAPIView(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="PROJECT") + def post(self, request, slug, project_id, package_id, artifact_id, job_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + validate_segment(artifact_id, "artifactId") + + upstream_status, upstream_payload = _call_transcode_service("POST", f"/transcoding/jobs/{job_id}/retry", {}) + if upstream_status >= 400: + return Response(upstream_payload, status=upstream_status) + _update_artifact_transcode_meta( + project_id_str, + package_id, + artifact_id, + { + "transcode_job_id": job_id, + "transcode_status": upstream_payload.get("status") or "QUEUED", + "transcode_progress": upstream_payload.get("progress") or 0, + "transcode_error": None, + }, + ) + return Response(upstream_payload, status=status.HTTP_200_OK) + + +class MediaArtifactTranscodeJobCancelAPIView(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="PROJECT") + def post(self, request, slug, project_id, package_id, artifact_id, job_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + validate_segment(artifact_id, "artifactId") + + upstream_status, upstream_payload = _call_transcode_service("POST", f"/transcoding/jobs/{job_id}/cancel", {}) + if upstream_status >= 400: + return Response(upstream_payload, status=upstream_status) + _update_artifact_transcode_meta( + project_id_str, + package_id, + artifact_id, + { + "transcode_job_id": job_id, + "transcode_status": upstream_payload.get("status") or "CANCEL_REQUESTED", + "transcode_progress": upstream_payload.get("progress"), + }, + ) + return Response(upstream_payload, status=status.HTTP_200_OK) + + +class MediaTranscodeCallbackAPIView(BaseAPIView): + authentication_classes = [] + permission_classes = [] + + def post(self, request): + expected_token = (getattr(settings, "MEDIA_TRANSCODE_INTERNAL_API_TOKEN", "") or "").strip() + if expected_token: + auth_header = request.headers.get("authorization", "") + if auth_header != f"Bearer {expected_token}": + return Response( + {"error": {"code": "UNAUTHORIZED", "message": "Unauthorized."}}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + payload = request.data if isinstance(request.data, dict) else {} + project_id = str(payload.get("project_id") or "").strip() + package_id = str(payload.get("package_id") or "").strip() + artifact_id = str(payload.get("artifact_id") or "").strip() + job_id = str(payload.get("job_id") or "").strip() + if not (project_id and package_id and artifact_id and job_id): + return Response( + {"error": {"code": "INVALID_CALLBACK", "message": "project_id, package_id, artifact_id, and job_id are required."}}, + status=status.HTTP_400_BAD_REQUEST, + ) + validate_segment(project_id, "projectId") + validate_segment(package_id, "packageId") + validate_segment(artifact_id, "artifactId") + + raw_status = str(payload.get("status") or "").strip().upper() + output = payload.get("output_metadata") if isinstance(payload.get("output_metadata"), dict) else {} + meta_updates = { + "transcode_job_id": job_id, + "transcode_status": "FAILED" if raw_status == "FAILED" else "COMPLETED", + "transcode_progress": 100, + } + artifact_updates = None + + if raw_status == "FAILED": + error = payload.get("error") + if isinstance(error, dict): + meta_updates["transcode_error"] = error.get("message") or error.get("code") + else: + meta_updates["transcode_error"] = "Transcoding failed." + meta_updates["hls_pending"] = False + else: + output_location = ( + payload.get("hls_master_playlist") + or output.get("public_or_internal_url") + or output.get("master_playlist_location") + ) + playable_url = _normalize_transcode_output_url(output_location) + thumbnails = output.get("thumbnails") if isinstance(output.get("thumbnails"), dict) else {} + poster_url = _build_transcode_output_asset_url(playable_url, thumbnails.get("poster")) + meta_updates.update( + { + "transcode_completed_at": payload.get("completed_at") or output.get("completed_at"), + "hls_master_playlist": playable_url or output_location, + "hls_renditions": payload.get("renditions") or output.get("renditions"), + "hls_pending": False, + "poster": thumbnails, + "poster_url": poster_url, + "thumbnail": poster_url, + "thumbnail_artifact_id": f"{artifact_id}-thumbnail" if poster_url else None, + "thumbnail_artifact_path": poster_url or None, + "transcode_error": None, + } + ) + if playable_url: + artifact_updates = {"path": playable_url, "format": "m3u8", "action": "play_hls"} + + updated = _update_artifact_transcode_meta(project_id, package_id, artifact_id, meta_updates, artifact_updates) + return Response({"updated": updated}, status=status.HTTP_200_OK) + + +class MediaArtifactsListAPIView(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT") + def get(self, request, slug, project_id, package_id): + try: + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + manifest = read_manifest(manifest_file) + artifacts = manifest.get("artifacts", []) + metadata = manifest.get("metadata") if isinstance(manifest, dict) else {} + query = request.query_params.get("q") or "" + section = request.query_params.get("section") or "" + format_values = request.query_params.getlist("formats") + if not format_values: + format_param = request.query_params.get("formats") or "" + format_values = [entry.strip() for entry in format_param.split(",") if entry.strip()] + filters_raw = request.query_params.get("filters") + filters = None + if filters_raw: + try: + filters = json.loads(filters_raw) + except json.JSONDecodeError: + return Response( + {"error": "Filters must be valid JSON."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + artifacts = filter_media_library_artifacts( + artifacts, + query=query, + filters=filters, + section=section, + formats=format_values, + metadata=metadata, + ) + except Exception as exc: + log_exception(exc) + if "cursor" in request.query_params or "per_page" in request.query_params: + hydrated = hydrate_artifacts_with_meta(artifacts, metadata) + return self.paginate(request=request, paginator=ListPaginator(hydrated)) + return Response(hydrate_artifacts_with_meta(artifacts, metadata), status=status.HTTP_200_OK) + except Exception as exc: + log_exception(exc) + message = str(exc) if settings.DEBUG else "Something went wrong please try again later" + return Response({"error": message}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="PROJECT") + def post(self, request, slug, project_id, package_id): + handler_started_at = time.monotonic() + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + validate_segment(package_id, "packageId") + + manifest_file = manifest_path(project_id_str, package_id) + if not manifest_file.exists(): + raise NotFound("Manifest not found.") + + payload = request.data + file_obj = request.FILES.get("file") + is_bulk = isinstance(payload, list) or (isinstance(payload, dict) and "artifacts" in payload) + artifacts_payload = [] + file_path = None + transcode_source_file_path = None + transcode_source_storage_path = None + artifact_dir = None + should_transcode = False + thumbnail_name = None + thumbnail_path = None + thumbnail_relative_path = None + doc_thumbnail_name = None + doc_thumbnail_file_name = None + doc_thumbnail_path = None + doc_thumbnail_relative_path = None + doc_thumbnail_source = None + doc_thumbnail_action = None + image_thumbnail_name = None + image_thumbnail_file_name = None + image_thumbnail_path = None + image_thumbnail_relative_path = None + image_thumbnail_action = None + video_thumbnail_name = None + video_thumbnail_path = None + video_thumbnail_relative_path = None + video_thumbnail_action = None + transcode_job_response = None + transcode_job_error = None + timestamp = _now_iso() + trace_fields = _get_upload_trace_fields(request) + _log_media_upload_event( + logging.INFO, + "request_received", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + content_length=request.META.get("CONTENT_LENGTH"), + request_content_type=request.META.get("CONTENT_TYPE"), + file_name=getattr(file_obj, "name", None), + file_size=getattr(file_obj, "size", None), + file_type=getattr(file_obj, "content_type", None), + is_bulk=is_bulk, + ) + + if file_obj: + raw_name = file_obj.name or "artifact" + base_name = Path(raw_name).stem or "artifact" + extension = Path(raw_name).suffix.lstrip(".").lower() + if not extension: + return Response( + {"error": "File extension is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + format_value = (request.data.get("format") or extension).lower() + artifact_name = request.data.get("name") or base_name + validate_segment(artifact_name, "artifactId") + title = request.data.get("title") or base_name + primary_artifact_name = artifact_name + primary_title = title + link = request.data.get("link") + if isinstance(link, str) and link.strip().lower() in {"", "null"}: + link = None + work_item_id = request.data.get("work_item_id") + if isinstance(work_item_id, str) and not work_item_id.strip(): + work_item_id = None + + meta = request.data.get("meta") or {} + raw_metadata_ref = request.data.get("metadata_ref") or request.data.get("metadataRef") + metadata_ref = normalize_metadata_ref(raw_metadata_ref) + if raw_metadata_ref and not metadata_ref: + return Response( + {"error": "metadata_ref must be a valid identifier."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(meta, str): + try: + meta = json.loads(meta) + except json.JSONDecodeError: + return Response( + {"error": "Meta must be valid JSON."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if meta is None: + meta = {} + trace_fields = {**trace_fields, **_get_upload_trace_fields(request, meta)} + for key in ("upload_id", "request_id", "upload_client"): + if trace_fields.get(key): + meta.setdefault(key, trace_fields[key]) + + created_at = request.data.get("created_at") or timestamp + updated_at = request.data.get("updated_at") or created_at + primary_created_at = created_at + primary_updated_at = updated_at + artifacts_root = package_root(project_id_str, package_id) / "artifacts" + attachment_root = package_root(project_id_str, package_id) / "attachment" + is_video_upload = format_value in _VIDEO_FORMATS or extension in _VIDEO_FORMATS + is_transcode_source_upload = extension in _TRANSCODE_SOURCE_FORMATS + # Keep uploaded videos in their original format; do not auto-transcode to HLS on upload. + should_transcode = False + if should_transcode: + if shutil.which("ffmpeg") is None: + return Response( + {"error": "ffmpeg is not installed. Install ffmpeg or upload a non-video file."}, + status=status.HTTP_400_BAD_REQUEST, + ) + artifact_dir = artifacts_root / primary_artifact_name + artifact_file_name = "index.m3u8" + file_path = artifact_dir / artifact_file_name + relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{primary_artifact_name}/{artifact_file_name}" + ) + thumbnail_name = f"{primary_artifact_name}-thumbnail" + thumbnail_path = artifact_dir / "thumbnail.webp" + thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{primary_artifact_name}/thumbnail.webp" + ) + meta.setdefault("source_format", extension) + meta.setdefault("hls", True) + else: + if is_transcode_source_upload: + asset_id = _transcode_asset_id(project_id_str, package_id, primary_artifact_name) + transcode_source_storage_path, transcode_source_file_path = _build_transcode_source_paths( + asset_id, + extension, + ) + relative_path = _normalize_transcode_output_url(f"media/{asset_id}/master.m3u8") + meta.setdefault("source_format", extension) + meta.setdefault("original_filename", raw_name) + meta.setdefault("source_file_size", file_obj.size) + meta["transcode_source_path"] = transcode_source_storage_path + meta.setdefault("transcode_status", "UPLOAD_COMPLETE") + meta.setdefault("hls_pending", True) + format_value = "m3u8" + else: + artifact_file_name = f"{artifact_name}.{extension}" + file_path = artifacts_root / artifact_file_name + relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{artifact_file_name}" + ) + if not is_transcode_source_upload and format_value in _VIDEO_FORMATS and format_value != "m3u8": + video_thumbnail_name = f"{primary_artifact_name}-thumbnail" + video_thumbnail_file_name = f"{primary_artifact_name}-thumbnail.webp" + video_thumbnail_path = artifacts_root / video_thumbnail_file_name + video_thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{video_thumbnail_file_name}" + ) + video_thumbnail_action = "preview" + if format_value in _IMAGE_FORMATS and format_value != "thumbnail": + image_thumbnail_name = f"{primary_artifact_name}-thumbnail" + image_thumbnail_file_name = f"{primary_artifact_name}-thumbnail.webp" + image_thumbnail_path = artifacts_root / image_thumbnail_file_name + image_thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{image_thumbnail_file_name}" + ) + image_thumbnail_action = "view" + if format_value not in _VIDEO_FORMATS and format_value not in _IMAGE_FORMATS: + thumbnail_hint = get_document_thumbnail_hint(format_value, meta) + doc_thumbnail_source = get_document_icon_source(format_value, thumbnail_hint) + if doc_thumbnail_source: + doc_thumbnail_name = f"{primary_artifact_name}-thumb" + doc_thumbnail_file_name = None + if isinstance(thumbnail_hint, str): + hint_name = Path(thumbnail_hint).name + if hint_name: + doc_thumbnail_file_name = f"{Path(hint_name).stem}.webp" + if not doc_thumbnail_file_name: + doc_thumbnail_file_name = f"{primary_artifact_name}-thumbnail.webp" + doc_thumbnail_path = attachment_root / doc_thumbnail_file_name + doc_thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/attachment/{doc_thumbnail_file_name}" + ) + + action = request.data.get("action") + if is_transcode_source_upload: + action = "play_hls" + if not action: + if format_value in _VIDEO_FORMATS: + action = "play" + elif format_value in _IMAGE_FORMATS: + action = "view" + else: + action = "download" + if doc_thumbnail_name: + doc_thumbnail_action = "open_pdf" if format_value == "pdf" else action + primary_metadata_ref = metadata_ref or artifact_name + primary_entry = { + "name": artifact_name, + "title": title, + "description": _default_artifact_description(title), + "format": format_value, + "path": relative_path, + "link": link, + "action": action, + "metadata_ref": primary_metadata_ref, + "meta": meta, + "created_at": created_at, + "updated_at": updated_at, + } + if work_item_id is not None: + primary_entry["work_item_id"] = work_item_id + artifacts_payload = [primary_entry] + is_bulk = False + elif isinstance(payload, list): + artifacts_payload = payload + elif isinstance(payload, dict) and "artifacts" in payload: + artifacts_payload = payload.get("artifacts") or [] + elif isinstance(payload, dict): + artifacts_payload = [payload] + + if not artifacts_payload: + return Response({"error": "Artifacts payload required."}, status=status.HTTP_400_BAD_REQUEST) + + prepared_payload = [] + for artifact in artifacts_payload: + if not isinstance(artifact, dict): + return Response({"error": "Each artifact must be an object."}, status=status.HTTP_400_BAD_REQUEST) + entry = artifact.copy() + if "metadata_ref" not in entry and "metadataRef" in entry: + entry["metadata_ref"] = entry.pop("metadataRef") + if entry.get("format") == "thumbnail": + entry.pop("description", None) + elif not entry.get("description"): + title_value = entry.get("title") or "Uploaded file" + entry["description"] = _default_artifact_description(title_value) + elif isinstance(entry.get("description"), str): + entry["description"] = _ensure_description_image_sources( + entry.get("description"), + slug=slug, + project_id=project_id_str, + ) + if not entry.get("created_at"): + entry["created_at"] = timestamp + if not entry.get("updated_at"): + entry["updated_at"] = entry["created_at"] + if not entry.get("metadata_ref") and entry.get("format") == "thumbnail": + link_ref = normalize_metadata_ref(entry.get("link")) + if link_ref: + entry["metadata_ref"] = link_ref + prepared_payload.append(entry) + + _log_media_upload_event( + logging.INFO, + "metadata_validation_started", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + artifact_count=len(prepared_payload), + ) + serializer = MediaArtifactSerializer(data=prepared_payload, many=True) + serializer.is_valid(raise_exception=True) + validated_artifacts = serializer.validated_data + for artifact in validated_artifacts: + if not artifact.get("metadata_ref"): + artifact["metadata_ref"] = artifact.get("name") + _log_media_upload_event( + logging.INFO, + "metadata_validation_completed", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + artifact_count=len(validated_artifacts), + ) + + manifest = read_manifest(manifest_file) + existing_artifacts = manifest.get("artifacts") or [] + existing_names = {artifact.get("name") for artifact in existing_artifacts if artifact.get("name")} + incoming_names = set() + for artifact in validated_artifacts: + artifact_name = artifact.get("name") + validate_segment(artifact_name, "artifactId") + if artifact_name in existing_names: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + if artifact_name in incoming_names: + return Response( + {"error": "Duplicate artifact name in request."}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming_names.add(artifact_name) + + if thumbnail_name: + validate_segment(thumbnail_name, "artifactId") + if thumbnail_name in existing_names: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + if thumbnail_name in incoming_names: + return Response( + {"error": "Duplicate artifact name in request."}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming_names.add(thumbnail_name) + if doc_thumbnail_name: + validate_segment(doc_thumbnail_name, "artifactId") + if doc_thumbnail_name in existing_names: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + if doc_thumbnail_name in incoming_names: + return Response( + {"error": "Duplicate artifact name in request."}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming_names.add(doc_thumbnail_name) + if image_thumbnail_name: + validate_segment(image_thumbnail_name, "artifactId") + if image_thumbnail_name in existing_names: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + if image_thumbnail_name in incoming_names: + return Response( + {"error": "Duplicate artifact name in request."}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming_names.add(image_thumbnail_name) + if video_thumbnail_name: + validate_segment(video_thumbnail_name, "artifactId") + if video_thumbnail_name in existing_names: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + if video_thumbnail_name in incoming_names: + return Response( + {"error": "Duplicate artifact name in request."}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming_names.add(video_thumbnail_name) + + if file_obj and transcode_source_file_path: + if transcode_source_file_path.exists(): + return Response({"error": "Artifact source file already exists."}, status=status.HTTP_409_CONFLICT) + transcode_source_file_path.parent.mkdir(parents=True, exist_ok=True) + uploading_path = transcode_source_file_path.with_name(f"{transcode_source_file_path.name}.uploading") + if uploading_path.exists(): + return Response({"error": "Artifact source file upload already in progress."}, status=status.HTTP_409_CONFLICT) + try: + file_write_started_at = time.monotonic() + bytes_written = 0 + _log_media_upload_event( + logging.INFO, + "file_write_started", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + artifact_name=primary_artifact_name, + file_name=raw_name, + file_size=getattr(file_obj, "size", None), + file_type=getattr(file_obj, "content_type", None), + source_file_name=transcode_source_file_path.name, + ) + with open(uploading_path, "wb") as handle: + for chunk in file_obj.chunks(): + bytes_written += len(chunk) + handle.write(chunk) + os.replace(uploading_path, transcode_source_file_path) + _log_media_upload_event( + logging.INFO, + "file_write_completed", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + artifact_name=primary_artifact_name, + bytes_written=bytes_written, + duration_ms=int((time.monotonic() - file_write_started_at) * 1000), + source_file_name=transcode_source_file_path.name, + ) + finally: + try: + uploading_path.unlink(missing_ok=True) + except OSError: + pass + + primary_artifact = validated_artifacts[0] if validated_artifacts else None + primary_meta = primary_artifact.get("meta") if isinstance(primary_artifact, dict) else None + if not isinstance(primary_meta, dict): + primary_meta = {} + if isinstance(primary_artifact, dict): + primary_artifact["meta"] = primary_meta + if isinstance(primary_artifact, dict): + asset_id = _transcode_asset_id(project_id_str, package_id, primary_artifact_name) + transcode_payload = { + "asset_id": asset_id, + "input_path": transcode_source_storage_path, + "encoding_profile": "adaptive-1080p", + "generate_thumbnails": True, + "workspace_slug": slug, + "project_id": project_id_str, + "package_id": package_id, + "artifact_id": primary_artifact_name, + } + for key in ("upload_id", "request_id", "upload_client"): + if trace_fields.get(key): + transcode_payload[key] = trace_fields[key] + transcode_enqueue_started_at = time.monotonic() + _log_media_upload_event( + logging.INFO, + "transcode_enqueue_started", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + artifact_name=primary_artifact_name, + transcode_asset_id=asset_id, + transcode_profile="adaptive-1080p", + ) + upstream_status, upstream_payload = _call_transcode_service("POST", "/transcoding/jobs", transcode_payload) + if upstream_status < 400: + transcode_job_response = upstream_payload + primary_meta.update( + { + "transcode_asset_id": asset_id, + "transcode_job_id": upstream_payload.get("job_id"), + "transcode_status": upstream_payload.get("status") or "QUEUED", + "transcode_profile": "adaptive-1080p", + "transcode_progress": upstream_payload.get("progress") or 0, + "hls_pending": True, + "transcode_error": None, + } + ) + _log_media_upload_event( + logging.INFO, + "transcode_enqueue_completed", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + artifact_name=primary_artifact_name, + transcode_asset_id=asset_id, + transcode_job_id=upstream_payload.get("job_id"), + transcode_status=upstream_payload.get("status") or "QUEUED", + upstream_status=upstream_status, + duration_ms=int((time.monotonic() - transcode_enqueue_started_at) * 1000), + ) + else: + transcode_job_error = upstream_payload + error_payload = upstream_payload.get("error") if isinstance(upstream_payload, dict) else None + error_message = None + if isinstance(error_payload, dict): + error_message = error_payload.get("message") or error_payload.get("code") + primary_meta.update( + { + "transcode_asset_id": asset_id, + "transcode_status": "QUEUE_FAILED", + "transcode_profile": "adaptive-1080p", + "transcode_error": error_message or "Transcoding job could not be queued.", + "hls_pending": True, + } + ) + _log_media_upload_event( + logging.WARNING, + "transcode_enqueue_failed", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + artifact_name=primary_artifact_name, + transcode_asset_id=asset_id, + upstream_status=upstream_status, + duration_ms=int((time.monotonic() - transcode_enqueue_started_at) * 1000), + error=error_message or "Transcoding job could not be queued.", + ) + elif file_obj and file_path: + if should_transcode: + if not artifact_dir: + return Response({"error": "Artifact directory missing."}, status=status.HTTP_400_BAD_REQUEST) + if artifact_dir.exists(): + return Response({"error": "Artifact file already exists."}, status=status.HTTP_409_CONFLICT) + try: + _, created_thumbnail = transcode_mp4_to_hls( + file_obj, + artifact_dir, + thumbnail_path=thumbnail_path, + ) + except FileExistsError: + return Response({"error": "Artifact file already exists."}, status=status.HTTP_409_CONFLICT) + except MediaLibraryTranscodeError as exc: + return Response( + {"error": str(exc)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + if created_thumbnail and thumbnail_relative_path and thumbnail_name: + thumbnail_entry = { + "name": thumbnail_name, + "title": f"{primary_title}", + "format": "thumbnail", + "path": thumbnail_relative_path, + "link": primary_artifact_name, + "action": "preview", + "metadata_ref": primary_metadata_ref, + "created_at": primary_created_at, + "updated_at": primary_updated_at, + } + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + else: + if file_path.exists(): + return Response({"error": "Artifact file already exists."}, status=status.HTTP_409_CONFLICT) + file_path.parent.mkdir(parents=True, exist_ok=True) + file_write_started_at = time.monotonic() + bytes_written = 0 + _log_media_upload_event( + logging.INFO, + "file_write_started", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + artifact_name=primary_artifact_name, + file_name=raw_name, + file_size=getattr(file_obj, "size", None), + file_type=getattr(file_obj, "content_type", None), + source_file_name=file_path.name, + ) + with open(file_path, "wb") as handle: + for chunk in file_obj.chunks(): + bytes_written += len(chunk) + handle.write(chunk) + _log_media_upload_event( + logging.INFO, + "file_write_completed", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + artifact_name=primary_artifact_name, + bytes_written=bytes_written, + duration_ms=int((time.monotonic() - file_write_started_at) * 1000), + source_file_name=file_path.name, + ) + # Always rewrite uploaded MP4/M4V with +faststart so playback starts quickly. + _ensure_mp4_faststart(file_path, primary_artifact_name, force=True) + if video_thumbnail_name and video_thumbnail_path and video_thumbnail_relative_path: + if shutil.which("ffmpeg") is None: + logger.error("ffmpeg is not installed. Skipping video thumbnail for %s.", primary_artifact_name) + else: + created_thumbnail = _create_video_thumbnail(file_path, video_thumbnail_path) + if created_thumbnail: + thumbnail_entry = { + "name": video_thumbnail_name, + "title": f"{primary_title}", + "format": "thumbnail", + "path": video_thumbnail_relative_path, + "link": primary_artifact_name, + "action": video_thumbnail_action or "preview", + "metadata_ref": primary_metadata_ref, + "created_at": primary_created_at, + "updated_at": primary_updated_at, + } + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + if image_thumbnail_name and image_thumbnail_relative_path: + max_bytes = getattr(settings, "MEDIA_LIBRARY_THUMBNAIL_MAX_BYTES", 51200) + thumbnail_relative_path = image_thumbnail_relative_path + use_existing = False + try: + if file_path.suffix.lower() == ".webp" and file_path.stat().st_size <= max_bytes: + thumbnail_relative_path = relative_path + use_existing = True + except OSError: + pass + if not use_existing: + if not (image_thumbnail_path and generate_thumbnail(file_path, image_thumbnail_path, seek=None)): + thumbnail_relative_path = None + if thumbnail_relative_path: + thumbnail_entry = { + "name": image_thumbnail_name, + "title": f"{primary_title}", + "format": "thumbnail", + "path": thumbnail_relative_path, + "link": primary_artifact_name, + "action": image_thumbnail_action or "view", + "metadata_ref": primary_metadata_ref, + "created_at": primary_created_at, + "updated_at": primary_updated_at, + } + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + if doc_thumbnail_name and doc_thumbnail_relative_path and doc_thumbnail_source and doc_thumbnail_path: + if generate_thumbnail(doc_thumbnail_source, doc_thumbnail_path, seek=None): + thumbnail_entry = { + "name": doc_thumbnail_name, + "title": f"{primary_title}", + "format": "thumbnail", + "path": doc_thumbnail_relative_path, + "link": primary_artifact_name, + "action": doc_thumbnail_action or "download", + "metadata_ref": primary_metadata_ref, + "created_at": primary_created_at, + "updated_at": primary_updated_at, + } + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + + if not file_obj: + artifacts_root = package_root(project_id_str, package_id) / "artifacts" + attachment_root = package_root(project_id_str, package_id) / "attachment" + for artifact in list(validated_artifacts): + format_value = str(artifact.get("format") or "").lower() + if format_value == "thumbnail": + continue + action_value = str(artifact.get("action") or "").lower() + is_video = ( + format_value in _VIDEO_FORMATS + or format_value == "stream" + or action_value in {"play_streaming", "play_hls", "play", "open_mp4"} + ) + raw_path = artifact.get("path") or "" + + if is_video: + if not isinstance(raw_path, str) or not raw_path.startswith(("http://", "https://", "/")): + continue + thumbnail_name = f"{artifact.get('name')}-thumbnail" + validate_segment(thumbnail_name, "artifactId") + if thumbnail_name in existing_names or thumbnail_name in incoming_names: + continue + if shutil.which("ffmpeg") is None: + logger.error("ffmpeg is not installed. Skipping video thumbnail for %s.", artifact.get("name")) + continue + source_urls = _resolve_external_video_sources(raw_path, request, project_id_str) + if not source_urls: + continue + artifacts_root.mkdir(parents=True, exist_ok=True) + thumbnail_file_name = f"{artifact.get('name')}-thumbnail.webp" + thumbnail_path = artifacts_root / thumbnail_file_name + created_thumbnail = False + for source_url in source_urls: + try: + if thumbnail_path.exists(): + thumbnail_path.unlink() + except OSError: + pass + if _create_video_thumbnail_from_source(source_url, thumbnail_path): + created_thumbnail = True + break + if not created_thumbnail: + try: + if thumbnail_path.exists(): + thumbnail_path.unlink() + except OSError: + pass + created_thumbnail = _create_media_fallback_thumbnail(format_value, thumbnail_path) + if not created_thumbnail: + continue + thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{thumbnail_file_name}" + ) + thumbnail_entry = { + "name": thumbnail_name, + "title": artifact.get("title") or "Video thumbnail", + "format": "thumbnail", + "path": thumbnail_relative_path, + "link": artifact.get("name"), + "action": "preview", + "metadata_ref": artifact.get("metadata_ref") or artifact.get("name"), + "created_at": artifact.get("created_at") or timestamp, + "updated_at": artifact.get("updated_at") or artifact.get("created_at") or timestamp, + } + work_item_id = artifact.get("work_item_id") + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + incoming_names.add(thumbnail_name) + continue + + if format_value in _IMAGE_FORMATS: + raw_path = artifact.get("path") or "" + if not isinstance(raw_path, str) or not raw_path: + continue + thumbnail_name = f"{artifact.get('name')}-thumbnail" + validate_segment(thumbnail_name, "artifactId") + if thumbnail_name in existing_names or thumbnail_name in incoming_names: + continue + artifacts_root.mkdir(parents=True, exist_ok=True) + thumbnail_file_name = f"{artifact.get('name')}-thumbnail.webp" + thumbnail_path = artifacts_root / thumbnail_file_name + max_bytes = getattr(settings, "MEDIA_LIBRARY_THUMBNAIL_MAX_BYTES", 51200) + + resolved_path = _resolve_artifact_disk_path(artifact, media_library_root()) + created_thumbnail = False + thumbnail_relative_path = None + + if resolved_path and resolved_path.exists(): + try: + if ( + resolved_path.suffix.lower() == ".webp" + and resolved_path.stat().st_size <= max_bytes + ): + thumbnail_relative_path = raw_path + created_thumbnail = True + except OSError: + pass + if not created_thumbnail: + if generate_thumbnail(resolved_path, thumbnail_path, seek=None): + created_thumbnail = True + else: + source_urls = _resolve_external_video_sources(raw_path, request, project_id_str) + for source_url in source_urls: + try: + if thumbnail_path.exists(): + thumbnail_path.unlink() + except OSError: + pass + if generate_thumbnail(source_url, thumbnail_path, seek=None): + created_thumbnail = True + break + + if not created_thumbnail: + try: + if thumbnail_path.exists(): + thumbnail_path.unlink() + except OSError: + pass + created_thumbnail = _create_media_fallback_thumbnail(format_value, thumbnail_path) + if not created_thumbnail: + continue + if not thumbnail_relative_path: + thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/artifacts/{thumbnail_file_name}" + ) + thumbnail_entry = { + "name": thumbnail_name, + "title": artifact.get("title") or "Image thumbnail", + "format": "thumbnail", + "path": thumbnail_relative_path, + "link": artifact.get("name"), + "action": "view", + "metadata_ref": artifact.get("metadata_ref") or artifact.get("name"), + "created_at": artifact.get("created_at") or timestamp, + "updated_at": artifact.get("updated_at") or artifact.get("created_at") or timestamp, + } + work_item_id = artifact.get("work_item_id") + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + incoming_names.add(thumbnail_name) + continue + + thumbnail_name = f"{artifact.get('name')}-thumb" + validate_segment(thumbnail_name, "artifactId") + if thumbnail_name in existing_names or thumbnail_name in incoming_names: + continue + meta_value = artifact.get("meta") + thumbnail_hint = get_document_thumbnail_hint(format_value, meta_value) + doc_thumbnail_source = get_document_icon_source(format_value, thumbnail_hint) + if not doc_thumbnail_source: + continue + doc_thumbnail_file_name = None + if isinstance(thumbnail_hint, str): + hint_name = Path(thumbnail_hint).name + if hint_name: + doc_thumbnail_file_name = f"{Path(hint_name).stem}.webp" + if not doc_thumbnail_file_name: + doc_thumbnail_file_name = f"{artifact.get('name')}-thumbnail.webp" + doc_thumbnail_path = attachment_root / doc_thumbnail_file_name + if not generate_thumbnail(doc_thumbnail_source, doc_thumbnail_path, seek=None): + continue + doc_thumbnail_relative_path = ( + f"projects/{project_id_str}/packages/{package_id}/attachment/{doc_thumbnail_file_name}" + ) + thumbnail_entry = { + "name": thumbnail_name, + "title": artifact.get("title") or "Document thumbnail", + "format": "thumbnail", + "path": doc_thumbnail_relative_path, + "link": artifact.get("name"), + "action": "open_pdf" if format_value == "pdf" else action_value or "download", + "metadata_ref": artifact.get("metadata_ref") or artifact.get("name"), + "created_at": artifact.get("created_at") or timestamp, + "updated_at": artifact.get("updated_at") or artifact.get("created_at") or timestamp, + } + work_item_id = artifact.get("work_item_id") + if work_item_id is not None: + thumbnail_entry["work_item_id"] = work_item_id + thumbnail_serializer = MediaArtifactSerializer(data=thumbnail_entry) + thumbnail_serializer.is_valid(raise_exception=True) + validated_artifacts.append(thumbnail_serializer.validated_data) + incoming_names.add(thumbnail_name) + + manifest_update_started_at = time.monotonic() + _log_media_upload_event( + logging.INFO, + "manifest_update_started", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + artifact_count=len(validated_artifacts), + ) + with manifest_write_lock(manifest_file): + manifest = read_manifest(manifest_file) + existing_artifacts = manifest.get("artifacts") or [] + existing_names = {artifact.get("name") for artifact in existing_artifacts if artifact.get("name")} + for artifact in validated_artifacts: + artifact_name = artifact.get("name") + if artifact_name in existing_names: + return Response({"error": "Artifact already exists."}, status=status.HTTP_409_CONFLICT) + existing_names.add(artifact_name) + artifacts_for_manifest = [artifact.copy() for artifact in validated_artifacts] + existing_artifacts.extend(artifacts_for_manifest) + manifest["artifacts"] = existing_artifacts + manifest["updatedAt"] = _now_iso() + normalize_manifest_metadata(manifest) + write_manifest_atomic(manifest_file, manifest) + _log_media_upload_event( + logging.INFO, + "manifest_update_completed", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + artifact_count=len(validated_artifacts), + duration_ms=int((time.monotonic() - manifest_update_started_at) * 1000), + ) + + response_payload = validated_artifacts if is_bulk else validated_artifacts[0] + if not is_bulk and isinstance(response_payload, dict): + if transcode_job_response: + response_payload = {**response_payload, "transcode_job": transcode_job_response} + elif transcode_job_error: + response_payload = {**response_payload, "transcode_job_error": transcode_job_error} + _log_media_upload_event( + logging.INFO, + "response_returned", + trace_fields, + workspace_slug=slug, + project_id=project_id_str, + package_id=package_id, + status_code=status.HTTP_201_CREATED, + handler_status="success", + artifact_count=len(validated_artifacts), + transcode_job_id=transcode_job_response.get("job_id") if isinstance(transcode_job_response, dict) else None, + duration_ms=int((time.monotonic() - handler_started_at) * 1000), + ) + return Response(response_payload, status=status.HTTP_201_CREATED) + + +_DOC_FORMATS = {"json", "csv", "pdf", "docx", "xlsx", "pptx", "txt"} +_FALSEY_VALUES = {"", "0", "false", "no", "off"} +_ARTIFACT_SEGMENT_RE = re.compile(r"[^a-z0-9_-]+") +_IMAGE_TAG_RE = re.compile(r"<(?:img|image-component)\b[^>]*>", re.IGNORECASE) +_IMAGE_ATTR_RE = re.compile( + r"(?:src|data-src|data-source)\s*=\s*(?:\"([^\"]+)\"|'([^']+)'|([^\s>]+))", + re.IGNORECASE, +) +_IMAGE_ID_ATTR_RE = re.compile( + r"\bid\s*=\s*(?:\"([^\"]+)\"|'([^']+)'|([^\s>]+))", + re.IGNORECASE, +) +_ARTIFACT_ACTION_CHOICES = { + "play", + "stream", + "view", + "download", + "preview", + "edit", + "navigate", + "play_hls", + "open_mp4", + "open_pdf", + "attach_captions", +} +_SUPPORTED_ARTIFACT_FORMATS = _IMAGE_FORMATS | _VIDEO_FORMATS | _DOC_FORMATS + + +def _coerce_bool(value: object, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() not in _FALSEY_VALUES + return bool(value) + + +def _sanitize_artifact_segment(value: str) -> str: + normalized = (value or "").strip().lower() + normalized = _ARTIFACT_SEGMENT_RE.sub("-", normalized) + return normalized.strip("-") + + +def _build_artifact_name(file_name: str, source_id: str) -> str: + base_name = _sanitize_artifact_segment(Path(file_name).stem or "attachment") + suffix = _sanitize_artifact_segment(source_id) or uuid4().hex[:8] + return f"{base_name}-{suffix}" if base_name else f"attachment-{suffix}" + + +def _resolve_artifact_format_from_name(file_name: str) -> str: + extension = Path(file_name or "").suffix.lstrip(".").lower() + if extension in _IMAGE_FORMATS or extension in _VIDEO_FORMATS or extension in _DOC_FORMATS: + return extension + return "" + + +def _resolve_artifact_action(format_value: str) -> str: + normalized = (format_value or "").lower() + if normalized in _VIDEO_FORMATS: + return "play" + if normalized in _IMAGE_FORMATS: + return "view" + return "download" + + +def _resolve_attachment_file_name(asset: FileAsset) -> str: + attributes = asset.attributes if isinstance(asset.attributes, dict) else {} + name = attributes.get("name") + if isinstance(name, str) and name.strip(): + return name.strip() + return f"attachment-{asset.id}" + + +def _resolve_payload_media_path(raw_path: object, request, slug: str, project_id: str) -> str: + if not isinstance(raw_path, str): + return "" + path = raw_path.strip() + if not path: + return "" + if path.startswith(("http://", "https://")): + return path + if "/" not in path: + try: + UUID(path) + return request.build_absolute_uri( + f"/api/assets/v2/workspaces/{slug}/projects/{project_id}/{path}/" + ) + except Exception: + return path + if path.startswith("/"): + try: + return request.build_absolute_uri(path) + except Exception: + return path + return path + + +def _resolve_payload_media_asset(path_value: str, slug: str, project_id: str) -> FileAsset | None: + candidate = (path_value or "").strip() + if not candidate: + return None + asset_id = _extract_asset_id_from_url(candidate) + if not asset_id: + try: + asset_id = str(UUID(candidate)) + except ValueError: + asset_id = None + if not asset_id: + return None + return FileAsset.objects.filter( + id=asset_id, + workspace__slug=slug, + project_id=project_id, + is_deleted=False, + is_uploaded=True, + ).first() + + +def _normalize_inline_source(value: str) -> str: + if not isinstance(value, str): + return "" + trimmed = value.strip() + if not trimmed: + return "" + if trimmed.startswith(("data:", "blob:")): + return "" + try: + parsed = urlparse(trimmed) + return parsed._replace(query="", fragment="").geturl() + except ValueError: + return trimmed + + +def _extract_tag_attr_value(tag: str, pattern: re.Pattern[str]) -> str: + if not isinstance(tag, str) or not tag: + return "" + match = pattern.search(tag) + if not match: + return "" + raw_value = next((group for group in match.groups() if group), "") + return unescape((raw_value or "").strip()) + + +def _resolve_asset_uuid(value: object) -> str | None: + if not isinstance(value, str): + return None + candidate = value.strip() + if not candidate: + return None + try: + return str(UUID(candidate)) + except (TypeError, ValueError): + return None + + +def _asset_exists_for_project(asset_id: str, slug: str, project_id: str) -> bool: + return FileAsset.objects.filter( + id=asset_id, + workspace__slug=slug, + project_id=project_id, + is_deleted=False, + is_uploaded=True, + ).exists() + + +def _ensure_description_image_sources(description_html: str | None, slug: str, project_id: str) -> str | None: + if not isinstance(description_html, str) or not description_html: + return description_html + + asset_exists_cache: dict[str, bool] = {} + + def _replace_tag(match: re.Match[str]) -> str: + tag = match.group(0) + if _extract_tag_attr_value(tag, _IMAGE_ATTR_RE): + return tag + + tag_id = _extract_tag_attr_value(tag, _IMAGE_ID_ATTR_RE) + asset_id = _resolve_asset_uuid(tag_id) + if not asset_id: + return tag + + exists = asset_exists_cache.get(asset_id) + if exists is None: + exists = _asset_exists_for_project(asset_id, slug, project_id) + asset_exists_cache[asset_id] = exists + if not exists: + return tag + + insertion = f' src="{asset_id}"' + if tag.endswith("/>"): + return f"{tag[:-2]}{insertion}/>" + if tag.endswith(">"): + return f"{tag[:-1]}{insertion}>" + return tag + + normalized = _IMAGE_TAG_RE.sub(_replace_tag, description_html) + return normalized + + +def _extract_description_image_sources( + description_html: str | None, slug: str | None = None, project_id: str | None = None +) -> list[str]: + if not isinstance(description_html, str) or not description_html: + return [] + sources: list[str] = [] + seen: set[str] = set() + asset_exists_cache: dict[str, bool] = {} + for tag_match in _IMAGE_TAG_RE.finditer(description_html): + tag = tag_match.group(0) + source = _extract_tag_attr_value(tag, _IMAGE_ATTR_RE) + if not source: + tag_id = _extract_tag_attr_value(tag, _IMAGE_ID_ATTR_RE) + asset_id = _resolve_asset_uuid(tag_id) + if asset_id and slug and project_id: + exists = asset_exists_cache.get(asset_id) + if exists is None: + exists = _asset_exists_for_project(asset_id, slug, project_id) + asset_exists_cache[asset_id] = exists + if exists: + source = asset_id + if not source: + continue + key = source.lower() + if key in seen: + continue + seen.add(key) + sources.append(source) + return sources + + +def _resolve_file_name_from_url(value: str, fallback: str) -> str: + if not value: + return fallback + try: + parsed = urlparse(value) + candidate = Path(parsed.path).name + except ValueError: + candidate = Path(value).name + return candidate or fallback + + +def _resolve_format_from_mime(mime_type: str) -> str: + if not isinstance(mime_type, str) or not mime_type: + return "" + normalized = mime_type.lower().strip() + if normalized.startswith("image/"): + subtype = normalized.split("/", 1)[1] + return "svg" if subtype == "svg+xml" else subtype + if normalized.startswith("video/"): + return normalized.split("/", 1)[1] + if normalized == "application/pdf": + return "pdf" + return "" + + +def _resolve_issue_created_by_label(issue: Issue) -> str: + created_by = getattr(issue, "created_by", None) + if not created_by: + return "" + display_name = getattr(created_by, "display_name", "") or "" + if isinstance(display_name, str) and display_name.strip(): + if "-intake" in display_name: + return "Plane" + return display_name.strip() + email = getattr(created_by, "email", "") or "" + if isinstance(email, str): + return email.strip() + return "" + + +def _create_media_fallback_thumbnail(format_value: str, thumbnail_path: Path) -> bool: + normalized = (format_value or "").lower() + hint = "attachment/default-icon.png" + if normalized in _VIDEO_FORMATS or normalized in {"stream", "m3u8"}: + hint = "attachment/video-icon.png" + elif normalized in _IMAGE_FORMATS: + hint = "attachment/img-icon.png" + source = get_document_icon_source(normalized or "default", hint) + if not source: + source = get_document_icon_source("default") + if not source: + return False + return generate_thumbnail(source, thumbnail_path, seek=None) + + +def _serialize_issue_start_time(issue: Issue) -> str | None: + start_time = getattr(issue, "start_time", None) + if not start_time: + return None + try: + value = start_time.replace(microsecond=0).isoformat() + except Exception: + value = str(start_time) + if value.endswith("+00:00"): + value = value.replace("+00:00", "Z") + return value + + +def _build_issue_artifact_meta(issue: Issue, source: str) -> dict: + meta: dict = { + "category": getattr(issue, "category", None) or "Work items", + "source": source, + "work_item_id": str(issue.id), + } + created_by = _resolve_issue_created_by_label(issue) + if created_by: + meta["created_by"] = created_by + if getattr(issue, "start_date", None): + meta["start_date"] = str(issue.start_date) + start_time_value = _serialize_issue_start_time(issue) + if start_time_value: + meta["start_time"] = start_time_value + if getattr(issue, "level", None): + meta["level"] = issue.level + if getattr(issue, "program", None): + meta["program"] = issue.program + if getattr(issue, "sport", None): + meta["sport"] = issue.sport + if getattr(issue, "opposition_team", None): + meta["opposition"] = issue.opposition_team + if getattr(issue, "year", None): + meta["season"] = issue.year + return meta + + +def _build_issue_manifest_meta(issue: Issue) -> dict: + return { + "category": getattr(issue, "category", None) or "Work items", + "start_date": str(issue.start_date) if getattr(issue, "start_date", None) else None, + "start_time": _serialize_issue_start_time(issue), + "level": getattr(issue, "level", None), + "program": getattr(issue, "program", None), + "sport": getattr(issue, "sport", None), + "opposition": getattr(issue, "opposition_team", None), + "season": getattr(issue, "year", None), + } + + +def _ensure_media_library_manifest(project_id: str) -> dict: + packages_root = ensure_project_library(project_id) + package_dirs = [path for path in packages_root.iterdir() if path.is_dir()] + if package_dirs: + for package_dir in sorted(package_dirs, key=lambda path: path.name): + manifest_file = package_dir / "manifest.json" + if manifest_file.exists(): + try: + return read_manifest(manifest_file) + except Exception: + manifest = create_manifest( + project_id=project_id, + package_id=package_dir.name, + name=package_dir.name, + title="Media Library Package", + ) + write_manifest_atomic(manifest_file, manifest) + return manifest + manifest = create_manifest( + project_id=project_id, + package_id=package_dir.name, + name=package_dir.name, + title="Media Library Package", + ) + write_manifest_atomic(manifest_file, manifest) + return manifest + package_id = f"package-{uuid4().hex[:8]}" + root = package_root(project_id, package_id) + (root / "artifacts").mkdir(parents=True, exist_ok=False) + (root / "attachment").mkdir(parents=True, exist_ok=False) + manifest = create_manifest( + project_id=project_id, + package_id=package_id, + name=package_id, + title="Media Library Package", + ) + write_manifest_atomic(manifest_path(project_id, package_id), manifest) + return manifest + + +def _build_internal_request(original_request, payload: dict | list[dict]): + return SimpleNamespace( + data=payload, + FILES={}, + user=original_request.user, + query_params=getattr(original_request, "query_params", {}), + build_absolute_uri=original_request.build_absolute_uri, + ) + + +def _resolve_description_image_candidate( + source: str, + request, + slug: str, + project_id: str, + index: int, +) -> dict | None: + if not isinstance(source, str): + return None + raw_source = source.strip() + if not raw_source or raw_source.startswith(("data:", "blob:")): + return None + + resolved_source = raw_source + asset = None + + if not raw_source.lower().startswith(("http://", "https://")): + if "/" not in raw_source: + try: + UUID(raw_source) + asset = FileAsset.objects.filter( + id=raw_source, + workspace__slug=slug, + project_id=project_id, + is_deleted=False, + is_uploaded=True, + ).first() + except ValueError: + asset = None + if asset and asset.asset_url: + resolved_source = asset.asset_url + else: + resolved_source = f"/api/assets/v2/workspaces/{slug}/projects/{project_id}/{raw_source}/" + elif raw_source.startswith("/"): + resolved_source = raw_source + else: + resolved_source = f"/{raw_source.lstrip('/')}" + + if resolved_source.startswith("/"): + try: + resolved_source = request.build_absolute_uri(resolved_source) + except Exception: + pass + + asset_id = _extract_asset_id_from_url(resolved_source) + if asset is None and asset_id: + asset = FileAsset.objects.filter( + id=asset_id, + workspace__slug=slug, + project_id=project_id, + is_deleted=False, + is_uploaded=True, + ).first() + + fallback_name = f"inline-image-{index + 1}.png" + file_name = None + if asset and isinstance(asset.attributes, dict): + asset_name = asset.attributes.get("name") + if isinstance(asset_name, str) and asset_name.strip(): + file_name = asset_name.strip() + if not file_name: + file_name = _resolve_file_name_from_url(resolved_source, fallback_name) + + format_value = _resolve_artifact_format_from_name(file_name) + if not format_value and asset and isinstance(asset.attributes, dict): + format_value = _resolve_format_from_mime(asset.attributes.get("type", "")) + + if not format_value or format_value not in _IMAGE_FORMATS or format_value == "thumbnail": + return None + + if "." not in Path(file_name).name: + file_name = f"{file_name}.{format_value}" + + inline_source = _normalize_inline_source(resolved_source) + file_id = asset_id or f"inline-{index + 1}" + + return { + "path": resolved_source, + "file_name": file_name, + "format": format_value, + "file_id": file_id, + "inline_source": inline_source, + } + + +def _derive_source_id_from_path(path: str, index: int) -> str: + asset_id = _extract_asset_id_from_url(path) + if asset_id: + return asset_id + normalized = _normalize_inline_source(path) or path or str(index) + digest = sha1(normalized.encode("utf-8")).hexdigest()[:12] + return f"media-{digest}" + + +class MediaWorkItemSyncAPIView(BaseAPIView): + authentication_classes = [APIKeyAuthentication, BaseSessionAuthentication] + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="PROJECT") + def post(self, request, slug, project_id): + project_id_str = str(project_id) + validate_segment(project_id_str, "projectId") + + payload = request.data or {} + work_item_id = str(payload.get("work_item_id") or payload.get("workItemId") or "").strip() + if not work_item_id: + return Response({"error": "work_item_id is required."}, status=status.HTTP_400_BAD_REQUEST) + + include_attachments = _coerce_bool( + payload.get("include_attachments", payload.get("includeAttachments")), + default=True, + ) + include_description_images = _coerce_bool( + payload.get("include_description_images", payload.get("includeDescriptionImages")), + default=True, + ) + update_manifest_meta = _coerce_bool( + payload.get("update_manifest_meta", payload.get("updateManifestMeta")), + default=True, + ) + + try: + work_item_uuid = UUID(work_item_id) + except ValueError: + return Response({"error": "work_item_id must be a valid UUID."}, status=status.HTTP_400_BAD_REQUEST) + + issue = ( + Issue.objects.select_related("created_by") + .filter( + id=work_item_uuid, + workspace__slug=slug, + project_id=project_id, + deleted_at__isnull=True, + ) + .first() + ) + if not issue: + return Response({"error": "Work item not found."}, status=status.HTTP_404_NOT_FOUND) + + manifest = _ensure_media_library_manifest(project_id_str) + package_id = str(manifest.get("id") or "") + if not package_id: + return Response( + {"error": "Unable to resolve media library package."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + summary = { + "attachments": {"candidates": 0, "created": 0, "skipped": 0, "failed": 0}, + "description_images": {"candidates": 0, "created": 0, "skipped": 0, "failed": 0}, + "payload_media": {"candidates": 0, "created": 0, "skipped": 0, "failed": 0}, + } + candidates: list[dict] = [] + candidate_names: set[str] = set() + + def append_candidate(entry: dict, source_type: str) -> None: + name = entry.get("name") + if not isinstance(name, str) or not name: + summary[source_type]["failed"] += 1 + return + if name in candidate_names: + summary[source_type]["skipped"] += 1 + return + candidate_names.add(name) + payload_entry = dict(entry) + payload_entry["_source_type"] = source_type + candidates.append(payload_entry) + summary[source_type]["candidates"] += 1 + + if include_attachments: + attachment_meta = _build_issue_artifact_meta(issue, source="work_item_attachment") + attachments = ( + FileAsset.objects.filter( + issue_id=issue.id, + workspace__slug=slug, + project_id=project_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + is_deleted=False, + is_uploaded=True, + ) + .order_by("created_at") + ) + for attachment in attachments: + file_name = _resolve_attachment_file_name(attachment) + format_value = _resolve_artifact_format_from_name(file_name) + if not format_value: + summary["attachments"]["skipped"] += 1 + continue + + asset_path = request.build_absolute_uri( + f"/api/assets/v2/workspaces/{slug}/projects/{project_id}/issues/{issue.id}/attachments/{attachment.id}/" + ) + + artifact_name = _build_artifact_name(file_name, str(attachment.id)) + title = Path(file_name).stem or "Attachment" + action = _resolve_artifact_action(format_value) + meta = dict(attachment_meta) + + if format_value in _DOC_FORMATS: + meta["kind"] = "document_file" + attributes = attachment.attributes if isinstance(attachment.attributes, dict) else {} + file_size = attributes.get("size") + if file_size is None: + file_size = attachment.size + if file_size is not None: + meta["file_size"] = file_size + meta["file_type"] = format_value + + append_candidate( + { + "name": artifact_name, + "title": title, + "format": format_value, + "path": asset_path, + "link": None, + "action": action, + "meta": meta, + "work_item_id": str(issue.id), + }, + "attachments", + ) + + if include_description_images: + description_meta = _build_issue_artifact_meta(issue, source="work_item_description") + description_sources = _extract_description_image_sources( + issue.description_html, + slug=slug, + project_id=project_id_str, + ) + seen_inline_sources: set[str] = set() + for index, source in enumerate(description_sources): + candidate = _resolve_description_image_candidate(source, request, slug, project_id_str, index) + if not candidate: + summary["description_images"]["skipped"] += 1 + continue + inline_source = candidate.get("inline_source") + if inline_source and inline_source in seen_inline_sources: + summary["description_images"]["skipped"] += 1 + continue + if inline_source: + seen_inline_sources.add(inline_source) + + file_name = candidate["file_name"] + artifact_name = _build_artifact_name(file_name, candidate["file_id"]) + title = Path(file_name).stem or "Inline image" + meta = dict(description_meta) + if inline_source: + meta["inline_source"] = inline_source + + append_candidate( + { + "name": artifact_name, + "title": title, + "format": candidate["format"], + "path": candidate["path"], + "link": None, + "action": _resolve_artifact_action(candidate["format"]), + "meta": meta, + "work_item_id": str(issue.id), + }, + "description_images", + ) + + payload_media_items: list[dict] = [] + raw_media_assets = payload.get("media_assets", payload.get("mediaAssets")) + raw_media_paths = payload.get("media_paths", payload.get("mediaPaths")) + + if isinstance(raw_media_assets, dict): + payload_media_items.append(raw_media_assets) + elif isinstance(raw_media_assets, list): + for value in raw_media_assets: + if isinstance(value, dict): + payload_media_items.append(value) + elif isinstance(value, str): + payload_media_items.append({"path": value}) + else: + summary["payload_media"]["failed"] += 1 + elif raw_media_assets not in (None, ""): + summary["payload_media"]["failed"] += 1 + + if isinstance(raw_media_paths, str): + payload_media_items.append({"path": raw_media_paths}) + elif isinstance(raw_media_paths, list): + for value in raw_media_paths: + if isinstance(value, str): + payload_media_items.append({"path": value}) + elif isinstance(value, dict): + payload_media_items.append(value) + else: + summary["payload_media"]["failed"] += 1 + elif raw_media_paths not in (None, ""): + summary["payload_media"]["failed"] += 1 + + if payload_media_items: + payload_media_meta = _build_issue_artifact_meta(issue, source="payload_media") + for index, item in enumerate(payload_media_items): + if not isinstance(item, dict): + summary["payload_media"]["failed"] += 1 + continue + + resolved_path = _resolve_payload_media_path( + item.get("path") + or item.get("url") + or item.get("source") + or item.get("src") + or item.get("asset_url"), + request=request, + slug=slug, + project_id=project_id_str, + ) + if not resolved_path: + summary["payload_media"]["failed"] += 1 + continue + + asset = _resolve_payload_media_asset(resolved_path, slug, project_id_str) + asset_attributes = asset.attributes if asset and isinstance(asset.attributes, dict) else {} + + file_name = ( + item.get("file_name") + or item.get("fileName") + or item.get("filename") + or item.get("name") + ) + if isinstance(file_name, str): + file_name = file_name.strip() + if not file_name: + asset_name = asset_attributes.get("name") + if isinstance(asset_name, str) and asset_name.strip(): + file_name = asset_name.strip() + if not file_name: + file_name = _resolve_file_name_from_url(resolved_path, f"media-{index + 1}") + + format_value = item.get("format") + if isinstance(format_value, str): + format_value = format_value.strip().lower() + else: + format_value = "" + if not format_value: + format_value = _resolve_artifact_format_from_name(file_name) + if not format_value: + asset_name = asset_attributes.get("name") + if isinstance(asset_name, str): + format_value = _resolve_artifact_format_from_name(asset_name) + if not format_value: + format_value = _resolve_format_from_mime( + str(item.get("mime_type") or item.get("mimeType") or item.get("type") or "") + ) + if not format_value: + format_value = _resolve_format_from_mime(str(asset_attributes.get("type") or "")) + if format_value not in _SUPPORTED_ARTIFACT_FORMATS: + summary["payload_media"]["skipped"] += 1 + continue + + action_value = item.get("action") + if isinstance(action_value, str): + action_value = action_value.strip() + else: + action_value = "" + if action_value not in _ARTIFACT_ACTION_CHOICES: + action_value = _resolve_artifact_action(format_value) + + source_id = str( + item.get("source_id") + or item.get("sourceId") + or _derive_source_id_from_path(resolved_path, index + 1) + ) + raw_artifact_name = item.get("artifact_name") or item.get("artifactName") + if isinstance(raw_artifact_name, str): + candidate_artifact_name = _sanitize_artifact_segment(raw_artifact_name) + else: + candidate_artifact_name = "" + artifact_name = candidate_artifact_name or _build_artifact_name(file_name, source_id) + + title_value = item.get("title") + if not isinstance(title_value, str) or not title_value.strip(): + title_value = Path(file_name).stem or "Media file" + else: + title_value = title_value.strip() + + link_value = item.get("link") + if isinstance(link_value, str): + link_value = link_value.strip() or None + elif link_value is not None: + link_value = str(link_value).strip() or None + + meta_value = item.get("meta") + if meta_value is None: + custom_meta = {} + elif isinstance(meta_value, dict): + custom_meta = dict(meta_value) + else: + summary["payload_media"]["failed"] += 1 + continue + meta = dict(payload_media_meta) + meta.update(custom_meta) + if format_value in _DOC_FORMATS: + if "kind" not in meta: + meta["kind"] = "document_file" + if "file_size" not in meta: + file_size = item.get("file_size") or item.get("fileSize") + if file_size is None: + file_size = asset_attributes.get("size") + if file_size is None and asset is not None: + file_size = asset.size + if file_size is not None: + meta["file_size"] = file_size + if "file_type" not in meta: + meta["file_type"] = format_value + + payload_candidate: dict = { + "name": artifact_name, + "title": title_value, + "format": format_value, + "path": resolved_path, + "link": link_value, + "action": action_value, + "meta": meta, + "work_item_id": str(issue.id), + } + + description_value = item.get("description") + if isinstance(description_value, str) and description_value.strip(): + payload_candidate["description"] = description_value.strip() + + metadata_ref = item.get("metadata_ref") or item.get("metadataRef") + if isinstance(metadata_ref, str) and metadata_ref.strip(): + payload_candidate["metadata_ref"] = metadata_ref.strip() + + created_at = item.get("created_at") or item.get("createdAt") + if isinstance(created_at, str) and created_at.strip(): + payload_candidate["created_at"] = created_at.strip() + updated_at = item.get("updated_at") or item.get("updatedAt") + if isinstance(updated_at, str) and updated_at.strip(): + payload_candidate["updated_at"] = updated_at.strip() + + append_candidate(payload_candidate, "payload_media") + + latest_manifest = read_manifest(manifest_path(project_id_str, package_id)) + existing_names = { + artifact.get("name") + for artifact in (latest_manifest.get("artifacts") or []) + if isinstance(artifact, dict) and artifact.get("name") + } + + artifacts_payload: list[dict] = [] + source_by_name: dict[str, str] = {} + for candidate in candidates: + source_type = str(candidate.pop("_source_type", "attachments")) + name = candidate.get("name") + if name in existing_names: + summary[source_type]["skipped"] += 1 + continue + source_by_name[str(name)] = source_type + artifacts_payload.append(candidate) + existing_names.add(name) + + artifact_error = None + if artifacts_payload: + artifacts_request = _build_internal_request(request, artifacts_payload) + artifacts_response = MediaArtifactsListAPIView().post( + artifacts_request, + slug=slug, + project_id=project_id, + package_id=package_id, + ) + if artifacts_response.status_code == status.HTTP_201_CREATED: + payload_data = artifacts_response.data + response_items = payload_data if isinstance(payload_data, list) else [payload_data] + created_primary_names = { + item.get("name") + for item in response_items + if isinstance(item, dict) and item.get("name") in source_by_name + } + for artifact_name, source_type in source_by_name.items(): + if artifact_name in created_primary_names: + summary[source_type]["created"] += 1 + else: + summary[source_type]["failed"] += 1 + elif artifacts_response.status_code == status.HTTP_409_CONFLICT: + latest_manifest = read_manifest(manifest_path(project_id_str, package_id)) + refreshed_names = { + artifact.get("name") + for artifact in (latest_manifest.get("artifacts") or []) + if isinstance(artifact, dict) and artifact.get("name") + } + for artifact_name, source_type in source_by_name.items(): + if artifact_name in refreshed_names: + summary[source_type]["skipped"] += 1 + else: + summary[source_type]["failed"] += 1 + else: + artifact_error = artifacts_response.data + for source_type in source_by_name.values(): + summary[source_type]["failed"] += 1 + + manifest_meta_updated = 0 + manifest_meta_error = None + if update_manifest_meta: + manifest_request = _build_internal_request( + request, + { + "work_item_id": str(issue.id), + "meta": _build_issue_manifest_meta(issue), + }, + ) + manifest_response = MediaManifestDetailAPIView().patch( + manifest_request, + slug=slug, + project_id=project_id, + package_id=package_id, + ) + if manifest_response.status_code == status.HTTP_200_OK and isinstance(manifest_response.data, dict): + manifest_meta_updated = int(manifest_response.data.get("updated") or 0) + elif manifest_response.status_code != status.HTTP_200_OK: + manifest_meta_error = manifest_response.data + + totals = { + "candidates": ( + summary["attachments"]["candidates"] + + summary["description_images"]["candidates"] + + summary["payload_media"]["candidates"] + ), + "created": ( + summary["attachments"]["created"] + + summary["description_images"]["created"] + + summary["payload_media"]["created"] + ), + "skipped": ( + summary["attachments"]["skipped"] + + summary["description_images"]["skipped"] + + summary["payload_media"]["skipped"] + ), + "failed": ( + summary["attachments"]["failed"] + + summary["description_images"]["failed"] + + summary["payload_media"]["failed"] + ), + } + + response_payload = { + "package_id": package_id, + "work_item_id": str(issue.id), + "totals": totals, + "details": summary, + "manifest_meta_updated": manifest_meta_updated, + } + if artifact_error is not None: + response_payload["artifact_error"] = artifact_error + if manifest_meta_error is not None: + response_payload["manifest_meta_error"] = manifest_meta_error + return Response(response_payload, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index 84b2a5629ab..fb699436c08 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -39,6 +39,7 @@ from plane.bgtasks.recent_visited_task import recent_visited_task from plane.utils.exception_logger import log_exception from plane.utils.host import base_host +from plane.utils.media_library import delete_project_library class ProjectViewSet(BaseViewSet): @@ -175,6 +176,7 @@ def list(self, request, slug): "guest_view_all_features", "project_lead", "network", + "sport", "created_at", "updated_at", "created_by", @@ -418,6 +420,10 @@ def destroy(self, request, slug, pk): ): project = Project.objects.get(pk=pk, workspace__slug=slug) project.delete() + try: + delete_project_library(str(pk)) + except Exception as exc: + log_exception(exc) webhook_activity.delay( event="project", verb="deleted", diff --git a/apps/api/plane/app/views/roster.py b/apps/api/plane/app/views/roster.py new file mode 100644 index 00000000000..d30bc8fad37 --- /dev/null +++ b/apps/api/plane/app/views/roster.py @@ -0,0 +1,78 @@ +# Django imports +from django.db import transaction + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE +from plane.app.serializers import RosterPlayerImportSerializer, RosterPlayerSerializer +from plane.db.models import Project, RosterPlayer +from .base import BaseViewSet + + +class RosterPlayerViewSet(BaseViewSet): + permission_classes = [ProjectEntityPermission] + model = RosterPlayer + serializer_class = RosterPlayerSerializer + filterset_fields = ["position", "status", "class_year"] + search_fields = ["player_name", "jersey_number", "position"] + + def get_queryset(self): + queryset = ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + ) + + return queryset.order_by(RosterPlayer.jersey_number_ordering(), "player_name", "created_at") + + def get_serializer_context(self): + context = super().get_serializer_context() + context["project"] = Project.objects.get(workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")) + return context + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def list(self, request, slug, project_id): + queryset = self.filter_queryset(self.get_queryset()) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def retrieve(self, request, slug, project_id, pk): + return super().retrieve(request, slug, project_id, pk) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id): + return super().create(request, slug, project_id) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def partial_update(self, request, slug, project_id, pk): + return super().partial_update(request, slug, project_id, pk) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, pk): + roster_player = self.get_object() + roster_player.delete() + return Response({"success": True, "message": "Player deleted successfully."}, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def import_players(self, request, slug, project_id): + serializer = RosterPlayerImportSerializer(data=request.data, context=self.get_serializer_context()) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + players = serializer.save() + + response_serializer = self.get_serializer(players, many=True) + return Response( + { + "success": True, + "data": response_serializer.data, + "imported_count": len(response_serializer.data), + "message": "Roster imported successfully.", + }, + status=status.HTTP_201_CREATED, + ) diff --git a/apps/api/plane/authentication/middleware/session.py b/apps/api/plane/authentication/middleware/session.py index c367a15d36f..456fc32d6b6 100644 --- a/apps/api/plane/authentication/middleware/session.py +++ b/apps/api/plane/authentication/middleware/session.py @@ -15,11 +15,39 @@ def __init__(self, get_response): engine = import_module(settings.SESSION_ENGINE) self.SessionStore = engine.SessionStore + def _is_admin_path(self, request): + return "instances" in request.path + + def _is_coach_path(self, request): + return request.path.startswith("/auth/coach/") + + def _get_cookie_name(self, request): + if self._is_admin_path(request): + return settings.ADMIN_SESSION_COOKIE_NAME + + if request.COOKIES.get(settings.SESSION_COOKIE_NAME): + return settings.SESSION_COOKIE_NAME + + if request.COOKIES.get(settings.COACH_SESSION_COOKIE_NAME) or self._is_coach_path(request): + return settings.COACH_SESSION_COOKIE_NAME + + return settings.SESSION_COOKIE_NAME + + def _get_cookie_age(self, request): + cookie_name = getattr(request, "_session_cookie_name", self._get_cookie_name(request)) + + if cookie_name == settings.ADMIN_SESSION_COOKIE_NAME: + return settings.ADMIN_SESSION_COOKIE_AGE + + if cookie_name == settings.COACH_SESSION_COOKIE_NAME: + return settings.COACH_SESSION_COOKIE_AGE + + return request.session.get_expiry_age() + def process_request(self, request): - if "instances" in request.path: - session_key = request.COOKIES.get(settings.ADMIN_SESSION_COOKIE_NAME) - else: - session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME) + cookie_name = self._get_cookie_name(request) + session_key = request.COOKIES.get(cookie_name) + request._session_cookie_name = cookie_name request.session = self.SessionStore(session_key) def process_response(self, request, response): @@ -36,8 +64,7 @@ def process_response(self, request, response): return response # First check if we need to delete this cookie. # The session should be deleted only if the session is entirely empty. - is_admin_path = "instances" in request.path - cookie_name = settings.ADMIN_SESSION_COOKIE_NAME if is_admin_path else settings.SESSION_COOKIE_NAME + cookie_name = getattr(request, "_session_cookie_name", self._get_cookie_name(request)) if cookie_name in request.COOKIES and empty: response.delete_cookie( @@ -55,12 +82,7 @@ def process_response(self, request, response): max_age = None expires = None else: - # Use different max_age based on whether it's an admin cookie - if is_admin_path: - max_age = settings.ADMIN_SESSION_COOKIE_AGE - else: - max_age = request.session.get_expiry_age() - + max_age = self._get_cookie_age(request) expires_time = time.time() + max_age expires = http_date(expires_time) diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index d8b5799de1a..a60b5b80ddf 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -2,6 +2,9 @@ from .views import ( CSRFTokenEndpoint, + CoachSessionEndpoint, + CoachSignInEndpoint, + CoachSignOutEndpoint, ForgotPasswordEndpoint, SetUserPasswordEndpoint, ResetPasswordEndpoint, @@ -39,6 +42,10 @@ ) urlpatterns = [ + # coach auth + path("coach/sign-in/", CoachSignInEndpoint.as_view(), name="coach-sign-in"), + path("coach/sign-out/", CoachSignOutEndpoint.as_view(), name="coach-sign-out"), + path("coach/session/", CoachSessionEndpoint.as_view(), name="coach-session"), # credentials path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"), path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"), diff --git a/apps/api/plane/authentication/utils/login.py b/apps/api/plane/authentication/utils/login.py index fe6fdad931a..a27d9c4a174 100644 --- a/apps/api/plane/authentication/utils/login.py +++ b/apps/api/plane/authentication/utils/login.py @@ -13,6 +13,8 @@ def user_login(request, user, is_app=False, is_admin=False, is_space=False): # If is admin cookie set the custom age if is_admin: request.session.set_expiry(settings.ADMIN_SESSION_COOKIE_AGE) + elif getattr(request, "_session_cookie_name", None) == settings.COACH_SESSION_COOKIE_NAME: + request.session.set_expiry(settings.COACH_SESSION_COOKIE_AGE) device_info = { "user_agent": request.META.get("HTTP_USER_AGENT", ""), diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index 24ae1f673f4..1e1dc3c8fde 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -1,4 +1,5 @@ from .common import ChangePasswordEndpoint, CSRFTokenEndpoint, SetUserPasswordEndpoint +from .coach import CoachSessionEndpoint, CoachSignInEndpoint, CoachSignOutEndpoint from .app.check import EmailCheckEndpoint diff --git a/apps/api/plane/authentication/views/coach.py b/apps/api/plane/authentication/views/coach.py new file mode 100644 index 00000000000..03e2de83f05 --- /dev/null +++ b/apps/api/plane/authentication/views/coach.py @@ -0,0 +1,157 @@ +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.contrib.auth import logout +from django.utils import timezone +from django.db.models import F + +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from plane.app.serializers import UserMeSerializer +from plane.authentication.adapter.error import AUTHENTICATION_ERROR_CODES, AuthenticationException +from plane.authentication.session import BaseSessionAuthentication +from plane.authentication.utils.host import user_ip +from plane.authentication.utils.login import user_login +from plane.db.models import User, WorkspaceMember +from plane.license.models import Instance +from plane.license.utils.instance_value import get_configuration_value + + +def get_coach_auth_payload(user): + serializer = UserMeSerializer(user) + workspace_access = list( + WorkspaceMember.objects.filter(member=user, is_active=True, deleted_at__isnull=True) + .select_related("workspace") + .order_by("workspace__name") + .annotate(workspace_slug=F("workspace__slug"), workspace_name=F("workspace__name")) + .values("workspace_id", "workspace_slug", "workspace_name", "role") + ) + + return { + "is_authenticated": True, + "user": serializer.data, + "workspace_access": workspace_access, + } + + +class CoachSignInEndpoint(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + + def post(self, request): + instance = Instance.objects.first() + + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + return Response(exc.get_error_dict(), status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + email = request.data.get("email", False) + password = request.data.get("password", False) + + if not email or not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_EMAIL_PASSWORD_SIGN_IN"], + error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN", + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_IN"], + error_message="INVALID_EMAIL_SIGN_IN", + payload={"email": str(email)}, + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + (enable_email_password,) = get_configuration_value( + [{"key": "ENABLE_EMAIL_PASSWORD", "default": "1"}] + ) + + if enable_email_password == "0": + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_PASSWORD_AUTHENTICATION_DISABLED"], + error_message="EMAIL_PASSWORD_AUTHENTICATION_DISABLED", + ) + return Response(exc.get_error_dict(), status=status.HTTP_403_FORBIDDEN) + + user = User.objects.filter(email=email).first() + + if not user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + payload={"email": str(email)}, + ) + return Response(exc.get_error_dict(), status=status.HTTP_404_NOT_FOUND) + + if not user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"], + error_message="USER_ACCOUNT_DEACTIVATED", + payload={"email": str(email)}, + ) + return Response(exc.get_error_dict(), status=status.HTTP_403_FORBIDDEN) + + if not user.check_password(password): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["AUTHENTICATION_FAILED_SIGN_IN"], + error_message="AUTHENTICATION_FAILED_SIGN_IN", + payload={"email": str(email)}, + ) + return Response(exc.get_error_dict(), status=status.HTTP_401_UNAUTHORIZED) + + user.last_login_medium = "coach" + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = user_ip(request=request) + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save( + update_fields=[ + "last_login_medium", + "last_active", + "last_login_time", + "last_login_ip", + "last_login_uagent", + "token_updated_at", + ] + ) + + user_login(request=request, user=user, is_app=True) + + return Response(get_coach_auth_payload(user), status=status.HTTP_200_OK) + + +class CoachSessionEndpoint(APIView): + permission_classes = [AllowAny] + authentication_classes = [BaseSessionAuthentication] + + def get(self, request): + if not request.user.is_authenticated: + return Response({"is_authenticated": False}, status=status.HTTP_200_OK) + + user = User.objects.get(pk=request.user.id) + return Response(get_coach_auth_payload(user), status=status.HTTP_200_OK) + + +class CoachSignOutEndpoint(APIView): + permission_classes = [AllowAny] + authentication_classes = [BaseSessionAuthentication] + + def post(self, request): + if request.user.is_authenticated: + user = User.objects.get(pk=request.user.id) + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save(update_fields=["last_logout_ip", "last_logout_time"]) + + logout(request) + return Response({"success": True}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/bgtasks/service_gateway_sync_helpers.py b/apps/api/plane/bgtasks/service_gateway_sync_helpers.py new file mode 100644 index 00000000000..4f33ed2a27f --- /dev/null +++ b/apps/api/plane/bgtasks/service_gateway_sync_helpers.py @@ -0,0 +1,652 @@ +import datetime +import functools +import logging +import re +import uuid +from typing import Any, Dict, Optional +from urllib.parse import urlsplit, urlunsplit +from zoneinfo import ZoneInfo + +import requests +from django.conf import settings +from django.utils import timezone + +logger = logging.getLogger("plane.worker") + + +SCHEDULED_EVENT_CATEGORIES = { + "game": "Game", + "practice": "Practice", + "scrimmage": "Scrimmage", + "other": "Other", +} +JSON_SCALAR_TYPES = (str, int, float, bool) +NULLISH_STRINGS = frozenset({"none", "null", "undefined", "n/a", "na", "nan"}) +STRING_LOOKUP_KEYS = ("name", "label", "title", "display_name", "value", "id") +DATE_FIELD_CANDIDATES = ("start_date", "target_date", "due_date") +TEAM_ID_KEYS = ("team_id", "teamId", "team", "team_detail", "team_details") +CLOCK_TIME_PATTERN = re.compile(r"^(\d{1,2}):(\d{2})(?::\d{2})?$") +FLEX_CLOCK_TIME_PATTERN = re.compile( + r"^\s*(\d{1,2})\s*:\s*(\d{1,2})(?:\s*:\s*(\d{1,2}))?\s*([a-zA-Z.\s]*)\s*$" +) +SESSION_YEAR_PATTERN = re.compile(r"^\s*(\d{4})\s*[-/]\s*(\d{4})\s*$") + + +def _none_if_blank(value: Any) -> Any: + if value is None: + return None + if isinstance(value, str): + value = value.strip() + if not value: + return None + if value.lower() in NULLISH_STRINGS: + return None + return value + return value + + +def _json_safe_value(value: Any) -> Any: + value = _none_if_blank(value) + if value is None or isinstance(value, JSON_SCALAR_TYPES): + return value + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + if isinstance(value, uuid.UUID): + return str(value) + if isinstance(value, dict): + return {str(key): _json_safe_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_json_safe_value(item) for item in value] + return str(value) + + +def _pick_first(event_data: Dict[str, Any], *keys: str) -> Any: + if not isinstance(event_data, dict): + return None + for key in keys: + value = _none_if_blank(event_data.get(key)) + if value is not None: + return value + return None + + +def _string_field(value: Any) -> Optional[str]: + value = _none_if_blank(value) + if value is None: + return None + + if isinstance(value, (str, int, float, bool, uuid.UUID)): + return str(value) + + if isinstance(value, dict): + for key in STRING_LOOKUP_KEYS: + picked = _none_if_blank(value.get(key)) + if picked is not None and not isinstance(picked, (dict, list, tuple, set)): + return str(picked) + return None + + if isinstance(value, (list, tuple, set)): + for item in value: + candidate = _string_field(item) + if candidate is not None: + return candidate + return None + + return str(value) + + +def _int_field(value: Any) -> Optional[int]: + value = _none_if_blank(value) + if value is None: + return None + + if isinstance(value, bool): + return int(value) + + if isinstance(value, int): + return value + + if isinstance(value, float): + return int(value) + + if isinstance(value, str): + text = value.strip() + if not text: + return None + if text.startswith(("+", "-")): + return int(text) if text[1:].isdigit() else None + return int(text) if text.isdigit() else None + + if isinstance(value, dict): + for key in ("team_id", "teamId", "id", "value"): + nested = _int_field(value.get(key)) + if nested is not None: + return nested + + return None + + +def _clean_phone(value: Any) -> Optional[str]: + value = _none_if_blank(value) + if value is None: + return None + + text = str(value).strip() + lowered = text.lower() + if "undefined" in lowered or lowered in NULLISH_STRINGS: + return None + + return text + + +def _normalize_contact_item(source: Dict[str, Any]) -> Dict[str, Any]: + phone = _clean_phone(_pick_first(source, "phone", "mobile", "phone_number")) + country_code = _string_field(_pick_first(source, "country_Code", "country_code", "countryCode")) + if country_code is None and phone and phone.startswith("+1"): + country_code = "US" + + return { + "contact_name": _string_field(_pick_first(source, "contact_name", "display_name", "name", "first_name")), + "country_Code": country_code, + "email": _string_field(source.get("email")), + "phone": phone, + } + + +def _extract_date(event_data: Dict[str, Any]) -> Optional[str]: + for key in DATE_FIELD_CANDIDATES: + value = _none_if_blank(event_data.get(key)) + if not value: + continue + value = str(value) + if len(value) >= 10 and value[4] == "-" and value[7] == "-": + return value[:10] + return None + + +def _parse_session_year_range(event_data: Dict[str, Any]) -> Optional[tuple[int, int]]: + raw_year = _string_field(_pick_first(event_data, "year", "session", "season", "academic_year", "academicYear")) + if not raw_year: + return None + + match = SESSION_YEAR_PATTERN.match(raw_year) + if not match: + return None + + start_year = int(match.group(1)) + end_year = int(match.group(2)) + if end_year < start_year: + return None + return start_year, end_year + + +def _normalize_session_year_text(event_data: Dict[str, Any]) -> Optional[str]: + year_range = _parse_session_year_range(event_data) + if year_range is None: + return _string_field(event_data.get("year")) + return f"{year_range[0]}-{year_range[1]}" + + +def _align_event_date_to_session(event_date: Optional[str], event_data: Dict[str, Any]) -> Optional[str]: + if not event_date: + return event_date + + year_range = _parse_session_year_range(event_data) + if year_range is None: + return event_date + + start_year, end_year = year_range + try: + dt = datetime.date.fromisoformat(event_date) + except ValueError: + return event_date + + if start_year <= dt.year <= end_year: + return event_date + + target_year = start_year if dt.month >= 7 else end_year + try: + aligned = dt.replace(year=target_year) + except ValueError: + if dt.month == 2 and dt.day == 29: + aligned = datetime.date(target_year, 2, 28) + else: + return event_date + + logger.info( + "Aligned event date %s -> %s using session %s-%s for work-item %s", + event_date, + aligned.isoformat(), + start_year, + end_year, + event_data.get("id"), + ) + return aligned.isoformat() + + +def _parse_meridiem_token(raw_token: str) -> Optional[str]: + token = re.sub(r"[^a-z]", "", raw_token.lower()) + if not token: + return None + if token in {"am", "a"}: + return "am" + if token in {"pm", "p"}: + return "pm" + if token.endswith("p"): + return "pm" + if token.endswith("a"): + return "am" + return None + + +@functools.lru_cache(maxsize=4) +def _service_gateway_tzinfo() -> datetime.tzinfo: + # service-gateway stores wall-clock event date/time values, so Plane must + # serialize them in UTC to match the gateway runtime interpretation. + tz_name = _none_if_blank(getattr(settings, "SERVICE_GATEWAY_TIMEZONE", None)) or "UTC" + try: + return ZoneInfo(str(tz_name)) + except Exception: + return timezone.get_default_timezone() + + +def _parse_clock_time(value: Any) -> Optional[tuple[int, int]]: + value = _none_if_blank(value) + if not value: + return None + + text = str(value).strip() + normalized = text.replace(".", ":") + match = FLEX_CLOCK_TIME_PATTERN.match(normalized) + if not match: + match = CLOCK_TIME_PATTERN.match(text) + if not match: + return None + hour = int(match.group(1)) + minute = int(match.group(2)) + if hour > 23 or minute > 59: + return None + return hour, minute + + hour = int(match.group(1)) + minute = int(match.group(2)) + if minute > 59: + return None + + meridiem = _parse_meridiem_token(match.group(4) or "") + raw_suffix = (match.group(4) or "").strip() + if raw_suffix and meridiem is None: + return None + + if meridiem is None: + if hour > 23: + return None + else: + if hour < 1 or hour > 12: + return None + if meridiem == "am": + hour = 0 if hour == 12 else hour + else: + hour = hour if hour == 12 else hour + 12 + + return hour, minute + + +def _parse_datetime(value: Any) -> Optional[datetime.datetime]: + value = _none_if_blank(value) + if not value: + return None + + clock = _parse_clock_time(value) + if clock is not None: + hour, minute = clock + now = timezone.localtime() + return now.replace(hour=hour, minute=minute, second=0, microsecond=0) + + if isinstance(value, datetime.datetime): + dt = value + else: + text = str(value).strip() + if not text: + return None + if text.endswith("Z"): + text = f"{text[:-1]}+00:00" + try: + dt = datetime.datetime.fromisoformat(text) + except ValueError: + return None + + if timezone.is_naive(dt): + dt = timezone.make_aware(dt, _service_gateway_tzinfo()) + + return timezone.localtime(dt, _service_gateway_tzinfo()) + + +def _extract_time(event_data: Dict[str, Any]) -> Optional[int]: + start_time = _none_if_blank(event_data.get("start_time")) + if start_time is not None: + parsed = _parse_datetime(start_time) + if parsed: + return (parsed.hour * 100) + parsed.minute + logger.warning( + "Unparseable start_time for work-item %s: %r", + event_data.get("id"), + start_time, + ) + return None + + for key in ("scheduled_at", "time"): + parsed = _parse_datetime(event_data.get(key)) + if parsed: + return (parsed.hour * 100) + parsed.minute + + return None + + +def _build_event_contact(event_data: Dict[str, Any]) -> Any: + raw_contact = event_data.get("contact") + if isinstance(raw_contact, dict): + return [_normalize_contact_item(raw_contact)] + + if isinstance(raw_contact, list): + contacts = [_normalize_contact_item(item) for item in raw_contact if isinstance(item, dict)] + return contacts if contacts else None + + assignees = event_data.get("assignees") + if isinstance(assignees, list): + for assignee in assignees: + if not isinstance(assignee, dict): + continue + return [_normalize_contact_item(assignee)] + + return None + + +def _build_event_venue(event_data: Dict[str, Any]) -> Any: + raw_venue = event_data.get("venue") + if isinstance(raw_venue, dict): + location = _string_field(_pick_first(raw_venue, "location", "address", "street_address", "city_address")) + venue_type = _string_field(_pick_first(raw_venue, "type", "venue_type")) + if location is None and venue_type is None: + return None + return {"location": location, "type": venue_type} + + location = _pick_first(event_data, "location", "address") + venue_type = _string_field(_pick_first(event_data, "venue_type", "type")) + if location is None and venue_type is None: + return None + + return { + "location": _string_field(location), + "type": venue_type, + } + + +def _normalize_scheduled_category(event_data: Dict[str, Any]) -> str: + category = (_string_field(event_data.get("category")) or "").strip().lower() + if category in SCHEDULED_EVENT_CATEGORIES: + return SCHEDULED_EVENT_CATEGORIES[category] + + labels = event_data.get("labels") + if isinstance(labels, list): + for label in labels: + if not isinstance(label, dict): + continue + label_name = str(label.get("name", "")).strip().lower() + if label_name in SCHEDULED_EVENT_CATEGORIES: + return SCHEDULED_EVENT_CATEGORIES[label_name] + + return "Other" + + +def _build_issue_pin(event_data: Dict[str, Any]) -> Optional[str]: + raw_issue_id = _none_if_blank(event_data.get("id")) + source_id = str(raw_issue_id).strip().replace("-", "") if raw_issue_id is not None else "" + if not source_id: + return None + return source_id[-8:].upper() + + +def _build_pin(event_data: Dict[str, Any], service_gateway_event_id: int) -> str: + issue_pin = _build_issue_pin(event_data) + if issue_pin: + return issue_pin + return f"PLN{service_gateway_event_id}" + + +def _extract_team_id(event_data: Dict[str, Any], default_team_id: int = 0) -> int: + for key in TEAM_ID_KEYS: + team_id = _int_field(event_data.get(key)) + if team_id and team_id > 0: + return team_id + return default_team_id if default_team_id > 0 else 0 + + +def _make_event_payload( + event_data: Dict[str, Any], + contact: Any = None, + venue: Any = None, + event_date: Optional[str] = None, + event_time: Optional[int] = None, +) -> Dict[str, Any]: + if event_date is None: + event_date = _extract_date(event_data) + event_date = _align_event_date_to_session(event_date, event_data) + if event_time is None: + event_time = _extract_time(event_data) + + title = _string_field(_pick_first(event_data, "name", "title")) + if venue is None: + venue = _build_event_venue(event_data) + if contact is None: + contact = _build_event_contact(event_data) + + category = _string_field(event_data.get("category")) + normalized_year = _normalize_session_year_text(event_data) + + return { + "table": "event", + "columns": [ + {"field": "title", "type": 1, "value": _json_safe_value(title)}, + {"field": "dt_event", "type": 4, "value": _json_safe_value(event_date)}, + {"field": "tm_event", "type": 0, "value": _json_safe_value(event_time)}, + {"field": "status", "type": 1, "value": "upcoming"}, + {"field": "type", "type": 1, "value": "scheduled"}, + {"field": "sport", "type": 1, "value": _json_safe_value(_string_field(event_data.get("sport")))}, + {"field": "level", "type": 1, "value": _json_safe_value(_string_field(event_data.get("level")))}, + {"field": "program", "type": 1, "value": _json_safe_value(_string_field(event_data.get("program")))}, + {"field": "year", "type": 1, "value": _json_safe_value(normalized_year)}, + {"field": "category", "type": 1, "value": _json_safe_value(category)}, + {"field": "venue", "type": 5, "value": _json_safe_value(venue)}, + {"field": "contact", "type": 5, "value": _json_safe_value(contact)}, + ], + } + + +def _make_scheduled_event_payload( + event_data: Dict[str, Any], + service_gateway_event_id: int, + default_team_id: int = 0, + contact: Any = None, +) -> Dict[str, Any]: + pin = _build_pin(event_data, service_gateway_event_id) + if contact is None: + contact = _build_event_contact(event_data) + if contact is None: + contact = [] + + team_id = _extract_team_id(event_data, default_team_id=default_team_id) + pin_plus = { + "source": "plane", + "workspace_id": _json_safe_value(event_data.get("workspace_id")), + "project_id": _json_safe_value(event_data.get("project_id")), + "issue_id": _json_safe_value(event_data.get("id")), + "service_gateway_event_id": service_gateway_event_id, + "team_id": _json_safe_value(team_id), + } + + return { + "table": "scheduled_event", + "columns": [ + {"field": "event_id", "type": 0, "value": _json_safe_value(service_gateway_event_id)}, + {"field": "on_premise", "type": 3, "value": True}, + {"field": "pin", "type": 1, "value": _json_safe_value(pin)}, + {"field": "contact", "type": 5, "value": _json_safe_value(contact)}, + {"field": "category", "type": 1, "value": _json_safe_value(_normalize_scheduled_category(event_data))}, + {"field": "pin_plus", "type": 5, "value": _json_safe_value(pin_plus)}, + {"field": "team_id", "type": 0, "value": _json_safe_value(team_id)}, + {"field": "other_team", "type": 5, "value": None}, + ], + } + + +def _make_force_upcoming_payload(service_gateway_event_id: int) -> Dict[str, Any]: + return { + "table": "event", + "columns": [ + {"field": "status", "type": 1, "value": "upcoming"}, + ], + "criteria": [ + {"field": "id", "type": 0, "value": _json_safe_value(service_gateway_event_id)}, + ], + } + + +def _make_event_send_payload(service_gateway_event_id: int) -> Dict[str, Any]: + return { + "table": "event", + "criteria": [ + {"field": "id", "type": 0, "value": _json_safe_value(service_gateway_event_id)}, + ], + } + + +def _with_row_id_criteria(payload: Dict[str, Any], row_id: int) -> Dict[str, Any]: + updated_payload = dict(payload) + updated_payload["criteria"] = [ + {"field": "id", "type": 0, "value": _json_safe_value(row_id)}, + ] + return updated_payload + + +def _make_delete_payload(table: str, row_id: int) -> Dict[str, Any]: + return { + "table": table, + "criteria": [ + {"field": "id", "type": 0, "value": _json_safe_value(row_id)}, + ], + } + + +def _make_scheduled_event_soft_delete_payload(row_id: int) -> Dict[str, Any]: + tombstone_pin = f"DELETED-{row_id}-{uuid.uuid4().hex[:8].upper()}" + return { + "table": "scheduled_event", + "columns": [ + {"field": "event_id", "type": 0, "value": None}, + {"field": "on_premise", "type": 3, "value": False}, + {"field": "pin", "type": 1, "value": tombstone_pin}, + {"field": "contact", "type": 5, "value": []}, + {"field": "pin_plus", "type": 5, "value": None}, + ], + "criteria": [ + {"field": "id", "type": 0, "value": _json_safe_value(row_id)}, + ], + } + + +def _extract_result_rows(response_json: Dict[str, Any]) -> list[Dict[str, Any]]: + data = response_json.get("Gateway Response", response_json) + if not isinstance(data, dict): + return [] + + rows: list[Dict[str, Any]] = [] + for raw_row in data.get("result", []): + if isinstance(raw_row, dict): + rows.append({str(key): _json_safe_value(value) for key, value in raw_row.items()}) + continue + + if not isinstance(raw_row, list): + continue + + row: Dict[str, Any] = {} + for column in raw_row: + if not isinstance(column, dict): + continue + field_name = column.get("field") + if not isinstance(field_name, str) or not field_name: + continue + row[field_name] = _json_safe_value(column.get("value")) + if row: + rows.append(row) + + return rows + + +def _gateway_error_message(response_json: Dict[str, Any]) -> Optional[str]: + top_level_error = _none_if_blank(response_json.get("error")) + if top_level_error is not None: + return str(top_level_error) + + data = response_json.get("Gateway Response", response_json) + if isinstance(data, dict): + nested_error = _none_if_blank(data.get("error")) + if nested_error is not None: + return str(nested_error) + + return None + + +def _extract_created_id(response_json: Dict[str, Any]) -> Optional[int]: + for row in _extract_result_rows(response_json): + identifier = _int_field(row.get("id")) + if identifier is not None: + return identifier + return None + + +def _safe_response_json(response: requests.Response) -> Dict[str, Any]: + try: + parsed = response.json() + except ValueError: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _derive_scheduled_event_api(event_api: str) -> str: + if not event_api: + return "" + + parsed = urlsplit(event_api.strip()) + path = parsed.path.rstrip("/") + if path.endswith("/api/event"): + path = f"{path[: -len('/api/event')]}/api/scheduled-event" + elif path.endswith("/event"): + path = f"{path[: -len('/event')]}/scheduled-event" + else: + return "" + + return urlunsplit((parsed.scheme, parsed.netloc, path, parsed.query, parsed.fragment)) + + +def _derive_event_send_api(event_api: str) -> str: + if not event_api: + return "" + + parsed = urlsplit(event_api.strip()) + path = parsed.path.rstrip("/") + if path.endswith("/send"): + return urlunsplit((parsed.scheme, parsed.netloc, path, parsed.query, parsed.fragment)) + + return urlunsplit((parsed.scheme, parsed.netloc, f"{path}/send", parsed.query, parsed.fragment)) + + +def _unique_positive_ints(values: list[Optional[int]]) -> list[int]: + unique_values: list[int] = [] + seen: set[int] = set() + for value in values: + if value is None or value <= 0 or value in seen: + continue + seen.add(value) + unique_values.append(value) + return unique_values diff --git a/apps/api/plane/bgtasks/service_gateway_webhook_task.py b/apps/api/plane/bgtasks/service_gateway_webhook_task.py new file mode 100644 index 00000000000..737b466d15b --- /dev/null +++ b/apps/api/plane/bgtasks/service_gateway_webhook_task.py @@ -0,0 +1,715 @@ +import logging +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import requests +from celery import shared_task +from django.conf import settings + +from plane.bgtasks import service_gateway_sync_helpers as sg +from plane.db.models import Issue +from plane.utils.exception_logger import log_exception + +logger = logging.getLogger("plane.worker") + +SUPPORTED_EVENTS = {"issue"} +SUPPORTED_VERBS = {"created", "updated", "deleted"} + + +@dataclass(frozen=True) +class _EventSyncContext: + event_data: Dict[str, Any] + contact: Any + venue: Any + event_date: Optional[str] + event_time: Optional[int] + + +def _build_sync_context(event_data: Dict[str, Any]) -> _EventSyncContext: + return _EventSyncContext( + event_data=event_data, + contact=sg._build_event_contact(event_data), + venue=sg._build_event_venue(event_data), + event_date=sg._extract_date(event_data), + event_time=sg._extract_time(event_data), + ) + + +def _send_request( + session: requests.Session, + method: str, + url: str, + payload: Optional[Dict[str, Any]], + timeout: int, + params: Optional[Dict[str, str]] = None, +) -> tuple[requests.Response, Dict[str, Any]]: + response = session.request(method=method, url=url, json=payload, params=params, timeout=timeout) + response.raise_for_status() + + response_json = sg._safe_response_json(response) + error_message = sg._gateway_error_message(response_json) + if error_message is not None: + raise requests.HTTPError(f"Service-gateway returned error: {error_message}", response=response) + + return response, response_json + + +def _http_error_text(http_error: requests.HTTPError) -> str: + response = getattr(http_error, "response", None) + if response is None: + return "" + response_json = sg._safe_response_json(response) + return sg._gateway_error_message(response_json) or response.text or "" + + +def _set_issue_sg_event_id(event_data: Dict[str, Any], sg_event_id: Optional[int]) -> None: + issue_id = event_data.get("id") + if not issue_id: + return + + try: + Issue.all_objects.filter(pk=issue_id).update(sg_event_id=sg_event_id) + except Exception as exc: + logger.warning( + "Could not update sg_event_id for Plane work-item %s to %s: %s", + issue_id, + sg_event_id, + exc, + ) + + +def _resolve_existing_gateway_rows( + session: requests.Session, + scheduled_event_api: str, + event_data: Dict[str, Any], + timeout: int, +) -> list[Dict[str, Optional[int]]]: + issue_pin = sg._build_issue_pin(event_data) + if not issue_pin or not scheduled_event_api: + return [] + + escaped_pin = issue_pin.replace("'", "''") + _, response_json = _send_request( + session=session, + method="GET", + url=scheduled_event_api, + payload=None, + params={"pin": f"'{escaped_pin}'"}, + timeout=timeout, + ) + + rows: list[Dict[str, Optional[int]]] = [] + seen: set[tuple[Optional[int], Optional[int]]] = set() + + for item in sg._extract_result_rows(response_json): + scheduled_event_id = sg._int_field(item.get("id")) + event_id = sg._int_field(item.get("event_id")) + if event_id is None: + pin_plus = item.get("pin_plus") + if isinstance(pin_plus, dict): + event_id = sg._int_field(pin_plus.get("service_gateway_event_id")) + + key = (scheduled_event_id, event_id) + if key in seen: + continue + + seen.add(key) + rows.append({"scheduled_event_id": scheduled_event_id, "event_id": event_id}) + + rows.sort(key=lambda row: row.get("scheduled_event_id") or 0, reverse=True) + return rows + + +def _force_upcoming_status( + session: requests.Session, + event_api: str, + service_gateway_event_id: int, + timeout: int, +) -> None: + try: + _send_request( + session=session, + method="PUT", + url=event_api, + payload=sg._make_force_upcoming_payload(service_gateway_event_id), + timeout=timeout, + ) + except Exception as force_exc: + logger.warning( + "Could not force status=upcoming for service-gateway event id=%s: %s", + service_gateway_event_id, + force_exc, + ) + + +def _trigger_event_send( + session: requests.Session, + event_api: str, + event_send_api: str, + service_gateway_event_id: int, + issue_id: Any, + timeout: int, +) -> None: + resolved_event_send_api = event_send_api or sg._derive_event_send_api(event_api) + if not resolved_event_send_api: + logger.warning( + "SERVICE_GATEWAY_EVENT_SEND_API is empty, skipping event send for Plane work-item %s", + issue_id, + ) + return + + try: + _send_request( + session=session, + method="POST", + url=resolved_event_send_api, + payload=sg._make_event_send_payload(service_gateway_event_id), + timeout=timeout, + ) + logger.info( + "Triggered service-gateway /api/event/send for Plane work-item %s event id=%s", + issue_id, + service_gateway_event_id, + ) + except Exception as exc: + logger.warning( + "Could not trigger service-gateway /api/event/send for Plane work-item %s event id=%s: %s", + issue_id, + service_gateway_event_id, + exc, + ) + + +def _trigger_event_send_for_ids( + session: requests.Session, + event_api: str, + event_send_api: str, + service_gateway_event_ids: list[Optional[int]], + issue_id: Any, + timeout: int, +) -> None: + for service_gateway_event_id in sg._unique_positive_ints(service_gateway_event_ids): + _trigger_event_send( + session=session, + event_api=event_api, + event_send_api=event_send_api, + service_gateway_event_id=service_gateway_event_id, + issue_id=issue_id, + timeout=timeout, + ) + + +def _sync_created_event( + session: requests.Session, + event_api: str, + event_send_api: str, + scheduled_event_api: str, + timeout: int, + default_team_id: int, + sync_ctx: _EventSyncContext, +) -> None: + event_payload = sg._make_event_payload( + sync_ctx.event_data, + contact=sync_ctx.contact, + venue=sync_ctx.venue, + event_date=sync_ctx.event_date, + event_time=sync_ctx.event_time, + ) + event_response, event_response_json = _send_request( + session=session, + method="POST", + url=event_api, + payload=event_payload, + timeout=timeout, + ) + + service_gateway_event_id = sg._extract_created_id(event_response_json) + if not service_gateway_event_id: + logger.error( + "Failed to parse event id from service-gateway response status=%s body=%s", + event_response.status_code, + event_response.text, + ) + return + + _force_upcoming_status(session, event_api, service_gateway_event_id, timeout) + _set_issue_sg_event_id(sync_ctx.event_data, service_gateway_event_id) + + if scheduled_event_api and sync_ctx.event_date is not None and sync_ctx.event_time is not None: + scheduled_payload = sg._make_scheduled_event_payload( + sync_ctx.event_data, + service_gateway_event_id, + default_team_id=default_team_id, + contact=sync_ctx.contact, + ) + _, scheduled_response_json = _send_request( + session=session, + method="POST", + url=scheduled_event_api, + payload=scheduled_payload, + timeout=timeout, + ) + + scheduled_event_id = sg._extract_created_id(scheduled_response_json) + logger.info( + ( + "Synced Plane work-item %s to service-gateway " + "/api/event id=%s and /api/scheduled-event id=%s" + ), + sync_ctx.event_data.get("id"), + service_gateway_event_id, + scheduled_event_id, + ) + _trigger_event_send_for_ids( + session=session, + event_api=event_api, + event_send_api=event_send_api, + service_gateway_event_ids=[service_gateway_event_id], + issue_id=sync_ctx.event_data.get("id"), + timeout=timeout, + ) + return + + if scheduled_event_api: + logger.warning( + ( + "Synced Plane work-item %s to service-gateway /api/event id=%s, " + "but skipped /api/scheduled-event because dt_event/tm_event is missing" + ), + sync_ctx.event_data.get("id"), + service_gateway_event_id, + ) + _trigger_event_send_for_ids( + session=session, + event_api=event_api, + event_send_api=event_send_api, + service_gateway_event_ids=[service_gateway_event_id], + issue_id=sync_ctx.event_data.get("id"), + timeout=timeout, + ) + return + + logger.warning( + ( + "Synced Plane work-item %s to service-gateway /api/event id=%s, " + "but skipped /api/scheduled-event because endpoint is not configured" + ), + sync_ctx.event_data.get("id"), + service_gateway_event_id, + ) + _trigger_event_send_for_ids( + session=session, + event_api=event_api, + event_send_api=event_send_api, + service_gateway_event_ids=[service_gateway_event_id], + issue_id=sync_ctx.event_data.get("id"), + timeout=timeout, + ) + + +def _sync_updated_event( + session: requests.Session, + event_api: str, + event_send_api: str, + scheduled_event_api: str, + timeout: int, + default_team_id: int, + sync_ctx: _EventSyncContext, +) -> None: + linked_rows = _resolve_existing_gateway_rows( + session=session, + scheduled_event_api=scheduled_event_api, + event_data=sync_ctx.event_data, + timeout=timeout, + ) + event_ids = sg._unique_positive_ints([row.get("event_id") for row in linked_rows]) + + if not event_ids: + if not scheduled_event_api: + logger.warning( + ( + "Skipped update sync for Plane work-item %s because " + "SERVICE_GATEWAY_SCHEDULED_EVENT_API is not configured and no existing mapping was found." + ), + sync_ctx.event_data.get("id"), + ) + return + + logger.warning( + "No existing service-gateway mapping found for Plane work-item %s. Falling back to create flow.", + sync_ctx.event_data.get("id"), + ) + _sync_created_event( + session=session, + event_api=event_api, + event_send_api=event_send_api, + scheduled_event_api=scheduled_event_api, + timeout=timeout, + default_team_id=default_team_id, + sync_ctx=sync_ctx, + ) + return + + base_event_payload = sg._make_event_payload( + sync_ctx.event_data, + contact=sync_ctx.contact, + venue=sync_ctx.venue, + event_date=sync_ctx.event_date, + event_time=sync_ctx.event_time, + ) + + for service_gateway_event_id in event_ids: + _send_request( + session=session, + method="PUT", + url=event_api, + payload=sg._with_row_id_criteria(base_event_payload, service_gateway_event_id), + timeout=timeout, + ) + _force_upcoming_status(session, event_api, service_gateway_event_id, timeout) + _set_issue_sg_event_id(sync_ctx.event_data, event_ids[0]) + + if not scheduled_event_api: + logger.warning( + "Updated service-gateway /api/event rows for work-item %s but skipped scheduled-event (endpoint missing)", + sync_ctx.event_data.get("id"), + ) + _trigger_event_send_for_ids( + session=session, + event_api=event_api, + event_send_api=event_send_api, + service_gateway_event_ids=event_ids, + issue_id=sync_ctx.event_data.get("id"), + timeout=timeout, + ) + return + + scheduled_event_ids = sg._unique_positive_ints([row.get("scheduled_event_id") for row in linked_rows]) + + if sync_ctx.event_date is None or sync_ctx.event_time is None: + removed_scheduled_count = 0 + if scheduled_event_ids: + removed_scheduled_count = _delete_scheduled_rows_if_supported( + session=session, + scheduled_event_api=scheduled_event_api, + timeout=timeout, + scheduled_event_ids=scheduled_event_ids, + ) + + logger.info( + ( + "Updated service-gateway /api/event rows for work-item %s, " + "and removed /api/scheduled-event rows=%s because dt_event/tm_event is missing" + ), + sync_ctx.event_data.get("id"), + removed_scheduled_count, + ) + _trigger_event_send_for_ids( + session=session, + event_api=event_api, + event_send_api=event_send_api, + service_gateway_event_ids=event_ids, + issue_id=sync_ctx.event_data.get("id"), + timeout=timeout, + ) + return + + scheduled_to_event = { + row.get("scheduled_event_id"): row.get("event_id") + for row in linked_rows + if row.get("scheduled_event_id") is not None and row.get("event_id") in event_ids + } + + updated_scheduled_count = 0 + for scheduled_event_id in scheduled_event_ids: + target_event_id = scheduled_to_event.get(scheduled_event_id) or event_ids[0] + scheduled_payload = sg._make_scheduled_event_payload( + sync_ctx.event_data, + target_event_id, + default_team_id=default_team_id, + contact=sync_ctx.contact, + ) + _send_request( + session=session, + method="PUT", + url=scheduled_event_api, + payload=sg._with_row_id_criteria(scheduled_payload, scheduled_event_id), + timeout=timeout, + ) + updated_scheduled_count += 1 + + if updated_scheduled_count > 0: + logger.info( + ( + "Updated service-gateway rows for Plane work-item %s " + "(event rows=%s, scheduled-event rows=%s)" + ), + sync_ctx.event_data.get("id"), + len(event_ids), + updated_scheduled_count, + ) + _trigger_event_send_for_ids( + session=session, + event_api=event_api, + event_send_api=event_send_api, + service_gateway_event_ids=event_ids, + issue_id=sync_ctx.event_data.get("id"), + timeout=timeout, + ) + return + + scheduled_payload = sg._make_scheduled_event_payload( + sync_ctx.event_data, + event_ids[0], + default_team_id=default_team_id, + contact=sync_ctx.contact, + ) + _, scheduled_response_json = _send_request( + session=session, + method="POST", + url=scheduled_event_api, + payload=scheduled_payload, + timeout=timeout, + ) + + scheduled_event_id = sg._extract_created_id(scheduled_response_json) + logger.info( + ( + "Updated service-gateway /api/event rows for Plane work-item %s " + "and created /api/scheduled-event id=%s" + ), + sync_ctx.event_data.get("id"), + scheduled_event_id, + ) + _trigger_event_send_for_ids( + session=session, + event_api=event_api, + event_send_api=event_send_api, + service_gateway_event_ids=event_ids, + issue_id=sync_ctx.event_data.get("id"), + timeout=timeout, + ) + + +def _delete_scheduled_rows_if_supported( + session: requests.Session, + scheduled_event_api: str, + timeout: int, + scheduled_event_ids: list[int], +) -> int: + deleted_count = 0 + direct_delete_supported = True + + for scheduled_event_id in scheduled_event_ids: + if direct_delete_supported: + try: + _send_request( + session=session, + method="DELETE", + url=scheduled_event_api, + payload=sg._make_delete_payload("scheduled_event", scheduled_event_id), + timeout=timeout, + ) + deleted_count += 1 + continue + except requests.HTTPError as http_error: + error_text = _http_error_text(http_error) + + if "Handler not found" not in error_text: + logger.warning( + "Failed deleting /api/scheduled-event id=%s: %s", + scheduled_event_id, + error_text or http_error, + ) + continue + + direct_delete_supported = False + logger.warning( + ( + "DELETE /api/scheduled-event is unavailable in service-gateway. " + "Falling back to soft-delete via PUT." + ) + ) + + try: + _send_request( + session=session, + method="PUT", + url=scheduled_event_api, + payload=sg._make_scheduled_event_soft_delete_payload(scheduled_event_id), + timeout=timeout, + ) + deleted_count += 1 + except requests.HTTPError as http_error: + logger.warning( + "Failed soft-deleting /api/scheduled-event id=%s: %s", + scheduled_event_id, + _http_error_text(http_error) or http_error, + ) + + return deleted_count + + +def _sync_deleted_event( + session: requests.Session, + event_api: str, + event_send_api: str, + scheduled_event_api: str, + timeout: int, + event_data: Dict[str, Any], +) -> None: + linked_rows = _resolve_existing_gateway_rows( + session=session, + scheduled_event_api=scheduled_event_api, + event_data=event_data, + timeout=timeout, + ) + event_ids = sg._unique_positive_ints( + [sg._int_field(event_data.get("sg_event_id"))] + [row.get("event_id") for row in linked_rows] + ) + if not event_ids: + _set_issue_sg_event_id(event_data, None) + logger.warning( + "No service-gateway mapping found for deleted Plane work-item %s; nothing to delete.", + event_data.get("id"), + ) + return + + _trigger_event_send_for_ids( + session=session, + event_api=event_api, + event_send_api=event_send_api, + service_gateway_event_ids=event_ids, + issue_id=event_data.get("id"), + timeout=timeout, + ) + + for service_gateway_event_id in event_ids: + _send_request( + session=session, + method="DELETE", + url=event_api, + payload=sg._make_delete_payload("event", service_gateway_event_id), + timeout=timeout, + ) + + scheduled_event_ids = sg._unique_positive_ints([row.get("scheduled_event_id") for row in linked_rows]) + deleted_scheduled_count = 0 + if scheduled_event_api and scheduled_event_ids: + deleted_scheduled_count = _delete_scheduled_rows_if_supported( + session=session, + scheduled_event_api=scheduled_event_api, + timeout=timeout, + scheduled_event_ids=scheduled_event_ids, + ) + + logger.info( + ( + "Deleted service-gateway rows for Plane work-item %s " + "(event rows=%s, scheduled-event rows=%s)" + ), + event_data.get("id"), + len(event_ids), + deleted_scheduled_count, + ) + _set_issue_sg_event_id(event_data, None) + + +@shared_task +def service_gateway_event_sync(event: str, verb: str, event_data: Optional[Dict[str, Any]]) -> None: + if not getattr(settings, "SERVICE_GATEWAY_WEBHOOK_ENABLED", False): + return + + if event not in SUPPORTED_EVENTS or verb not in SUPPORTED_VERBS: + return + + if not isinstance(event_data, dict): + logger.warning("Skipping service-gateway sync because event_data is invalid") + return + + if verb == "created": + existing_sg_event_id = sg._int_field(event_data.get("sg_event_id")) + if existing_sg_event_id is not None and existing_sg_event_id > 0: + logger.info( + "Skipping service-gateway create sync for work-item %s because sg_event_id=%s already exists", + event_data.get("id"), + existing_sg_event_id, + ) + return + + event_api = getattr(settings, "SERVICE_GATEWAY_EVENT_API", "") + if not event_api: + logger.warning("SERVICE_GATEWAY_EVENT_API is empty, skipping service-gateway sync") + return + + event_send_api = getattr(settings, "SERVICE_GATEWAY_EVENT_SEND_API", "") + scheduled_event_api = getattr(settings, "SERVICE_GATEWAY_SCHEDULED_EVENT_API", "") + if not scheduled_event_api: + scheduled_event_api = sg._derive_scheduled_event_api(event_api) + + timeout = int(getattr(settings, "SERVICE_GATEWAY_WEBHOOK_TIMEOUT", 30)) + default_team_id = sg._int_field(getattr(settings, "SERVICE_GATEWAY_DEFAULT_TEAM_ID", 0)) or 0 + + try: + sync_ctx = _build_sync_context(event_data) + + if verb in {"created", "updated"} and ( + sync_ctx.event_date is None or sync_ctx.event_time is None + ): + _set_issue_sg_event_id(sync_ctx.event_data, None) + logger.info( + ( + "Skipping service-gateway %s sync for Plane work-item %s " + "because date/time is missing. Keeping work-item in Plane only." + ), + verb, + sync_ctx.event_data.get("id"), + ) + return + + with requests.Session() as session: + session.headers.update({"Content-Type": "application/json"}) + handlers = { + "created": lambda: _sync_created_event( + session=session, + event_api=event_api, + event_send_api=event_send_api, + scheduled_event_api=scheduled_event_api, + timeout=timeout, + default_team_id=default_team_id, + sync_ctx=sync_ctx, + ), + "updated": lambda: _sync_updated_event( + session=session, + event_api=event_api, + event_send_api=event_send_api, + scheduled_event_api=scheduled_event_api, + timeout=timeout, + default_team_id=default_team_id, + sync_ctx=sync_ctx, + ), + "deleted": lambda: _sync_deleted_event( + session=session, + event_api=event_api, + event_send_api=event_send_api, + scheduled_event_api=scheduled_event_api, + timeout=timeout, + event_data=sync_ctx.event_data, + ), + } + handlers[verb]() + + except requests.HTTPError as http_error: + response = getattr(http_error, "response", None) + if response is not None: + logger.error( + "Service-gateway webhook sync failed with HTTP %s body=%s", + response.status_code, + response.text, + ) + else: + logger.error("Service-gateway webhook sync failed with HTTP error: %s", http_error) + log_exception(http_error) + except Exception as exc: + log_exception(exc) + logger.error("Service-gateway webhook sync failed: %s", exc) diff --git a/apps/api/plane/bgtasks/webhook_task.py b/apps/api/plane/bgtasks/webhook_task.py index 2504eb7341e..73139b0c140 100644 --- a/apps/api/plane/bgtasks/webhook_task.py +++ b/apps/api/plane/bgtasks/webhook_task.py @@ -47,6 +47,7 @@ IssueAssignee, ) from plane.license.utils.instance_value import get_email_configuration +from plane.bgtasks.service_gateway_webhook_task import service_gateway_event_sync from plane.utils.exception_logger import log_exception from plane.settings.mongo import MongoConnection @@ -385,6 +386,8 @@ def webhook_activity( event_id: str | uuid.UUID, old_identifier: Optional[str], new_identifier: Optional[str], + event_data: Optional[Dict[str, Any]] = None, + skip_service_gateway: bool = False, ) -> None: """ Process and send webhook notifications for various activities in the system. @@ -404,6 +407,8 @@ def webhook_activity( event_id (str | uuid.UUID): ID of the event object old_identifier (Optional[str]): Previous identifier if any new_identifier (Optional[str]): New identifier if any + event_data (Optional[Dict[str, Any]]): Optional explicit event payload. + skip_service_gateway (bool): When True, do not run service-gateway sync from this task. Returns: None @@ -430,23 +435,32 @@ def webhook_activity( if event == "issue_comment": webhooks = webhooks.filter(issue_comment=True) + if event_data is not None: + event_payload = event_data + else: + event_payload = {"id": event_id} if verb == "deleted" else get_model_data(event=event, event_id=event_id) + actor_payload = get_model_data(event="user", event_id=actor_id) + for webhook in webhooks: webhook_send_task.delay( webhook_id=webhook.id, slug=slug, event=event, - event_data=({"id": event_id} if verb == "deleted" else get_model_data(event=event, event_id=event_id)), + event_data=event_payload, action=verb, current_site=current_site, activity={ "field": field, "new_value": new_value, "old_value": old_value, - "actor": get_model_data(event="user", event_id=actor_id), + "actor": actor_payload, "old_identifier": old_identifier, "new_identifier": new_identifier, }, ) + + if not skip_service_gateway: + service_gateway_event_sync(event=event, verb=verb, event_data=event_payload) return except Exception as e: # Return if a does not exist error occurs diff --git a/apps/api/plane/bgtasks/workspace_seed_task.py b/apps/api/plane/bgtasks/workspace_seed_task.py index fb9980c3fd6..5a9a693ea87 100644 --- a/apps/api/plane/bgtasks/workspace_seed_task.py +++ b/apps/api/plane/bgtasks/workspace_seed_task.py @@ -126,7 +126,7 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]: workspace_id=workspace.id, display_filters={ "layout": "list", - "calendar": {"layout": "month", "show_weekends": False}, + "calendar": {"layout": "month", "show_weekends": True}, "group_by": "state", "order_by": "sort_order", "sub_issue": True, diff --git a/apps/api/plane/db/migrations/0108_issue_start_time.py b/apps/api/plane/db/migrations/0108_issue_start_time.py new file mode 100644 index 00000000000..b55b6e3f035 --- /dev/null +++ b/apps/api/plane/db/migrations/0108_issue_start_time.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.25 on 2025-12-04 11:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0107_migrate_filters_to_rich_filters'), + ] + + operations = [ + migrations.AddField( + model_name='issue', + name='start_time', + field=models.DateTimeField(null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0109_issue_category_issue_level_issue_program_issue_sport_and_more.py b/apps/api/plane/db/migrations/0109_issue_category_issue_level_issue_program_issue_sport_and_more.py new file mode 100644 index 00000000000..dad06d6316b --- /dev/null +++ b/apps/api/plane/db/migrations/0109_issue_category_issue_level_issue_program_issue_sport_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.25 on 2025-12-08 07:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0108_issue_start_time'), + ] + + operations = [ + migrations.AddField( + model_name='issue', + name='category', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='issue', + name='level', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='issue', + name='program', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='issue', + name='sport', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='issue', + name='year', + field=models.CharField(blank=True, max_length=20, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0111_issue_sg_event_id.py b/apps/api/plane/db/migrations/0111_issue_sg_event_id.py new file mode 100644 index 00000000000..a21303dcca9 --- /dev/null +++ b/apps/api/plane/db/migrations/0111_issue_sg_event_id.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0109_issue_category_issue_level_issue_program_issue_sport_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="issue", + name="sg_event_id", + field=models.BigIntegerField(blank=True, db_index=True, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0112_issue_opposition_team.py b/apps/api/plane/db/migrations/0112_issue_opposition_team.py new file mode 100644 index 00000000000..7f995320a3d --- /dev/null +++ b/apps/api/plane/db/migrations/0112_issue_opposition_team.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.25 on 2026-04-27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0111_issue_sg_event_id"), + ] + + operations = [ + migrations.AddField( + model_name="issue", + name="opposition_team", + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0113_rosterplayer.py b/apps/api/plane/db/migrations/0113_rosterplayer.py new file mode 100644 index 00000000000..654504ff29f --- /dev/null +++ b/apps/api/plane/db/migrations/0113_rosterplayer.py @@ -0,0 +1,98 @@ +# Generated by Django 4.2.25 on 2026-05-22 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0112_issue_opposition_team"), + ] + + operations = [ + migrations.CreateModel( + name="RosterPlayer", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Last Modified At")), + ("deleted_at", models.DateTimeField(blank=True, null=True, verbose_name="Deleted At")), + ( + "id", + models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True + ), + ), + ("player_name", models.CharField(max_length=255)), + ("jersey_number", models.CharField(blank=True, max_length=20, null=True)), + ("position", models.CharField(blank=True, max_length=50, null=True)), + ("height", models.CharField(blank=True, max_length=50, null=True)), + ("weight", models.CharField(blank=True, max_length=50, null=True)), + ("class_year", models.CharField(blank=True, max_length=50, null=True)), + ( + "status", + models.CharField( + choices=[ + ("active", "Active"), + ("injured", "Injured"), + ("inactive", "Inactive"), + ("pending", "Pending"), + ], + default="active", + max_length=20, + ), + ), + ("notes", models.TextField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="rosterplayer_created_by", + to="db.user", + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_rosterplayer", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="rosterplayer_updated_by", + to="db.user", + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_rosterplayer", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Roster Player", + "verbose_name_plural": "Roster Players", + "db_table": "roster_players", + "ordering": ("player_name",), + }, + ), + migrations.AddConstraint( + model_name="rosterplayer", + constraint=models.UniqueConstraint( + condition=models.Q(deleted_at__isnull=True, jersey_number__isnull=False) & ~models.Q(jersey_number=""), + fields=("project", "jersey_number"), + name="roster_player_unique_project_jersey_when_active", + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0114_project_sport.py b/apps/api/plane/db/migrations/0114_project_sport.py new file mode 100644 index 00000000000..244ad4cc30f --- /dev/null +++ b/apps/api/plane/db/migrations/0114_project_sport.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0113_rosterplayer"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="sport", + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0115_customplaylist.py b/apps/api/plane/db/migrations/0115_customplaylist.py new file mode 100644 index 00000000000..4ba8cff0064 --- /dev/null +++ b/apps/api/plane/db/migrations/0115_customplaylist.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.25 on 2026-07-17 + +import uuid + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0114_project_sport"), + ] + + operations = [ + migrations.CreateModel( + name="CustomPlaylist", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Last Modified At")), + ("deleted_at", models.DateTimeField(blank=True, null=True, verbose_name="Deleted At")), + ( + "id", + models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True + ), + ), + ("event_id", models.UUIDField(db_index=True)), + ("name", models.CharField(max_length=255)), + ("url", models.URLField()), + ("thumbnail", models.URLField(blank=True, null=True)), + ("clip", models.PositiveIntegerField(default=0)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="customplaylist_created_by", + to="db.user", + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="customplaylist_updated_by", + to="db.user", + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Custom Playlist", + "verbose_name_plural": "Custom Playlists", + "db_table": "custom_playlists", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apps/api/plane/db/migrations/0116_alter_customplaylist_url_thumbnail.py b/apps/api/plane/db/migrations/0116_alter_customplaylist_url_thumbnail.py new file mode 100644 index 00000000000..c3f81a38eed --- /dev/null +++ b/apps/api/plane/db/migrations/0116_alter_customplaylist_url_thumbnail.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.25 on 2026-07-17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0115_customplaylist"), + ] + + operations = [ + migrations.AlterField( + model_name="customplaylist", + name="thumbnail", + field=models.URLField(blank=True, max_length=2048, null=True), + ), + migrations.AlterField( + model_name="customplaylist", + name="url", + field=models.URLField(max_length=2048), + ), + ] diff --git a/apps/api/plane/db/migrations/0117_customplaylist_event_id_sg_event_id.py b/apps/api/plane/db/migrations/0117_customplaylist_event_id_sg_event_id.py new file mode 100644 index 00000000000..7e5498d97c7 --- /dev/null +++ b/apps/api/plane/db/migrations/0117_customplaylist_event_id_sg_event_id.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.25 on 2026-07-17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0116_alter_customplaylist_url_thumbnail"), + ] + + operations = [ + migrations.RemoveField( + model_name="customplaylist", + name="event_id", + ), + migrations.AddField( + model_name="customplaylist", + name="event_id", + field=models.BigIntegerField(db_index=True, default=0), + preserve_default=False, + ), + ] diff --git a/apps/api/plane/db/migrations/0118_alter_customplaylist_url_thumbnail.py b/apps/api/plane/db/migrations/0118_alter_customplaylist_url_thumbnail.py new file mode 100644 index 00000000000..e352f0cf0eb --- /dev/null +++ b/apps/api/plane/db/migrations/0118_alter_customplaylist_url_thumbnail.py @@ -0,0 +1,52 @@ +from urllib.parse import unquote, urlparse + +from django.db import migrations, models + + +def get_last_path_segment(value): + normalized_value = (value or "").strip() + if not normalized_value: + return normalized_value + + parsed_value = urlparse(normalized_value) + path_value = parsed_value.path if parsed_value.scheme or parsed_value.netloc else normalized_value + return unquote(path_value.replace("\\", "/").rstrip("/").split("/")[-1]).strip() or normalized_value + + +def normalize_custom_playlist_files(apps, schema_editor): + CustomPlaylist = apps.get_model("db", "CustomPlaylist") + + for playlist in CustomPlaylist.objects.all().only("id", "url", "thumbnail").iterator(): + normalized_url = get_last_path_segment(playlist.url) + normalized_thumbnail = get_last_path_segment(playlist.thumbnail) if playlist.thumbnail else playlist.thumbnail + + update_fields = [] + if normalized_url != playlist.url: + playlist.url = normalized_url + update_fields.append("url") + if normalized_thumbnail != playlist.thumbnail: + playlist.thumbnail = normalized_thumbnail + update_fields.append("thumbnail") + + if update_fields: + playlist.save(update_fields=update_fields) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0117_customplaylist_event_id_sg_event_id"), + ] + + operations = [ + migrations.RunPython(normalize_custom_playlist_files, migrations.RunPython.noop), + migrations.AlterField( + model_name="customplaylist", + name="thumbnail", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="customplaylist", + name="url", + field=models.CharField(max_length=255), + ), + ] diff --git a/apps/api/plane/db/migrations/0119_normalize_customplaylist_url_thumbnail.py b/apps/api/plane/db/migrations/0119_normalize_customplaylist_url_thumbnail.py new file mode 100644 index 00000000000..e2224727bbf --- /dev/null +++ b/apps/api/plane/db/migrations/0119_normalize_customplaylist_url_thumbnail.py @@ -0,0 +1,42 @@ +from urllib.parse import unquote, urlparse + +from django.db import migrations + + +def get_last_path_segment(value): + normalized_value = (value or "").strip() + if not normalized_value: + return normalized_value + + parsed_value = urlparse(normalized_value) + path_value = parsed_value.path if parsed_value.scheme or parsed_value.netloc else normalized_value + return unquote(path_value.replace("\\", "/").rstrip("/").split("/")[-1]).strip() or normalized_value + + +def normalize_custom_playlist_files(apps, schema_editor): + CustomPlaylist = apps.get_model("db", "CustomPlaylist") + + for playlist in CustomPlaylist.objects.all().only("id", "url", "thumbnail").iterator(): + normalized_url = get_last_path_segment(playlist.url) + normalized_thumbnail = get_last_path_segment(playlist.thumbnail) if playlist.thumbnail else playlist.thumbnail + + update_fields = [] + if normalized_url != playlist.url: + playlist.url = normalized_url + update_fields.append("url") + if normalized_thumbnail != playlist.thumbnail: + playlist.thumbnail = normalized_thumbnail + update_fields.append("thumbnail") + + if update_fields: + playlist.save(update_fields=update_fields) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0118_alter_customplaylist_url_thumbnail"), + ] + + operations = [ + migrations.RunPython(normalize_custom_playlist_files, migrations.RunPython.noop), + ] diff --git a/apps/api/plane/db/migrations/0120_customplaylist_clips.py b/apps/api/plane/db/migrations/0120_customplaylist_clips.py new file mode 100644 index 00000000000..8a0a3d47796 --- /dev/null +++ b/apps/api/plane/db/migrations/0120_customplaylist_clips.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0119_normalize_customplaylist_url_thumbnail"), + ] + + operations = [ + migrations.AddField( + model_name="customplaylist", + name="clips", + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/apps/api/plane/db/migrations/0121_customplaylist_subtitle.py b/apps/api/plane/db/migrations/0121_customplaylist_subtitle.py new file mode 100644 index 00000000000..526d128fc10 --- /dev/null +++ b/apps/api/plane/db/migrations/0121_customplaylist_subtitle.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0120_customplaylist_clips"), + ] + + operations = [ + migrations.AddField( + model_name="customplaylist", + name="subtitle", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0122_drop_stale_customplaylist_annotations.py b/apps/api/plane/db/migrations/0122_drop_stale_customplaylist_annotations.py new file mode 100644 index 00000000000..2ba52d991fc --- /dev/null +++ b/apps/api/plane/db/migrations/0122_drop_stale_customplaylist_annotations.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0121_customplaylist_subtitle"), + ] + + operations = [ + migrations.RunSQL( + sql="ALTER TABLE custom_playlists DROP COLUMN IF EXISTS annotations;", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py index fcf77b936b4..fe7b51313b6 100644 --- a/apps/api/plane/db/models/__init__.py +++ b/apps/api/plane/db/models/__init__.py @@ -77,6 +77,7 @@ from .issue_type import IssueType from .recent_visit import UserRecentVisit +from .roster import RosterPlayer, RosterPlayerStatus from .label import Label @@ -85,3 +86,5 @@ from .sticky import Sticky from .description import Description, DescriptionVersion + +from .custom_playlist import CustomPlaylist diff --git a/apps/api/plane/db/models/custom_playlist.py b/apps/api/plane/db/models/custom_playlist.py new file mode 100644 index 00000000000..cc8b10e95e3 --- /dev/null +++ b/apps/api/plane/db/models/custom_playlist.py @@ -0,0 +1,22 @@ +from django.db import models + +from .base import BaseModel + + +class CustomPlaylist(BaseModel): + event_id = models.BigIntegerField(db_index=True) + name = models.CharField(max_length=255) + subtitle = models.CharField(max_length=255, null=True, blank=True) + url = models.CharField(max_length=255) + thumbnail = models.CharField(max_length=255, null=True, blank=True) + clip = models.PositiveIntegerField(default=0) + clips = models.JSONField(default=list, blank=True) + + class Meta: + verbose_name = "Custom Playlist" + verbose_name_plural = "Custom Playlists" + db_table = "custom_playlists" + ordering = ("-created_at",) + + def __str__(self): + return self.name diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index c495fdb5741..87fe0e68767 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -136,6 +136,18 @@ class Issue(ProjectBaseModel): description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) description_binary = models.BinaryField(null=True) + start_time = models.DateTimeField(null=True) # For issues that require time tracking + + # full-stack Sport App Fields + # These fields define the classification and context of a sport-related program + level = models.CharField(max_length=100, null=True, blank=True) + program = models.CharField(max_length=100, null=True, blank=True) + sport = models.CharField(max_length=100, null=True, blank=True) + year = models.CharField(max_length=20, null=True, blank=True) + category = models.CharField(max_length=100, null=True, blank=True) + opposition_team = models.JSONField(null=True, blank=True) + sg_event_id = models.BigIntegerField(null=True, blank=True, db_index=True) + priority = models.CharField( max_length=30, choices=PRIORITY_CHOICES, @@ -146,7 +158,7 @@ class Issue(ProjectBaseModel): target_date = models.DateField(null=True, blank=True) assignees = models.ManyToManyField( settings.AUTH_USER_MODEL, - blank=True, + blank=True, related_name="assignee", through="IssueAssignee", through_fields=("issue", "assignee"), @@ -176,6 +188,13 @@ class Meta: ordering = ("-created_at",) def save(self, *args, **kwargs): + if self._state.adding and self.project_id: + project_sport = getattr(self.project, "sport", None) + if isinstance(project_sport, str): + project_sport = project_sport.strip() or None + if project_sport: + self.sport = project_sport + if self.state is None: try: from plane.db.models import State @@ -725,6 +744,15 @@ def log_issue_version(cls, issue, user): priority=issue.priority, start_date=issue.start_date, target_date=issue.target_date, + start_time=issue.start_time, + + # Sport App Fields + level=issue.level, + sport=issue.sport, + program=issue.program, + year= issue.year, + category= issue.category, + assignees=list(IssueAssignee.objects.filter(issue=issue).values_list("assignee_id", flat=True)), sequence_id=issue.sequence_id, labels=list(IssueLabel.objects.filter(issue=issue).values_list("label_id", flat=True)), diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index ed5a0877231..699432aa45d 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -68,6 +68,7 @@ class Project(BaseModel): description = models.TextField(verbose_name="Project Description", blank=True) description_text = models.JSONField(verbose_name="Project Description RT", blank=True, null=True) description_html = models.JSONField(verbose_name="Project Description HTML", blank=True, null=True) + sport = models.CharField(max_length=100, null=True, blank=True) network = models.PositiveSmallIntegerField(default=2, choices=NETWORK_CHOICES) workspace = models.ForeignKey("db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project") identifier = models.CharField(max_length=12, verbose_name="Project Identifier", db_index=True) diff --git a/apps/api/plane/db/models/roster.py b/apps/api/plane/db/models/roster.py new file mode 100644 index 00000000000..94884edca86 --- /dev/null +++ b/apps/api/plane/db/models/roster.py @@ -0,0 +1,49 @@ +# Django imports +from django.db import models +from django.db.models import Q, Case, When, Value, IntegerField +from django.db.models.functions import Cast + +# Module imports +from .project import ProjectBaseModel + + +class RosterPlayerStatus(models.TextChoices): + ACTIVE = "active", "Active" + INJURED = "injured", "Injured" + INACTIVE = "inactive", "Inactive" + PENDING = "pending", "Pending" + + +class RosterPlayer(ProjectBaseModel): + player_name = models.CharField(max_length=255) + jersey_number = models.CharField(max_length=20, null=True, blank=True) + position = models.CharField(max_length=50, null=True, blank=True) + height = models.CharField(max_length=50, null=True, blank=True) + weight = models.CharField(max_length=50, null=True, blank=True) + class_year = models.CharField(max_length=50, null=True, blank=True) + status = models.CharField(max_length=20, choices=RosterPlayerStatus.choices, default=RosterPlayerStatus.ACTIVE) + notes = models.TextField(null=True, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["project", "jersey_number"], + condition=Q(deleted_at__isnull=True) & Q(jersey_number__isnull=False) & ~Q(jersey_number=""), + name="roster_player_unique_project_jersey_when_active", + ) + ] + verbose_name = "Roster Player" + verbose_name_plural = "Roster Players" + db_table = "roster_players" + ordering = ("player_name",) + + def __str__(self): + return f"{self.player_name} <{self.project_id}>" + + @classmethod + def jersey_number_ordering(cls): + return Case( + When(jersey_number__regex=r"^\d+$", then=Cast("jersey_number", IntegerField())), + default=Value(2147483647), + output_field=IntegerField(), + ) diff --git a/apps/api/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py index c598acfef93..967f9e8a817 100644 --- a/apps/api/plane/license/api/views/instance.py +++ b/apps/api/plane/license/api/views/instance.py @@ -155,6 +155,7 @@ def get(self, request): # File size settings data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + data["media_library_file_size_limit"] = float(os.environ.get("MEDIA_LIBRARY_FILE_SIZE_LIMIT", 5368709120)) # is smtp configured data["is_smtp_configured"] = bool(EMAIL_HOST) diff --git a/apps/api/plane/seeds/data/states.json b/apps/api/plane/seeds/data/states.json index 5eff65b9d22..53cb90b0a89 100644 --- a/apps/api/plane/seeds/data/states.json +++ b/apps/api/plane/seeds/data/states.json @@ -1,7 +1,7 @@ [ { "id": 1, - "name": "Backlog", + "name": "Scheduled Streaming Event", "color": "#A3A3A3", "sequence": 15000, "group": "backlog", @@ -10,7 +10,7 @@ }, { "id": 2, - "name": "Todo", + "name": "Past Event", "color": "#3A3A3A", "sequence": 25000, "group": "unstarted", diff --git a/apps/api/plane/seeds/data/views.json b/apps/api/plane/seeds/data/views.json index f9d182324fa..9dad1c4e7f4 100644 --- a/apps/api/plane/seeds/data/views.json +++ b/apps/api/plane/seeds/data/views.json @@ -6,7 +6,7 @@ "access": 1, "filters": {}, "project_id": 1, - "display_filters": {"layout": "list", "calendar": {"layout": "month", "show_weekends": false}, "group_by": "state", "order_by": "sort_order", "sub_issue": false, "sub_group_by": null, "show_empty_groups": false}, + "display_filters": {"layout": "list", "calendar": {"layout": "month", "show_weekends": true}, "group_by": "state", "order_by": "sort_order", "sub_issue": false, "sub_group_by": null, "show_empty_groups": false}, "display_properties": {"key": true, "link": true, "cycle": true, "state": true, "labels": true, "modules": true, "assignee": true, "due_date": true, "estimate": true, "priority": true, "created_on": true, "issue_type": true, "start_date": true, "updated_on": true, "customer_count": true, "sub_issue_count": true, "attachment_count": true, "customer_request_count": true}, "sort_order": 75535, "rich_filters": {"priority__in": "urgent"} diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index d47bf6293fd..757aeb75c38 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -206,6 +206,12 @@ # Media Settings MEDIA_ROOT = "mediafiles" MEDIA_URL = "/media/" +MEDIA_LIBRARY_ROOT = os.environ.get("MEDIA_LIBRARY_ROOT", os.path.join(BASE_DIR, "media-library")) +MEDIA_TRANSCODE_SOURCE_ROOT = os.environ.get( + "MEDIA_TRANSCODE_SOURCE_ROOT", + "/data/uploads", +) +MEDIA_TRANSCODE_SOURCE_STORAGE_PREFIX = os.environ.get("MEDIA_TRANSCODE_SOURCE_STORAGE_PREFIX", "uploads") # Internationalization LANGUAGE_CODE = "en-us" @@ -277,6 +283,14 @@ ) FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) +MEDIA_LIBRARY_FILE_SIZE_LIMIT = int(os.environ.get("MEDIA_LIBRARY_FILE_SIZE_LIMIT", 5368709120)) +MEDIA_LIBRARY_HLS_SIZE_THRESHOLD = int(os.environ.get("MEDIA_LIBRARY_HLS_SIZE_THRESHOLD", 104857600)) +MEDIA_LIBRARY_STREAM_CHUNK_BYTES = int(os.environ.get("MEDIA_LIBRARY_STREAM_CHUNK_BYTES", 4 * 1024 * 1024)) +MEDIA_LIBRARY_THUMBNAIL_MAX_BYTES = int(os.environ.get("MEDIA_LIBRARY_THUMBNAIL_MAX_BYTES", 51200)) +MEDIA_TRANSCODE_SERVICE_URL = os.environ.get("MEDIA_TRANSCODE_SERVICE_URL", "http://thumbnail-service:5000") +MEDIA_TRANSCODE_INTERNAL_API_TOKEN = os.environ.get("MEDIA_TRANSCODE_INTERNAL_API_TOKEN", "") +MEDIA_TRANSCODE_REQUEST_TIMEOUT = float(os.environ.get("MEDIA_TRANSCODE_REQUEST_TIMEOUT", 10)) +MEDIA_TRANSCODE_OUTPUT_BASE_URL = os.environ.get("MEDIA_TRANSCODE_OUTPUT_BASE_URL", "") # Unsplash Access key UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") @@ -294,7 +308,7 @@ # Skip environment variable configuration SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1" -DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) +DATA_UPLOAD_MAX_MEMORY_SIZE = max(FILE_SIZE_LIMIT, MEDIA_LIBRARY_FILE_SIZE_LIMIT) # Cookie Settings SESSION_COOKIE_SECURE = secure_origins @@ -304,6 +318,8 @@ SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id") SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) SESSION_SAVE_EVERY_REQUEST = os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1" +COACH_SESSION_COOKIE_NAME = os.environ.get("COACH_SESSION_COOKIE_NAME", "coach-session-id") +COACH_SESSION_COOKIE_AGE = int(os.environ.get("COACH_SESSION_COOKIE_AGE", SESSION_COOKIE_AGE)) # Admin Cookie ADMIN_SESSION_COOKIE_NAME = "admin-session-id" @@ -452,3 +468,17 @@ # MongoDB Settings MONGO_DB_URL = os.environ.get("MONGO_DB_URL", False) MONGO_DB_DATABASE = os.environ.get("MONGO_DB_DATABASE", False) + +# Service Gateway Webhook Sync +SERVICE_GATEWAY_EVENT_API = os.environ.get("SERVICE_GATEWAY_EVENT_API", "") +SERVICE_GATEWAY_EVENT_SEND_API = os.environ.get("SERVICE_GATEWAY_EVENT_SEND_API", "") +SERVICE_GATEWAY_SCHEDULED_EVENT_API = os.environ.get("SERVICE_GATEWAY_SCHEDULED_EVENT_API", "") +SERVICE_GATEWAY_WEBHOOK_ENABLED = ( + os.environ.get("SERVICE_GATEWAY_WEBHOOK_ENABLED", "1" if SERVICE_GATEWAY_EVENT_API else "0") == "1" +) +SERVICE_GATEWAY_WEBHOOK_TIMEOUT = int(os.environ.get("SERVICE_GATEWAY_WEBHOOK_TIMEOUT", 30)) +SERVICE_GATEWAY_TIMEZONE = os.environ.get("SERVICE_GATEWAY_TIMEZONE", "UTC") +try: + SERVICE_GATEWAY_DEFAULT_TEAM_ID = int(os.environ.get("SERVICE_GATEWAY_DEFAULT_TEAM_ID", 0)) +except (TypeError, ValueError): + SERVICE_GATEWAY_DEFAULT_TEAM_ID = 0 diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py index 0a072008638..ce71f31fd0d 100644 --- a/apps/api/plane/settings/storage.py +++ b/apps/api/plane/settings/storage.py @@ -29,6 +29,9 @@ def __init__(self, request=None): self.aws_region = os.environ.get("AWS_REGION") # Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL self.aws_s3_endpoint_url = os.environ.get("AWS_S3_ENDPOINT_URL") or os.environ.get("MINIO_ENDPOINT_URL") + self.aws_s3_internal_endpoint_url = ( + os.environ.get("AWS_S3_INTERNAL_ENDPOINT_URL") or os.environ.get("MINIO_INTERNAL_ENDPOINT_URL") + ) if os.environ.get("USE_MINIO") == "1": # Determine protocol based on environment variable @@ -37,22 +40,28 @@ def __init__(self, request=None): else: endpoint_protocol = request.scheme if request else "http" # Create an S3 client for MinIO + endpoint_url = ( + f"{endpoint_protocol}://{request.get_host()}" + if request + else self.aws_s3_internal_endpoint_url or self.aws_s3_endpoint_url + ) self.s3_client = boto3.client( "s3", aws_access_key_id=self.aws_access_key_id, aws_secret_access_key=self.aws_secret_access_key, region_name=self.aws_region, - endpoint_url=(f"{endpoint_protocol}://{request.get_host()}" if request else self.aws_s3_endpoint_url), + endpoint_url=endpoint_url, config=boto3.session.Config(signature_version="s3v4"), ) else: + endpoint_url = self.aws_s3_endpoint_url if request else (self.aws_s3_internal_endpoint_url or self.aws_s3_endpoint_url) # Create an S3 client self.s3_client = boto3.client( "s3", aws_access_key_id=self.aws_access_key_id, aws_secret_access_key=self.aws_secret_access_key, region_name=self.aws_region, - endpoint_url=self.aws_s3_endpoint_url, + endpoint_url=endpoint_url, config=boto3.session.Config(signature_version="s3v4"), ) diff --git a/apps/api/plane/space/serializer/issue.py b/apps/api/plane/space/serializer/issue.py index a89846cfc78..9a7dce9ebd0 100644 --- a/apps/api/plane/space/serializer/issue.py +++ b/apps/api/plane/space/serializer/issue.py @@ -196,11 +196,18 @@ class Meta: "description", "description_html", "priority", + "start_time", "start_date", "target_date", "sequence_id", "sort_order", "is_draft", + "level", # sport app Field + "sport", # sport app Field + "program", # sport app Field + "category", # sport app Field + "year", # sport app Field + "opposition_team", ] diff --git a/apps/api/plane/static/attachment/audio-icon.png b/apps/api/plane/static/attachment/audio-icon.png new file mode 100644 index 00000000000..a3e551ea7c9 Binary files /dev/null and b/apps/api/plane/static/attachment/audio-icon.png differ diff --git a/apps/api/plane/static/attachment/css-icon.png b/apps/api/plane/static/attachment/css-icon.png new file mode 100644 index 00000000000..cfb502d97ca Binary files /dev/null and b/apps/api/plane/static/attachment/css-icon.png differ diff --git a/apps/api/plane/static/attachment/csv-icon.png b/apps/api/plane/static/attachment/csv-icon.png new file mode 100644 index 00000000000..39d0ee713d1 Binary files /dev/null and b/apps/api/plane/static/attachment/csv-icon.png differ diff --git a/apps/api/plane/static/attachment/default-icon.png b/apps/api/plane/static/attachment/default-icon.png new file mode 100644 index 00000000000..eb1ea5175c5 Binary files /dev/null and b/apps/api/plane/static/attachment/default-icon.png differ diff --git a/apps/api/plane/static/attachment/doc-icon.png b/apps/api/plane/static/attachment/doc-icon.png new file mode 100644 index 00000000000..d1433721b5b Binary files /dev/null and b/apps/api/plane/static/attachment/doc-icon.png differ diff --git a/apps/api/plane/static/attachment/excel-icon.png b/apps/api/plane/static/attachment/excel-icon.png new file mode 100644 index 00000000000..b3a1b851edf Binary files /dev/null and b/apps/api/plane/static/attachment/excel-icon.png differ diff --git a/apps/api/plane/static/attachment/figma-icon.png b/apps/api/plane/static/attachment/figma-icon.png new file mode 100644 index 00000000000..b4a1b63b3df Binary files /dev/null and b/apps/api/plane/static/attachment/figma-icon.png differ diff --git a/apps/api/plane/static/attachment/html-icon.png b/apps/api/plane/static/attachment/html-icon.png new file mode 100644 index 00000000000..b6259a2befb Binary files /dev/null and b/apps/api/plane/static/attachment/html-icon.png differ diff --git a/apps/api/plane/static/attachment/img-icon.png b/apps/api/plane/static/attachment/img-icon.png new file mode 100644 index 00000000000..6c5b8fce0c3 Binary files /dev/null and b/apps/api/plane/static/attachment/img-icon.png differ diff --git a/apps/api/plane/static/attachment/jpg-icon.png b/apps/api/plane/static/attachment/jpg-icon.png new file mode 100644 index 00000000000..dfd2c9fde17 Binary files /dev/null and b/apps/api/plane/static/attachment/jpg-icon.png differ diff --git a/apps/api/plane/static/attachment/js-icon.png b/apps/api/plane/static/attachment/js-icon.png new file mode 100644 index 00000000000..66aacdaff2f Binary files /dev/null and b/apps/api/plane/static/attachment/js-icon.png differ diff --git a/apps/api/plane/static/attachment/pdf-icon.png b/apps/api/plane/static/attachment/pdf-icon.png new file mode 100644 index 00000000000..21c42d73fe0 Binary files /dev/null and b/apps/api/plane/static/attachment/pdf-icon.png differ diff --git a/apps/api/plane/static/attachment/png-icon.png b/apps/api/plane/static/attachment/png-icon.png new file mode 100644 index 00000000000..f04207daaab Binary files /dev/null and b/apps/api/plane/static/attachment/png-icon.png differ diff --git a/apps/api/plane/static/attachment/rar-icon.png b/apps/api/plane/static/attachment/rar-icon.png new file mode 100644 index 00000000000..7305455bd9a Binary files /dev/null and b/apps/api/plane/static/attachment/rar-icon.png differ diff --git a/apps/api/plane/static/attachment/svg-icon.png b/apps/api/plane/static/attachment/svg-icon.png new file mode 100644 index 00000000000..856f94fbeae Binary files /dev/null and b/apps/api/plane/static/attachment/svg-icon.png differ diff --git a/apps/api/plane/static/attachment/txt-icon.png b/apps/api/plane/static/attachment/txt-icon.png new file mode 100644 index 00000000000..1c1babf9c34 Binary files /dev/null and b/apps/api/plane/static/attachment/txt-icon.png differ diff --git a/apps/api/plane/static/attachment/video-icon.png b/apps/api/plane/static/attachment/video-icon.png new file mode 100644 index 00000000000..510838d918b Binary files /dev/null and b/apps/api/plane/static/attachment/video-icon.png differ diff --git a/apps/api/plane/static/attachment/zip-icon.png b/apps/api/plane/static/attachment/zip-icon.png new file mode 100644 index 00000000000..0db1da136df Binary files /dev/null and b/apps/api/plane/static/attachment/zip-icon.png differ diff --git a/apps/api/plane/tests/contract/app/test_custom_playlist_app.py b/apps/api/plane/tests/contract/app/test_custom_playlist_app.py new file mode 100644 index 00000000000..d63e48cea49 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_custom_playlist_app.py @@ -0,0 +1,357 @@ +import uuid + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from plane.db.models import ( + CustomPlaylist, + Issue, + Project, + ProjectMember, + User, +) + + +class TestCustomPlaylistBase: + def get_playlist_url(self, playlist_id: uuid.UUID | None = None) -> str: + base_url = "/api/custom-playlists/" + if playlist_id: + return f"{base_url}{playlist_id}/" + return base_url + + def create_project_event(self, workspace, user, name="Football Final Match"): + project = Project.objects.create(name=f"{name} Project", identifier=uuid.uuid4().hex[:8], workspace=workspace) + ProjectMember.objects.create(project=project, member=user, role=20, is_active=True) + event = Issue.objects.create(project=project, name=name, sg_event_id=100000 + uuid.uuid4().int % 900000) + return project, event + + def playlist_payload(self, event): + return { + "event_id": event.sg_event_id, + "name": "Football Final Match", + "url": "https://sports.kanavio.com/hls/final-match/master.m3u8", + "thumbnail": "https://sports.kanavio.com/thumbnails/final-match.jpg", + "clip": 12, + "clips": [ + { + "id": "tag-row-1", + "title": "Fast Break Dunk", + "durationSeconds": 12, + "fallbackTimestamp": "00:28:45", + "timestamp": "00:28:45", + "thumbnail": "https://sports.kanavio.com/thumbnails/fast-break.jpg", + "timecode": "00:28:45-00:28:57", + } + ], + } + + def project_playlist_payload(self, project, event_id=1313): + return { + "event_id": event_id, + "project_id": str(project.id), + "workspace_slug": project.workspace.slug, + "name": "Media Library Event Playlist", + "url": "https://sports.kanavio.com/hls/media-library-event/master.m3u8", + "thumbnail": "https://sports.kanavio.com/thumbnails/media-library-event.jpg", + "clip": 7, + "clips": [ + { + "id": "tag-row-1", + "title": "Fast Break Dunk", + "durationSeconds": 12, + "fallbackTimestamp": "00:28:45", + "timestamp": "00:28:45", + "thumbnail": "https://sports.kanavio.com/thumbnails/fast-break.jpg", + "timecode": "00:28:45-00:28:57", + } + ], + } + + +@pytest.mark.contract +class TestCustomPlaylistAPI(TestCustomPlaylistBase): + @pytest.mark.django_db + def test_create_playlist_successfully(self, session_client, workspace, create_user): + _, event = self.create_project_event(workspace, create_user) + + response = session_client.post(self.get_playlist_url(), self.playlist_payload(event), format="json") + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["event_id"] == event.sg_event_id + assert data["name"] == "Football Final Match" + assert data["url"] == "master.m3u8" + assert data["thumbnail"] == "final-match.jpg" + assert data["clip"] == 12 + assert data["clips"][0]["title"] == "Fast Break Dunk" + assert "annotations" not in data + assert "durationSeconds" not in data["clips"][0] + assert "fallbackTimestamp" not in data["clips"][0] + assert "timecode" not in data["clips"][0] + assert CustomPlaylist.objects.filter(pk=data["id"], event_id=event.sg_event_id).exists() + + @pytest.mark.django_db + def test_list_playlists_returns_only_accessible_events(self, session_client, workspace, create_user): + _, event = self.create_project_event(workspace, create_user) + accessible_playlist = CustomPlaylist.objects.create( + event_id=event.sg_event_id, + name="Accessible Playlist", + url="https://sports.kanavio.com/hls/accessible/master.m3u8", + ) + + hidden_project = Project.objects.create(name="Hidden Project", identifier="HIDE", workspace=workspace) + hidden_event = Issue.objects.create( + project=hidden_project, + name="Hidden Event", + sg_event_id=100000 + uuid.uuid4().int % 900000, + ) + CustomPlaylist.objects.create( + event_id=hidden_event.sg_event_id, + name="Hidden Playlist", + url="https://sports.kanavio.com/hls/hidden/master.m3u8", + ) + + response = session_client.get(self.get_playlist_url()) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert [playlist["id"] for playlist in data] == [str(accessible_playlist.id)] + + @pytest.mark.django_db + def test_filter_playlists_by_event(self, session_client, workspace, create_user): + _, event = self.create_project_event(workspace, create_user, name="Event One") + _, other_event = self.create_project_event(workspace, create_user, name="Event Two") + playlist = CustomPlaylist.objects.create( + event_id=event.sg_event_id, + name="Event One Playlist", + url="https://sports.kanavio.com/hls/event-one/master.m3u8", + ) + CustomPlaylist.objects.create( + event_id=other_event.sg_event_id, + name="Event Two Playlist", + url="https://sports.kanavio.com/hls/event-two/master.m3u8", + ) + + response = session_client.get(self.get_playlist_url(), {"event_id": event.sg_event_id}) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == str(playlist.id) + assert data[0]["event_id"] == event.sg_event_id + + @pytest.mark.django_db + def test_create_playlist_with_project_context_when_event_issue_is_missing( + self, session_client, workspace, create_user + ): + project = Project.objects.create(name="Media Library Project", identifier="MLIB", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + response = session_client.post(self.get_playlist_url(), self.project_playlist_payload(project), format="json") + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["event_id"] == 1313 + assert data["name"] == "Media Library Event Playlist" + assert data["url"] == "master.m3u8" + assert data["thumbnail"] == "media-library-event.jpg" + assert data["clip"] == 7 + assert data["clips"][0]["title"] == "Fast Break Dunk" + assert "durationSeconds" not in data["clips"][0] + assert "fallbackTimestamp" not in data["clips"][0] + assert "timecode" not in data["clips"][0] + assert "project_id" not in data + assert "workspace_slug" not in data + + @pytest.mark.django_db + def test_filter_playlists_by_event_with_project_context_when_event_issue_is_missing( + self, session_client, workspace, create_user + ): + project = Project.objects.create(name="Media Library Project", identifier="MLIB", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + playlist = CustomPlaylist.objects.create( + event_id=1313, + name="Media Library Event Playlist", + url="master.m3u8", + thumbnail="media-library-event.jpg", + clip=7, + ) + + response = session_client.get( + self.get_playlist_url(), + { + "event_id": 1313, + "project_id": str(project.id), + "workspace_slug": project.workspace.slug, + }, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == str(playlist.id) + assert data[0]["event_id"] == 1313 + + @pytest.mark.django_db + def test_retrieve_playlist_successfully(self, session_client, workspace, create_user): + _, event = self.create_project_event(workspace, create_user) + playlist = CustomPlaylist.objects.create( + event_id=event.sg_event_id, + name="Playlist", + url="https://sports.kanavio.com/hls/playlist/master.m3u8", + ) + + response = session_client.get(self.get_playlist_url(playlist.id)) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["id"] == str(playlist.id) + + @pytest.mark.django_db + def test_update_playlist_successfully(self, session_client, workspace, create_user): + _, event = self.create_project_event(workspace, create_user) + playlist = CustomPlaylist.objects.create( + event_id=event.sg_event_id, + name="Old Playlist", + url="https://sports.kanavio.com/hls/old/master.m3u8", + ) + + response = session_client.patch( + self.get_playlist_url(playlist.id), + { + "name": "Updated Playlist", + "subtitle": "Offense clips", + "thumbnail": "", + "clip": 3, + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + playlist.refresh_from_db() + assert playlist.name == "Updated Playlist" + assert playlist.subtitle == "Offense clips" + assert playlist.thumbnail is None + assert playlist.clip == 3 + assert response.json()["subtitle"] == "Offense clips" + assert response.json()["thumbnail"] is None + assert response.json()["clip"] == 3 + assert "annotations" not in response.json() + + @pytest.mark.django_db + def test_delete_playlist_successfully(self, session_client, workspace, create_user): + _, event = self.create_project_event(workspace, create_user) + playlist = CustomPlaylist.objects.create( + event_id=event.sg_event_id, + name="Playlist", + url="https://sports.kanavio.com/hls/playlist/master.m3u8", + ) + + response = session_client.delete(self.get_playlist_url(playlist.id)) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not CustomPlaylist.all_objects.filter(pk=playlist.id).exists() + + @pytest.mark.django_db + def test_delete_soft_deleted_playlist_hard_deletes_row(self, session_client, workspace, create_user): + _, event = self.create_project_event(workspace, create_user) + playlist = CustomPlaylist.objects.create( + event_id=event.sg_event_id, + name="Playlist", + url="https://sports.kanavio.com/hls/playlist/master.m3u8", + ) + playlist.delete() + + response = session_client.delete(self.get_playlist_url(playlist.id)) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not CustomPlaylist.all_objects.filter(pk=playlist.id).exists() + + @pytest.mark.django_db + def test_create_playlist_rejects_invalid_input(self, session_client, workspace, create_user): + _, event = self.create_project_event(workspace, create_user) + + response = session_client.post( + self.get_playlist_url(), + { + "event_id": event.sg_event_id, + "name": " ", + "url": "", + "thumbnail": f"{'a' * 256}.jpg", + "clip": -1, + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + data = response.json() + assert "name" in data + assert "url" in data + assert "thumbnail" in data + assert "clip" in data + + @pytest.mark.django_db + def test_create_playlist_accepts_file_names(self, session_client, workspace, create_user): + _, event = self.create_project_event(workspace, create_user) + + response = session_client.post( + self.get_playlist_url(), + { + "event_id": event.sg_event_id, + "name": "Filename Playlist", + "url": "990ef30c.m3u8", + "thumbnail": "gYOMnVLyxdWHQWFG.jpg", + "clip": 1, + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["url"] == "990ef30c.m3u8" + assert data["thumbnail"] == "gYOMnVLyxdWHQWFG.jpg" + + @pytest.mark.django_db + def test_create_playlist_returns_404_for_missing_event(self, session_client): + response = session_client.post( + self.get_playlist_url(), + { + "event_id": 999999999, + "name": "Missing Event Playlist", + "url": "https://sports.kanavio.com/hls/missing/master.m3u8", + }, + format="json", + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_create_playlist_returns_403_for_inaccessible_event(self, workspace, create_user): + other_user = User.objects.create(email="other-user@plane.so", first_name="Other", last_name="User") + project, event = self.create_project_event(workspace, other_user) + + denied_user = create_user + ProjectMember.objects.filter(project=project, member=denied_user).delete() + client = APIClient() + client.force_authenticate(user=denied_user) + + response = client.post(self.get_playlist_url(), self.playlist_payload(event), format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.django_db + def test_retrieve_playlist_returns_403_for_inaccessible_event(self, workspace, create_user): + other_user = User.objects.create(email="playlist-owner@plane.so", first_name="Owner", last_name="User") + _, event = self.create_project_event(workspace, other_user) + playlist = CustomPlaylist.objects.create( + event_id=event.sg_event_id, + name="Hidden Playlist", + url="https://sports.kanavio.com/hls/hidden/master.m3u8", + ) + + client = APIClient() + client.force_authenticate(user=create_user) + + response = client.get(self.get_playlist_url(playlist.id)) + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/apps/api/plane/tests/contract/app/test_project_app.py b/apps/api/plane/tests/contract/app/test_project_app.py index 38b0f51f3b5..b16a87e1fde 100644 --- a/apps/api/plane/tests/contract/app/test_project_app.py +++ b/apps/api/plane/tests/contract/app/test_project_app.py @@ -203,6 +203,7 @@ def test_create_project_with_all_optional_fields(self, session_client, workspace "name": "Full Project", "identifier": "FP", "description": "A comprehensive test project", + "sport": "Cricket", "network": 2, "cycle_view": True, "issue_views_view": False, @@ -222,6 +223,7 @@ def test_create_project_with_all_optional_fields(self, session_client, workspace response_data = response.json() assert response_data["description"] == project_data["description"] + assert response_data["sport"] == project_data["sport"] assert response_data["network"] == project_data["network"] @@ -378,6 +380,7 @@ def test_partial_update_project_success(self, session_client, workspace, create_ update_data = { "name": "Updated Project", "description": "Updated description", + "sport": "Football", "cycle_view": True, "module_view": False, } @@ -390,9 +393,31 @@ def test_partial_update_project_success(self, session_client, workspace, create_ project.refresh_from_db() assert project.name == "Updated Project" assert project.description == "Updated description" + assert project.sport == "Football" assert project.cycle_view is True assert project.module_view is False + @pytest.mark.django_db + def test_partial_update_project_rejects_sport_change_once_set(self, session_client, workspace, create_user): + """Test project sport cannot be changed after it has been saved.""" + project = Project.objects.create( + name="Locked Sport Project", + identifier="LSP", + workspace=workspace, + sport="Cricket", + ) + + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = session_client.patch(url, {"sport": "Football"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["sport"] == ["PROJECT_SPORT_ALREADY_LOCKED"] + + project.refresh_from_db() + assert project.sport == "Cricket" + @pytest.mark.django_db def test_partial_update_project_forbidden_non_admin(self, session_client, workspace): """Test that non-admin project members cannot update project""" diff --git a/apps/api/plane/tests/contract/app/test_roster_app.py b/apps/api/plane/tests/contract/app/test_roster_app.py new file mode 100644 index 00000000000..86405453a92 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_roster_app.py @@ -0,0 +1,177 @@ +import uuid + +import pytest +from rest_framework import status + +from plane.db.models import Project, ProjectMember, RosterPlayer + + +class TestRosterBase: + def get_roster_url(self, workspace_slug: str, project_id: uuid.UUID, player_id: uuid.UUID | None = None) -> str: + base_url = f"/api/workspaces/{workspace_slug}/projects/{project_id}/roster/" + if player_id: + return f"{base_url}{player_id}/" + return base_url + + def get_roster_import_url(self, workspace_slug: str, project_id: uuid.UUID) -> str: + return f"/api/workspaces/{workspace_slug}/projects/{project_id}/roster/import/" + + +@pytest.mark.contract +class TestRosterAPI(TestRosterBase): + @pytest.mark.django_db + def test_create_player_successfully(self, session_client, workspace, create_user): + project = Project.objects.create(name="Roster Project", identifier="RP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + response = session_client.post( + self.get_roster_url(workspace.slug, project.id), + { + "player_name": "J. Brandon", + "jersey_number": "17", + "position": "QB", + "status": "active", + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert RosterPlayer.objects.filter(project=project, player_name="J. Brandon").exists() + assert response.json()["program_id"] == str(project.id) + + @pytest.mark.django_db + def test_fetch_roster_by_program(self, session_client, workspace, create_user): + project = Project.objects.create(name="Roster Project", identifier="RP", workspace=workspace) + other_project = Project.objects.create(name="Other Project", identifier="OP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + ProjectMember.objects.create(project=other_project, member=create_user, role=20, is_active=True) + RosterPlayer.objects.create(project=project, player_name="A Player", jersey_number="10", status="active") + RosterPlayer.objects.create(project=other_project, player_name="B Player", jersey_number="11", status="active") + + response = session_client.get(self.get_roster_url(workspace.slug, project.id)) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 1 + assert data[0]["player_name"] == "A Player" + + @pytest.mark.django_db + def test_update_player_successfully(self, session_client, workspace, create_user): + project = Project.objects.create(name="Roster Project", identifier="RP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + player = RosterPlayer.objects.create(project=project, player_name="A Player", jersey_number="10", status="active") + + response = session_client.patch( + self.get_roster_url(workspace.slug, project.id, player.id), + {"status": "injured", "notes": "Week-to-week"}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + player.refresh_from_db() + assert player.status == "injured" + assert player.notes == "Week-to-week" + + @pytest.mark.django_db + def test_delete_player_successfully(self, session_client, workspace, create_user): + project = Project.objects.create(name="Roster Project", identifier="RP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + player = RosterPlayer.objects.create(project=project, player_name="A Player", jersey_number="10", status="active") + + response = session_client.delete(self.get_roster_url(workspace.slug, project.id, player.id)) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["success"] is True + assert not RosterPlayer.objects.filter(pk=player.id).exists() + + @pytest.mark.django_db + def test_duplicate_jersey_number_validation(self, session_client, workspace, create_user): + project = Project.objects.create(name="Roster Project", identifier="RP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + RosterPlayer.objects.create(project=project, player_name="A Player", jersey_number="10", status="active") + + response = session_client.post( + self.get_roster_url(workspace.slug, project.id), + {"player_name": "B Player", "jersey_number": "10", "status": "active"}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "jersey_number" in response.json() + + @pytest.mark.django_db + def test_cannot_access_player_from_another_program(self, session_client, workspace, create_user): + project = Project.objects.create(name="Roster Project", identifier="RP", workspace=workspace) + other_project = Project.objects.create(name="Other Project", identifier="OP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + ProjectMember.objects.create(project=other_project, member=create_user, role=20, is_active=True) + player = RosterPlayer.objects.create(project=other_project, player_name="Hidden Player", jersey_number="99") + + response = session_client.get(self.get_roster_url(workspace.slug, project.id, player.id)) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_empty_roster_response(self, session_client, workspace, create_user): + project = Project.objects.create(name="Roster Project", identifier="RP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + response = session_client.get(self.get_roster_url(workspace.slug, project.id)) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + + @pytest.mark.django_db + def test_invalid_status_validation(self, session_client, workspace, create_user): + project = Project.objects.create(name="Roster Project", identifier="RP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + response = session_client.post( + self.get_roster_url(workspace.slug, project.id), + {"player_name": "A Player", "status": "unknown-status"}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "status" in response.json() + + @pytest.mark.django_db + def test_import_roster_successfully(self, session_client, workspace, create_user): + project = Project.objects.create(name="Roster Project", identifier="RP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + response = session_client.post( + self.get_roster_import_url(workspace.slug, project.id), + { + "players": [ + {"player_name": "J. Brandon", "jersey_number": "17", "position": "QB", "status": "active"}, + {"player_name": "A. Broome", "jersey_number": "20", "position": "RB", "status": "injured"}, + ] + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["success"] is True + assert response.json()["imported_count"] == 2 + assert RosterPlayer.objects.filter(project=project).count() == 2 + + @pytest.mark.django_db + def test_import_roster_rejects_duplicate_jersey_numbers(self, session_client, workspace, create_user): + project = Project.objects.create(name="Roster Project", identifier="RP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + + response = session_client.post( + self.get_roster_import_url(workspace.slug, project.id), + { + "players": [ + {"player_name": "J. Brandon", "jersey_number": "17", "position": "QB", "status": "active"}, + {"player_name": "T. Castellanos", "jersey_number": "17", "position": "QB", "status": "active"}, + ] + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "players" in response.json() + assert RosterPlayer.objects.filter(project=project).count() == 0 diff --git a/apps/api/plane/tests/unit/app/test_media_library_upload_logging.py b/apps/api/plane/tests/unit/app/test_media_library_upload_logging.py new file mode 100644 index 00000000000..97b1514f15c --- /dev/null +++ b/apps/api/plane/tests/unit/app/test_media_library_upload_logging.py @@ -0,0 +1,47 @@ +import logging + +from plane.app.views.media_library import _get_upload_trace_fields, _log_media_upload_event + + +class DummyRequest: + headers = { + "X-Upload-ID": " upload-20260818T130800Z-game-clip ", + "X-Request-ID": " upload-20260818T130800Z-game-clip-try-1 ", + "Cookie": "session=secret", + "Authorization": "Bearer secret", + } + + +def test_get_upload_trace_fields_preserves_safe_correlation_fields(): + trace = _get_upload_trace_fields( + DummyRequest(), + { + "upload_client": "plane-web", + "authorization": "Bearer secret", + "cookie": "session=secret", + }, + ) + + assert trace == { + "upload_id": "upload-20260818T130800Z-game-clip", + "request_id": "upload-20260818T130800Z-game-clip-try-1", + "upload_client": "plane-web", + } + + +def test_log_media_upload_event_uses_structured_payload(caplog): + with caplog.at_level(logging.INFO): + _log_media_upload_event( + logging.INFO, + "request_received", + {"upload_id": "upload-1", "request_id": "request-1"}, + workspace_slug="workspace", + secret_token="must-not-log", + duration_ms=25, + ) + + assert "media_library_upload_request_received" in caplog.text + assert "upload-1" in caplog.text + assert "request-1" in caplog.text + assert "workspace" in caplog.text + assert "must-not-log" not in caplog.text diff --git a/apps/api/plane/tests/unit/bg_tasks/test_service_gateway_sync_helpers.py b/apps/api/plane/tests/unit/bg_tasks/test_service_gateway_sync_helpers.py new file mode 100644 index 00000000000..8cbd08c1ec5 --- /dev/null +++ b/apps/api/plane/tests/unit/bg_tasks/test_service_gateway_sync_helpers.py @@ -0,0 +1,22 @@ +import datetime + +import pytest + +from plane.bgtasks import service_gateway_sync_helpers as sg + + +@pytest.mark.unit +class TestServiceGatewaySyncHelpers: + def test_extract_time_uses_utc_by_default_for_aware_datetimes(self): + sg._service_gateway_tzinfo.cache_clear() + + event_time = datetime.datetime( + 2026, + 4, + 28, + 12, + 30, + tzinfo=datetime.timezone.utc, + ) + + assert sg._extract_time({"start_time": event_time}) == 1230 diff --git a/apps/api/plane/tests/unit/bg_tasks/test_service_gateway_webhook_task.py b/apps/api/plane/tests/unit/bg_tasks/test_service_gateway_webhook_task.py new file mode 100644 index 00000000000..811c2b31050 --- /dev/null +++ b/apps/api/plane/tests/unit/bg_tasks/test_service_gateway_webhook_task.py @@ -0,0 +1,156 @@ +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from plane.bgtasks.service_gateway_webhook_task import ( + _sync_deleted_event, + _trigger_event_send, + _trigger_event_send_for_ids, +) + + +@pytest.mark.unit +class TestServiceGatewayWebhookTask: + def test_trigger_event_send_uses_derived_send_url(self): + session = MagicMock() + + with patch("plane.bgtasks.service_gateway_webhook_task._send_request") as mock_send_request: + _trigger_event_send( + session=session, + event_api="http://drake.in:1437/api/event", + event_send_api="", + service_gateway_event_id=321, + issue_id="issue-123", + timeout=30, + ) + + mock_send_request.assert_called_once_with( + session=session, + method="POST", + url="http://drake.in:1437/api/event/send", + payload={ + "table": "event", + "criteria": [ + {"field": "id", "type": 0, "value": 321}, + ], + }, + timeout=30, + ) + + def test_trigger_event_send_uses_explicit_send_url(self): + session = MagicMock() + + with patch("plane.bgtasks.service_gateway_webhook_task._send_request") as mock_send_request: + _trigger_event_send( + session=session, + event_api="http://drake.in:1437/api/event", + event_send_api="http://drake.in:1437/api/event/send-now", + service_gateway_event_id=654, + issue_id="issue-456", + timeout=45, + ) + + mock_send_request.assert_called_once_with( + session=session, + method="POST", + url="http://drake.in:1437/api/event/send-now", + payload={ + "table": "event", + "criteria": [ + {"field": "id", "type": 0, "value": 654}, + ], + }, + timeout=45, + ) + + def test_trigger_event_send_swallow_errors(self): + session = MagicMock() + + with patch( + "plane.bgtasks.service_gateway_webhook_task._send_request", + side_effect=requests.HTTPError("boom"), + ): + _trigger_event_send( + session=session, + event_api="http://drake.in:1437/api/event", + event_send_api="", + service_gateway_event_id=999, + issue_id="issue-789", + timeout=30, + ) + + def test_trigger_event_send_for_ids_calls_each_unique_event_id(self): + session = MagicMock() + + with patch("plane.bgtasks.service_gateway_webhook_task._trigger_event_send") as mock_trigger_event_send: + _trigger_event_send_for_ids( + session=session, + event_api="http://drake.in:1437/api/event", + event_send_api="", + service_gateway_event_ids=[321, None, 0, 321, 654], + issue_id="issue-999", + timeout=30, + ) + + assert mock_trigger_event_send.call_count == 2 + mock_trigger_event_send.assert_any_call( + session=session, + event_api="http://drake.in:1437/api/event", + event_send_api="", + service_gateway_event_id=321, + issue_id="issue-999", + timeout=30, + ) + mock_trigger_event_send.assert_any_call( + session=session, + event_api="http://drake.in:1437/api/event", + event_send_api="", + service_gateway_event_id=654, + issue_id="issue-999", + timeout=30, + ) + + def test_sync_deleted_event_uses_sg_event_id_without_mapping(self): + session = MagicMock() + + with patch( + "plane.bgtasks.service_gateway_webhook_task._resolve_existing_gateway_rows", + return_value=[], + ), patch( + "plane.bgtasks.service_gateway_webhook_task._trigger_event_send_for_ids" + ) as mock_trigger_event_send_for_ids, patch( + "plane.bgtasks.service_gateway_webhook_task._send_request" + ) as mock_send_request, patch( + "plane.bgtasks.service_gateway_webhook_task._set_issue_sg_event_id" + ) as mock_set_issue_sg_event_id: + _sync_deleted_event( + session=session, + event_api="http://drake.in:1437/api/event", + event_send_api="http://drake.in:1437/api/event/send", + scheduled_event_api="", + timeout=30, + event_data={"id": "issue-111", "sg_event_id": 777}, + ) + + mock_trigger_event_send_for_ids.assert_called_once_with( + session=session, + event_api="http://drake.in:1437/api/event", + event_send_api="http://drake.in:1437/api/event/send", + service_gateway_event_ids=[777], + issue_id="issue-111", + timeout=30, + ) + mock_send_request.assert_called_once_with( + session=session, + method="DELETE", + url="http://drake.in:1437/api/event", + payload={ + "table": "event", + "criteria": [ + {"field": "id", "type": 0, "value": 777}, + ], + }, + timeout=30, + ) + mock_set_issue_sg_event_id.assert_called_once_with({"id": "issue-111", "sg_event_id": 777}, None) diff --git a/apps/api/plane/tests/unit/bg_tasks/test_webhook_task.py b/apps/api/plane/tests/unit/bg_tasks/test_webhook_task.py new file mode 100644 index 00000000000..4de4a964997 --- /dev/null +++ b/apps/api/plane/tests/unit/bg_tasks/test_webhook_task.py @@ -0,0 +1,65 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from plane.bgtasks.webhook_task import webhook_activity + + +@pytest.mark.unit +class TestWebhookTask: + def test_webhook_activity_uses_explicit_deleted_event_data(self): + webhook_queryset = MagicMock() + webhook_queryset.filter.return_value = webhook_queryset + webhook_queryset.__iter__.return_value = iter([]) + + with patch("plane.bgtasks.webhook_task.Webhook.objects.filter", return_value=webhook_queryset), patch( + "plane.bgtasks.webhook_task.get_model_data", + return_value={"id": "user-123"}, + ), patch("plane.bgtasks.webhook_task.service_gateway_event_sync") as mock_service_gateway_event_sync: + webhook_activity( + event="issue", + verb="deleted", + field=None, + old_value=None, + new_value=None, + actor_id="user-123", + slug="workspace-1", + current_site="http://localhost:3000", + event_id="issue-123", + old_identifier=None, + new_identifier=None, + event_data={"id": "issue-123", "sg_event_id": 321}, + ) + + mock_service_gateway_event_sync.assert_called_once_with( + event="issue", + verb="deleted", + event_data={"id": "issue-123", "sg_event_id": 321}, + ) + + def test_webhook_activity_can_skip_service_gateway(self): + webhook_queryset = MagicMock() + webhook_queryset.filter.return_value = webhook_queryset + webhook_queryset.__iter__.return_value = iter([]) + + with patch("plane.bgtasks.webhook_task.Webhook.objects.filter", return_value=webhook_queryset), patch( + "plane.bgtasks.webhook_task.get_model_data", + return_value={"id": "user-123"}, + ), patch("plane.bgtasks.webhook_task.service_gateway_event_sync") as mock_service_gateway_event_sync: + webhook_activity( + event="issue", + verb="deleted", + field=None, + old_value=None, + new_value=None, + actor_id="user-123", + slug="workspace-1", + current_site="http://localhost:3000", + event_id="issue-123", + old_identifier=None, + new_identifier=None, + event_data={"id": "issue-123", "sg_event_id": 321}, + skip_service_gateway=True, + ) + + mock_service_gateway_event_sync.assert_not_called() diff --git a/apps/api/plane/tests/unit/serializers/test_issue_inherits_project_sport.py b/apps/api/plane/tests/unit/serializers/test_issue_inherits_project_sport.py new file mode 100644 index 00000000000..cf5de0040bb --- /dev/null +++ b/apps/api/plane/tests/unit/serializers/test_issue_inherits_project_sport.py @@ -0,0 +1,64 @@ +import pytest + +from plane.api.serializers.issue import IssueSerializer as APIIssueSerializer +from plane.app.serializers.issue import IssueCreateSerializer as AppIssueSerializer +from plane.db.models import Project, User, Workspace + + +@pytest.mark.unit +class TestIssueInheritsProjectSport: + @staticmethod + def _create_project(sport: str | None = "Cricket"): + user = User.objects.create(email="sport-owner@example.com", first_name="Sport", last_name="Owner") + workspace = Workspace.objects.create(name="Sport Workspace", slug="sport-workspace", owner=user) + project = Project.objects.create( + name="Sport Project", + identifier="SPRT", + workspace=workspace, + sport=sport, + created_by=user, + updated_by=user, + ) + return workspace, project + + @staticmethod + def _get_context(workspace, project): + return { + "workspace_id": workspace.id, + "project_id": project.id, + "default_assignee_id": None, + } + + @pytest.mark.parametrize("serializer_class", [APIIssueSerializer, AppIssueSerializer]) + def test_create_uses_project_sport(self, db, serializer_class): + workspace, project = self._create_project("Cricket") + serializer = serializer_class( + data={ + "name": "Inherited Sport Issue", + "sport": "Football", + }, + context=self._get_context(workspace, project), + ) + + assert serializer.is_valid(), serializer.errors + + issue = serializer.save() + + assert issue.sport == "Cricket" + + @pytest.mark.parametrize("serializer_class", [APIIssueSerializer, AppIssueSerializer]) + def test_create_preserves_explicit_sport_when_project_has_none(self, db, serializer_class): + workspace, project = self._create_project(None) + serializer = serializer_class( + data={ + "name": "Fallback Sport Issue", + "sport": "Football", + }, + context=self._get_context(workspace, project), + ) + + assert serializer.is_valid(), serializer.errors + + issue = serializer.save() + + assert issue.sport == "Football" diff --git a/apps/api/plane/tests/unit/serializers/test_issue_opposition_team_validation.py b/apps/api/plane/tests/unit/serializers/test_issue_opposition_team_validation.py new file mode 100644 index 00000000000..df60485359b --- /dev/null +++ b/apps/api/plane/tests/unit/serializers/test_issue_opposition_team_validation.py @@ -0,0 +1,77 @@ +import pytest + +from plane.api.serializers.issue import IssueSerializer as APIIssueSerializer +from plane.app.serializers.issue import IssueCreateSerializer as AppIssueSerializer +from plane.db.models import Project, User, Workspace + + +@pytest.mark.unit +class TestIssueOppositionTeamValidation: + @staticmethod + def _create_project(): + user = User.objects.create(email="opposition-owner@example.com", first_name="Opposition", last_name="Owner") + workspace = Workspace.objects.create(name="Opposition Workspace", slug="opposition-workspace", owner=user) + project = Project.objects.create( + name="Opposition Project", + identifier="OPP", + workspace=workspace, + created_by=user, + updated_by=user, + ) + return workspace, project + + @staticmethod + def _get_context(workspace, project): + return { + "workspace_id": workspace.id, + "project_id": project.id, + "default_assignee_id": None, + } + + @pytest.mark.parametrize("serializer_class", [APIIssueSerializer, AppIssueSerializer]) + def test_accepts_object_payload(self, db, serializer_class): + workspace, project = self._create_project() + serializer = serializer_class( + data={ + "name": "Issue With Opposition", + "opposition_team": {"name": "Nissan Stadium", "logo": "opposition-teams/nissan.png"}, + }, + context=self._get_context(workspace, project), + ) + + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data["opposition_team"] == { + "name": "Nissan Stadium", + "logo": "opposition-teams/nissan.png", + } + + @pytest.mark.parametrize("serializer_class", [APIIssueSerializer, AppIssueSerializer]) + def test_accepts_legacy_json_string_payload(self, db, serializer_class): + workspace, project = self._create_project() + serializer = serializer_class( + data={ + "name": "Issue With Legacy Opposition", + "opposition_team": '{"name":"Nissan Stadium","logo":"opposition-teams/nissan.png","address":"ignored"}', + }, + context=self._get_context(workspace, project), + ) + + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data["opposition_team"] == { + "name": "Nissan Stadium", + "logo": "opposition-teams/nissan.png", + } + + @pytest.mark.parametrize("serializer_class", [APIIssueSerializer, AppIssueSerializer]) + def test_rejects_payload_without_name(self, db, serializer_class): + workspace, project = self._create_project() + serializer = serializer_class( + data={ + "name": "Issue With Invalid Opposition", + "opposition_team": {"logo": "opposition-teams/nissan.png"}, + }, + context=self._get_context(workspace, project), + ) + + assert not serializer.is_valid() + assert serializer.errors["opposition_team"][0] == "Opposition team name is required." diff --git a/apps/api/plane/tests/unit/serializers/test_issue_start_datetime_validation.py b/apps/api/plane/tests/unit/serializers/test_issue_start_datetime_validation.py new file mode 100644 index 00000000000..2be698b4f09 --- /dev/null +++ b/apps/api/plane/tests/unit/serializers/test_issue_start_datetime_validation.py @@ -0,0 +1,92 @@ +from datetime import datetime, date + +import pytest +from freezegun import freeze_time +from django.utils import timezone + +from plane.api.serializers.issue import IssueSerializer as APIIssueSerializer +from plane.app.serializers.issue import IssueCreateSerializer as AppIssueSerializer +from plane.db.models import Issue, Project, User, Workspace + + +ERROR_MESSAGE = "Event date and time cannot be earlier than the current time." + + +@pytest.mark.unit +class TestIssueStartDateTimeValidation: + @staticmethod + def _create_project(): + user = User.objects.create(email="event-owner@example.com", first_name="Event", last_name="Owner") + workspace = Workspace.objects.create(name="Test Workspace", slug="event-workspace", owner=user) + project = Project.objects.create( + name="Test Project", + identifier="TST", + workspace=workspace, + created_by=user, + updated_by=user, + ) + return workspace, project + + @staticmethod + def _get_context(workspace, project): + return { + "workspace_id": workspace.id, + "project_id": project.id, + "default_assignee_id": None, + } + + @pytest.mark.parametrize("serializer_class", [APIIssueSerializer, AppIssueSerializer]) + @freeze_time("2026-03-23 04:30:00") + def test_create_rejects_past_start_datetime(self, db, serializer_class): + workspace, project = self._create_project() + + with timezone.override("Asia/Kolkata"): + serializer = serializer_class( + data={ + "name": "Past Event", + "start_date": "2026-03-23", + "start_time": "2026-03-23T03:45:00Z", + }, + context=self._get_context(workspace, project), + ) + + assert not serializer.is_valid() + assert serializer.errors["start_time"][0] == ERROR_MESSAGE + + @pytest.mark.parametrize("serializer_class", [APIIssueSerializer, AppIssueSerializer]) + @freeze_time("2026-03-23 04:30:00") + def test_create_allows_future_start_datetime(self, db, serializer_class): + workspace, project = self._create_project() + + with timezone.override("Asia/Kolkata"): + serializer = serializer_class( + data={ + "name": "Future Event", + "start_date": "2026-03-23", + "start_time": "2026-03-23T05:00:00Z", + }, + context=self._get_context(workspace, project), + ) + + assert serializer.is_valid(), serializer.errors + + @pytest.mark.parametrize("serializer_class", [APIIssueSerializer, AppIssueSerializer]) + @freeze_time("2026-03-23 04:30:00") + def test_partial_update_without_datetime_change_is_allowed(self, db, serializer_class): + workspace, project = self._create_project() + issue = Issue.objects.create( + name="Existing Event", + project=project, + start_date=date(2026, 3, 23), + start_time=datetime.fromisoformat("2026-03-23T03:45:00+00:00"), + ) + + with timezone.override("Asia/Kolkata"): + serializer = serializer_class( + instance=issue, + data={"name": "Renamed Event"}, + partial=True, + context=self._get_context(workspace, project), + ) + + assert serializer.is_valid(), serializer.errors diff --git a/apps/api/plane/tests/unit/utils/test_media_library.py b/apps/api/plane/tests/unit/utils/test_media_library.py new file mode 100644 index 00000000000..7ede49cb152 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_media_library.py @@ -0,0 +1,42 @@ +import pytest + +from plane.utils.media_library import get_document_icon_source, get_document_thumbnail_hint + + +@pytest.mark.unit +class TestGetDocumentThumbnailHint: + def test_returns_explicit_thumbnail_hint_when_present(self): + assert ( + get_document_thumbnail_hint( + "json", + {"source": "plane-coach", "thumbnail": "attachment/custom-icon.png"}, + ) + == "attachment/custom-icon.png" + ) + + def test_uses_video_icon_for_plane_coach_json_documents(self): + assert get_document_thumbnail_hint("json", {"source": "plane-coach"}) == "attachment/video-icon.png" + + def test_uses_poster_hint_before_plane_coach_json_fallback(self): + assert ( + get_document_thumbnail_hint( + "json", + {"source": "plane-coach", "poster_url": "/coach/defualt.jpg"}, + ) + == "/coach/defualt.jpg" + ) + + def test_does_not_override_non_plane_coach_documents(self): + assert get_document_thumbnail_hint("json", {"source": "manual-upload"}) is None + + def test_does_not_override_non_json_plane_coach_documents(self): + assert get_document_thumbnail_hint("pdf", {"source": "plane-coach"}) is None + + +@pytest.mark.unit +class TestGetDocumentIconSource: + def test_resolves_plane_coach_public_thumbnail_hint(self): + icon_source = get_document_icon_source("json", "/coach/defualt.jpg") + + assert icon_source is not None + assert icon_source.name == "defualt.jpg" diff --git a/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py index ff06a562fa8..b510f0e60ca 100644 --- a/apps/api/plane/utils/content_validator.py +++ b/apps/api/plane/utils/content_validator.py @@ -73,6 +73,7 @@ def validate_binary_data(data): "label", "input", "image-component", + "link-embed-component", } ALLOWED_TAGS = nh3.ALLOWED_TAGS | CUSTOM_TAGS @@ -130,6 +131,13 @@ def validate_binary_data(data): "title", }, "mention-component": {"id", "entity_identifier", "entity_name"}, + "link-embed-component": { + "data-url", + "data-title", + "data-description", + "data-image", + "data-favicon", + }, "th": { "colspan", "rowspan", diff --git a/apps/api/plane/utils/grouper.py b/apps/api/plane/utils/grouper.py index 1ec004e95ad..3d55918850d 100644 --- a/apps/api/plane/utils/grouper.py +++ b/apps/api/plane/utils/grouper.py @@ -107,6 +107,7 @@ def issue_on_results( "completed_at", "estimate_point", "priority", + "start_time", "start_date", "target_date", "sequence_id", @@ -123,6 +124,11 @@ def issue_on_results( "is_draft", "archived_at", "state__group", + "level", + "sport", + "program", + "year", + "category", ] if group_by in FIELD_MAPPER: diff --git a/apps/api/plane/utils/issue_datetime.py b/apps/api/plane/utils/issue_datetime.py new file mode 100644 index 00000000000..d1e9751428e --- /dev/null +++ b/apps/api/plane/utils/issue_datetime.py @@ -0,0 +1,34 @@ +from datetime import datetime + +from django.utils import timezone + + +def get_issue_start_datetime(start_date, start_time): + if start_date is None or start_time is None: + return None + + current_timezone = timezone.get_current_timezone() + localized_start_time = ( + timezone.localtime(start_time, current_timezone) + if timezone.is_aware(start_time) + else timezone.make_aware(start_time, current_timezone) + ) + start_datetime = timezone.make_aware( + datetime.combine(start_date, datetime.min.time()), + current_timezone, + ) + + return start_datetime.replace( + hour=localized_start_time.hour, + minute=localized_start_time.minute, + second=localized_start_time.second, + microsecond=localized_start_time.microsecond, + ) + + +def is_issue_start_datetime_in_past(start_date, start_time): + start_datetime = get_issue_start_datetime(start_date, start_time) + if start_datetime is None: + return False + + return start_datetime < timezone.now() diff --git a/apps/api/plane/utils/media_library.py b/apps/api/plane/utils/media_library.py new file mode 100644 index 00000000000..975623ed8be --- /dev/null +++ b/apps/api/plane/utils/media_library.py @@ -0,0 +1,1173 @@ +# Python imports +import json +import logging +import os +import re +import shutil +import tempfile +import subprocess +import time +from datetime import datetime, timezone as dt_timezone +from contextlib import contextmanager +from pathlib import Path +from urllib.parse import unquote, urlparse + +# Django imports +from django.conf import settings +from django.utils import timezone + +# Third party imports +from dateutil.parser import parse as dateutil_parse +from rest_framework.serializers import ValidationError + +MANIFEST_VERSION = 1 + +_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$") +logger = logging.getLogger(__name__) +META_FILTER_EXCLUDED_KEYS = { + "duration", + "duration_sec", + "durationSec", + "for", + "hls", + "kind", + "source", + "source_format", + "source format", +} +THUMBNAIL_MAX_BYTES = int(getattr(settings, "MEDIA_LIBRARY_THUMBNAIL_MAX_BYTES", 51200)) +THUMBNAIL_PRESETS: tuple[tuple[int, int], ...] = ( + (480, 82), + (400, 78), + (320, 74), + (256, 70), + (200, 66), + (160, 60), + (128, 54), + (96, 48), + (64, 42), +) + +HLS_RENDITIONS: tuple[dict[str, str | int], ...] = ( + { + "name": "480p", + "width": 854, + "height": 480, + "video_bitrate": "1400k", + "maxrate": "1498k", + "bufsize": "2100k", + "audio_bitrate": "96k", + "bandwidth": 1498000, + }, + { + "name": "720p", + "width": 1280, + "height": 720, + "video_bitrate": "2800k", + "maxrate": "2996k", + "bufsize": "4200k", + "audio_bitrate": "128k", + "bandwidth": 2996000, + }, + { + "name": "1080p", + "width": 1920, + "height": 1080, + "video_bitrate": "5000k", + "maxrate": "5350k", + "bufsize": "7500k", + "audio_bitrate": "128k", + "bandwidth": 5350000, + }, + { + "name": "1440p", + "width": 2560, + "height": 1440, + "video_bitrate": "8000k", + "maxrate": "8560k", + "bufsize": "12000k", + "audio_bitrate": "160k", + "bandwidth": 8560000, + }, + { + "name": "2160p", + "width": 3840, + "height": 2160, + "video_bitrate": "14000k", + "maxrate": "14980k", + "bufsize": "21000k", + "audio_bitrate": "192k", + "bandwidth": 14980000, + }, + { + "name": "4320p", + "width": 7680, + "height": 4320, + "video_bitrate": "28000k", + "maxrate": "29960k", + "bufsize": "42000k", + "audio_bitrate": "192k", + "bandwidth": 29960000, + }, +) + +EVENT_META_KEYS = { + "category", + "start_date", + "start_time", + "level", + "program", + "sport", + "opposition", + "season", +} + +ARTIFACT_FIELD_KEYS = { + "action", + "title", + "description", + "format", + "link", + "meta", + "path", +} + + +def _apply_event_meta(existing: dict, updates: dict) -> dict: + if not isinstance(existing, dict): + existing = {} + if not isinstance(updates, dict): + updates = {} + merged = dict(existing) + for key in EVENT_META_KEYS: + if key not in updates: + continue + value = updates.get(key) + if value is None or value == "": + merged.pop(key, None) + else: + merged[key] = value + return merged + + +def update_manifest_event_meta(manifest: dict, work_item_id: str, updates: dict) -> int: + if not work_item_id or not isinstance(manifest, dict): + return 0 + artifacts = manifest.get("artifacts") or [] + if not isinstance(artifacts, list) or not artifacts: + return 0 + metadata = ensure_manifest_metadata(manifest) + updated_refs: set[str] = set() + inline_updates = 0 + for artifact in artifacts: + if not isinstance(artifact, dict): + continue + if str(artifact.get("work_item_id") or "") != work_item_id: + continue + metadata_ref = normalize_metadata_ref(artifact.get("metadata_ref")) or normalize_metadata_ref(artifact.get("name")) + if metadata_ref: + updated_refs.add(metadata_ref) + continue + existing_meta = artifact.get("meta") + if not isinstance(existing_meta, dict): + existing_meta = {} + next_meta = _apply_event_meta(existing_meta, updates) + if next_meta != existing_meta: + artifact["meta"] = next_meta + inline_updates += 1 + for metadata_ref in updated_refs: + existing_entry = metadata.get(metadata_ref) + if not isinstance(existing_entry, dict): + existing_entry = {} + metadata[metadata_ref] = _apply_event_meta(existing_entry, updates) + manifest["metadata"] = metadata + return len(updated_refs) + inline_updates + + +def update_manifest_artifact_fields( + manifest: dict, updates: dict, work_item_id: str | None = None, artifact_id: str | None = None +) -> int: + if not isinstance(manifest, dict): + return 0 + artifacts = manifest.get("artifacts") or [] + if not isinstance(artifacts, list) or not artifacts: + return 0 + target_work_item = str(work_item_id or "") + target_artifact = str(artifact_id or "") + if not target_work_item and not target_artifact: + return 0 + updated_count = 0 + for artifact in artifacts: + if not isinstance(artifact, dict): + continue + if target_artifact: + if str(artifact.get("name") or "") != target_artifact: + continue + elif str(artifact.get("work_item_id") or "") != target_work_item: + continue + changed = False + for key in ARTIFACT_FIELD_KEYS: + if key not in updates: + continue + value = updates.get(key) + if key == "meta" and value not in (None, "") and not isinstance(value, dict): + continue + if value is None or value == "": + if key in artifact: + artifact.pop(key, None) + changed = True + elif artifact.get(key) != value: + artifact[key] = value + changed = True + if changed: + updated_count += 1 + return updated_count + + +def normalize_metadata_ref(value: object) -> str | None: + if not isinstance(value, str): + return None + trimmed = value.strip() + if not trimmed: + return None + if not _SEGMENT_RE.match(trimmed): + return None + return trimmed + + +def ensure_manifest_metadata(manifest: dict) -> dict: + metadata = manifest.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + manifest["metadata"] = metadata + return metadata + + +def normalize_manifest_metadata(manifest: dict) -> dict: + metadata = ensure_manifest_metadata(manifest) + artifacts = manifest.get("artifacts") or [] + normalized_artifacts: list[dict] = [] + for artifact in artifacts: + if not isinstance(artifact, dict): + normalized_artifacts.append(artifact) + continue + meta = artifact.pop("meta", None) + metadata_ref = normalize_metadata_ref(artifact.get("metadata_ref")) + if not metadata_ref: + metadata_ref = normalize_metadata_ref(artifact.get("name")) + if metadata_ref: + artifact["metadata_ref"] = metadata_ref + if isinstance(meta, dict) and meta: + existing = metadata.get(metadata_ref) + if isinstance(existing, dict): + merged = dict(existing) + merged.update(meta) + metadata[metadata_ref] = merged + else: + metadata[metadata_ref] = dict(meta) + elif metadata_ref not in metadata: + metadata[metadata_ref] = {} + else: + if isinstance(meta, dict): + artifact["meta"] = meta + normalized_artifacts.append(artifact) + manifest["artifacts"] = normalized_artifacts + manifest["metadata"] = metadata + return manifest + + +def resolve_artifact_metadata(artifact: dict, metadata: dict | None = None) -> dict: + direct_meta = artifact.get("meta") + if isinstance(direct_meta, dict): + return direct_meta + metadata_ref = normalize_metadata_ref(artifact.get("metadata_ref")) + if not metadata_ref: + metadata_ref = normalize_metadata_ref(artifact.get("name")) + if metadata and metadata_ref: + meta = metadata.get(metadata_ref) + if isinstance(meta, dict): + return meta + return {} + + +def hydrate_artifacts_with_meta( + artifacts: list[dict], + metadata: dict | None = None, +) -> list[dict]: + if not metadata: + return artifacts + hydrated: list[dict] = [] + for artifact in artifacts: + if not isinstance(artifact, dict): + hydrated.append(artifact) + continue + if isinstance(artifact.get("meta"), dict): + hydrated.append(artifact) + continue + meta = resolve_artifact_metadata(artifact, metadata) + if meta: + entry = artifact.copy() + entry["meta"] = meta + hydrated.append(entry) + else: + hydrated.append(artifact) + return hydrated + + +_META_OBJECT_DISPLAY_KEYS = ( + "name", + "title", + "label", + "display_name", + "displayName", + "team_name", + "teamName", +) +_START_TIME_FILTER_KEY = "start_time" +_START_DATE_FILTER_KEY = "start_date" +_START_DATE_META_ALIASES = ("start_date", "startDate", "start date") +_START_TIME_META_ALIASES = ("start_time", "startTime", "start time") +_TIME_VALUE_RE = re.compile(r"^\s*(\d{1,2}):(\d{2})(?::\d{2})?\s*([AaPp][Mm])?\s*$") + + +def _get_object_display_values(value: dict) -> list[str]: + results: list[str] = [] + for key in _META_OBJECT_DISPLAY_KEYS: + candidate = value.get(key) + if isinstance(candidate, str) and candidate.strip(): + results.append(candidate.strip()) + break + raw_value = value.get("value") + if isinstance(raw_value, str) and raw_value.strip(): + results.append(raw_value.strip()) + return results + + +def _normalize_meta_values(value: object) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + trimmed = value.strip() + return [trimmed] if trimmed else [] + if isinstance(value, (int, float, bool)): + return [str(value)] + if isinstance(value, list): + results: list[str] = [] + for entry in value: + results.extend(_normalize_meta_values(entry)) + return results + if isinstance(value, dict): + results = _get_object_display_values(value) + serialized = json.dumps(value) + if serialized: + results.append(serialized) + # Preserve order while removing duplicates + return list(dict.fromkeys(results)) + return [json.dumps(value)] + + +def _normalize_meta_key(key: str) -> str: + normalized = re.sub( + r"([a-z0-9])([A-Z])", r"\1 \2", key.replace("-", " ").replace("_", " ") + ) + return re.sub(r"\s+", "_", normalized.strip().lower()) + + +def _get_meta_filter_values(meta: dict, meta_key: str) -> list[str]: + normalized_key = _normalize_meta_key(meta_key) + aliases = None + if normalized_key == _START_DATE_FILTER_KEY: + aliases = _START_DATE_META_ALIASES + elif normalized_key == _START_TIME_FILTER_KEY: + aliases = _START_TIME_META_ALIASES + + if aliases: + for alias in aliases: + values = _normalize_meta_values(meta.get(alias)) + if values: + return values + return [] + + return _normalize_meta_values(meta.get(meta_key)) + + +def _parse_datetime_value(value: str) -> float | None: + if not isinstance(value, str) or not value.strip(): + return None + try: + parsed = dateutil_parse(value) + except (TypeError, ValueError, OverflowError): + return None + if not isinstance(parsed, datetime): + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt_timezone.utc) + else: + parsed = parsed.astimezone(dt_timezone.utc) + return parsed.timestamp() + + +def _parse_time_value(value: str) -> int | None: + if not isinstance(value, str) or not value.strip(): + return None + + parsed_datetime = _parse_datetime_value(value) + if parsed_datetime is not None: + parsed = datetime.fromtimestamp(parsed_datetime, tz=dt_timezone.utc) + return parsed.hour * 60 + parsed.minute + + match = _TIME_VALUE_RE.match(value.strip()) + if not match: + return None + + hours = int(match.group(1)) + minutes = int(match.group(2)) + meridiem = (match.group(3) or "").lower() + + if meridiem: + if hours < 1 or hours > 12: + return None + if meridiem == "am": + hours = 0 if hours == 12 else hours + elif meridiem == "pm": + hours = 12 if hours == 12 else hours + 12 + elif hours < 0 or hours > 23: + return None + + if minutes < 0 or minutes > 59: + return None + + return hours * 60 + minutes + + +def _matches_meta_range(item_values: list[str], condition_values: list[str], meta_key: str) -> bool: + if len(condition_values) < 2: + return True + + normalized_key = _normalize_meta_key(meta_key) + parse_value = _parse_time_value if normalized_key == _START_TIME_FILTER_KEY else _parse_datetime_value + + lower = parse_value(condition_values[0]) + upper = parse_value(condition_values[1]) + if lower is None or upper is None: + return True + + range_start = min(lower, upper) + range_end = max(lower, upper) + + for item_value in item_values: + parsed_value = parse_value(item_value) + if parsed_value is None: + continue + if range_start <= parsed_value <= range_end: + return True + + return False + + +def _get_meta_string(meta: dict, keys: list[str], fallback: str = "") -> str: + for key in keys: + value = meta.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return fallback + + +def _get_primary_tag(meta: dict) -> str: + return _get_meta_string(meta, ["category", "sport", "program"], "Library") + + +def _get_secondary_tag(meta: dict) -> str: + return _get_meta_string(meta, ["season", "level", "coach"], "Media") + + +def _get_author(meta: dict) -> str: + return _get_meta_string(meta, ["coach", "author", "creator"], "Media Library") + + +def _stringify_value(value: object) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + return str(value) + + +def _build_query_haystack(artifact: dict, metadata: dict | None = None) -> str: + meta = resolve_artifact_metadata(artifact, metadata) + docs = meta.get("docs") + docs_text = "" + if isinstance(docs, list): + docs_text = " ".join([entry for entry in docs if isinstance(entry, str)]) + tags_text = "" + tags_values = _normalize_meta_values(meta.get("tags")) + if tags_values: + tags_text = " ".join([entry for entry in tags_values if entry]) + + items_count = meta.get("itemsCount") + if items_count is None: + items_count = meta.get("items_count") + + values = [ + _stringify_value(artifact.get("title")), + _stringify_value(artifact.get("description")), + _get_author(meta), + _stringify_value(artifact.get("created_at") or artifact.get("updated_at")), + _stringify_value(meta.get("views")), + _get_primary_tag(meta), + _get_secondary_tag(meta), + _stringify_value(items_count), + tags_text, + docs_text, + ] + return " ".join([value for value in values if value]).lower() + + +def filter_media_library_artifacts( + artifacts: list[dict], + query: str | None = None, + filters: list[dict] | None = None, + section: str | None = None, + formats: list[str] | None = None, + metadata: dict | None = None, +) -> list[dict]: + if not artifacts: + return [] + + normalized_query = (query or "").strip().lower() + normalized_section = (section or "").strip() + normalized_formats = { + str(entry).lower() for entry in (formats or []) if str(entry).strip() + } + + def matches_filters(artifact: dict) -> bool: + if not filters: + return True + if not isinstance(filters, list): + return True + meta = resolve_artifact_metadata(artifact, metadata) + for condition in filters: + if not isinstance(condition, dict): + continue + property_name = condition.get("property") + if not isinstance(property_name, str) or not property_name.startswith("meta."): + continue + meta_key = property_name[len("meta."):] + if not meta_key or meta_key in META_FILTER_EXCLUDED_KEYS: + continue + item_values = _get_meta_filter_values(meta, meta_key) + if not item_values: + return False + value = condition.get("value") + condition_values = value if isinstance(value, list) else [value] + condition_values = [ + str(entry) + for entry in condition_values + if entry is not None and str(entry).strip() + ] + if not condition_values: + continue + operator = condition.get("operator") + if operator in ("exact", "in"): + if not any(entry in item_values for entry in condition_values): + return False + elif operator == "range": + if not _matches_meta_range(item_values, condition_values, meta_key): + return False + return True + + def matches_section(artifact: dict) -> bool: + if not normalized_section: + return True + meta = resolve_artifact_metadata(artifact, metadata) + return _get_primary_tag(meta) == normalized_section + + def matches_query(artifact: dict) -> bool: + if not normalized_query: + return True + return normalized_query in _build_query_haystack(artifact, metadata) + + def matches_format(artifact: dict) -> bool: + if not normalized_formats: + return True + return str(artifact.get("format") or "").lower() in normalized_formats + + return [ + artifact + for artifact in artifacts + if matches_format(artifact) + and matches_section(artifact) + and matches_query(artifact) + and matches_filters(artifact) + ] + + +def _thumbnail_within_limit(path: Path, max_bytes: int) -> bool: + try: + return path.stat().st_size <= max_bytes + except OSError: + return False + + +def _thumbnail_scale_filter(max_dim: int) -> str: + return ( + "scale=" + f"'min({max_dim},iw)':'min({max_dim},ih)':" + "force_original_aspect_ratio=decrease" + ) + + +def generate_thumbnail( + source: str | Path, + thumbnail_path: Path, + *, + max_bytes: int | None = None, + seek: str | None = "00:00:00.000", +) -> bool: + if shutil.which("ffmpeg") is None: + logger.error("ffmpeg is not installed. Skipping thumbnail generation for %s.", source) + return False + + effective_max = max_bytes if isinstance(max_bytes, int) and max_bytes > 0 else THUMBNAIL_MAX_BYTES + thumbnail_path.parent.mkdir(parents=True, exist_ok=True) + + for max_dim, quality in THUMBNAIL_PRESETS: + cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"] + if seek: + cmd.extend(["-ss", seek]) + cmd.extend( + [ + "-i", + str(source), + "-vf", + _thumbnail_scale_filter(max_dim), + "-frames:v", + "1", + "-vcodec", + "libwebp", + "-lossless", + "0", + "-compression_level", + "4", + "-q:v", + str(quality), + str(thumbnail_path), + ] + ) + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or exc.stdout or "").strip() + if detail: + detail = detail.replace("\n", " ") + if len(detail) > 500: + detail = f"{detail[:500]}..." + logger.error("ffmpeg thumbnail failed: %s", detail) + else: + logger.error("ffmpeg thumbnail failed with exit code %s", exc.returncode) + if thumbnail_path.exists(): + try: + thumbnail_path.unlink() + except OSError: + pass + return False + + if _thumbnail_within_limit(thumbnail_path, effective_max): + return True + + if thumbnail_path.exists() and not _thumbnail_within_limit(thumbnail_path, effective_max): + try: + thumbnail_path.unlink() + except OSError: + pass + return False + +_DOCUMENT_ICON_MAP = { + "pdf": "pdf-icon.png", + "doc": "doc-icon.png", + "docx": "doc-icon.png", + "ppt": "doc-icon.png", + "pptx": "doc-icon.png", + "xls": "excel-icon.png", + "xlsx": "excel-icon.png", + "csv": "csv-icon.png", + "txt": "txt-icon.png", + "json": "txt-icon.png", + "md": "txt-icon.png", + "log": "txt-icon.png", + "xml": "txt-icon.png", + "yml": "txt-icon.png", + "yaml": "txt-icon.png", + "html": "html-icon.png", + "css": "css-icon.png", +} +_DOCUMENT_ICON_DEFAULT = "default-icon.png" + + +def _now_iso() -> str: + return timezone.now().replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def validate_segment(value: str, field: str) -> str: + if not value or not _SEGMENT_RE.match(value): + raise ValidationError({field: "Invalid value."}) + return value + + +def media_library_root() -> Path: + return Path(settings.MEDIA_LIBRARY_ROOT).resolve(strict=False) + + +def _meta_text(meta: dict, *keys: str) -> str | None: + for key in keys: + value = meta.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _thumbnail_hint_candidates(thumbnail_hint: str | None) -> list[str]: + if not isinstance(thumbnail_hint, str) or not thumbnail_hint.strip(): + return [] + + parsed_hint = urlparse(thumbnail_hint.strip()) + normalized_hint = parsed_hint.path or thumbnail_hint.strip() + try: + normalized_hint = unquote(normalized_hint) + except ValueError: + pass + normalized_hint = normalized_hint.lstrip("/").replace("\\", "/") + parts = [part for part in normalized_hint.split("/") if part] + if not parts: + return [] + + candidates = ["/".join(parts)] + if parts[0] in {"coach", "plane-coach"} and len(parts) > 1: + candidates.append("/".join(parts[1:])) + + return list(dict.fromkeys(candidates)) + + +def get_document_thumbnail_hint(format_value: str, meta: dict | None = None) -> str | None: + if isinstance(meta, dict): + thumbnail_hint = _meta_text(meta, "thumbnail") + if thumbnail_hint: + return thumbnail_hint + + poster_hint = _meta_text(meta, "poster_url", "posterUrl", "poster") + if not poster_hint: + event_meta = meta.get("event") + if isinstance(event_meta, dict): + poster_hint = _meta_text(event_meta, "poster_url", "posterUrl", "poster") + if poster_hint: + return poster_hint + + source_value = str(meta.get("source") or "").strip().lower() + if source_value == "plane-coach" and str(format_value or "").strip().lower() == "json": + return "attachment/video-icon.png" + + return None + + +def get_document_icon_source(format_value: str, thumbnail_hint: str | None = None) -> Path | None: + base_public_dirs: list[Path] = [] + + def append_public_dir(candidate: Path) -> None: + if candidate not in base_public_dirs: + base_public_dirs.append(candidate) + + base_from_settings = Path(settings.BASE_DIR).parent.parent / "web" / "public" + append_public_dir(base_from_settings) + base_from_repo = None + base_from_plane_coach = None + resolved_path = Path(__file__).resolve() + for parent in resolved_path.parents: + candidate = parent / "apps" / "web" / "public" + if candidate.exists(): + base_from_repo = candidate + break + for parent in resolved_path.parents: + candidate = parent / "plane-coach" / "public" + if candidate.exists(): + base_from_plane_coach = candidate + break + if base_from_repo: + append_public_dir(base_from_repo) + if base_from_plane_coach: + append_public_dir(base_from_plane_coach) + base_from_static = Path(settings.BASE_DIR) / "static" + append_public_dir(base_from_static) + + for base_public_dir in base_public_dirs: + if not base_public_dir.exists(): + continue + for normalized_hint in _thumbnail_hint_candidates(thumbnail_hint): + candidate = (base_public_dir / normalized_hint).resolve(strict=False) + try: + if ( + os.path.commonpath([str(base_public_dir), str(candidate)]) == str(base_public_dir) + and candidate.exists() + ): + return candidate + except ValueError: + continue + + if not format_value: + return None + + for base_public_dir in base_public_dirs: + attachment_dir = base_public_dir / "attachment" + if not attachment_dir.exists(): + continue + key = format_value.lower() + icon_name = _DOCUMENT_ICON_MAP.get(key, _DOCUMENT_ICON_DEFAULT) + icon_path = attachment_dir / icon_name + if icon_path.exists(): + return icon_path + fallback_path = attachment_dir / _DOCUMENT_ICON_DEFAULT + if fallback_path.exists(): + return fallback_path + + return None + + +def safe_join(base: Path, *segments: str) -> Path: + base_resolved = Path(base).resolve(strict=False) + path = base_resolved + for seg in segments: + validate_segment(seg, "path") + path = path / seg + resolved = path.resolve(strict=False) + if os.path.commonpath([str(base_resolved), str(resolved)]) != str(base_resolved): + raise ValidationError({"path": "Resolved path escapes base directory."}) + return resolved + + +def package_root(project_id: str, package_id: str) -> Path: + validate_segment(project_id, "projectId") + validate_segment(package_id, "packageId") + return safe_join( + media_library_root(), + "projects", + project_id, + "packages", + package_id, + ) + + +def project_root(project_id: str) -> Path: + validate_segment(project_id, "projectId") + return safe_join(media_library_root(), "projects", project_id) + + +def ensure_project_library(project_id: str) -> Path: + root = project_root(project_id) + packages_root = root / "packages" + packages_root.mkdir(parents=True, exist_ok=True) + return packages_root + + +def manifest_path(project_id: str, package_id: str) -> Path: + return package_root(project_id, package_id) / "manifest.json" + + +def create_manifest(project_id: str, package_id: str, name: str, title: str, artifacts=None) -> dict: + timestamp = _now_iso() + manifest = { + "manifestVersion": MANIFEST_VERSION, + "id": package_id, + "type": "package", + "projectId": project_id, + "name": name, + "title": title, + "createdAt": timestamp, + "updatedAt": timestamp, + "artifacts": list(artifacts) if artifacts else [], + "metadata": {}, + } + if artifacts: + normalize_manifest_metadata(manifest) + return manifest + + +def read_manifest(path: Path) -> dict: + if not path.exists(): + raise ValidationError({"manifest": "Manifest not found."}) + with open(path, "r", encoding="utf-8-sig") as handle: + try: + data = json.load(handle) + except json.JSONDecodeError as exc: + raise ValidationError({"manifest": "Invalid manifest JSON."}) from exc + if not isinstance(data.get("artifacts"), list): + raise ValidationError({"manifest": "Invalid manifest: artifacts must be a list."}) + return normalize_manifest_metadata(data) + + +def write_manifest_atomic(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=False) + handle.write("\n") + os.replace(tmp_path, path) + finally: + if tmp_path and os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + + +@contextmanager +def manifest_write_lock(path: Path, timeout: float = 10.0, poll_interval: float = 0.1): + lock_path = Path(f"{path}.lock") + start = time.monotonic() + while True: + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.close(fd) + break + except FileExistsError: + try: + age = time.time() - lock_path.stat().st_mtime + if age > timeout: + lock_path.unlink(missing_ok=True) + continue + except FileNotFoundError: + continue + if time.monotonic() - start >= timeout: + raise TimeoutError("Timed out waiting for manifest lock.") + time.sleep(poll_interval) + try: + yield + finally: + try: + lock_path.unlink(missing_ok=True) + except OSError: + pass + + +def delete_project_library(project_id: str) -> bool: + root = project_root(project_id) + if not root.exists(): + return False + shutil.rmtree(root) + return True + + +class MediaLibraryTranscodeError(RuntimeError): + pass + + +def transcode_mp4_to_hls( + file_obj, + output_dir: Path, + segment_seconds: int = 6, + thumbnail_path: Path | None = None, +) -> tuple[Path, Path | None]: + if shutil.which("ffmpeg") is None: + raise MediaLibraryTranscodeError("ffmpeg is not installed.") + + output_dir.mkdir(parents=True, exist_ok=False) + tmp_input_path = None + success = False + + def _render_ffmpeg_error(exc: subprocess.CalledProcessError) -> str: + detail = (exc.stderr or exc.stdout or "").strip() + if detail: + detail = detail.replace("\n", " ") + if len(detail) > 500: + detail = f"{detail[:500]}..." + return detail + + def _run_ffmpeg(cmd: list[str]) -> None: + subprocess.run(cmd, check=True, cwd=str(output_dir), capture_output=True, text=True) + + try: + with tempfile.NamedTemporaryFile(dir=output_dir.parent, suffix=".mp4", delete=False) as handle: + tmp_input_path = Path(handle.name) + for chunk in file_obj.chunks(): + handle.write(chunk) + + playlist_name = "index.m3u8" + master_lines = ["#EXTM3U", "#EXT-X-VERSION:3"] + for rendition in HLS_RENDITIONS: + name = str(rendition["name"]) + width = int(rendition["width"]) + height = int(rendition["height"]) + video_bitrate = str(rendition["video_bitrate"]) + maxrate = str(rendition["maxrate"]) + bufsize = str(rendition["bufsize"]) + audio_bitrate = str(rendition["audio_bitrate"]) + bandwidth = int(rendition["bandwidth"]) + variant_playlist_name = f"{name}.m3u8" + segment_pattern = f"{name}_segment_%05d.ts" + scaling_filter = ( + f"scale=w={width}:h={height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2" + ) + cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(tmp_input_path), + "-map", + "0:v:0", + "-map", + "0:a?", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-vf", + scaling_filter, + "-b:v", + video_bitrate, + "-maxrate", + maxrate, + "-bufsize", + bufsize, + "-c:a", + "aac", + "-ar", + "48000", + "-ac", + "2", + "-b:a", + audio_bitrate, + "-hls_time", + str(segment_seconds), + "-hls_list_size", + "0", + "-hls_flags", + "independent_segments", + "-hls_segment_filename", + segment_pattern, + "-f", + "hls", + variant_playlist_name, + ] + _run_ffmpeg(cmd) + master_lines.append( + f"#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},RESOLUTION={width}x{height}" + ) + master_lines.append(variant_playlist_name) + (output_dir / playlist_name).write_text("\n".join(master_lines) + "\n", encoding="utf-8") + + created_thumbnail = None + if thumbnail_path: + if generate_thumbnail(tmp_input_path, thumbnail_path): + created_thumbnail = thumbnail_path + success = True + return output_dir / playlist_name, created_thumbnail + except FileNotFoundError as exc: + logger.error("ffmpeg is not installed.", exc_info=exc) + raise MediaLibraryTranscodeError("ffmpeg is not installed.") from exc + except subprocess.CalledProcessError as exc: + detail = _render_ffmpeg_error(exc) + if detail: + logger.error("ffmpeg failed: %s", detail) + raise MediaLibraryTranscodeError(f"Video conversion failed: {detail}") from exc + logger.error("ffmpeg failed with exit code %s", exc.returncode) + raise MediaLibraryTranscodeError("Video conversion failed.") from exc + finally: + if tmp_input_path and tmp_input_path.exists(): + try: + tmp_input_path.unlink() + except OSError: + pass + if not success and output_dir.exists(): + shutil.rmtree(output_dir, ignore_errors=True) + + +def transcode_video_to_mp4(input_path: str | Path, output_path: Path) -> Path: + if shutil.which("ffmpeg") is None: + raise MediaLibraryTranscodeError("ffmpeg is not installed.") + + source_value = str(input_path) + if isinstance(input_path, Path): + if not input_path.exists(): + raise MediaLibraryTranscodeError("Source video not found.") + elif not source_value.startswith(("http://", "https://")): + candidate = Path(source_value) + if not candidate.exists(): + raise MediaLibraryTranscodeError("Source video not found.") + source_value = str(candidate) + + output_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_output = tempfile.mkstemp(dir=output_path.parent, suffix=".tmp.mp4") + os.close(fd) + tmp_output_path = Path(tmp_output) + + def _render_ffmpeg_error(exc: subprocess.CalledProcessError) -> str: + detail = (exc.stderr or exc.stdout or "").strip() + if detail: + detail = detail.replace("\n", " ") + if len(detail) > 500: + detail = f"{detail[:500]}..." + return detail + + try: + copy_cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + source_value, + "-c", + "copy", + "-movflags", + "+faststart", + str(tmp_output_path), + ] + try: + subprocess.run(copy_cmd, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as exc: + detail = _render_ffmpeg_error(exc) + if detail: + logger.info("ffmpeg stream copy failed, falling back to encode: %s", detail) + else: + logger.info("ffmpeg stream copy failed, falling back to encode.") + encode_cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + source_value, + "-c:v", + "libx264", + "-preset", + "veryfast", + "-c:a", + "aac", + "-b:a", + "128k", + "-movflags", + "+faststart", + str(tmp_output_path), + ] + subprocess.run(encode_cmd, check=True, capture_output=True, text=True) + + os.replace(tmp_output_path, output_path) + return output_path + except subprocess.CalledProcessError as exc: + detail = _render_ffmpeg_error(exc) + if detail: + logger.error("ffmpeg failed: %s", detail) + raise MediaLibraryTranscodeError(f"Video conversion failed: {detail}") from exc + logger.error("ffmpeg failed with exit code %s", exc.returncode) + raise MediaLibraryTranscodeError("Video conversion failed.") from exc + finally: + if tmp_output_path.exists(): + try: + tmp_output_path.unlink() + except OSError: + pass diff --git a/apps/api/plane/utils/opposition_team.py b/apps/api/plane/utils/opposition_team.py new file mode 100644 index 00000000000..809d0b25936 --- /dev/null +++ b/apps/api/plane/utils/opposition_team.py @@ -0,0 +1,36 @@ +import json + + +def normalize_opposition_team(value): + if value in (None, "", {}): + return None + + if isinstance(value, str): + trimmed_value = value.strip() + if not trimmed_value: + return None + + try: + value = json.loads(trimmed_value) + except json.JSONDecodeError: + return {"name": trimmed_value, "logo": ""} + + if not isinstance(value, dict): + raise ValueError("Opposition team must be an object with name and logo.") + + name = value.get("name") + logo = value.get("logo", "") + + if not isinstance(name, str) or not name.strip(): + raise ValueError("Opposition team name is required.") + + if logo is None: + logo = "" + + if not isinstance(logo, str): + raise ValueError("Opposition team logo must be a string.") + + return { + "name": name.strip(), + "logo": logo.strip(), + } diff --git a/apps/live/Dockerfile.live b/apps/live/Dockerfile.live index 92fbee6a115..1b6e9e440fa 100644 --- a/apps/live/Dockerfile.live +++ b/apps/live/Dockerfile.live @@ -3,7 +3,7 @@ FROM node:22-alpine AS base # Setup pnpm package manager with corepack and configure global bin directory for caching ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" +ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" RUN corepack enable # ***************************************************************************** diff --git a/apps/space/Dockerfile.space b/apps/space/Dockerfile.space index 570511b9d30..ed95886a400 100644 --- a/apps/space/Dockerfile.space +++ b/apps/space/Dockerfile.space @@ -3,7 +3,7 @@ FROM node:22-alpine AS base # Setup pnpm package manager with corepack and configure global bin directory for caching ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" +ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" RUN corepack enable # ***************************************************************************** diff --git a/apps/space/core/store/helpers/base-issues.store.ts b/apps/space/core/store/helpers/base-issues.store.ts index 01d4d706ba6..620eb533844 100644 --- a/apps/space/core/store/helpers/base-issues.store.ts +++ b/apps/space/core/store/helpers/base-issues.store.ts @@ -60,6 +60,7 @@ export const ISSUE_FILTER_DEFAULT_DATA: Record { >
- Projects + Programs {isAuthorizedUser && ( + +
+ + ); +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/components/devices-grid.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/components/devices-grid.tsx new file mode 100644 index 00000000000..e136e28b04d --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/components/devices-grid.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react"; +import { Copy, Pencil, Trash2 } from "lucide-react"; +import { Tooltip } from "@plane/propel/tooltip"; +import { renderFormattedDate } from "@plane/utils"; +import type { TDevice } from "../devices.types"; + +type TDevicesGridProps = { + devices: TDevice[]; + copiedDeviceId: number | null; + isLoading: boolean; + isMutating: boolean; + onCopyUrl: (device: TDevice) => void; + onEdit: (device: TDevice) => void; + onDelete: (device: TDevice) => void; +}; + +export const DevicesGrid = ({ + devices, + copiedDeviceId, + isLoading, + isMutating, + onCopyUrl, + onEdit, + onDelete, +}: TDevicesGridProps) => { + const getCreatedDate = (createdAt: string | null) => { + if (!createdAt) return "-"; + return renderFormattedDate(createdAt) ?? createdAt.slice(0, 10); + }; + + const _getMaskedPin = (pin: string) => (pin.trim().length > 0 ? "••••" : "-"); + + const actionButtonClassName = + "inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-custom-background-80 px-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"; + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (devices.length === 0) { + return

No devices found.

; + } + + return ( +
+ {devices.map((device) => ( +
+
+

{device.deviceName}

+

Application Name: {device.appName || "-"}

+
+ Device Type: {device.deviceType || "-"} + {/* PIN: {getMaskedPin(device.pin)} */} +
+
+ +
+
+ + + + + + + +
+
+
+ ))} +
+ ); +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/devices.api.ts b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/devices.api.ts new file mode 100644 index 00000000000..cb8a1aecd75 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/devices.api.ts @@ -0,0 +1,436 @@ +import type { TDevice, TDeviceFormOptions, TDeviceFormValues, TUserOption } from "./devices.types"; +import { buildStreamingUrl, parseGatewayRows, toNumberOrNull, toStringOrEmpty } from "./devices.utils"; + +const DEVICES_ENDPOINT = "/devices"; +const USERS_ENDPOINT = "/user-profiles"; +const DEVICE_TYPES_ENDPOINT = "/meta-type?key='DEVICETYPE'"; +const APPLICATIONS_ENDPOINT = "/omal/apps?vhost=spip"; +const AUTO_DEVICE_CODE_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const AUTO_DEVICE_CODE_DIGITS = "123456789"; +const AUTO_DEVICE_CODE_LETTER_COUNT = 3; +const AUTO_DEVICE_CODE_DIGIT_COUNT = 4; +const AUTO_DEVICE_CODE_MAX_ATTEMPTS = 20_000; + +const templateCache = new Map>(); +const FAILURE_TEXT_MARKERS = ["error", "fail", "failed", "failure", "invalid"]; +const hasFailureMarker = (text: string) => FAILURE_TEXT_MARKERS.some((marker) => text.toLowerCase().includes(marker)); + +const toNonEmptyString = (value: unknown): string | null => + typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + +const normalizeApplicationOption = (value: unknown): string | null => { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + + if (!value || typeof value !== "object") return null; + + const record = value as Record; + const candidate = + record.app_name ?? record.appName ?? record.name ?? record.label ?? record.value ?? record.application; + + return typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : null; +}; + +const hasFailureStatus = (value: unknown): boolean => { + if (typeof value === "boolean") return value === false; + if (typeof value === "number") return value >= 400 || value < 0; + + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return FAILURE_TEXT_MARKERS.some((marker) => normalized.includes(marker)); + } + + return false; +}; + +const extractResponseErrorMessage = (value: unknown, depth = 0): string | null => { + if (depth > 4 || value === null || value === undefined) return null; + + if (Array.isArray(value)) { + for (const entry of value) { + if (entry && typeof entry === "object") { + const row = entry as { field?: unknown; value?: unknown }; + if (typeof row.field === "string" && ["error", "message", "detail", "reason"].includes(row.field)) { + const rowMessage = toNonEmptyString(row.value); + if (!rowMessage) continue; + if (row.field === "error" || hasFailureMarker(rowMessage)) return rowMessage; + } + } + } + + for (const entry of value) { + const nested = extractResponseErrorMessage(entry, depth + 1); + if (nested) return nested; + } + + return null; + } + + if (typeof value !== "object") return null; + + const record = value as Record; + const failureFlag = + hasFailureStatus(record.status) || + hasFailureStatus(record.success) || + hasFailureStatus(record.ok) || + hasFailureStatus(record.code) || + hasFailureStatus(record.statusCode); + + const hardErrorFields = ["error", "errorMessage", "error_message", "detail", "reason"]; + for (const key of hardErrorFields) { + const direct = toNonEmptyString(record[key]); + if (direct) return direct; + } + + if (failureFlag) { + const failureMessageFields = ["message", "msg", "statusText", "status_message"]; + for (const key of failureMessageFields) { + const failureMessage = toNonEmptyString(record[key]); + if (failureMessage) return failureMessage; + } + } + + const nestedCandidates = [ + record["Gateway Response"], + record.gatewayResponse, + record.response, + record.result, + record.data, + record.error, + ]; + + for (const candidate of nestedCandidates) { + const nested = extractResponseErrorMessage(candidate, depth + 1); + if (nested) return nested; + } + + return null; +}; + +const getResponsePayload = async (response: Response): Promise => { + try { + return await response.json(); + } catch { + try { + const text = await response.text(); + return toNonEmptyString(text); + } catch { + return null; + } + } +}; + +const getJson = async (url: string) => { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Request failed (${response.status}).`); + } + + return response.json(); +}; + +const normalizeDeviceCode = (deviceCode: string) => deviceCode.trim().toUpperCase(); + +const pickRandomChar = (characters: string) => characters.charAt(Math.floor(Math.random() * characters.length)); + +const escapeSqlStringValue = (value: unknown) => (typeof value === "string" ? value.replace(/'/g, "''") : value); + +const createRandomDeviceCode = () => { + const letters = Array.from({ length: AUTO_DEVICE_CODE_LETTER_COUNT }, () => pickRandomChar(AUTO_DEVICE_CODE_LETTERS)) + .join("") + .toUpperCase(); + const digits = Array.from({ length: AUTO_DEVICE_CODE_DIGIT_COUNT }, () => pickRandomChar(AUTO_DEVICE_CODE_DIGITS)) + .join("") + .toUpperCase(); + + return `${letters}-${digits}`; +}; + +const generateDeviceCode = (existingCodes: Set) => { + for (let attempt = 0; attempt < AUTO_DEVICE_CODE_MAX_ATTEMPTS; attempt += 1) { + const candidate = createRandomDeviceCode(); + + if (!existingCodes.has(candidate)) { + return candidate; + } + } + + throw new Error("Unable to auto-generate device ID. Please try again."); +}; + +const fetchExistingDeviceCodes = async (cpServerBaseUrl: string): Promise> => { + const payload = await getJson(`${cpServerBaseUrl}${DEVICES_ENDPOINT}`); + const rows = parseGatewayRows(payload); + + const deviceCodes = rows + .map((row) => normalizeDeviceCode(toStringOrEmpty(row.deviceId ?? row.device_id))) + .filter((deviceCode) => deviceCode.length > 0); + + return new Set(deviceCodes); +}; + +const getTemplateMap = async (cpServerBaseUrl: string, resource: string): Promise> => { + const cached = templateCache.get(resource); + if (cached) return cached; + + const payload = (await getJson(`${cpServerBaseUrl}/${resource}/template`)) as { + "Gateway Response"?: Array<{ field?: unknown; type?: unknown }>; + }; + + const gatewayResponse = payload?.["Gateway Response"]; + const template = Array.isArray(gatewayResponse) + ? gatewayResponse.reduce>((acc, item) => { + const field = item?.field; + const type = item?.type; + + if (typeof field === "string" && typeof type === "number") { + acc[field] = type; + } + + return acc; + }, {}) + : {}; + + templateCache.set(resource, template); + return template; +}; + +const resolveFieldMapping = ( + template: Record, + options: Array<{ field: string; templateField: string }> +): { field: string; type: number } | null => { + for (const option of options) { + const type = template[option.templateField]; + if (typeof type === "number") { + return { + field: option.field, + type, + }; + } + } + + return null; +}; + +const buildDeviceMutationPayload = async (cpServerBaseUrl: string, values: TDeviceFormValues) => { + const template = await getTemplateMap(cpServerBaseUrl, "devices"); + const normalizedDeviceCode = values.deviceCode.trim(); + + const fields: Array<{ + options: Array<{ field: string; templateField: string }>; + value: unknown; + }> = [ + { options: [{ field: "type", templateField: "type" }], value: values.deviceType }, + { options: [{ field: "name", templateField: "name" }], value: values.deviceName }, + { + options: [ + { field: "device_id", templateField: "device_id" }, + { field: '"deviceId"', templateField: "deviceId" }, + { field: "deviceId", templateField: "deviceId" }, + ], + value: normalizedDeviceCode ? normalizedDeviceCode : undefined, + }, + { options: [{ field: "pin", templateField: "pin" }], value: values.pin }, + { + options: [ + { field: "user_id", templateField: "user_id" }, + { field: "userId", templateField: "userId" }, + ], + value: values.userId ?? undefined, + }, + { + options: [ + { field: "app_name", templateField: "app_name" }, + { field: "appName", templateField: "appName" }, + ], + value: values.appName, + }, + ]; + + const columns = fields + .filter(({ value }) => value !== undefined) + .map(({ options, value }) => { + const mapping = resolveFieldMapping(template, options); + if (!mapping) return null; + + return { + field: mapping.field, + type: mapping.type, + // CP server interpolates these values into SQL strings, so apostrophes must be escaped. + value: escapeSqlStringValue(value), + }; + }) + .filter((item): item is { field: string; type: number; value: unknown } => item !== null); + + return { + table: "devices", + columns, + criteria: [{ field: "id", value: values.id }], + }; +}; + +const mutateDevice = async ( + cpServerBaseUrl: string, + method: "POST" | "PUT", + values: TDeviceFormValues +): Promise => { + const payload = await buildDeviceMutationPayload(cpServerBaseUrl, values); + const actionLabel = method === "POST" ? "create" : "update"; + + const response = await fetch(`${cpServerBaseUrl}${DEVICES_ENDPOINT}`, { + method, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + const responsePayload = await getResponsePayload(response); + const responseErrorMessage = extractResponseErrorMessage(responsePayload); + + if (!response.ok) { + throw new Error(responseErrorMessage ?? `Failed to ${actionLabel} device (${response.status}).`); + } + + if (responseErrorMessage) { + throw new Error(responseErrorMessage); + } +}; + +const mapDeviceFromRow = (row: Record, cpServerBaseUrl: string): TDevice | null => { + const id = toNumberOrNull(row.id); + if (id === null) return null; + + const appName = toStringOrEmpty(row.app_name ?? row.appName); + const pin = toStringOrEmpty(row.pin); + const createdAtRaw = row.created_at ?? row.createdAt ?? null; + const createdAt = + typeof createdAtRaw === "string" + ? createdAtRaw + : typeof createdAtRaw === "number" + ? new Date(createdAtRaw > 10_000_000_000 ? createdAtRaw : createdAtRaw * 1000).toISOString() + : null; + + const device: TDevice = { + id, + deviceName: toStringOrEmpty(row.name), + deviceType: toStringOrEmpty(row.type), + deviceCode: toStringOrEmpty(row.deviceId ?? row.device_id), + appName, + pin, + userId: toNumberOrNull(row.user_id ?? row.userId), + createdAt, + streamingUrl: "", + }; + + return { + ...device, + streamingUrl: buildStreamingUrl(device, cpServerBaseUrl), + }; +}; + +const mapDeviceTypes = (rows: Record[]): string[] => { + const values = rows[0]?.values; + if (!Array.isArray(values)) return []; + + return values + .map((entry) => { + if (typeof entry === "string") return entry; + if (entry && typeof entry === "object") { + const label = (entry as { value?: unknown; name?: unknown }).value ?? (entry as { name?: unknown }).name; + return typeof label === "string" ? label : ""; + } + + return ""; + }) + .filter((item) => item.length > 0); +}; + +const mapApplications = (value: unknown): string[] => + Array.isArray(value) + ? Array.from( + new Set( + value + .map((entry) => normalizeApplicationOption(entry)) + .filter((item): item is string => typeof item === "string") + ) + ).sort((a, b) => a.localeCompare(b)) + : []; + +const mapUsers = (rows: Record[]): TUserOption[] => + rows + .map((row) => { + const id = toNumberOrNull(row.id); + if (id === null) return null; + + const firstName = toStringOrEmpty(row.firstname ?? row.first_name); + const lastName = toStringOrEmpty(row.lastname ?? row.last_name); + const fullName = `${firstName} ${lastName}`.trim(); + + return { + id, + label: fullName || `User ${id}`, + }; + }) + .filter((item): item is TUserOption => item !== null); + +export const fetchDevices = async (cpServerBaseUrl: string): Promise => { + const payload = await getJson(`${cpServerBaseUrl}${DEVICES_ENDPOINT}`); + const rows = parseGatewayRows(payload); + + return rows + .map((row) => mapDeviceFromRow(row, cpServerBaseUrl)) + .filter((item): item is TDevice => item !== null) + .sort((a, b) => a.deviceName.localeCompare(b.deviceName)); +}; + +export const fetchDeviceFormOptions = async (cpServerBaseUrl: string): Promise => { + const [deviceTypePayload, usersPayload, applicationsPayload] = await Promise.all([ + getJson(`${cpServerBaseUrl}${DEVICE_TYPES_ENDPOINT}`), + getJson(`${cpServerBaseUrl}${USERS_ENDPOINT}`), + getJson(`${cpServerBaseUrl}${APPLICATIONS_ENDPOINT}`), + ]); + + const applications = mapApplications( + (applicationsPayload as { "Gateway Response"?: { applications?: unknown } })?.["Gateway Response"]?.applications + ); + + return { + applications, + deviceTypes: mapDeviceTypes(parseGatewayRows(deviceTypePayload)), + users: mapUsers(parseGatewayRows(usersPayload)), + }; +}; + +export const createDevice = async (cpServerBaseUrl: string, values: TDeviceFormValues) => { + const normalizedDeviceCode = normalizeDeviceCode(values.deviceCode); + const deviceCode = normalizedDeviceCode || generateDeviceCode(await fetchExistingDeviceCodes(cpServerBaseUrl)); + + await mutateDevice(cpServerBaseUrl, "POST", { + ...values, + deviceCode, + }); +}; + +export const updateDevice = async (cpServerBaseUrl: string, values: TDeviceFormValues) => { + await mutateDevice(cpServerBaseUrl, "PUT", values); +}; + +export const deleteDevice = async (cpServerBaseUrl: string, id: number): Promise => { + const response = await fetch(`${cpServerBaseUrl}${DEVICES_ENDPOINT}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + table: "devices", + columns: [], + criteria: [{ field: "id", value: id }], + }), + }); + + if (!response.ok) { + throw new Error(`Failed to delete device (${response.status}).`); + } +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/devices.types.ts b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/devices.types.ts new file mode 100644 index 00000000000..80f25c89370 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/devices.types.ts @@ -0,0 +1,43 @@ +export type TDevice = { + id: number; + deviceName: string; + deviceType: string; + deviceCode: string; + appName: string; + pin: string; + userId: number | null; + createdAt: string | null; + streamingUrl: string; +}; + +export type TUserOption = { + id: number; + label: string; +}; + +export type TDeviceFormValues = { + id?: number; + appName: string; + deviceName: string; + deviceType: string; + userId: number | null; + deviceCode: string; + pin: string; +}; + +export type TDeviceFormMode = "create" | "edit"; + +export type TDeviceFormOptions = { + applications: string[]; + deviceTypes: string[]; + users: TUserOption[]; +}; + +export const DEVICE_FORM_DEFAULT_VALUES: TDeviceFormValues = { + appName: "", + deviceName: "", + deviceType: "", + userId: null, + deviceCode: "", + pin: "", +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/devices.utils.ts b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/devices.utils.ts new file mode 100644 index 00000000000..cbedd3f1cc8 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/devices.utils.ts @@ -0,0 +1,95 @@ +import type { TDevice } from "./devices.types"; + +type TCppField = { + field: string; + type: number; + value: unknown; +}; + +export const getErrorMessage = (error: unknown, fallback: string) => { + if (error instanceof Error && error.message) return error.message; + return fallback; +}; + +export const getCpServerBaseUrl = () => process.env.NEXT_PUBLIC_CP_SERVER_URL?.replace(/\/$/, "") ?? ""; + +export const cppToObject = (cppObj: unknown): Record => { + if (!Array.isArray(cppObj)) return {}; + + return cppObj.reduce>((acc, entry) => { + if (!entry || typeof entry !== "object") return acc; + + const { field, type, value } = entry as TCppField; + if (typeof field !== "string") return acc; + + if (type === 6 && Array.isArray(value)) { + acc[field] = value.map((item) => cppToObject(item)); + return acc; + } + + acc[field] = value; + return acc; + }, {}); +}; + +export const parseGatewayRows = (payload: unknown): Record[] => { + if (!payload || typeof payload !== "object") return []; + + const gatewayResponse = (payload as { "Gateway Response"?: unknown })["Gateway Response"]; + if (!gatewayResponse || typeof gatewayResponse !== "object") return []; + + const result = (gatewayResponse as { result?: unknown }).result; + if (!Array.isArray(result)) return []; + + return result.map((row) => cppToObject(row)); +}; + +const getHostFromUrl = (url: string): string | null => { + try { + return new URL(url).hostname; + } catch { + return null; + } +}; + +const getRtmpHost = (cpServerBaseUrl: string): string => { + const configuredRtmpUrl = process.env.NEXT_PUBLIC_RTMP_URL; + + if (configuredRtmpUrl) { + const cleaned = configuredRtmpUrl.replace(/^[a-zA-Z]+:\/\//, ""); + const host = cleaned.split(":")[0]?.trim(); + if (host) return host; + } + + return getHostFromUrl(cpServerBaseUrl) ?? "localhost"; +}; + +const getRtmpProtocol = () => { + const configuredRtmpUrl = process.env.NEXT_PUBLIC_RTMP_URL; + if (!configuredRtmpUrl) return "rtmp"; + + if (configuredRtmpUrl.startsWith("rtmps://")) return "rtmps"; + return "rtmp"; +}; + +export const getRtmpBaseUrl = (cpServerBaseUrl: string) => { + const protocol = getRtmpProtocol(); + const host = getRtmpHost(cpServerBaseUrl); + return `${protocol}://${host}:1935`; +}; + +export const toNumberOrNull = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +export const toStringOrEmpty = (value: unknown): string => (typeof value === "string" ? value : ""); + +export const buildStreamingUrl = (device: Pick, cpServerBaseUrl: string) => { + const rtmpBaseUrl = getRtmpBaseUrl(cpServerBaseUrl); + return `${rtmpBaseUrl}/${device.appName}/${device.id}/${device.pin}/`; +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/page.tsx new file mode 100644 index 00000000000..fb722c78fdf --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/page.tsx @@ -0,0 +1,3 @@ +import WorkspaceDevicesSettingsPage from "./workspace-devices-settings-page"; + +export default WorkspaceDevicesSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/workspace-devices-settings-page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/workspace-devices-settings-page.tsx new file mode 100644 index 00000000000..9e58ab59d8e --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/devices/workspace-devices-settings-page.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { Plus } from "lucide-react"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { AlertModalCore } from "@plane/ui"; +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 { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +import { DeviceFormModal } from "./components/device-form-modal"; +import { DevicesGrid } from "./components/devices-grid"; +import { createDevice, deleteDevice, fetchDeviceFormOptions, fetchDevices, updateDevice } from "./devices.api"; +import { DEVICE_FORM_DEFAULT_VALUES } from "./devices.types"; +import type { TDevice, TDeviceFormMode, TDeviceFormOptions, TDeviceFormValues } from "./devices.types"; +import { getCpServerBaseUrl, getErrorMessage } from "./devices.utils"; + +const EMPTY_DEVICE_OPTIONS: TDeviceFormOptions = { + applications: [], + deviceTypes: [], + users: [], +}; +const SERVICE_GATEWAY_USER_LABEL = "service gateway"; + +const getServiceGatewayUserId = (users: TDeviceFormOptions["users"]) => + users.find((user) => user.label.trim().toLowerCase() === SERVICE_GATEWAY_USER_LABEL)?.id ?? null; + +const WorkspaceDevicesSettingsPage = observer(() => { + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentWorkspace } = useWorkspace(); + // const searchParams = useSearchParams(); + + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Devices` : undefined; + // const forceSkeleton = searchParams.get("skeleton") === "1"; + + const cpServerBaseUrl = useMemo(() => getCpServerBaseUrl(), []); + + const [devices, setDevices] = useState([]); + const [formOptions, setFormOptions] = useState(EMPTY_DEVICE_OPTIONS); + const [isLoading, setIsLoading] = useState(true); + const [isFormOptionsLoading, setIsFormOptionsLoading] = useState(false); + const [isMutating, setIsMutating] = useState(false); + const [error, setError] = useState(null); + const [copiedDeviceId, setCopiedDeviceId] = useState(null); + + const [isFormModalOpen, setIsFormModalOpen] = useState(false); + const [formMode, setFormMode] = useState("create"); + const [formInitialValues, setFormInitialValues] = useState(DEVICE_FORM_DEFAULT_VALUES); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [deviceToDelete, setDeviceToDelete] = useState(null); + + const resolveCpServerBaseUrl = useCallback(() => { + if (cpServerBaseUrl) return cpServerBaseUrl; + + setToast({ + type: TOAST_TYPE.ERROR, + title: "NEXT_PUBLIC_CP_SERVER_URL is not configured.", + }); + + return null; + }, [cpServerBaseUrl]); + + const loadDevices = useCallback(async (): Promise => { + const baseUrl = resolveCpServerBaseUrl(); + if (!baseUrl) { + setError("NEXT_PUBLIC_CP_SERVER_URL is not configured."); + setIsLoading(false); + return []; + } + + setIsLoading(true); + + try { + const data = await fetchDevices(baseUrl); + setDevices(data); + setError(null); + return data; + } catch (err) { + setError(getErrorMessage(err, "Unable to load devices.")); + return []; + } finally { + setIsLoading(false); + } + }, [resolveCpServerBaseUrl]); + + const loadFormOptions = useCallback(async (): Promise => { + if (isFormOptionsLoading) return null; + + const baseUrl = resolveCpServerBaseUrl(); + if (!baseUrl) { + return null; + } + + setIsFormOptionsLoading(true); + + try { + const options = await fetchDeviceFormOptions(baseUrl); + setFormOptions(options); + return options; + } catch (err) { + setToast({ + type: TOAST_TYPE.ERROR, + title: getErrorMessage(err, "Unable to load device form options."), + }); + return null; + } finally { + setIsFormOptionsLoading(false); + } + }, [isFormOptionsLoading, resolveCpServerBaseUrl]); + + useEffect(() => { + void loadDevices(); + }, [loadDevices]); + + const openCreateModal = async () => { + const options = await loadFormOptions(); + if (!options) return; + + setFormMode("create"); + setFormInitialValues({ + ...DEVICE_FORM_DEFAULT_VALUES, + userId: getServiceGatewayUserId(options.users), + }); + setIsFormModalOpen(true); + }; + + const openEditModal = async (device: TDevice) => { + const options = await loadFormOptions(); + if (!options) return; + + setFormMode("edit"); + setFormInitialValues({ + id: device.id, + appName: device.appName, + deviceName: device.deviceName, + deviceType: device.deviceType, + userId: device.userId ?? getServiceGatewayUserId(options.users), + deviceCode: device.deviceCode, + pin: device.pin, + }); + setIsFormModalOpen(true); + }; + + const closeFormModal = () => { + setIsFormModalOpen(false); + }; + + const handleFormSubmit = async (values: TDeviceFormValues) => { + const baseUrl = resolveCpServerBaseUrl(); + if (!baseUrl) return; + + setIsMutating(true); + + try { + if (formMode === "edit") { + await updateDevice(baseUrl, values); + } else { + await createDevice(baseUrl, values); + } + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: formMode === "edit" ? "Device updated." : "Device created.", + }); + + closeFormModal(); + const refreshedDevices = await loadDevices(); + + if (formMode === "edit" && values.id) { + const refreshedDevice = refreshedDevices.find((device) => device.id === values.id); + const submittedAppName = values.appName.trim(); + + if (refreshedDevice && refreshedDevice.appName.trim() !== submittedAppName) { + setToast({ + type: TOAST_TYPE.WARNING, + title: "The CP server is still returning a different app name.", + message: `Saved "${submittedAppName}", but the latest device payload still reports "${refreshedDevice.appName}".`, + }); + } + } + } catch (err) { + if (formMode === "edit") { + setToast({ + type: TOAST_TYPE.ERROR, + title: getErrorMessage(err, "Unable to update device."), + }); + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Device is not registered.", + message: getErrorMessage(err, "Unable to register device."), + }); + } + } finally { + setIsMutating(false); + } + }; + + const handleDeleteDevice = async (device: TDevice) => { + const baseUrl = resolveCpServerBaseUrl(); + if (!baseUrl) return; + + setIsMutating(true); + + try { + await deleteDevice(baseUrl, device.id); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Device deleted.", + }); + + await loadDevices(); + } catch (err) { + setToast({ + type: TOAST_TYPE.ERROR, + title: getErrorMessage(err, "Unable to delete device."), + }); + } finally { + setIsMutating(false); + } + }; + + const openDeleteModal = (device: TDevice) => { + setDeviceToDelete(device); + setIsDeleteModalOpen(true); + }; + + const closeDeleteModal = () => { + setIsDeleteModalOpen(false); + setDeviceToDelete(null); + }; + + const handleConfirmDeleteDevice = async () => { + if (!deviceToDelete) return; + await handleDeleteDevice(deviceToDelete); + closeDeleteModal(); + }; + + const fallbackCopy = (text: string) => { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + }; + + const handleCopyUrl = async (device: TDevice) => { + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(device.streamingUrl); + } else { + fallbackCopy(device.streamingUrl); + } + + setCopiedDeviceId(device.id); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "URL copied.", + }); + + window.setTimeout(() => { + setCopiedDeviceId((current) => (current === device.id ? null : current)); + }, 3000); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Failed to copy URL.", + }); + } + }; + + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } + + return ( + + +
+ } + onClick={() => { + void openCreateModal(); + }} + disabled={isFormOptionsLoading || isMutating} + > + Register Device + + } + /> + +
+ { + void handleCopyUrl(device); + }} + onEdit={(device) => { + void openEditModal(device); + }} + onDelete={(device) => { + openDeleteModal(device); + }} + /> + + {error &&

{error}

} +
+
+ + { + void handleConfirmDeleteDevice(); + }} + isSubmitting={isMutating} + isOpen={isDeleteModalOpen} + title="Delete device" + content={ + <> + Are you sure you want to delete{" "} + {deviceToDelete?.deviceName ?? "this device"}? + This action cannot be undone. + + } + /> + + { + void handleFormSubmit(values); + }} + /> +
+ ); +}); + +export default WorkspaceDevicesSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/application-configuration-card.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/application-configuration-card.tsx new file mode 100644 index 00000000000..4704af02902 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/application-configuration-card.tsx @@ -0,0 +1,64 @@ +import { Plus, Trash2 } from "lucide-react"; +import { Button } from "@plane/propel/button"; + +type TApplicationConfigurationCardProps = { + applications: string[]; + isLoading: boolean; + isMutating: boolean; + onOpenCreateModal: () => void; + onDeleteApplication: (applicationName: string) => void; +}; + +export const ApplicationConfigurationCard = ({ + applications, + isLoading, + isMutating, + onOpenCreateModal, + onDeleteApplication, +}: TApplicationConfigurationCardProps) => ( +
+
+

Application Configuration

+ + +
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+ ))} +
+ ) : applications.length > 0 ? ( + applications.map((application) => ( +
+
{application}
+ +
+ )) + ) : ( +

No applications found.

+ )} +
+
+); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/application-form-modal.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/application-form-modal.tsx new file mode 100644 index 00000000000..27636cb5a1b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/application-form-modal.tsx @@ -0,0 +1,60 @@ +import { useMemo } from "react"; +import { Button } from "@plane/propel/button"; +import { EModalWidth, Input, ModalCore } from "@plane/ui"; + +type TApplicationFormModalProps = { + isOpen: boolean; + isSubmitting: boolean; + applicationName: string; + onApplicationNameChange: (value: string) => void; + onClose: () => void; + onSubmit: () => void; +}; + +export const ApplicationFormModal = ({ + isOpen, + isSubmitting, + applicationName, + onApplicationNameChange, + onClose, + onSubmit, +}: TApplicationFormModalProps) => { + const canSubmit = useMemo(() => applicationName.trim().length > 0, [applicationName]); + + return ( + +
+

Create application

+ +
+ + onApplicationNameChange(event.target.value)} + placeholder="Application name" + className="w-full" + onKeyDown={(event) => { + if (event.key === "Enter" && canSubmit) { + event.preventDefault(); + onSubmit(); + } + }} + disabled={isSubmitting} + /> +
+
+ +
+ + +
+
+ ); +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/collapsible-configuration-card.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/collapsible-configuration-card.tsx new file mode 100644 index 00000000000..abd62ab15ac --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/collapsible-configuration-card.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +type TCollapsibleConfigurationCardProps = { + title: string; + isOpen: boolean; + onToggle: () => void; + children: ReactNode; +}; + +export const CollapsibleConfigurationCard = ({ + title, + isOpen, + onToggle, + children, +}: TCollapsibleConfigurationCardProps) => ( +
+ + + {isOpen &&
{children}
} +
+); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/server-configuration-content.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/server-configuration-content.tsx new file mode 100644 index 00000000000..9e6711fc6bd --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/server-configuration-content.tsx @@ -0,0 +1,20 @@ +type TServerConfigurationContentProps = { + hostName: string; +}; + +export const ServerConfigurationContent = ({ hostName }: TServerConfigurationContentProps) => ( + <> +
+ Host name + {hostName || "Not set"} +
+
+ Provider port + 1935 +
+
+ Publisher ports + Non TLS: 3333, TLS: 3334 +
+ +); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/virtual-host-configuration-content.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/virtual-host-configuration-content.tsx new file mode 100644 index 00000000000..ce0f0cac15b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/components/virtual-host-configuration-content.tsx @@ -0,0 +1,22 @@ +import type { TVirtualHostState } from "../media-server.types"; + +type TVirtualHostConfigurationContentProps = { + virtualHost: TVirtualHostState; +}; + +export const VirtualHostConfigurationContent = ({ virtualHost }: TVirtualHostConfigurationContentProps) => ( + <> +
+ Name + {virtualHost.name || "Not set"} +
+
+ Host + {virtualHost.hostName || "Not set"} +
+
+ Control server + {virtualHost.controlServerUrl || "Not set"} +
+ +); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/media-server.api.ts b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/media-server.api.ts new file mode 100644 index 00000000000..002cc29da67 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/media-server.api.ts @@ -0,0 +1,156 @@ +import type { TMediaServerData } from "./media-server.types"; +import { normalizeApplications, normalizeVirtualHost, parseVirtualHostRecords } from "./media-server.utils"; + +const APPLICATIONS_ENDPOINT = "/omal/apps?vhost=spip"; +const VHOST_ENDPOINT = "/omal/vhost-info"; +const CREATE_APP_ENDPOINTS = ["/omal/create-app", APPLICATIONS_ENDPOINT, "/omal/apps", "/omal/app"] as const; +const DELETE_APP_ENDPOINTS = ["/omal/app", APPLICATIONS_ENDPOINT, "/omal/apps"] as const; + +const toNonEmptyString = (value: unknown): string | null => + typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + +const throwIfNotOk = (response: Response, message: string) => { + if (!response.ok) { + throw new Error(`${message} (${response.status}).`); + } +}; + +const extractMutationErrorMessage = (value: unknown, depth = 0): string | null => { + if (depth > 4 || value === null || value === undefined) return null; + + if (typeof value === "string") return toNonEmptyString(value); + + if (Array.isArray(value)) { + for (const entry of value) { + const nested = extractMutationErrorMessage(entry, depth + 1); + if (nested) return nested; + } + + return null; + } + + if (typeof value !== "object") return null; + + const record = value as Record; + const directFields = ["error", "message", "detail", "reason", "statusText"]; + + for (const key of directFields) { + const direct = toNonEmptyString(record[key]); + if (direct) return direct; + } + + const nestedCandidates = [record["Gateway Response"], record.gatewayResponse, record.response, record.data, record.result]; + + for (const candidate of nestedCandidates) { + const nested = extractMutationErrorMessage(candidate, depth + 1); + if (nested) return nested; + } + + return null; +}; + +const getMutationPayload = async (response: Response): Promise => { + try { + return await response.json(); + } catch { + // Fall through to text parsing. + } + + try { + const text = await response.text(); + return text.trim() || null; + } catch { + return null; + } +}; + +const shouldTryMutationFallback = (status: number, message: string | null) => { + if (status === 404) return true; + if (status !== 500 || !message) return false; + return message.toLowerCase().includes("handler not found"); +}; + +const mutateApplication = async ( + cpServerBaseUrl: string, + method: "POST" | "DELETE", + applicationName: string, + failureMessage: string, + endpoints: readonly string[] +) => { + let lastError: Error | null = null; + + for (const endpoint of endpoints) { + const response = await fetch(`${cpServerBaseUrl}${endpoint}`, { + method, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ "app-name": applicationName }), + }); + + const mutationPayload = await getMutationPayload(response); + const mutationError = extractMutationErrorMessage(mutationPayload); + + if (response.ok && !mutationError) return; + + const errorMessage = mutationError ?? `${failureMessage} (${response.status}).`; + const isFallbackAllowed = + endpoint !== endpoints[endpoints.length - 1] && + shouldTryMutationFallback(response.status, mutationError); + + if (isFallbackAllowed) { + lastError = new Error(errorMessage); + continue; + } + + throw new Error(errorMessage); + } + + throw lastError ?? new Error(failureMessage); +}; + +export const fetchMediaServerData = async (cpServerBaseUrl: string): Promise => { + const [applications, virtualHost] = await Promise.all([ + fetchApplications(cpServerBaseUrl), + fetchVirtualHost(cpServerBaseUrl), + ]); + + return { + applications, + virtualHost, + }; +}; + +export const fetchApplications = async (cpServerBaseUrl: string): Promise => { + const applicationsResponse = await fetch(`${cpServerBaseUrl}${APPLICATIONS_ENDPOINT}`, { cache: "no-store" }); + + throwIfNotOk(applicationsResponse, "Failed to fetch applications"); + + const applicationsPayload = (await applicationsResponse.json()) as { + "Gateway Response"?: { + applications?: unknown; + }; + }; + + return normalizeApplications(applicationsPayload?.["Gateway Response"]?.applications); +}; + +export const fetchVirtualHost = async (cpServerBaseUrl: string) => { + const virtualHostResponse = await fetch(`${cpServerBaseUrl}${VHOST_ENDPOINT}`, { cache: "no-store" }); + + throwIfNotOk(virtualHostResponse, "Failed to fetch virtual host info"); + + const virtualHostPayload = (await virtualHostResponse.json()) as unknown; + + const virtualHostRecords = parseVirtualHostRecords(virtualHostPayload); + + return normalizeVirtualHost(virtualHostRecords[0]); +}; + +export const createApplication = async (cpServerBaseUrl: string, applicationName: string) => { + await mutateApplication(cpServerBaseUrl, "POST", applicationName, "Failed to add application", CREATE_APP_ENDPOINTS); +}; + +export const removeApplication = async (cpServerBaseUrl: string, applicationName: string) => { + await mutateApplication(cpServerBaseUrl, "DELETE", applicationName, "Failed to remove application", DELETE_APP_ENDPOINTS); +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/media-server.types.ts b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/media-server.types.ts new file mode 100644 index 00000000000..646cf4c86c9 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/media-server.types.ts @@ -0,0 +1,26 @@ +export type TVirtualHostApiRecord = { + name?: unknown; + host?: { + names?: unknown; + } | null; + admissionWebhooks?: { + controlServerUrl?: unknown; + } | null; +}; + +export type TVirtualHostState = { + name: string; + hostName: string; + controlServerUrl: string; +}; + +export type TMediaServerData = { + applications: string[]; + virtualHost: TVirtualHostState; +}; + +export const EMPTY_VIRTUAL_HOST: TVirtualHostState = { + name: "", + hostName: "", + controlServerUrl: "", +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/media-server.utils.ts b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/media-server.utils.ts new file mode 100644 index 00000000000..3b3a1126d64 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/media-server.utils.ts @@ -0,0 +1,49 @@ +import { EMPTY_VIRTUAL_HOST } from "./media-server.types"; +import type { TVirtualHostApiRecord, TVirtualHostState } from "./media-server.types"; + +export const getErrorMessage = (error: unknown, fallback: string) => { + if (error instanceof Error && error.message) return error.message; + return fallback; +}; + +export const getCpServerBaseUrl = () => process.env.NEXT_PUBLIC_CP_SERVER_URL?.replace(/\/$/, "") ?? ""; + +export const normalizeApplications = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +}; + +export const parseVirtualHostRecords = (payload: unknown): TVirtualHostApiRecord[] => { + if (!payload || typeof payload !== "object") return []; + + const gatewayResponse = (payload as { "Gateway Response"?: unknown })["Gateway Response"]; + if (!gatewayResponse || typeof gatewayResponse !== "object") return []; + + const result = (gatewayResponse as { result?: unknown }).result; + if (!Array.isArray(result)) return []; + + if (result.length > 0 && Array.isArray(result[0])) { + return (result[0] as unknown[]).filter( + (item): item is TVirtualHostApiRecord => typeof item === "object" && item !== null + ); + } + + return result.filter((item): item is TVirtualHostApiRecord => typeof item === "object" && item !== null); +}; + +export const normalizeVirtualHost = (record?: TVirtualHostApiRecord): TVirtualHostState => { + if (!record) return EMPTY_VIRTUAL_HOST; + + const names = record.host?.names; + + return { + name: typeof record.name === "string" ? record.name : "", + hostName: Array.isArray(names) && typeof names[0] === "string" ? names[0] : "", + controlServerUrl: + typeof record.admissionWebhooks?.controlServerUrl === "string" ? record.admissionWebhooks.controlServerUrl : "", + }; +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/page.tsx new file mode 100644 index 00000000000..f0d99bcd95a --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/page.tsx @@ -0,0 +1,3 @@ +import WorkspaceMediaServerSettingsPage from "./workspace-media-server-settings-page"; + +export default WorkspaceMediaServerSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/workspace-media-server-settings-page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/workspace-media-server-settings-page.tsx new file mode 100644 index 00000000000..83c3db8ca30 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/media-server/workspace-media-server-settings-page.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +// import { useSearchParams } from "next/navigation"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { AlertModalCore } from "@plane/ui"; +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 { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +import { deleteDevice, fetchDevices } from "../devices/devices.api"; +import { ApplicationConfigurationCard } from "./components/application-configuration-card"; +import { ApplicationFormModal } from "./components/application-form-modal"; +import { CollapsibleConfigurationCard } from "./components/collapsible-configuration-card"; +import { ServerConfigurationContent } from "./components/server-configuration-content"; +import { VirtualHostConfigurationContent } from "./components/virtual-host-configuration-content"; +import { createApplication, fetchApplications, fetchVirtualHost, removeApplication } from "./media-server.api"; +import { EMPTY_VIRTUAL_HOST } from "./media-server.types"; +import type { TVirtualHostState } from "./media-server.types"; +import { getCpServerBaseUrl, getErrorMessage } from "./media-server.utils"; + +const normalizeApplicationKey = (value: string) => value.trim().toLowerCase(); + +const WorkspaceMediaServerSettingsPage = observer(() => { + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentWorkspace } = useWorkspace(); + // const searchParams = useSearchParams(); + + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Media Server` : undefined; + // const forceSkeleton = searchParams.get("skeleton") === "1"; + + const cpServerBaseUrl = useMemo(() => getCpServerBaseUrl(), []); + + const [applications, setApplications] = useState([]); + const [newApplicationName, setNewApplicationName] = useState(""); + const [virtualHost, setVirtualHost] = useState(EMPTY_VIRTUAL_HOST); + const [isApplicationsLoading, setIsApplicationsLoading] = useState(true); + const [isVirtualHostLoading, setIsVirtualHostLoading] = useState(false); + const [isMutating, setIsMutating] = useState(false); + const [applicationsError, setApplicationsError] = useState(null); + const [virtualHostError, setVirtualHostError] = useState(null); + const [hasLoadedVirtualHost, setHasLoadedVirtualHost] = useState(false); + const [isServerConfigOpen, setIsServerConfigOpen] = useState(false); + const [isVirtualHostOpen, setIsVirtualHostOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [applicationToDelete, setApplicationToDelete] = useState(null); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const loadApplications = useCallback(async () => { + if (!cpServerBaseUrl) { + setApplicationsError("NEXT_PUBLIC_CP_SERVER_URL is not configured."); + setIsApplicationsLoading(false); + return; + } + + setIsApplicationsLoading(true); + setApplicationsError(null); + + try { + setApplications(await fetchApplications(cpServerBaseUrl)); + } catch (err) { + setApplicationsError(getErrorMessage(err, "Unable to load media server applications.")); + } finally { + setIsApplicationsLoading(false); + } + }, [cpServerBaseUrl]); + + useEffect(() => { + void loadApplications(); + }, [loadApplications]); + + const loadVirtualHostData = useCallback(async () => { + if (!cpServerBaseUrl) { + setVirtualHostError("NEXT_PUBLIC_CP_SERVER_URL is not configured."); + return; + } + + setIsVirtualHostLoading(true); + setVirtualHostError(null); + + try { + setVirtualHost(await fetchVirtualHost(cpServerBaseUrl)); + setHasLoadedVirtualHost(true); + } catch (err) { + setVirtualHostError(getErrorMessage(err, "Unable to load virtual host details.")); + } finally { + setIsVirtualHostLoading(false); + } + }, [cpServerBaseUrl]); + + const resolveCpServerBaseUrl = useCallback(() => { + if (cpServerBaseUrl) return cpServerBaseUrl; + + setToast({ + type: TOAST_TYPE.ERROR, + title: "NEXT_PUBLIC_CP_SERVER_URL is not configured.", + }); + + return null; + }, [cpServerBaseUrl]); + + const handleAddApplication = useCallback(async () => { + const trimmedName = newApplicationName.trim(); + + if (!trimmedName) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Application name is required.", + }); + return; + } + + const baseUrl = resolveCpServerBaseUrl(); + if (!baseUrl) return; + + setIsMutating(true); + + try { + await createApplication(baseUrl, trimmedName); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Application added.", + }); + setNewApplicationName(""); + setIsCreateModalOpen(false); + await loadApplications(); + } catch (err) { + setToast({ + type: TOAST_TYPE.ERROR, + title: getErrorMessage(err, "Unable to add application."), + }); + } finally { + setIsMutating(false); + } + }, [loadApplications, newApplicationName, resolveCpServerBaseUrl]); + + const handleDeleteApplication = useCallback( + async (applicationName: string) => { + const baseUrl = resolveCpServerBaseUrl(); + if (!baseUrl) return; + + setIsMutating(true); + + try { + const normalizedApplicationName = applicationName.trim(); + const associatedDevices = (await fetchDevices(baseUrl)).filter( + (device) => normalizeApplicationKey(device.appName) === normalizeApplicationKey(normalizedApplicationName) + ); + + await removeApplication(baseUrl, normalizedApplicationName); + + const deviceDeletionResults = await Promise.allSettled( + associatedDevices.map((device) => deleteDevice(baseUrl, device.id)) + ); + const failedDeviceDeletionCount = deviceDeletionResults.filter((result) => result.status === "rejected").length; + const deletedDeviceCount = associatedDevices.length - failedDeviceDeletionCount; + + if (failedDeviceDeletionCount > 0) { + setToast({ + type: TOAST_TYPE.WARNING, + title: "Application removed.", + message: + failedDeviceDeletionCount === associatedDevices.length + ? "The stream name was deleted, but its associated devices could not be removed." + : `The stream name was deleted, ${deletedDeviceCount} associated ${ + deletedDeviceCount === 1 ? "device was" : "devices were" + } removed, and ${failedDeviceDeletionCount} ${failedDeviceDeletionCount === 1 ? "device" : "devices"} could not be deleted.`, + }); + } else { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Application removed.", + message: + associatedDevices.length > 0 + ? `${associatedDevices.length} associated ${ + associatedDevices.length === 1 ? "device was" : "devices were" + } also deleted.` + : undefined, + }); + } + + await loadApplications(); + } catch (err) { + setToast({ + type: TOAST_TYPE.ERROR, + title: getErrorMessage(err, "Unable to remove application."), + }); + } finally { + setIsMutating(false); + } + }, + [loadApplications, resolveCpServerBaseUrl] + ); + + const ensureVirtualHostLoaded = useCallback(() => { + if (hasLoadedVirtualHost || isVirtualHostLoading) return; + void loadVirtualHostData(); + }, [hasLoadedVirtualHost, isVirtualHostLoading, loadVirtualHostData]); + + const openDeleteModal = (applicationName: string) => { + setApplicationToDelete(applicationName); + setIsDeleteModalOpen(true); + }; + + const closeDeleteModal = () => { + setIsDeleteModalOpen(false); + setApplicationToDelete(null); + }; + + const handleConfirmDeleteApplication = async () => { + if (!applicationToDelete) return; + await handleDeleteApplication(applicationToDelete); + closeDeleteModal(); + }; + + const openCreateModal = () => { + setNewApplicationName(""); + setIsCreateModalOpen(true); + }; + + const closeCreateModal = () => { + setIsCreateModalOpen(false); + }; + + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } + + return ( + + +
+ + +
+ { + openDeleteModal(applicationName); + }} + /> + + { + const nextState = !isServerConfigOpen; + setIsServerConfigOpen(nextState); + if (nextState) ensureVirtualHostLoaded(); + }} + > + {isVirtualHostLoading ? ( +

Loading server configuration...

+ ) : virtualHostError ? ( +

{virtualHostError}

+ ) : ( + + )} +
+ + { + const nextState = !isVirtualHostOpen; + setIsVirtualHostOpen(nextState); + if (nextState) ensureVirtualHostLoaded(); + }} + > + {isVirtualHostLoading ? ( +

Loading virtual host configuration...

+ ) : virtualHostError ? ( +

{virtualHostError}

+ ) : ( + + )} +
+ + {applicationsError &&

{applicationsError}

} +
+
+ + { + void handleConfirmDeleteApplication(); + }} + isSubmitting={isMutating} + isOpen={isDeleteModalOpen} + title="Delete application" + content={ + <> + Are you sure you want to delete application{" "} + {applicationToDelete ?? "this application"}? If + you delete this application, its associated devices will also be deleted. + + } + /> + + { + void handleAddApplication(); + }} + /> +
+ ); +}); + +export default WorkspaceMediaServerSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx index bda42ccc398..1c37d704936 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -1,5 +1,5 @@ import { useParams, usePathname } from "next/navigation"; -import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react"; +import { ArrowUpToLine, Building, CreditCard, HardDrive, Server, Users, Webhook } from "lucide-react"; import { EUserPermissionsLevel, GROUPED_WORKSPACE_SETTINGS, @@ -17,6 +17,8 @@ const ICONS = { members: Users, export: ArrowUpToLine, "billing-and-plans": CreditCard, + "media-server": Server, + devices: HardDrive, webhooks: Webhook, }; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx index 2812d278e11..650b37feb4f 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx @@ -18,22 +18,22 @@ const ProjectSettingsPage = () => { : "/empty-state/project-settings/no-projects-light.png"; return (
- No projects yet -
No projects yet
+ No programs yet +
No programs yet
- Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you + Programs 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 + Learn more about programs
diff --git a/apps/web/app/(all)/layout.preload.tsx b/apps/web/app/(all)/layout.preload.tsx index fb72b72a5c0..da02071ee84 100644 --- a/apps/web/app/(all)/layout.preload.tsx +++ b/apps/web/app/(all)/layout.preload.tsx @@ -10,12 +10,13 @@ export const usePreloadResources = () => { ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" }); }; + const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? ""; const urls = [ - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/instances/`, - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/`, - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/profile/`, - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/settings/`, - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`, + `${apiBaseUrl}/api/instances/`, + `${apiBaseUrl}/api/users/me/`, + `${apiBaseUrl}/api/users/me/profile/`, + `${apiBaseUrl}/api/users/me/settings/`, + `${apiBaseUrl}/api/users/me/workspaces/?v=${Date.now()}`, ]; urls.forEach((url) => preloadItem(url)); diff --git a/apps/web/app/(all)/workspace-invitations/page.tsx b/apps/web/app/(all)/workspace-invitations/page.tsx index 6f9d78d56f8..7b3d10f93e1 100644 --- a/apps/web/app/(all)/workspace-invitations/page.tsx +++ b/apps/web/app/(all)/workspace-invitations/page.tsx @@ -82,7 +82,7 @@ const WorkspaceInvitationPage = observer(() => { ) : ( @@ -92,15 +92,15 @@ const WorkspaceInvitationPage = observer(() => { invitationDetail?.accepted ? ( ) : ( {!currentUser ? ( diff --git a/apps/web/app/api/coach/tagging/session/fetch-tags/route.ts b/apps/web/app/api/coach/tagging/session/fetch-tags/route.ts new file mode 100644 index 00000000000..039210d7863 --- /dev/null +++ b/apps/web/app/api/coach/tagging/session/fetch-tags/route.ts @@ -0,0 +1,107 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { getKanavioTaggingServiceHeaders } from "@/lib/kanavio-tagging-service"; + +const DEFAULT_CP_SERVER_URL = "https://sports.kanavio.com/sports/api"; + +const normalizeBaseUrl = (value: string) => { + const trimmedValue = value.trim(); + if (!trimmedValue) return null; + + try { + const url = new URL(trimmedValue); + const normalizedPath = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/g, ""); + return `${url.origin}${normalizedPath}`; + } catch { + return null; + } +}; + +const joinApiPath = (baseUrl: string, path: string) => `${baseUrl.replace(/\/+$/g, "")}/${path.replace(/^\/+/g, "")}`; + +const getFetchTagsUrl = () => + process.env.KANAVIO_FETCH_TAGS_URL?.trim() || + process.env.NEXT_PUBLIC_KANAVIO_FETCH_TAGS_URL?.trim() || + joinApiPath( + normalizeBaseUrl( + process.env.COACH_SERVICE_GATEWAY_URL?.trim() || + process.env.SERVICE_GATEWAY_INTERNAL_BASE_URL?.trim() || + process.env.NEXT_PUBLIC_CP_SERVER_URL?.trim() || + process.env.NEXT_PUBLIC_SERVICE_GATEWAY_URL?.trim() || + DEFAULT_CP_SERVER_URL + ) ?? DEFAULT_CP_SERVER_URL, + "tagging-session/fetch-tags" + ); + +const readErrorFromText = (rawText: string, fallbackMessage: string) => { + try { + const data = JSON.parse(rawText) as { + detail?: string; + error?: string; + errorMessage?: string; + error_message?: string; + message?: string; + }; + + return data.error || data.detail || data.message || data.errorMessage || data.error_message || fallbackMessage; + } catch { + return fallbackMessage; + } +}; + +const normalizeEventId = (value: unknown) => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsedValue = Number(value.trim()); + return Number.isFinite(parsedValue) ? parsedValue : null; + } + + return null; +}; + +export async function POST(request: Request) { + let requestBody: Record; + + try { + requestBody = (await request.json()) as Record; + } catch { + return NextResponse.json({ error: "Invalid JSON payload." }, { status: 400 }); + } + + const eventId = normalizeEventId(requestBody.event_id); + + if (eventId == null) { + return NextResponse.json({ error: "A numeric event_id is required." }, { status: 400 }); + } + + try { + const response = await fetch(getFetchTagsUrl(), { + method: "POST", + headers: getKanavioTaggingServiceHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ event_id: eventId }), + cache: "no-store", + }); + + const responseText = await response.text(); + + if (!response.ok) { + return NextResponse.json( + { error: readErrorFromText(responseText, responseText || "Unable to fetch event tags.") }, + { status: response.status } + ); + } + + return new NextResponse(responseText, { + status: response.status, + headers: { + "content-type": response.headers.get("content-type") || "application/json", + }, + }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unable to fetch event tags." }, + { status: 502 } + ); + } +} diff --git a/apps/web/app/api/hls/route.ts b/apps/web/app/api/hls/route.ts new file mode 100644 index 00000000000..906d0e325b6 --- /dev/null +++ b/apps/web/app/api/hls/route.ts @@ -0,0 +1,204 @@ +"use server"; + +import net from "node:net"; + +const DEFAULT_ALLOWED_HOSTS = ["drake.in", "localhost", "192.168.1.55", "127.0.0.1"]; + +const getAllowedHosts = () => { + const raw = process.env.HLS_PROXY_ALLOWED_HOSTS; + const envHosts = (raw ?? "") + .split(",") + .map((host) => host.trim().toLowerCase().replace(/\.$/, "")) + .filter(Boolean); + + if (envHosts.includes("*")) return ["*"]; + + // Always include default local/test hosts unless wildcard is used. + return [...new Set([...DEFAULT_ALLOWED_HOSTS, ...envHosts])]; +}; + +const isPrivateHostname = (hostname: string) => { + if (hostname === "localhost") return true; + const ipVersion = net.isIP(hostname); + if (ipVersion === 4) { + const [first, second] = hostname.split(".").map((part) => Number(part)); + if (first === 10) return true; + if (first === 127) return true; + if (first === 169 && second === 254) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + if (first === 192 && second === 168) return true; + if (first === 100 && second >= 64 && second <= 127) return true; + return false; + } + if (ipVersion === 6) { + const normalized = hostname.toLowerCase(); + return ( + normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd") + ); + } + return false; +}; + +const isAllowedHost = (url: URL, allowedHosts: string[], requestHostname?: string | null) => { + if (allowedHosts.includes("*")) return true; + + const normalizedUrlHost = url.hostname.toLowerCase().replace(/\.$/, ""); + const normalizedRequestHost = requestHostname?.toLowerCase().replace(/\.$/, "") ?? null; + + if (allowedHosts.length > 0) { + const matchesAllowList = allowedHosts.some( + (host) => normalizedUrlHost === host || normalizedUrlHost.endsWith(`.${host}`) + ); + if (matchesAllowList) return true; + } + + if (process.env.NODE_ENV !== "production") { + if (normalizedRequestHost && normalizedUrlHost === normalizedRequestHost) return true; + if (isPrivateHostname(normalizedUrlHost)) return true; + } + + return false; +}; + +const toProxyUrl = (value: string, baseUrl: URL) => { + let resolved: URL; + try { + resolved = new URL(value, baseUrl); + } catch { + return value; + } + return `/api/hls?url=${encodeURIComponent(resolved.toString())}`; +}; + +const rewritePlaylist = (playlist: string, baseUrl: URL) => { + const lines = playlist.split(/\r?\n/); + return lines + .map((line) => { + const trimmed = line.trim(); + if (!trimmed) return line; + + if (trimmed.startsWith("#")) { + if (!/uri=/i.test(trimmed)) return line; + return line.replace(/URI=(?:"([^"]+)"|'([^']+)')/gi, (_match, doubleQuoted: string, singleQuoted: string) => { + const uri = doubleQuoted || singleQuoted; + return `URI="${toProxyUrl(uri, baseUrl)}"`; + }); + } + + return toProxyUrl(trimmed, baseUrl); + }) + .join("\n"); +}; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const rawUrl = searchParams.get("url"); + + if (!rawUrl) { + return new Response("Missing url parameter.", { status: 400 }); + } + + let targetUrl: URL; + try { + targetUrl = new URL(rawUrl); + } catch { + return new Response("Invalid url parameter.", { status: 400 }); + } + + // Preserve LL-HLS and cache-busting query params that video.js may append to the proxy URL. + // Example: _HLS_msn, _HLS_part, _HLS_skip, etc. + for (const [key, value] of searchParams.entries()) { + if (key === "url") continue; + targetUrl.searchParams.set(key, value); + } + + if (!["http:", "https:"].includes(targetUrl.protocol)) { + return new Response("Unsupported protocol.", { status: 400 }); + } + + const allowedHosts = getAllowedHosts(); + const requestHostname = request.headers.get("host")?.split(":")[0] ?? null; + if (!isAllowedHost(targetUrl, allowedHosts, requestHostname)) { + return new Response("Host not allowed.", { status: 403 }); + } + + const headers = new Headers(); + const range = request.headers.get("range"); + if (range) headers.set("range", range); + const acceptHeader = request.headers.get("accept"); + if (acceptHeader) headers.set("accept", acceptHeader); + const userAgent = request.headers.get("user-agent"); + if (userAgent) headers.set("user-agent", userAgent); + const referer = request.headers.get("referer"); + if (referer) headers.set("referer", referer); + const origin = request.headers.get("origin"); + if (origin) headers.set("origin", origin); + const cookie = request.headers.get("cookie"); + if (cookie) headers.set("cookie", cookie); + const authorization = request.headers.get("authorization"); + if (authorization) headers.set("authorization", authorization); + const apiKey = request.headers.get("x-api-key"); + if (apiKey) headers.set("x-api-key", apiKey); + + const upstream = await fetch(targetUrl, { + headers, + cache: "no-store", + redirect: "follow", + }); + + const contentType = upstream.headers.get("content-type") ?? ""; + const accept = request.headers.get("accept") ?? ""; + const normalizedContentType = contentType.toLowerCase(); + const normalizedAccept = accept.toLowerCase(); + const shouldInspectPlaylist = + targetUrl.pathname.endsWith(".m3u8") || + targetUrl.pathname.endsWith("/file/") || + normalizedContentType.includes("application/vnd.apple.mpegurl") || + normalizedContentType.includes("application/x-mpegurl") || + normalizedAccept.includes("application/vnd.apple.mpegurl") || + normalizedAccept.includes("application/x-mpegurl"); + + if (shouldInspectPlaylist) { + const buffer = await upstream.arrayBuffer(); + const text = new TextDecoder().decode(buffer); + const normalizedText = text.replace(/^\uFEFF/, "").trimStart(); + if (normalizedText.startsWith("#EXTM3U")) { + const rewritten = rewritePlaylist(text, targetUrl); + return new Response(rewritten, { + status: upstream.status, + headers: { + "content-type": "application/x-mpegURL", + "cache-control": "no-store", + }, + }); + } + + return new Response(buffer, { + status: upstream.status, + headers: { + "content-type": contentType || "application/octet-stream", + "cache-control": "no-store", + }, + }); + } + + const passthroughHeaders = new Headers(); + const headerAllowlist = [ + "content-type", + "content-length", + "accept-ranges", + "content-range", + "cache-control", + "etag", + "last-modified", + ]; + headerAllowlist.forEach((key) => { + const value = upstream.headers.get(key); + if (value) passthroughHeaders.set(key, value); + }); + + return new Response(upstream.body, { + status: upstream.status, + headers: passthroughHeaders, + }); +} diff --git a/apps/web/app/api/kanavio/tagging/events/[eventId]/route.ts b/apps/web/app/api/kanavio/tagging/events/[eventId]/route.ts new file mode 100644 index 00000000000..4ae29f66a20 --- /dev/null +++ b/apps/web/app/api/kanavio/tagging/events/[eventId]/route.ts @@ -0,0 +1,62 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { getKanavioTaggingServiceBaseUrl, getKanavioTaggingServiceHeaders } from "@/lib/kanavio-tagging-service"; + +// export const dynamic = "force-dynamic"; + +const TAGGING_SERVICE_NOT_CONFIGURED = + "Kanavio tagging service is not configured. Set KANAVIO_TAGGING_SERVICE_URL."; + +const readErrorFromText = (rawText: string, fallbackMessage: string) => { + try { + const data = JSON.parse(rawText) as { + detail?: string; + error?: string; + errorMessage?: string; + error_message?: string; + message?: string; + }; + + return ( + data.error || + data.detail || + data.message || + data.errorMessage || + data.error_message || + fallbackMessage + ); + } catch { + return fallbackMessage; + } +}; + +export async function GET(_request: Request, { params }: { params: { eventId: string } }) { + const baseUrl = getKanavioTaggingServiceBaseUrl(); + + if (!baseUrl) { + return NextResponse.json({ error: TAGGING_SERVICE_NOT_CONFIGURED }, { status: 503 }); + } + + const response = await fetch(`${baseUrl}/v1/events/${encodeURIComponent(params.eventId)}`, { + method: "GET", + headers: getKanavioTaggingServiceHeaders(), + cache: "no-store", + }); + + const responseText = await response.text(); + + if (!response.ok) { + return NextResponse.json( + { error: readErrorFromText(responseText, responseText || "Unable to fetch event.") }, + { status: response.status } + ); + } + + return new NextResponse(responseText, { + status: response.status, + headers: { + "content-type": response.headers.get("content-type") || "application/json", + }, + }); +} diff --git a/apps/web/app/api/link-preview/route.ts b/apps/web/app/api/link-preview/route.ts new file mode 100644 index 00000000000..bb8cdd968cf --- /dev/null +++ b/apps/web/app/api/link-preview/route.ts @@ -0,0 +1,71 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { url } = body; + + if (!url) { + return NextResponse.json({ error: "URL is required" }, { status: 400 }); + } + + let urlObj; + try { + urlObj = new URL(url); + } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }); + } + + try { + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + }); + + if (!response.ok) throw new Error("Failed to fetch"); + + const html = await response.text(); + + const getMetaContent = (property: string) => { + const patterns = [ + new RegExp(`]*property=["']${property}["'][^>]*content=["']([^"']*)["']`, "i"), + new RegExp(`]*content=["']([^"']*)["'][^>]*property=["']${property}["']`, "i"), + new RegExp(`]*name=["']${property}["'][^>]*content=["']([^"']*)["']`, "i"), + ]; + for (const pattern of patterns) { + const match = html.match(pattern); + if (match) return match[1]; + } + return null; + }; + + const title = getMetaContent("og:title") || getMetaContent("twitter:title") || html.match(/([^<]*)<\/title>/i)?.[1]; + const description = getMetaContent("og:description") || getMetaContent("twitter:description") || getMetaContent("description"); + const image = getMetaContent("og:image") || getMetaContent("twitter:image"); + const favicon = getMetaContent("icon") || `${urlObj.origin}/favicon.ico`; + + return NextResponse.json({ + title: title || urlObj.hostname.replace("www.", ""), + description: description || "", + image: image || null, + favicon: favicon || null, + }); + } catch { + // Fallback to basic info if fetch fails + const domain = urlObj.hostname.replace("www.", ""); + return NextResponse.json({ + title: domain, + description: `Link to ${domain}`, + image: null, + favicon: `${urlObj.origin}/favicon.ico`, + }); + } + } catch (error: any) { + console.error("Link preview error:", error?.message || error); + return NextResponse.json({ + error: error?.message || "Failed to fetch link preview" + }, { status: 500 }); + } +} diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index a6fa660a7ab..ce15d593591 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -44,7 +44,7 @@ export default function CustomErrorComponent() { src={maintenanceModeImage} height="176" width="288" - alt="ProjectSettingImg" + alt="Maintenance illustration" className="w-full h-full object-fill object-center" /> </div> diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index b2b274c7348..3252e6cf642 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -13,24 +13,24 @@ import { cn } from "@plane/utils"; import { AppProvider } from "./provider"; export const metadata: Metadata = { - title: "Plane | Simple, extensible, open-source project management tool.", + title: "Plane | Simple, extensible, open-source program management tool.", description: SITE_DESCRIPTION, metadataBase: new URL("https://app.plane.so"), openGraph: { - title: "Plane | Simple, extensible, open-source project management tool.", - description: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", + title: "Plane | Simple, extensible, open-source program management tool.", + description: "Open-source program management tool to manage work items, cycles, and product roadmaps easily", url: "https://app.plane.so/", images: [ { url: "/og-image.png", width: 1200, height: 630, - alt: "Plane - Modern project management", + alt: "Plane - Modern program management", }, ], }, keywords: - "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", + "software development, plan, ship, software, accelerate, code management, release management, program management, work item tracking, agile, scrum, kanban, collaboration", twitter: { site: "@planepowers", card: "summary_large_image", @@ -39,7 +39,7 @@ export const metadata: Metadata = { url: "/og-image.png", width: 1200, height: 630, - alt: "Plane - Modern project management", + alt: "Plane - Modern program management", }, ], }, diff --git a/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx b/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx index 1ce060b018d..af15bebdaa2 100644 --- a/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx +++ b/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx @@ -20,7 +20,7 @@ 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.", + "Zoom out to see running cycles across all your programs at once instead of going from Cycle to Cycle in each program.", icon: Folder, }, { @@ -53,7 +53,7 @@ export const WORKSPACE_ACTIVE_CYCLES_DETAILS = [ 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.", + "Spot challenges from one program to another and see inter-cycle dependencies that aren’t obvious from any other view.", icon: Microscope, }, ]; diff --git a/apps/web/ce/components/command-palette/helpers.tsx b/apps/web/ce/components/command-palette/helpers.tsx index 865aa9e53fe..58f42308229 100644 --- a/apps/web/ce/components/command-palette/helpers.tsx +++ b/apps/web/ce/components/command-palette/helpers.tsx @@ -101,7 +101,7 @@ export const commandGroups: TCommandGroups = { icon: <ProjectIcon className="h-3 w-3" />, itemName: (project: IWorkspaceProjectSearchResult) => project?.name, path: (project: IWorkspaceProjectSearchResult) => `/${project?.workspace__slug}/projects/${project?.id}/issues/`, - title: "Projects", + title: "Programs", }, workspace: { icon: <LayoutGrid className="h-3 w-3" />, diff --git a/apps/web/ce/components/global/product-updates-header.tsx b/apps/web/ce/components/global/product-updates-header.tsx index 26d4ebbdefd..b8223924624 100644 --- a/apps/web/ce/components/global/product-updates-header.tsx +++ b/apps/web/ce/components/global/product-updates-header.tsx @@ -4,7 +4,7 @@ import { PlaneLogo } from "@plane/propel/icons"; // helpers import { cn } from "@plane/utils"; // package.json -import packageJson from "package.json"; +import packageJson from "../../../package.json"; export const ProductUpdatesHeader = observer(() => { const { t } = useTranslation(); diff --git a/apps/web/ce/components/global/version-number.tsx b/apps/web/ce/components/global/version-number.tsx index f75bb10b10f..5506a025099 100644 --- a/apps/web/ce/components/global/version-number.tsx +++ b/apps/web/ce/components/global/version-number.tsx @@ -1,6 +1,6 @@ // assets import { useTranslation } from "@plane/i18n"; -import packageJson from "package.json"; +import packageJson from "../../../package.json"; export const PlaneVersionNumber: React.FC = () => { const { t } = useTranslation(); diff --git a/apps/web/ce/components/issues/header.tsx b/apps/web/ce/components/issues/header.tsx index 58f699be579..354b0d820a3 100644 --- a/apps/web/ce/components/issues/header.tsx +++ b/apps/web/ce/components/issues/header.tsx @@ -74,7 +74,7 @@ export const IssuesHeader = observer(() => { {issuesCount && issuesCount > 0 ? ( <Tooltip isMobile={isMobile} - tooltipContent={`There are ${issuesCount} ${issuesCount > 1 ? "work items" : "work item"} in this project`} + tooltipContent={`There are ${issuesCount} ${issuesCount > 1 ? "work items" : "work item"} in this program`} position="bottom" > <CountChip count={issuesCount} /> @@ -114,7 +114,7 @@ export const IssuesHeader = observer(() => { size="sm" > <div className="block sm:hidden">{t("issue.label", { count: 1 })}</div> - <div className="hidden sm:block">{t("issue.add.label")}</div> + <div className="hidden sm:block">Add Event</div> </Button> ) : ( <></> diff --git a/apps/web/ce/components/issues/issue-details/opposition-team-property.tsx b/apps/web/ce/components/issues/issue-details/opposition-team-property.tsx new file mode 100644 index 00000000000..c63a0118d8e --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/opposition-team-property.tsx @@ -0,0 +1,145 @@ +import React, { useEffect, useState, useRef } from "react"; +import { Ban, CirclePlus, Search } from "lucide-react"; +import { cn } from "@plane/utils"; +import { normalizeOppositionTeam, TOppositionTeamOption } from "@/helpers/opposition-team"; + +type Team = TOppositionTeamOption; + +interface OppositionTeamPropertyProps { + value?: Team | null; + onChange?: (team: Team | null) => void; + disabled?: boolean; + storageKey?: string; +} + +const OppositionTeamProperty: React.FC<OppositionTeamPropertyProps> = ({ + value, + onChange, + disabled = false, +}) => { + const [teams, setTeams] = useState<Team[]>([]); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(""); + const dropdownRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + const API_URL = `${process.env.NEXT_PUBLIC_CP_SERVER_URL}/meta-type?key='OPPOSITIONTEAM'`; + setLoading(true); + + fetch(API_URL) + .then(async (res) => { + if (!res.ok) throw new Error("Failed to fetch"); + const data = await res.json(); + + const items = data?.["Gateway Response"]?.result?.[0] ?? []; + const values = items.find((i: any) => i?.field === "values")?.value; + if (!Array.isArray(values)) throw new Error("Invalid structure"); + + setTeams(values.sort((a: Team, b: Team) => a.name.localeCompare(b.name))); + }) + .catch((e) => setLoadError(e.message)) + .finally(() => setLoading(false)); + }, []); + + // Outside click handler (remains the same) + useEffect(() => { + const handler = (e: MouseEvent) => { + if (!dropdownRef.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + // 3. Handling Select (Write Logic) + const handleSelect = (team: Team | null) => { + const normalizedTeam = normalizeOppositionTeam(team); + onChange?.(normalizedTeam); + setOpen(false); + setSearch(""); + }; + + const filteredTeams = teams.filter((t) => t.name.toLowerCase().includes(search.toLowerCase())); + + return ( + // ... (rest of the component JSX remains the same) ... + <div className="relative w-52" ref={dropdownRef}> + <div + onClick={() => !disabled && setOpen((o) => !o)} + className={cn( + "rounded-lg px-2 py-1 flex items-center justify-between", + value ? "text-custom-text-100" : "text-custom-text-300", + disabled + ? "cursor-default" + : "cursor-pointer hover:bg-custom-background-80 hover:text-custom-text-100" + )} + > + {value ? ( + <div className="flex items-center gap-1.5"> + {value.logo ? ( + <img + src={`${process.env.NEXT_PUBLIC_CP_SERVER_URL}/blobs/${value.logo}`} + alt={value.name} + className="w-5 h-5 rounded-full object-cover" + /> + ) : null} + <span className="text-xs whitespace-normal">{value.name}</span> + </div> + ) : ( + <div className="flex items-center gap-1.5"> + <CirclePlus className="w-4 h-4" /> + <span className="text-xs">Add Opposition Team</span> + </div> + )} + </div> + + {open && !disabled && ( + <div className="absolute mt-1 w-full rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-lg max-h-40 overflow-y-auto z-50 text-[#737373]"> + <div className="relative p-2"> + <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-3 w-3 text-gray-400" /> + <input + type="text" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search" + className="w-full py-1 pl-8 pr-2 text-xs rounded bg-custom-background-90 focus:outline-none" + /> + </div> + + <div + onClick={() => handleSelect(null)} + className="flex items-center gap-2 px-2 py-1 cursor-pointer hover:bg-custom-background-80" + > + <Ban className="w-4 h-4 text-gray-400" /> + <span className="text-xs text-gray-400">None</span> + </div> + + {loading && <div className="px-2 py-1 text-xs">Loading…</div>} + {loadError && <div className="px-2 py-1 text-xs text-red-500">Failed to load</div>} + + {!loading && + !loadError && + filteredTeams.map((team) => ( + <div + key={team.name} + onClick={() => handleSelect(team)} + className="flex items-center gap-2 px-2 py-1 cursor-pointer hover:bg-custom-background-80" + > + {team.logo ? ( + <img + src={`${process.env.NEXT_PUBLIC_CP_SERVER_URL}/blobs/${team.logo}`} + alt={team.name} + className="w-5 h-5 rounded-full object-cover" + /> + ) : null} + <span className="text-xs whitespace-normal">{team.name}</span> + </div> + ))} + </div> + )} + </div> + ); +}; + +export default OppositionTeamProperty; diff --git a/apps/web/ce/components/issues/issue-layouts/utils.tsx b/apps/web/ce/components/issues/issue-layouts/utils.tsx index 61a32c4fd90..717b819c530 100644 --- a/apps/web/ce/components/issues/issue-layouts/utils.tsx +++ b/apps/web/ce/components/issues/issue-layouts/utils.tsx @@ -3,6 +3,7 @@ import { CalendarCheck2, CalendarClock, CalendarDays, + Clock, LayersIcon, Link2, Paperclip, @@ -28,6 +29,7 @@ import { SpreadsheetLinkColumn, SpreadsheetPriorityColumn, SpreadsheetStartDateColumn, + SpreadsheetStartTimeColumn, SpreadsheetStateColumn, SpreadsheetSubIssueColumn, SpreadsheetUpdatedOnColumn, @@ -71,6 +73,7 @@ export const SpreadSheetPropertyIconMap: Record<string, FC<ISvgIcons>> = { CalenderCheck2: CalendarCheck2, Triangle: Triangle, Tag: Tag, + Clock: Clock, ModuleIcon: ModuleIcon, ContrastIcon: CycleIcon, Signal: Signal, @@ -92,6 +95,7 @@ export const SPREADSHEET_COLUMNS: { [key in keyof IIssueDisplayProperties]: TSpr link: SpreadsheetLinkColumn, priority: SpreadsheetPriorityColumn, start_date: SpreadsheetStartDateColumn, + start_time: SpreadsheetStartTimeColumn, state: SpreadsheetStateColumn, sub_issue_count: SpreadsheetSubIssueColumn, updated_on: SpreadsheetUpdatedOnColumn, diff --git a/apps/web/ce/components/projects/create/attributes.tsx b/apps/web/ce/components/projects/create/attributes.tsx index e1119f0522e..41ef28b3f4f 100644 --- a/apps/web/ce/components/projects/create/attributes.tsx +++ b/apps/web/ce/components/projects/create/attributes.tsx @@ -9,6 +9,7 @@ import { CustomSelect } from "@plane/ui"; import { getTabIndex } from "@plane/utils"; // components import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; +import SportDropdown from "@/components/dropdowns/sport-property"; import { ProjectNetworkIcon } from "@/components/project/project-network-icon"; type Props = { @@ -87,6 +88,21 @@ const ProjectAttributes: FC<Props> = (props) => { else return <></>; }} /> + <Controller + name="sport" + control={control} + render={({ field: { value, onChange } }) => ( + <div className="flex-shrink-0 h-7" tabIndex={getIndex("sport")}> + <SportDropdown + value={value ?? null} + onChange={onChange} + placeholder={t("add_sport")} + buttonVariant="border-with-text" + tabIndex={getIndex("sport")} + /> + </div> + )} + /> </div> ); }; diff --git a/apps/web/ce/components/projects/navigation/helper.tsx b/apps/web/ce/components/projects/navigation/helper.tsx index 811eb9a17d6..8ce5bc7454a 100644 --- a/apps/web/ce/components/projects/navigation/helper.tsx +++ b/apps/web/ce/components/projects/navigation/helper.tsx @@ -1,6 +1,7 @@ // plane imports import { EUserPermissions, EProjectFeatureKey } from "@plane/constants"; import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons"; +import { Users2Icon } from "lucide-react"; // components import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation"; @@ -75,4 +76,14 @@ export const getProjectFeatureNavigation = ( shouldRender: project.inbox_view, sortOrder: 6, }, + { + i18n_key: "Roster", + key: "roster" as EProjectFeatureKey, + name: "Roster", + href: `/${workspaceSlug}/projects/${projectId}/roster`, + icon: Users2Icon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: true, + sortOrder: 7, + }, ]; diff --git a/apps/web/ce/components/workspace/edition-badge.tsx b/apps/web/ce/components/workspace/edition-badge.tsx index bd846c9325d..19f53e1d9e4 100644 --- a/apps/web/ce/components/workspace/edition-badge.tsx +++ b/apps/web/ce/components/workspace/edition-badge.tsx @@ -6,7 +6,7 @@ import { Button } from "@plane/propel/button"; import { Tooltip } from "@plane/propel/tooltip"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; -import packageJson from "package.json"; +import packageJson from "../../../package.json"; // local components import { PaidPlanUpgradeModal } from "../license"; @@ -25,7 +25,8 @@ export const WorkspaceEditionBadge = observer(() => { handleClose={() => setIsPaidPlanPurchaseModalOpen(false)} /> <Tooltip tooltipContent={`Version: v${packageJson.version}`} isMobile={isMobile}> - <Button + <span className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none"></span> + {/* <Button tabIndex={-1} variant="accent-primary" className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none" @@ -34,7 +35,7 @@ export const WorkspaceEditionBadge = observer(() => { aria-label={t("aria_labels.projects_sidebar.edition_badge")} > Community - </Button> + </Button> */} </Tooltip> </> ); diff --git a/apps/web/ce/components/workspace/sidebar/helper.tsx b/apps/web/ce/components/workspace/sidebar/helper.tsx index 316f77b5d72..a2a8f3be732 100644 --- a/apps/web/ce/components/workspace/sidebar/helper.tsx +++ b/apps/web/ce/components/workspace/sidebar/helper.tsx @@ -1,3 +1,4 @@ +import { Users } from "lucide-react"; import { AnalyticsIcon, ArchiveIcon, @@ -19,6 +20,8 @@ export const getSidebarNavigationItemIcon = (key: string, className: string = "" return <InboxIcon className={cn("size-4 flex-shrink-0", className)} />; case "projects": return <ProjectIcon className={cn("size-4 flex-shrink-0", className)} />; + case "opposition": + return <Users className={cn("size-4 flex-shrink-0", className)} />; case "views": return <ViewsIcon className={cn("size-4 flex-shrink-0", className)} />; case "active_cycles": diff --git a/apps/web/ce/constants/project/settings/features.tsx b/apps/web/ce/constants/project/settings/features.tsx index 380272ea4a1..427548e1fea 100644 --- a/apps/web/ce/constants/project/settings/features.tsx +++ b/apps/web/ce/constants/project/settings/features.tsx @@ -28,7 +28,7 @@ export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = { key: "cycles", property: "cycle_view", title: "Cycles", - description: "Timebox work as you see fit per project and change frequency from one period to the next.", + description: "Timebox work as you see fit per program and change frequency from one period to the next.", icon: <CycleIcon className="h-5 w-5 flex-shrink-0 rotate-180 text-custom-text-300" />, isPro: false, isEnabled: true, @@ -37,7 +37,7 @@ export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = { key: "modules", property: "module_view", title: "Modules", - description: "Group work into sub-project-like set-ups with their own leads and assignees.", + description: "Group work into sub-program-like set-ups with their own leads and assignees.", icon: <ModuleIcon width={20} height={20} className="flex-shrink-0 text-custom-text-300" />, isPro: false, isEnabled: true, @@ -64,7 +64,7 @@ export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = { key: "intake", property: "inbox_view", title: "Intake", - description: "Consider and discuss work items before you add them to your project.", + description: "Consider and discuss work items before you add them to your program.", icon: <IntakeIcon className="h-5 w-5 flex-shrink-0 text-custom-text-300" />, isPro: false, isEnabled: true, @@ -105,8 +105,8 @@ type TProjectFeatures = { export const PROJECT_FEATURES_LIST: TProjectFeatures = { project_features: { key: "projects_and_issues", - title: "Projects and work items", - description: "Toggle these on or off this project.", + title: "Programs and work items", + description: "Toggle these on or off this program.", featureList: PROJECT_BASE_FEATURES_LIST, }, project_others: { diff --git a/apps/web/ce/features/media-library/README.md b/apps/web/ce/features/media-library/README.md new file mode 100644 index 00000000000..0d56a33cd4d --- /dev/null +++ b/apps/web/ce/features/media-library/README.md @@ -0,0 +1,12 @@ +# Media Library Feature + +Community Edition media-library UI lives here so the Next.js route folder only defines the URL. + +- `components/` contains route-level page components, upload UI, detail preview/sidebar, player UI, and peek overview UI. +- `hooks/` contains media-library fetching and preview hooks. +- `store/` contains the React context provider for list filters and refresh state. +- `types/` contains feature-specific media item types. +- `utils/` contains item mapping, filtering, and detail helper functions. +- `constants/` contains player styling constants. + +Routes import this feature through `@/plane-web/features/media-library`, which keeps the CE alias intact. diff --git a/apps/web/ce/features/media-library/components/detail-peek-overview/error.tsx b/apps/web/ce/features/media-library/components/detail-peek-overview/error.tsx new file mode 100644 index 00000000000..8c87912cf42 --- /dev/null +++ b/apps/web/ce/features/media-library/components/detail-peek-overview/error.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { FC } from "react"; +import { MoveRight } from "lucide-react"; +import { Tooltip } from "@plane/propel/tooltip"; +// components +import { EmptyState } from "@/components/common/empty-state"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; +// images +import emptyIssue from "@/public/empty-state/issue.svg"; + +type TIssuePeekOverviewError = { + removeRoutePeekId: () => void; +}; + +export const IssuePeekOverviewError: FC<TIssuePeekOverviewError> = (props) => { + const { removeRoutePeekId } = props; + // hooks + const { isMobile } = usePlatformOS(); + + return ( + <div className="w-full h-full overflow-hidden relative flex flex-col"> + <div className="flex-shrink-0 flex justify-start"> + <Tooltip tooltipContent="Close the peek view" isMobile={isMobile}> + <button onClick={removeRoutePeekId} className="w-5 h-5 m-5"> + <MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> + </button> + </Tooltip> + </div> + + <div className="w-full h-full"> + <EmptyState + image={emptyIssue ?? undefined} + title="Work item does not exist" + description="The work item you are looking for does not exist, has been archived, or has been deleted." + /> + </div> + </div> + ); +}; diff --git a/apps/web/ce/features/media-library/components/detail-peek-overview/header.tsx b/apps/web/ce/features/media-library/components/detail-peek-overview/header.tsx new file mode 100644 index 00000000000..4ef512664eb --- /dev/null +++ b/apps/web/ce/features/media-library/components/detail-peek-overview/header.tsx @@ -0,0 +1,1074 @@ +"use client"; + +import type { FC } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { Link2, MoveDiagonal, MoveRight, UploadCloud } from "lucide-react"; +// plane imports +import { API_BASE_URL, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { TIssueAttachment, TNameDescriptionLoader } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; +import { AlertModalCore, CustomSelect } from "@plane/ui"; +import { copyUrlToClipboard, generateWorkItemLink, getAssetIdFromUrl, getFileName, getFileURL } from "@plane/utils"; +// helpers +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useMember } from "@/hooks/store/use-member"; +import { useProject } from "@/hooks/store/use-project"; +import { useUser } from "@/hooks/store/user"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; +import { MediaLibraryService } from "@/services/media-library.service"; +import { + DOC_FORMATS, + IMAGE_FORMATS, + buildArtifactName, + buildEventMeta, + getErrorMessage, + isDuplicateArtifactError, + resolveArtifactAction, + resolveArtifactFormat, + resolveArtifactPathFromAssetUrl, + resolveAttachmentFileName, + resolveAttachmentDownloadUrl, +} from "@/components/issues/issue-detail-widgets/media-library-utils"; +import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status"; +import { IssueSubscription } from "@/components/issues/issue-detail/subscription"; +import { WorkItemDetailQuickActions } from "@/components/issues/issue-layouts/quick-action-dropdowns"; + +export type TPeekModes = "side-peek" | "modal" | "full-screen"; + +const PEEK_OPTIONS: { key: TPeekModes; icon: any; i18n_title: string }[] = [ + { + key: "side-peek", + icon: SidePanelIcon, + i18n_title: "common.side_peek", + }, + { + key: "modal", + icon: CenterPanelIcon, + i18n_title: "common.modal", + }, + { + key: "full-screen", + icon: FullScreenPanelIcon, + i18n_title: "common.full_screen", + }, +]; + +export type PeekOverviewHeaderProps = { + peekMode: TPeekModes; + setPeekMode: (value: TPeekModes) => void; + removeRoutePeekId: () => void; + workspaceSlug: string; + projectId: string; + issueId: string; + isArchived: boolean; + disabled: boolean; + embedIssue: boolean; + toggleDeleteIssueModal: (value: boolean) => void; + toggleArchiveIssueModal: (value: boolean) => void; + toggleDuplicateIssueModal: (value: boolean) => void; + toggleEditIssueModal: (value: boolean) => void; + handleRestoreIssue: () => Promise<void>; + isSubmitting: TNameDescriptionLoader; + descriptionImageUrls?: string[]; + onInlineCleanupModalChange?: (isOpen: boolean) => void; +}; + +type TMediaLibraryAddResult = { + total: number; + successCount: number; + skippedCount: number; + failedCount: number; +}; + +const resolveInlineAssetUrl = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("data:") || trimmed.startsWith("blob:")) return trimmed; + return getFileURL(trimmed) ?? trimmed; +}; + +const normalizeUrlForCompare = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("data:") || trimmed.startsWith("blob:")) return trimmed; + if (typeof window === "undefined") return trimmed; + try { + const parsed = new URL(trimmed, window.location.origin); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return trimmed; + } +}; + +const hashInlineSource = (value: string) => { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash).toString(16); +}; + +const normalizeInlineSourceKey = (value: string) => { + const resolved = resolveInlineAssetUrl(value); + if (!resolved) return ""; + const normalized = normalizeUrlForCompare(resolved); + if (!normalized) return ""; + if (normalized.startsWith("data:")) { + return `data:${hashInlineSource(normalized)}`; + } + return normalized; +}; + +const resolveInlineAssetId = (value: string) => { + const resolved = resolveInlineAssetUrl(value); + if (!resolved) return ""; + if (resolved.startsWith("data:") || resolved.startsWith("blob:")) return ""; + try { + const parsed = new URL(resolved, window.location.origin); + return getAssetIdFromUrl(parsed.pathname); + } catch { + return getAssetIdFromUrl(resolved); + } +}; + +const resolveManifestMeta = ( + artifact: Record<string, unknown>, + metadata: Record<string, Record<string, unknown>> | undefined +) => { + const direct = artifact.meta; + if (direct && typeof direct === "object" && !Array.isArray(direct)) return direct as Record<string, unknown>; + const metadataRef = (artifact.metadata_ref as string | undefined) || (artifact.name as string | undefined); + if (!metadataRef || !metadata || typeof metadata !== "object") return {}; + const resolved = metadata[metadataRef]; + if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) return resolved; + return {}; +}; + +const resolveInlineFileName = (value: string, index: number) => { + const trimmed = value.trim(); + if (!trimmed) return `image-${index}.png`; + if (trimmed.startsWith("data:")) { + const match = /^data:([^;]+);/i.exec(trimmed); + const mime = match?.[1]?.toLowerCase() ?? ""; + let extension = mime.startsWith("image/") ? mime.split("/")[1] : "png"; + if (extension === "svg+xml") extension = "svg"; + return `embedded-image-${index}.${extension}`; + } + if (typeof window !== "undefined") { + try { + const parsed = new URL(trimmed, window.location.origin); + const pathSegments = parsed.pathname.split("/").filter(Boolean); + const lastSegment = pathSegments[pathSegments.length - 1]; + if (lastSegment) return decodeURIComponent(lastSegment); + } catch { + // ignore parse error + } + } + return `image-${index}.png`; +}; + +const resolveInlineFileId = (value: string, index: number) => { + const trimmed = value.trim(); + if (!trimmed) return `inline-${index}`; + if (trimmed.startsWith("data:")) return `embedded-${index}`; + if (typeof window !== "undefined") { + try { + const parsed = new URL(trimmed, window.location.origin); + return getAssetIdFromUrl(parsed.pathname); + } catch { + return getAssetIdFromUrl(trimmed); + } + } + return getAssetIdFromUrl(trimmed); +}; + +const resolveInlineArtifactNames = (value: string, index: number) => { + const resolved = resolveInlineAssetUrl(value); + if (!resolved) return []; + const rawFileName = resolveInlineFileName(resolved, index + 1); + const fileId = resolveInlineFileId(resolved, index + 1); + if (!fileId) return []; + + const names: string[] = []; + if (rawFileName) { + names.push(buildArtifactName(rawFileName, fileId)); + } + if (rawFileName && !rawFileName.includes(".")) { + names.push(buildArtifactName(`${fileId}.asset`, fileId)); + } + return names.filter(Boolean); +}; + +const resolveFormatFromMime = (mime: string) => { + if (!mime) return ""; + const normalized = mime.toLowerCase(); + if (normalized.startsWith("image/")) { + const subtype = normalized.split("/")[1] ?? ""; + return subtype === "svg+xml" ? "svg" : subtype; + } + if (normalized.startsWith("video/")) return normalized.split("/")[1] ?? ""; + if (normalized === "application/pdf") return "pdf"; + if (normalized.includes("spreadsheet")) return "xlsx"; + if (normalized.includes("msword")) return "doc"; + return ""; +}; + +const resolveFormatFromDisposition = (value: string) => { + if (!value) return ""; + const filenameStarMatch = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(value); + if (filenameStarMatch?.[1]) { + return resolveArtifactFormat(decodeURIComponent(filenameStarMatch[1])); + } + const filenameMatch = /filename\s*=\s*"?([^\";]+)"?/i.exec(value); + if (filenameMatch?.[1]) { + return resolveArtifactFormat(decodeURIComponent(filenameMatch[1])); + } + return ""; +}; + +const resolveInlineImageFormatFromAssetUrl = async (url: string) => { + if (!url || !API_BASE_URL || !url.startsWith(API_BASE_URL)) return ""; + try { + const signedUrl = await resolveAttachmentDownloadUrl(url); + if (!signedUrl) return ""; + const parsed = new URL(signedUrl); + const disposition = parsed.searchParams.get("response-content-disposition") ?? ""; + const formatFromDisposition = resolveFormatFromDisposition(disposition); + if (formatFromDisposition) return formatFromDisposition; + const fileName = decodeURIComponent(parsed.pathname.split("/").pop() ?? ""); + return resolveArtifactFormat(fileName); + } catch { + return ""; + } +}; + +const getApiOrigin = () => { + if (!API_BASE_URL) return ""; + try { + return new URL(API_BASE_URL).origin; + } catch { + return ""; + } +}; + +const shouldIncludeCredentialsForUrl = (url: string) => { + if (typeof window === "undefined") return false; + try { + const parsed = new URL(url, window.location.origin); + const apiOrigin = getApiOrigin(); + return parsed.origin === window.location.origin || (apiOrigin && parsed.origin === apiOrigin); + } catch { + return false; + } +}; + +const appendJsonResponseParam = (url: string) => { + if (typeof window === "undefined") return url; + try { + const parsed = new URL(url, window.location.origin); + if (!parsed.searchParams.get("response")) { + parsed.searchParams.set("response", "json"); + } + return parsed.toString(); + } catch { + return url; + } +}; + +const fetchInlineImageResponse = async (url: string) => { + if (!url) { + throw new Error("Unable to access inline image."); + } + const apiOrigin = getApiOrigin(); + const parsedUrl = typeof window !== "undefined" ? new URL(url, window.location.origin) : null; + const isApiAssetUrl = + parsedUrl && apiOrigin && parsedUrl.origin === apiOrigin && parsedUrl.pathname.includes("/api/assets/v2/workspaces/"); + + const initialUrl = isApiAssetUrl ? appendJsonResponseParam(url) : url; + const response = await fetch(initialUrl, { + credentials: shouldIncludeCredentialsForUrl(initialUrl) ? "include" : "omit", + }); + if (response.ok) { + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data = (await response.json()) as { asset_url?: string; url?: string }; + const assetUrl = data.asset_url ?? data.url; + if (!assetUrl) { + throw new Error("Unable to access inline image."); + } + const assetResponse = await fetch(assetUrl, { credentials: "omit" }); + if (!assetResponse.ok) { + throw new Error("Unable to access inline image."); + } + return assetResponse; + } + return response; + } + + const fallbackUrl = await resolveAttachmentDownloadUrl(url); + if (!fallbackUrl) { + throw new Error("Unable to access inline image."); + } + const fallbackResponse = await fetch(fallbackUrl, { credentials: "omit" }); + if (!fallbackResponse.ok) { + throw new Error("Unable to access inline image."); + } + return fallbackResponse; +}; + +const resolveInlineManifestCleanupArtifacts = ({ + issueId, + candidates, + currentDescriptionImages, + manifestArtifacts, + manifestMetadata, +}: { + issueId: string; + candidates: Array<{ url: string; index: number }>; + currentDescriptionImages: string[]; + manifestArtifacts: Record<string, unknown>[]; + manifestMetadata?: Record<string, Record<string, unknown>>; +}) => { + const inlineSourceKeys = new Set(candidates.map(({ url }) => normalizeInlineSourceKey(url)).filter(Boolean)); + const inlineUrlKeys = new Set( + candidates + .map(({ url }) => normalizeUrlForCompare(resolveInlineAssetUrl(url))) + .filter((entry) => entry && !entry.startsWith("data:")) + ); + const inlineAssetIds = new Set(candidates.map(({ url }) => resolveInlineAssetId(url)).filter(Boolean)); + const currentInlineSourceKeys = new Set(currentDescriptionImages.map((url) => normalizeInlineSourceKey(url)).filter(Boolean)); + const currentInlineUrlKeys = new Set( + currentDescriptionImages + .map((url) => normalizeUrlForCompare(resolveInlineAssetUrl(url))) + .filter((entry) => entry && !entry.startsWith("data:")) + ); + const currentInlineAssetIds = new Set(currentDescriptionImages.map((url) => resolveInlineAssetId(url)).filter(Boolean)); + const artifactNameCandidates = new Set<string>(); + candidates.forEach(({ url, index }) => { + resolveInlineArtifactNames(url, index).forEach((name) => artifactNameCandidates.add(name)); + }); + + const namesToDelete = new Set<string>(); + + for (const artifact of manifestArtifacts) { + if (!artifact || typeof artifact !== "object") continue; + const artifactName = (artifact as { name?: string }).name; + if (!artifactName) continue; + const workItemId = (artifact as { work_item_id?: string | null }).work_item_id ?? ""; + if (workItemId && workItemId !== issueId) continue; + + const meta = resolveManifestMeta(artifact as Record<string, unknown>, manifestMetadata); + const inlineSource = typeof meta.inline_source === "string" ? meta.inline_source : ""; + if (inlineSource) { + if (currentInlineSourceKeys.has(inlineSource)) continue; + if (inlineSourceKeys.has(inlineSource)) { + namesToDelete.add(artifactName); + } + continue; + } + + const rawPath = (artifact as { path?: string }).path ?? ""; + if (rawPath && typeof rawPath === "string" && rawPath.startsWith("http")) { + const normalizedPath = normalizeUrlForCompare(rawPath); + if (normalizedPath) { + if (currentInlineUrlKeys.has(normalizedPath)) continue; + if (inlineUrlKeys.has(normalizedPath)) { + namesToDelete.add(artifactName); + continue; + } + } + } + + const lastSegment = artifactName.split("-").pop() ?? ""; + if (lastSegment) { + if (currentInlineAssetIds.has(lastSegment)) continue; + if (inlineAssetIds.has(lastSegment)) { + namesToDelete.add(artifactName); + continue; + } + } + + if (artifactNameCandidates.has(artifactName)) { + namesToDelete.add(artifactName); + } + } + + if (namesToDelete.size === 0 && currentDescriptionImages.length === 0) { + for (const artifact of manifestArtifacts) { + if (!artifact || typeof artifact !== "object") continue; + const artifactName = (artifact as { name?: string }).name; + if (!artifactName) continue; + const workItemId = (artifact as { work_item_id?: string | null }).work_item_id ?? ""; + if (workItemId && workItemId !== issueId) continue; + const meta = resolveManifestMeta(artifact as Record<string, unknown>, manifestMetadata); + const metaSource = typeof meta.source === "string" ? meta.source : ""; + if (metaSource === "work_item_description") { + namesToDelete.add(artifactName); + } + } + } + + return namesToDelete; +}; + +export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((props) => { + const { + peekMode, + setPeekMode, + workspaceSlug, + projectId, + issueId, + isArchived, + disabled, + embedIssue = false, + removeRoutePeekId, + toggleDeleteIssueModal, + toggleArchiveIssueModal, + toggleDuplicateIssueModal, + toggleEditIssueModal, + handleRestoreIssue, + isSubmitting, + descriptionImageUrls = [], + onInlineCleanupModalChange, + } = props; + // ref + const parentRef = useRef<HTMLDivElement>(null); + const { t } = useTranslation(); + // store hooks + const { data: currentUser } = useUser(); + const { + issue: { getIssueById }, + attachment, + fetchAttachments, + setPeekIssue, + removeIssue, + archiveIssue, + getIsIssuePeeked, + } = useIssueDetail(); + const { getUserDetails } = useMember(); + const { isMobile } = usePlatformOS(); + const { getProjectIdentifierById } = useProject(); + const [isAddingToMediaLibrary, setIsAddingToMediaLibrary] = useState(false); + const [isInlineCleanupModalOpen, setIsInlineCleanupModalOpen] = useState(false); + const [isInlineCleanupSubmitting, setIsInlineCleanupSubmitting] = useState(false); + const [inlineCleanupCandidates, setInlineCleanupCandidates] = useState< + Array<{ + url: string; + index: number; + }> + >([]); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + // derived values + const issueDetails = getIssueById(issueId); + const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode); + const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id); + const { + issues: { removeIssue: removeArchivedIssue }, + } = useIssues(EIssuesStoreType.ARCHIVED); + const createdByDetails = issueDetails?.created_by ? getUserDetails(issueDetails.created_by) : undefined; + const createdByName = createdByDetails?.display_name?.includes("-intake") + ? "Plane" + : createdByDetails?.display_name ?? issueDetails?.created_by ?? ""; + const baseEventMeta = useMemo(() => buildEventMeta(issueDetails, createdByName), [issueDetails, createdByName]); + const attachmentIds = attachment.getAttachmentsByIssueId(issueId) ?? []; + const attachmentCount = issueDetails?.attachment_count ?? attachmentIds.length; + const normalizedDescriptionImages = useMemo(() => { + const uniqueImages = new Map<string, string>(); + for (const rawValue of descriptionImageUrls) { + const resolved = resolveInlineAssetUrl(rawValue); + if (!resolved) continue; + const key = normalizeUrlForCompare(resolved); + if (!uniqueImages.has(key)) uniqueImages.set(key, resolved); + } + return Array.from(uniqueImages.values()); + }, [descriptionImageUrls]); + const previousDescriptionImagesRef = useRef<string[]>([]); + const previousIssueIdRef = useRef(issueId); + const hasMediaAssets = attachmentCount > 0 || normalizedDescriptionImages.length > 0; + + const setInlineCleanupModalOpen = useCallback( + (next: boolean) => { + setIsInlineCleanupModalOpen(next); + onInlineCleanupModalChange?.(next); + }, + [onInlineCleanupModalChange] + ); + + useEffect(() => { + if (previousIssueIdRef.current !== issueId) { + previousIssueIdRef.current = issueId; + previousDescriptionImagesRef.current = normalizedDescriptionImages; + setInlineCleanupCandidates([]); + setInlineCleanupModalOpen(false); + return; + } + + const previous = previousDescriptionImagesRef.current; + if (previous.length === 0) { + previousDescriptionImagesRef.current = normalizedDescriptionImages; + return; + } + + const currentKeys = new Set(normalizedDescriptionImages.map((url) => normalizeUrlForCompare(url))); + const removedImages = previous + .map((url, index) => ({ url, index })) + .filter(({ url }) => !currentKeys.has(normalizeUrlForCompare(url))); + + if (removedImages.length === 0) { + previousDescriptionImagesRef.current = normalizedDescriptionImages; + return; + } + + previousDescriptionImagesRef.current = normalizedDescriptionImages; + if (!workspaceSlug || !projectId) return; + + const candidates = removedImages; + if (candidates.length === 0) return; + let isMounted = true; + const verifyAndOpenInlineCleanup = async () => { + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const manifestArtifacts = Array.isArray(manifest?.artifacts) + ? (manifest.artifacts as unknown as Record<string, unknown>[]) + : []; + const manifestMetadata = + manifest && typeof manifest === "object" && manifest.metadata && typeof manifest.metadata === "object" + ? (manifest.metadata as Record<string, Record<string, unknown>>) + : undefined; + const namesToDelete = resolveInlineManifestCleanupArtifacts({ + issueId, + candidates, + currentDescriptionImages: normalizedDescriptionImages, + manifestArtifacts, + manifestMetadata, + }); + if (!isMounted || namesToDelete.size === 0) return; + setInlineCleanupCandidates((prev) => { + const merged = new Map<string, { url: string; index: number }>(); + prev.forEach((entry) => merged.set(`${entry.url}::${entry.index}`, entry)); + candidates.forEach((entry) => merged.set(`${entry.url}::${entry.index}`, entry)); + return Array.from(merged.values()); + }); + setInlineCleanupModalOpen(true); + } catch { + console.error("Failed to verify inline image cleanup candidates."); + // Ignore manifest lookup failures; do not prompt cleanup without verification. + } + }; + void verifyAndOpenInlineCleanup(); + + return () => { + isMounted = false; + }; + }, [issueId, normalizedDescriptionImages, mediaLibraryService, projectId, workspaceSlug, setInlineCleanupModalOpen]); + + const handleInlineCleanupClose = useCallback(() => { + setInlineCleanupModalOpen(false); + setInlineCleanupCandidates([]); + setIsInlineCleanupSubmitting(false); + }, [setInlineCleanupModalOpen]); + + const handleInlineCleanupConfirm = useCallback(async () => { + if (!workspaceSlug || !projectId) { + handleInlineCleanupClose(); + return; + } + setIsInlineCleanupSubmitting(true); + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + if (!packageId) { + handleInlineCleanupClose(); + return; + } + const manifestArtifacts = Array.isArray(manifest?.artifacts) + ? (manifest.artifacts as unknown as Record<string, unknown>[]) + : []; + const manifestMetadata = + manifest && typeof manifest === "object" && manifest.metadata && typeof manifest.metadata === "object" + ? (manifest.metadata as Record<string, Record<string, unknown>>) + : undefined; + const namesToDelete = resolveInlineManifestCleanupArtifacts({ + issueId, + candidates: inlineCleanupCandidates, + currentDescriptionImages: normalizedDescriptionImages, + manifestArtifacts, + manifestMetadata, + }); + + if (namesToDelete.size > 0) { + await Promise.all( + Array.from(namesToDelete).map(async (artifactName) => { + try { + await mediaLibraryService.deleteArtifact(workspaceSlug, projectId, packageId, artifactName); + } catch { + // ignore cleanup errors + } + }) + ); + } + } finally { + handleInlineCleanupClose(); + } + }, [ + handleInlineCleanupClose, + inlineCleanupCandidates, + issueId, + mediaLibraryService, + normalizedDescriptionImages, + projectId, + workspaceSlug, + ]); + + const workItemLink = generateWorkItemLink({ + workspaceSlug, + projectId: issueDetails?.project_id, + issueId, + projectIdentifier, + sequenceId: issueDetails?.sequence_id, + isArchived, + }); + + const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation(); + e.preventDefault(); + copyUrlToClipboard(workItemLink).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("common.link_copied"), + message: t("common.link_copied_to_clipboard"), + }); + }); + }; + + const handleAddAssetsToMediaLibrary = useCallback(async (): Promise<TMediaLibraryAddResult> => { + if (!workspaceSlug || !projectId || !issueId) { + throw new Error("Missing required fields."); + } + + setIsAddingToMediaLibrary(true); + try { + let resolvedAttachments = attachmentIds + .map((attachmentId) => attachment.getAttachmentById(attachmentId)) + .filter((item): item is TIssueAttachment => Boolean(item)); + + if (resolvedAttachments.length === 0) { + resolvedAttachments = await fetchAttachments(workspaceSlug, projectId, issueId); + } + + if (resolvedAttachments.length === 0 && normalizedDescriptionImages.length === 0) { + throw new Error("No attachments or inline images found for this work item."); + } + + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + if (!packageId) { + throw new Error("Media library package not available."); + } + + const attachmentUrlKeys = new Set( + resolvedAttachments + .map((attachmentItem) => resolveInlineAssetUrl(attachmentItem?.asset_url ?? "")) + .filter(Boolean) + .map((url) => normalizeUrlForCompare(url)) + ); + const uniqueInlineImages = normalizedDescriptionImages.filter( + (url) => !attachmentUrlKeys.has(normalizeUrlForCompare(url)) + ); + const result: TMediaLibraryAddResult = { + total: resolvedAttachments.length + uniqueInlineImages.length, + successCount: 0, + skippedCount: 0, + failedCount: 0, + }; + + for (const attachmentItem of resolvedAttachments) { + const fileName = resolveAttachmentFileName(attachmentItem); + const format = resolveArtifactFormat(fileName); + if (!format) { + result.skippedCount += 1; + continue; + } + + const assetUrl = resolveInlineAssetUrl(attachmentItem.asset_url ?? ""); + if (!assetUrl) { + result.failedCount += 1; + continue; + } + + try { + const directPath = resolveArtifactPathFromAssetUrl(assetUrl); + const artifactName = buildArtifactName(fileName, attachmentItem.id); + const title = getFileName(fileName) || "Attachment"; + const action = resolveArtifactAction(format); + const meta: Record<string, unknown> = { ...baseEventMeta }; + + if (DOC_FORMATS.has(format)) { + meta.kind = "document_file"; + meta.file_size = attachmentItem.attributes?.size; + meta.file_type = format; + } + + if (directPath) { + await mediaLibraryService.createArtifact(workspaceSlug, projectId, packageId, { + name: artifactName, + title, + format, + link: null, + action, + meta, + work_item_id: issueId, + path: directPath, + }); + result.successCount += 1; + continue; + } + + const downloadUrl = await resolveAttachmentDownloadUrl(assetUrl); + if (!downloadUrl) { + throw new Error(`Unable to fetch "${fileName}".`); + } + const response = await fetch(downloadUrl); + if (!response.ok) { + throw new Error(`Unable to fetch "${fileName}".`); + } + const blob = await response.blob(); + const file = new File([blob], fileName, { type: blob.type || undefined }); + + await mediaLibraryService.uploadArtifact( + workspaceSlug, + projectId, + packageId, + { + name: artifactName, + title, + format, + link: null, + action, + meta, + work_item_id: issueId, + }, + file + ); + result.successCount += 1; + } catch (error) { + if (isDuplicateArtifactError(error)) { + result.skippedCount += 1; + } else { + result.failedCount += 1; + } + } + } + + for (const [index, rawUrl] of uniqueInlineImages.entries()) { + const resolvedUrl = resolveInlineAssetUrl(rawUrl); + if (!resolvedUrl) { + result.failedCount += 1; + continue; + } + let fileName = resolveInlineFileName(resolvedUrl, index + 1); + let format = resolveArtifactFormat(fileName); + + try { + const meta: Record<string, unknown> = { + ...baseEventMeta, + source: "work_item_description", + inline_source: normalizeInlineSourceKey(resolvedUrl) || undefined, + }; + const directPath = resolveArtifactPathFromAssetUrl(resolvedUrl); + + if (directPath && !format) { + format = await resolveInlineImageFormatFromAssetUrl(directPath); + } + + if (directPath && format && IMAGE_FORMATS.has(format)) { + if (!fileName.toLowerCase().includes(".") && format) { + fileName = `${fileName}.${format}`; + } + const artifactName = buildArtifactName(fileName, resolveInlineFileId(resolvedUrl, index + 1)); + const title = getFileName(fileName) || "Inline image"; + const action = resolveArtifactAction(format); + await mediaLibraryService.createArtifact(workspaceSlug, projectId, packageId, { + name: artifactName, + title, + format, + link: null, + action, + meta, + work_item_id: issueId, + path: directPath, + }); + result.successCount += 1; + continue; + } + + const response = await fetchInlineImageResponse(resolvedUrl); + const blob = await response.blob(); + if (!format) { + format = resolveFormatFromMime(blob.type || ""); + } + if (!format || !IMAGE_FORMATS.has(format)) { + result.skippedCount += 1; + continue; + } + if (!fileName.toLowerCase().includes(".") && format) { + fileName = `${fileName}.${format}`; + } + const artifactName = buildArtifactName(fileName, resolveInlineFileId(resolvedUrl, index + 1)); + const title = getFileName(fileName) || "Inline image"; + const file = new File([blob], fileName, { type: blob.type || undefined }); + const action = resolveArtifactAction(format); + + await mediaLibraryService.uploadArtifact( + workspaceSlug, + projectId, + packageId, + { + name: artifactName, + title, + format, + link: null, + action, + meta, + work_item_id: issueId, + }, + file + ); + result.successCount += 1; + } catch (error) { + if (isDuplicateArtifactError(error)) { + result.skippedCount += 1; + } else { + result.failedCount += 1; + } + } + } + + if (result.successCount === 0) { + if (result.skippedCount > 0 && result.failedCount === 0) { + throw new Error("Assets already exist in the media library."); + } + if (result.skippedCount > 0 && result.failedCount > 0) { + throw new Error("Some assets could not be added to the media library."); + } + throw new Error("Unable to add assets to the media library."); + } + + return result; + } finally { + setIsAddingToMediaLibrary(false); + } + }, [ + attachment, + attachmentIds, + baseEventMeta, + fetchAttachments, + issueId, + mediaLibraryService, + normalizedDescriptionImages, + projectId, + workspaceSlug, + ]); + + const handleAddAssetsClick = useCallback(() => { + if (disabled || isAddingToMediaLibrary || !hasMediaAssets) return; + const addAssetsPromise = handleAddAssetsToMediaLibrary(); + setPromiseToast(addAssetsPromise, { + loading: "Adding assets to media library...", + success: { + title: "Assets added", + message: (data) => { + if (!data) return "Assets added to the media library."; + const { total, successCount, skippedCount, failedCount } = data; + if (failedCount === 0 && skippedCount === 0) { + return `${successCount} of ${total} assets added to the media library.`; + } + if (failedCount === 0) { + return `${successCount} of ${total} assets added. ${skippedCount} skipped.`; + } + return `${successCount} of ${total} assets added. ${skippedCount} skipped, ${failedCount} failed.`; + }, + }, + error: { + title: "Assets not added", + message: (error) => getErrorMessage(error) || "Unable to add assets to the media library.", + }, + }); + }, [disabled, handleAddAssetsToMediaLibrary, hasMediaAssets, isAddingToMediaLibrary]); + + const handleDeleteIssue = async () => { + try { + const deleteIssue = issueDetails?.archived_at ? removeArchivedIssue : removeIssue; + + return deleteIssue(workspaceSlug, projectId, issueId).then(() => { + setPeekIssue(undefined); + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.delete, + payload: { id: issueId }, + }); + }); + } catch (error) { + setToast({ + title: t("toast.error"), + type: TOAST_TYPE.ERROR, + message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }), + }); + captureError({ + eventName: WORK_ITEM_TRACKER_EVENTS.delete, + payload: { id: issueId }, + error: error as Error, + }); + } + }; + + const handleArchiveIssue = async () => { + try { + await archiveIssue(workspaceSlug, projectId, issueId); + // check and remove if issue is peeked + if (getIsIssuePeeked(issueId)) { + removeRoutePeekId(); + } + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.archive, + payload: { id: issueId }, + }); + } catch (error) { + captureError({ + eventName: WORK_ITEM_TRACKER_EVENTS.archive, + payload: { id: issueId }, + error: error as Error, + }); + } + }; + + return ( + <> + <AlertModalCore + isOpen={isInlineCleanupModalOpen} + handleClose={handleInlineCleanupClose} + handleSubmit={handleInlineCleanupConfirm} + isSubmitting={isInlineCleanupSubmitting} + title="Remove from media library?" + variant="danger" + primaryButtonText={{ + default: "Remove", + loading: "Removing", + }} + secondaryButtonText="Keep" + content={ + <> + You removed {inlineCleanupCandidates.length} inline image + {inlineCleanupCandidates.length === 1 ? "" : "s"} from the description. Do you also want to remove from the media library? + </> + } + /> + <div + className={`relative flex items-center justify-between p-4 ${ + currentMode?.key === "full-screen" ? "border-b border-custom-border-200" : "" + }`} + > + <div className="flex items-center gap-4"> + <Tooltip tooltipContent={t("common.close_peek_view")} isMobile={isMobile}> + <button onClick={removeRoutePeekId}> + <MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> + </button> + </Tooltip> + + <Tooltip tooltipContent={t("issue.open_in_full_screen")} isMobile={isMobile}> + <Link href={workItemLink} onClick={() => removeRoutePeekId()}> + <MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> + </Link> + </Tooltip> + {currentMode && embedIssue === false && ( + <div className="flex flex-shrink-0 items-center gap-2"> + <CustomSelect + value={currentMode} + onChange={(val: any) => setPeekMode(val)} + customButton={ + <Tooltip tooltipContent={t("common.toggle_peek_view_layout")} isMobile={isMobile}> + <button type="button" className=""> + <currentMode.icon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> + </button> + </Tooltip> + } + > + {PEEK_OPTIONS.map((mode) => ( + <CustomSelect.Option key={mode.key} value={mode.key}> + <div + className={`flex items-center gap-1.5 ${ + currentMode.key === mode.key + ? "text-custom-text-200" + : "text-custom-text-400 hover:text-custom-text-200" + }`} + > + <mode.icon className="-my-1 h-4 w-4 flex-shrink-0" /> + {t(mode.i18n_title)} + </div> + </CustomSelect.Option> + ))} + </CustomSelect> + </div> + )} + </div> + <div className="flex items-center gap-x-4"> + <NameDescriptionUpdateStatus isSubmitting={isSubmitting} /> + <div className="flex items-center gap-4"> + {currentUser && !isArchived && ( + <IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} /> + )} + {hasMediaAssets && ( + <Tooltip tooltipContent="Add assets in media library" isMobile={isMobile}> + <button + type="button" + onClick={handleAddAssetsClick} + disabled={disabled || isAddingToMediaLibrary} + className="disabled:cursor-not-allowed" + > + <UploadCloud + className={`h-4 w-4 ${ + disabled || isAddingToMediaLibrary + ? "text-custom-text-400" + : "text-custom-text-300 hover:text-custom-text-200" + }`} + /> + </button> + </Tooltip> + )} + <Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}> + <button type="button" onClick={handleCopyText}> + <Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" /> + </button> + </Tooltip> + {issueDetails && ( + <WorkItemDetailQuickActions + parentRef={parentRef} + issue={issueDetails} + handleDelete={handleDeleteIssue} + handleArchive={handleArchiveIssue} + handleRestore={handleRestoreIssue} + readOnly={disabled} + toggleDeleteIssueModal={toggleDeleteIssueModal} + toggleArchiveIssueModal={toggleArchiveIssueModal} + toggleDuplicateIssueModal={toggleDuplicateIssueModal} + toggleEditIssueModal={toggleEditIssueModal} + isPeekMode + /> + )} + </div> + </div> + </div> + </> + ); +}); diff --git a/apps/web/ce/features/media-library/components/detail-peek-overview/index.ts b/apps/web/ce/features/media-library/components/detail-peek-overview/index.ts new file mode 100644 index 00000000000..517bf77bd17 --- /dev/null +++ b/apps/web/ce/features/media-library/components/detail-peek-overview/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./media-root"; diff --git a/apps/web/ce/features/media-library/components/detail-peek-overview/issue-detail.tsx b/apps/web/ce/features/media-library/components/detail-peek-overview/issue-detail.tsx new file mode 100644 index 00000000000..18dbbc641f1 --- /dev/null +++ b/apps/web/ce/features/media-library/components/detail-peek-overview/issue-detail.tsx @@ -0,0 +1,342 @@ +"use-client"; +import type { FC } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { History } from "lucide-react"; +// plane imports +import type { EditorRefApi } from "@plane/editor"; +import type { TFileEntityInfo, TIssue, TNameDescriptionLoader } from "@plane/types"; +import { EFileAssetType } from "@plane/types"; +// components +import { calculateTimeAgo, getTextContent } from "@plane/utils"; +import { DescriptionVersionsRoot } from "@/components/core/description-versions"; +import { IssueDescriptionInput } from "@/components/issues/description-input"; +import type { TIssueOperations } from "@/components/issues/issue-detail"; +import { IssueParentDetail } from "@/components/issues/issue-detail/parent"; +import { IssueReaction } from "@/components/issues/issue-detail/reactions"; +import { IssueTitleInput } from "@/components/issues/title-input"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useMember } from "@/hooks/store/use-member"; +import { useProject } from "@/hooks/store/use-project"; +import { useUser } from "@/hooks/store/user"; +import useReloadConfirmations from "@/hooks/use-reload-confirmation"; +// plane web components +import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover"; +import { IssueTypeSwitcher } from "@/plane-web/components/issues/issue-details/issue-type-switcher"; +// plane web hooks +import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; +// services +import { WorkItemVersionService } from "@/services/issue"; +import { MediaLibraryService } from "@/services/media-library.service"; +import type { TMediaItem } from "../../types/media-library.types"; +// services init +const workItemVersionService = new WorkItemVersionService(); +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const isValidImageSource = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return false; + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return true; + return UUID_PATTERN.test(trimmed); +}; + +const sanitizeMediaDescriptionHtml = (value: string) => { + if (!value || typeof window === "undefined") return value; + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(value, "text/html"); + let changed = false; + doc.querySelectorAll("image-component, img").forEach((element) => { + const src = element.getAttribute("src"); + if (!src) return; + if (isValidImageSource(src)) return; + element.removeAttribute("src"); + changed = true; + }); + return changed ? doc.body.innerHTML : value; + } catch { + return value; + } +}; + +type Props = { + editorRef: React.RefObject<EditorRefApi>; + workspaceSlug: string; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + disabled: boolean; + isArchived: boolean; + isSubmitting: TNameDescriptionLoader; + setIsSubmitting: (value: TNameDescriptionLoader) => void; + onDescriptionChange?: (value: string) => void; + mediaItem?: TMediaItem; + onMediaItemUpdated?: (updates?: Partial<TMediaItem>) => void; +}; + +export const PeekOverviewIssueDetails: FC<Props> = observer((props) => { + const { + editorRef, + workspaceSlug, + projectId, + issueId, + issueOperations, + disabled, + isArchived, + isSubmitting, + setIsSubmitting, + mediaItem, + onMediaItemUpdated, + } = props; + const { onDescriptionChange } = props; + // store hooks + const { data: currentUser } = useUser(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + const { getUserDetails } = useMember(); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + // reload confirmation + const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); + + useEffect(() => { + if (isSubmitting === "submitted") { + setShowAlert(false); + setTimeout(async () => { + setIsSubmitting("saved"); + }, 2000); + } else if (isSubmitting === "submitting") { + setShowAlert(true); + } + }, [isSubmitting, setShowAlert, setIsSubmitting]); + + // derived values + const issue = issueId ? getIssueById(issueId) : undefined; + const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined; + const hasLinkedIssue = Boolean(issue && issue.project_id); + const isArtifactOnlyMode = Boolean(mediaItem && !hasLinkedIssue); + // debounced duplicate issues swr + const { duplicateIssues } = useDebouncedDuplicateIssues( + workspaceSlug, + projectDetails?.workspace.toString(), + projectDetails?.id, + { + name: issue?.name, + description_html: getTextContent(issue?.description_html), + issueId: issue?.id, + } + ); + + if (!hasLinkedIssue && !mediaItem) return <></>; + + const escapeHtml = useCallback( + (value: string) => + value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"), + [] + ); + + const getMediaDescriptionSeed = useCallback( + (item?: TMediaItem) => { + const html = item?.descriptionHtml?.trim(); + if (html) return sanitizeMediaDescriptionHtml(html); + const text = item?.description?.trim() ?? ""; + if (!text) return "<p></p>"; + return `<p>${escapeHtml(text).replace(/\n/g, "<br />")}</p>`; + }, + [escapeHtml] + ); + + const mediaTitle = mediaItem?.title ?? ""; + const [mediaDescriptionSeed, setMediaDescriptionSeed] = useState(() => getMediaDescriptionSeed(mediaItem)); + const mediaDescriptionIdRef = useRef<string | null>(mediaItem?.id ?? null); + useEffect(() => { + const nextId = mediaItem?.id ?? null; + if (nextId === mediaDescriptionIdRef.current) return; + mediaDescriptionIdRef.current = nextId; + setMediaDescriptionSeed(getMediaDescriptionSeed(mediaItem)); + }, [getMediaDescriptionSeed, mediaItem, mediaItem?.id]); + const mediaDescriptionHtml = mediaDescriptionSeed || "<p></p>"; + const artifactDescriptionUploadEntity = useMemo<TFileEntityInfo | undefined>(() => { + if (!isArtifactOnlyMode) return undefined; + if (!projectId) return undefined; + return { + entity_identifier: projectId, + entity_type: EFileAssetType.PROJECT_COVER, + }; + }, [isArtifactOnlyMode, projectId]); + + const updateMediaArtifact = useCallback( + async (data: Partial<TIssue>) => { + if (!mediaItem?.id || !mediaItem?.packageId) return; + const payload: { title?: string | null; description?: string | null } = {}; + const updatedFields: Partial<TMediaItem> = {}; + if (data.name !== undefined) { + const trimmed = data.name?.trim() ?? ""; + payload.title = trimmed ? trimmed : null; + updatedFields.title = trimmed; + } + if (data.description_html !== undefined) { + const htmlDescription = (data.description_html ?? "").trim(); + payload.description = htmlDescription ? htmlDescription : null; + updatedFields.descriptionHtml = htmlDescription || undefined; + updatedFields.description = htmlDescription ? getTextContent(htmlDescription) : ""; + } + if (Object.keys(payload).length === 0) return; + await mediaLibraryService.updateManifestArtifacts(workspaceSlug, projectId, mediaItem.packageId, { + artifact_id: mediaItem.id, + artifact: payload, + }); + if (Object.keys(updatedFields).length > 0) { + onMediaItemUpdated?.(updatedFields); + } + }, + [mediaItem, mediaLibraryService, onMediaItemUpdated, projectId, workspaceSlug] + ); + + const titleOps = useMemo<TIssueOperations>(() => { + if (!mediaItem) return issueOperations; + return { + ...issueOperations, + update: async (_workspaceSlug, _projectId, _issueId, data) => updateMediaArtifact(data), + }; + }, [issueOperations, mediaItem, updateMediaArtifact]); + + const issueDescription = + issue?.description_html !== undefined && issue?.description_html !== null && issue?.description_html !== "" + ? issue.description_html + : "<p></p>"; + const linkedProjectId = issue?.project_id ?? projectId; + const resolvedProjectId = hasLinkedIssue ? linkedProjectId : projectId; + const resolvedIssueId = hasLinkedIssue ? issue!.id : mediaItem?.id ?? issueId; + const mediaMeta = ((mediaItem?.meta ?? {}) as Record<string, unknown>) || {}; + const mediaLastEditedAt = + typeof mediaMeta.updated_at === "string" && /^\d{4}-\d{2}-\d{2}/.test(mediaMeta.updated_at) + ? mediaMeta.updated_at + : typeof mediaMeta.created_at === "string" && /^\d{4}-\d{2}-\d{2}/.test(mediaMeta.created_at) + ? mediaMeta.created_at + : null; + const showIssueFooterMeta = hasLinkedIssue || Boolean(mediaItem); + const issueLastEditedAt = issue?.updated_at ?? issue?.created_at ?? null; + const lastEditedAt = mediaItem ? mediaLastEditedAt ?? issueLastEditedAt : issueLastEditedAt; + const lastEditedByDisplayName = hasLinkedIssue + ? getUserDetails(issue?.updated_by ?? issue?.created_by ?? "")?.display_name ?? mediaItem?.author ?? "Deactivated user" + : mediaItem?.author ?? "Media Library"; + const lastEditedTimeLabel = lastEditedAt ? calculateTimeAgo(lastEditedAt) : mediaItem?.createdAt ? `on ${mediaItem.createdAt}` : ""; + + return ( + <div className="space-y-2"> + {hasLinkedIssue && issue?.parent_id && ( + <IssueParentDetail + workspaceSlug={workspaceSlug} + projectId={linkedProjectId} + issueId={issueId} + issue={issue!} + issueOperations={issueOperations} + /> + )} + {hasLinkedIssue && !isArtifactOnlyMode ? ( + <div className="flex items-center justify-between gap-2"> + <IssueTypeSwitcher issueId={issueId} disabled={isArchived || disabled} /> + {duplicateIssues?.length > 0 && ( + <DeDupeIssuePopoverRoot + workspaceSlug={workspaceSlug} + projectId={linkedProjectId} + rootIssueId={issueId} + issues={duplicateIssues} + issueOperations={issueOperations} + /> + )} + </div> + ) : null} + <IssueTitleInput + workspaceSlug={workspaceSlug} + projectId={resolvedProjectId} + issueId={resolvedIssueId} + isSubmitting={isSubmitting} + setIsSubmitting={(value) => setIsSubmitting(value)} + issueOperations={titleOps} + disabled={disabled || isArchived} + value={mediaItem ? mediaTitle : issue?.name} + containerClassName="-ml-3" + /> + + <IssueDescriptionInput + editorRef={editorRef} + workspaceSlug={workspaceSlug} + projectId={resolvedProjectId} + issueId={resolvedIssueId} + initialValue={mediaItem ? mediaDescriptionHtml : issueDescription} + disabled={disabled || isArchived} + issueOperations={titleOps} + setIsSubmitting={(value) => setIsSubmitting(value)} + containerClassName="-ml-3 border-none" + onDescriptionChange={onDescriptionChange} + assetUploadEntityInfo={artifactDescriptionUploadEntity} + /> + + {showIssueFooterMeta ? ( + <div className="flex items-center justify-between gap-2"> + {!mediaItem && currentUser && ( + <IssueReaction + workspaceSlug={workspaceSlug} + projectId={linkedProjectId} + issueId={issueId} + currentUser={currentUser} + disabled={isArchived} + /> + )} + <> + {mediaItem ? ( + <div className="ml-auto flex items-center gap-1 text-custom-text-300"> + <span className="flex-shrink-0 size-4 grid place-items-center"> + <History className="size-3.5" /> + </span> + <p className="text-xs"> + Last edited by <span className="font-medium">{lastEditedByDisplayName}</span>{" "} + {lastEditedTimeLabel} + </p> + </div> + ) : ( + !disabled && ( + <DescriptionVersionsRoot + className="flex-shrink-0" + entityInformation={{ + createdAt: issue!.created_at ? new Date(issue!.created_at) : new Date(), + createdByDisplayName: getUserDetails(issue!.created_by ?? "")?.display_name ?? "", + id: issueId, + isRestoreDisabled: disabled || isArchived, + }} + fetchHandlers={{ + listDescriptionVersions: (issueId) => + workItemVersionService.listDescriptionVersions( + workspaceSlug, + linkedProjectId, + issueId + ), + retrieveDescriptionVersion: (issueId, versionId) => + workItemVersionService.retrieveDescriptionVersion( + workspaceSlug, + linkedProjectId, + issueId, + versionId + ), + }} + handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)} + projectId={linkedProjectId} + workspaceSlug={workspaceSlug} + /> + ) + )} + </> + </div> + ) : null} + </div> + ); +}); diff --git a/apps/web/ce/features/media-library/components/detail-peek-overview/loader.tsx b/apps/web/ce/features/media-library/components/detail-peek-overview/loader.tsx new file mode 100644 index 00000000000..e16479e3944 --- /dev/null +++ b/apps/web/ce/features/media-library/components/detail-peek-overview/loader.tsx @@ -0,0 +1,107 @@ +"use client"; + +import type { FC } from "react"; +import { MoveRight } from "lucide-react"; +import { Tooltip } from "@plane/propel/tooltip"; +import { Loader } from "@plane/ui"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; + +type TIssuePeekOverviewLoader = { + removeRoutePeekId: () => void; +}; + +export const IssuePeekOverviewLoader: FC<TIssuePeekOverviewLoader> = (props) => { + const { removeRoutePeekId } = props; + // hooks + const { isMobile } = usePlatformOS(); + + return ( + <Loader className="w-full h-screen overflow-hidden p-5 space-y-6"> + <div className="flex justify-between items-center gap-2"> + <div className="flex items-center gap-2"> + <Tooltip tooltipContent="Close the peek view" isMobile={isMobile}> + <button onClick={removeRoutePeekId}> + <MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> + </button> + </Tooltip> + <Loader.Item width="30px" height="30px" /> + </div> + <div className="flex items-center gap-2"> + <Loader.Item width="80px" height="30px" /> + <Loader.Item width="30px" height="30px" /> + <Loader.Item width="30px" height="30px" /> + <Loader.Item width="30px" height="30px" /> + </div> + </div> + + {/* issue title and description and comments */} + <div className="space-y-3"> + <Loader.Item width="100px" height="20px" /> + + <div className="space-y-1"> + <Loader.Item width="300px" height="15px" /> + <Loader.Item width="400px" height="15px" /> + <div className="flex items-center gap-2"> + <Loader.Item width="20px" height="15px" /> + <Loader.Item width="500px" height="15px" /> + </div> + <div className="flex items-center gap-2"> + <Loader.Item width="20px" height="15px" /> + <Loader.Item width="200px" height="15px" /> + </div> + <Loader.Item width="300px" height="15px" /> + <Loader.Item width="200px" height="15px" /> + </div> + + <Loader.Item width="30px" height="30px" /> + </div> + + {/* sub issues */} + <div className="flex justify-between items-center gap-2"> + <Loader.Item width="80px" height="20px" /> + <Loader.Item width="100px" height="20px" /> + </div> + + {/* attachments */} + <div className="space-y-3"> + <Loader.Item width="80px" height="20px" /> + <div className="flex items-center gap-2"> + <Loader.Item width="250px" height="50px" /> + <Loader.Item width="250px" height="50px" /> + </div> + </div> + + {/* properties */} + <div className="space-y-3"> + <Loader.Item width="80px" height="20px" /> + <div className="space-y-2"> + <div className="flex items-center gap-8"> + <Loader.Item width="150px" height="25px" /> + <Loader.Item width="150px" height="25px" /> + </div> + <div className="flex items-center gap-8"> + <Loader.Item width="150px" height="25px" /> + <Loader.Item width="150px" height="25px" /> + </div> + <div className="flex items-center gap-8"> + <Loader.Item width="150px" height="25px" /> + <Loader.Item width="150px" height="25px" /> + </div> + <div className="flex items-center gap-8"> + <Loader.Item width="150px" height="25px" /> + <Loader.Item width="150px" height="25px" /> + </div> + <div className="flex items-center gap-8"> + <Loader.Item width="150px" height="25px" /> + <Loader.Item width="150px" height="25px" /> + </div> + <div className="flex items-center gap-8"> + <Loader.Item width="150px" height="25px" /> + <Loader.Item width="150px" height="25px" /> + </div> + </div> + </div> + </Loader> + ); +}; diff --git a/apps/web/ce/features/media-library/components/detail-peek-overview/media-root.tsx b/apps/web/ce/features/media-library/components/detail-peek-overview/media-root.tsx new file mode 100644 index 00000000000..59e70cc9ca9 --- /dev/null +++ b/apps/web/ce/features/media-library/components/detail-peek-overview/media-root.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { ReactNode } from "react"; +import { createPortal } from "react-dom"; + +type TMediaLibraryPeekOverviewProps = { + children: ReactNode; +}; + +export const MediaLibraryPeekOverview = ({ children }: TMediaLibraryPeekOverviewProps) => { + const portalContainer = typeof document !== "undefined" ? document.getElementById("full-screen-portal") : null; + + const content = ( + <div className="w-full !text-base"> + <div + className="absolute z-[25] flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300 top-0 bottom-0 right-0 w-full md:w-[50%] border-0 border-l" + style={{ + boxShadow: + "0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)", + }} + > + <div className="vertical-scrollbar scrollbar-md relative h-full w-full overflow-hidden overflow-y-auto"> + {children} + </div> + </div> + </div> + ); + + return <>{portalContainer ? createPortal(content, portalContainer) : content}</>; +}; diff --git a/apps/web/ce/features/media-library/components/detail-peek-overview/properties.tsx b/apps/web/ce/features/media-library/components/detail-peek-overview/properties.tsx new file mode 100644 index 00000000000..5ea25fe87ba --- /dev/null +++ b/apps/web/ce/features/media-library/components/detail-peek-overview/properties.tsx @@ -0,0 +1,394 @@ +"use client"; + +import type { FC } from "react"; +import { useCallback, useMemo } from "react"; +import { observer } from "mobx-react"; +import { Signal, Tag, CalendarClock, User, UserCircle2, Handshake, Volleyball, Calendar, Clock } from "lucide-react"; + +// i18n +import { useTranslation } from "@plane/i18n"; +import type { TIssue } from "@plane/types"; + +// utils +import { isDateTimePast, renderFormattedPayloadDate } from "@plane/utils"; + +// components +import { CategoryDropdown } from "@/components/dropdowns/category-property"; +import { DateDropdown } from "@/components/dropdowns/date"; +import { LevelDropdown } from "@/components/dropdowns/level-property"; +import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; +import { ProgramDropdown } from "@/components/dropdowns/program-property"; +import SportDropdown from "@/components/dropdowns/sport-property"; +import { TimeDropdown } from "@/components/dropdowns/time-picker"; +import { YearRangeDropdown } from "@/components/dropdowns/year-property"; +import { normalizeOppositionTeam, parseOppositionTeam, serializeOppositionTeam } from "@/helpers/opposition-team"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useMember } from "@/hooks/store/use-member"; +import { useProject } from "@/hooks/store/use-project"; +import { MediaLibraryService } from "@/services/media-library.service"; +import type { TMediaItem } from "../../types/media-library.types"; + +import OppositionTeamProperty from "@/plane-web/components/issues/issue-details/opposition-team-property"; +import type { TIssueOperations } from "@/components/issues/issue-detail"; + +interface IPeekOverviewProperties { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled: boolean; + readOnly?: boolean; + mediaItem?: TMediaItem; + issueOperations: TIssueOperations; +} + +export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((props) => { + const { workspaceSlug, projectId, issueId, issueOperations, disabled, mediaItem, readOnly = false } = props; + const { t } = useTranslation(); + + // store hooks + const { getProjectById } = useProject(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getUserDetails } = useMember(); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + + // derived values + const issue = getIssueById(issueId); + const mediaMeta = useMemo(() => (mediaItem?.meta ?? {}) as Record<string, unknown>, [mediaItem?.meta]); + + const getMetaStringValue = useCallback( + (keys: string[]) => { + for (const key of keys) { + const value = mediaMeta[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; + }, + [mediaMeta] + ); + + const pickIssueOrMetaString = useCallback( + (issueValue: string | null | undefined, keys: string[]) => { + if (typeof issueValue === "string" && issueValue.trim()) return issueValue.trim(); + const metaValue = getMetaStringValue(keys); + return metaValue || null; + }, + [getMetaStringValue] + ); + + const resolvedStartDate = pickIssueOrMetaString(issue?.start_date, ["start_date", "startDate"]); + const resolvedStartTime = pickIssueOrMetaString(issue?.start_time, ["start_time", "startTime"]); + const resolvedLevel = pickIssueOrMetaString(issue?.level, ["level"]); + const resolvedProgram = pickIssueOrMetaString(issue?.program, ["program"]); + const resolvedSport = pickIssueOrMetaString(issue?.sport, ["sport"]); + const resolvedCategory = pickIssueOrMetaString(issue?.category, ["category"]); + const issueSeasonValue = + typeof issue?.year === "string" ? issue.year : issue?.year ? String(issue.year) : null; + const resolvedSeason = pickIssueOrMetaString(issueSeasonValue, ["season"]); + + const resolvedOpposition = useMemo<any>(() => { + const parsedIssueOpposition = parseOppositionTeam(issue?.opposition_team); + if (parsedIssueOpposition) return parsedIssueOpposition; + + const metaOpposition = mediaMeta.opposition; + const normalizedMetaOpposition = normalizeOppositionTeam(metaOpposition); + if (normalizedMetaOpposition) return normalizedMetaOpposition; + + const oppositionLabel = getMetaStringValue(["opposition"]); + return oppositionLabel ? { name: oppositionLabel, logo: "" } : null; + }, [getMetaStringValue, issue?.opposition_team, mediaMeta.opposition]); + + if (!issue && !mediaItem) return <></>; + + const projectDetails = projectId ? getProjectById(projectId) : undefined; + const projectSport = projectDetails?.sport?.trim() || null; + + const createdByDetails = issue?.created_by ? getUserDetails(issue.created_by) : undefined; + const createdByLabel = + (createdByDetails?.display_name + ? createdByDetails.display_name.includes("-intake") + ? "Plane" + : createdByDetails.display_name + : "") || + getMetaStringValue(["created_by", "createdBy"]) || + mediaItem?.author || + ""; + + const minDate = new Date(); + minDate.setDate(minDate.getDate()); + + const isReadOnly = disabled || !issue; + const isDateTimeLocked = isReadOnly || isDateTimePast(resolvedStartDate, resolvedStartTime); + const isSportLocked = isReadOnly || !!projectSport; + + const buildManifestMeta = useCallback( + (currentIssue: TIssue) => ({ + category: currentIssue.category || "Work items", + start_date: currentIssue.start_date ?? null, + start_time: currentIssue.start_time ?? null, + level: currentIssue.level ?? null, + program: currentIssue.program ?? null, + sport: currentIssue.sport ?? null, + opposition: currentIssue.opposition_team ?? null, + season: currentIssue.year ?? null, + }), + [] + ); + + const updateManifestMeta = useCallback( + async (currentIssue: TIssue) => { + if (!workspaceSlug || !projectId || !issueId) return; + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + if (!packageId) return; + await mediaLibraryService.updateManifestMetadata(workspaceSlug, projectId, packageId, { + work_item_id: issueId, + meta: buildManifestMeta(currentIssue), + }); + } catch { + // Skip manifest updates if artifacts don't exist. + } + }, + [buildManifestMeta, issueId, mediaLibraryService, projectId, workspaceSlug] + ); + + const handlePropertyUpdate = useCallback( + async (data: Partial<TIssue>) => { + if (!issue || isReadOnly) return; + await issueOperations.update(workspaceSlug, projectId, issueId, data); + const nextIssue = { ...issue, ...data } as TIssue; + await updateManifestMeta(nextIssue); + }, + [isReadOnly, issue, issueId, issueOperations, projectId, updateManifestMeta, workspaceSlug] + ); + + const handleDateTimeUpdate = useCallback( + async (data: Partial<TIssue>) => { + if (isDateTimeLocked) return; + await handlePropertyUpdate(data); + }, + [handlePropertyUpdate, isDateTimeLocked] + ); + + return ( + <div> + <h6 className="text-sm font-medium">Event Details</h6> + + <div className={`w-full space-y-2 mt-3 ${isReadOnly && !readOnly ? "opacity-60" : ""}`}> + {/* created by */} + {createdByLabel ? ( + <div className="flex w-full items-center gap-3 h-8"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <UserCircle2 className="h-4 w-4 flex-shrink-0" /> + <span>{t("common.created_by")}</span> + </div> + <div className="w-full h-full flex items-center gap-1.5 rounded px-2 py-0.5 text-sm text-custom-text-100 justify-between cursor-default"> + {createdByDetails ? ( + <ButtonAvatars + showTooltip + userIds={createdByDetails.display_name.includes("-intake") ? null : createdByDetails.id} + /> + ) : null} + <span className="flex-grow truncate leading-5">{createdByLabel}</span> + </div> + </div> + ) : null} + + {/* start date */} + <div className="flex w-full items-center gap-3 h-8"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <CalendarClock className="h-4 w-4 flex-shrink-0" /> + <span>{t("common.order_by.start_date")}</span> + </div> + <DateDropdown + value={resolvedStartDate} + onChange={(val) => + void handleDateTimeUpdate({ + start_date: val ? renderFormattedPayloadDate(val) : null, + }) + } + placeholder={t("issue.add.start_date")} + buttonVariant="transparent-with-text" + minDate={minDate ?? undefined} + disabled={isDateTimeLocked} + className="w-3/4 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${resolvedStartDate ? "text-custom-text-100" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + {/* start time */} + <div className="flex h-8 items-center gap-3 w-full"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <Clock className="h-4 w-4 flex-shrink-0" /> + <span>{t("starting_time")}</span> + </div> + <TimeDropdown + value={resolvedStartTime} + onChange={(val) => { + void handleDateTimeUpdate({ + start_time: val, + }); + }} + placeholder={t("add_start_time")} + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + disabled={isDateTimeLocked} + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${resolvedStartTime ? "text-custom-text-100" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + {/* Level */} + <div className="flex w-full items-center gap-3 h-8"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <Signal className="h-4 w-4 flex-shrink-0" /> + <p>{t("level_field")}</p> + </div> + + <LevelDropdown + value={resolvedLevel} + onChange={(level) => { + void handlePropertyUpdate({ + level: level, + }); + }} + placeholder={t("add_level")} + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + disabled={isReadOnly} + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${resolvedLevel ? "text-custom-text-100" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + {/* Program */} + <div className="flex w-full items-center gap-3 h-8"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <User className="h-4 w-4 flex-shrink-0" /> + <p>Program</p> + </div> + + <ProgramDropdown + value={resolvedProgram} + onChange={(program) => { + void handlePropertyUpdate({ + program: program, + }); + }} + placeholder={t("add_program")} + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + disabled={isReadOnly} + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${resolvedProgram ? "text-custom-text-100" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + {/* Sport */} + <div className="flex w-full items-center gap-3 h-8"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <Volleyball className="h-4 w-4 flex-shrink-0" /> + <p>Sport</p> + </div> + + <SportDropdown + value={resolvedSport} + onChange={(sport) => { + void handlePropertyUpdate({ + sport: sport, + }); + }} + placeholder={t("add_sport")} + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + disabled={isSportLocked} + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${resolvedSport ? "text-custom-text-100" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + {/* Opposition */} + <div className="flex w-full items-center gap-3 h-8"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <Handshake className="h-4 w-4 flex-shrink-0" /> + <p>Opposition</p> + </div> + + <OppositionTeamProperty + storageKey={`opp-team-${issueId}`} + value={resolvedOpposition as any} + onChange={(team) => + void handlePropertyUpdate({ + opposition_team: serializeOppositionTeam(team), + }) + } + disabled={isReadOnly} + /> + </div> + + {/* Category */} + <div className="flex w-full items-center gap-3 h-8"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <Tag className="h-4 w-4 flex-shrink-0" /> + <p>Category</p> + </div> + + <CategoryDropdown + value={resolvedCategory} + onChange={(category) => { + void handlePropertyUpdate({ + category: category, + }); + }} + placeholder={t("add_category")} + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + disabled={isReadOnly} + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${resolvedCategory ? "text-custom-text-100" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + {/* Year */} + <div className="flex w-full items-center gap-3 h-8"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <Calendar className="h-4 w-4 flex-shrink-0" /> + <p>Season</p> + </div> + + <YearRangeDropdown + value={resolvedSeason} + onChange={(year) => { + void handlePropertyUpdate({ + year: year, + }); + }} + placeholder={t("add_year")} + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + disabled={isReadOnly} + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${resolvedSeason ? "text-custom-text-100" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + </div> + </div> + ); +}); diff --git a/apps/web/ce/features/media-library/components/detail-peek-overview/root.tsx b/apps/web/ce/features/media-library/components/detail-peek-overview/root.tsx new file mode 100644 index 00000000000..11d9df2df88 --- /dev/null +++ b/apps/web/ce/features/media-library/components/detail-peek-overview/root.tsx @@ -0,0 +1,324 @@ +"use client"; + +import type { FC } from "react"; +import { useEffect, useState, useMemo, useCallback } from "react"; +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +// Plane imports +import { EUserPermissions, EUserPermissionsLevel, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast"; +import type { IWorkItemPeekOverview, TIssue } from "@plane/types"; +import { EIssueServiceType, EIssuesStoreType } from "@plane/types"; +// hooks +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; +import { useWorkItemProperties } from "@/plane-web/hooks/use-issue-properties"; +import type { TIssueOperations } from "@/components/issues/issue-detail"; +// local imports +import { IssueView } from "./view"; +import type { TMediaItem } from "../../types/media-library.types"; + +export const DetailIssueOverview: FC< + IWorkItemPeekOverview & { mediaItem?: TMediaItem; onMediaItemUpdated?: (updates?: Partial<TMediaItem>) => void } +> = observer( + (props) => { + const { + embedIssue = false, + embedRemoveCurrentNotification, + is_draft = false, + storeType: issueStoreFromProps, + mediaItem, + onMediaItemUpdated, + } = props; + const { t } = useTranslation(); + // router + const pathname = usePathname(); + // store hook + const { allowPermissions } = useUserPermissions(); + + const { + issues: { restoreIssue }, + } = useIssues(EIssuesStoreType.ARCHIVED); + const { + peekIssue, + setPeekIssue, + issue: { fetchIssue, getIsFetchingIssueDetails }, + fetchActivities, + } = useIssueDetail(); + const issueStoreType = useIssueStoreType(); + const storeType = issueStoreFromProps ?? issueStoreType; + const { issues } = useIssues(storeType); + + useWorkItemProperties( + peekIssue?.projectId, + peekIssue?.workspaceSlug, + peekIssue?.issueId, + storeType === EIssuesStoreType.EPIC ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES + ); + // state + const [error, setError] = useState(false); + + const removeRoutePeekId = useCallback(() => { + setPeekIssue(undefined); + if (embedIssue) embedRemoveCurrentNotification?.(); + }, [embedIssue, embedRemoveCurrentNotification, setPeekIssue]); + + const issueOperations: TIssueOperations = useMemo( + () => ({ + fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + setError(false); + await fetchIssue(workspaceSlug, projectId, issueId); + } catch (error) { + setError(true); + console.error("Error fetching the parent issue", error); + } + }, + update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => { + if (issues?.updateIssue) { + await issues + .updateIssue(workspaceSlug, projectId, issueId, data) + .then(async () => { + fetchActivities(workspaceSlug, projectId, issueId); + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issueId }, + }); + }) + .catch((error) => { + captureError({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issueId }, + error: error as Error, + }); + setToast({ + title: t("toast.error"), + type: TOAST_TYPE.ERROR, + message: t("entity.update.failed", { entity: t("issue.label", { count: 1 }) }), + }); + }); + } + }, + remove: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + return issues?.removeIssue(workspaceSlug, projectId, issueId).then(() => { + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.delete, + payload: { id: issueId }, + }); + removeRoutePeekId(); + }); + } catch (error) { + setToast({ + title: t("toast.error"), + type: TOAST_TYPE.ERROR, + message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }), + }); + captureError({ + eventName: WORK_ITEM_TRACKER_EVENTS.delete, + payload: { id: issueId }, + error: error as Error, + }); + } + }, + archive: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + if (!issues?.archiveIssue) return; + await issues.archiveIssue(workspaceSlug, projectId, issueId); + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.archive, + payload: { id: issueId }, + }); + } catch (error) { + captureError({ + eventName: WORK_ITEM_TRACKER_EVENTS.archive, + payload: { id: issueId }, + error: error as Error, + }); + } + }, + restore: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + await restoreIssue(workspaceSlug, projectId, issueId); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("issue.restore.success.title"), + message: t("issue.restore.success.message"), + }); + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.restore, + payload: { id: issueId }, + }); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("issue.restore.failed.message"), + }); + captureError({ + eventName: WORK_ITEM_TRACKER_EVENTS.restore, + payload: { id: issueId }, + error: error as Error, + }); + } + }, + addCycleToIssue: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { + try { + await issues.addCycleToIssue(workspaceSlug, projectId, cycleId, issueId); + fetchActivities(workspaceSlug, projectId, issueId); + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issueId }, + }); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("issue.add.cycle.failed"), + }); + captureError({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issueId }, + error: error as Error, + }); + } + }, + addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { + try { + await issues.addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issueIds }, + }); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("issue.add.cycle.failed"), + }); + captureError({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issueIds }, + error: error as Error, + }); + } + }, + removeIssueFromCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { + try { + const removeFromCyclePromise = issues.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); + setPromiseToast(removeFromCyclePromise, { + loading: t("issue.remove.cycle.loading"), + success: { + title: t("toast.success"), + message: () => t("issue.remove.cycle.success"), + }, + error: { + title: t("toast.error"), + message: () => t("issue.remove.cycle.failed"), + }, + }); + await removeFromCyclePromise; + fetchActivities(workspaceSlug, projectId, issueId); + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issueId }, + }); + } catch (error) { + captureError({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issueId }, + error: error as Error, + }); + } + }, + changeModulesInIssue: async ( + workspaceSlug: string, + projectId: string, + issueId: string, + addModuleIds: string[], + removeModuleIds: string[] + ) => { + const promise = await issues.changeModulesInIssue( + workspaceSlug, + projectId, + issueId, + addModuleIds, + removeModuleIds + ); + fetchActivities(workspaceSlug, projectId, issueId); + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issueId }, + }); + return promise; + }, + removeIssueFromModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => { + try { + const removeFromModulePromise = issues.removeIssuesFromModule(workspaceSlug, projectId, moduleId, [issueId]); + setPromiseToast(removeFromModulePromise, { + loading: t("issue.remove.module.loading"), + success: { + title: t("toast.success"), + message: () => t("issue.remove.module.success"), + }, + error: { + title: t("toast.error"), + message: () => t("issue.remove.module.failed"), + }, + }); + await removeFromModulePromise; + fetchActivities(workspaceSlug, projectId, issueId); + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issueId }, + }); + } catch (error) { + captureError({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issueId }, + error: error as Error, + }); + } + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [fetchIssue, is_draft, issues, fetchActivities, pathname, removeRoutePeekId, restoreIssue] + ); + + useEffect(() => { + if (peekIssue) { + issueOperations.fetch(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId); + } + }, [peekIssue, issueOperations]); + + if (!peekIssue?.workspaceSlug || !peekIssue?.projectId || !peekIssue?.issueId) return <></>; + + // Check if issue is editable, based on user role + const isEditable = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + peekIssue?.workspaceSlug, + peekIssue?.projectId + ); + + return ( + <IssueView + workspaceSlug={peekIssue.workspaceSlug} + projectId={peekIssue.projectId} + issueId={peekIssue.issueId} + isLoading={getIsFetchingIssueDetails(peekIssue.issueId)} + isError={error} + is_archived={!!peekIssue.isArchived} + disabled={!isEditable} + embedIssue={embedIssue} + embedRemoveCurrentNotification={embedRemoveCurrentNotification} + issueOperations={issueOperations} + mediaItem={mediaItem} + onMediaItemUpdated={onMediaItemUpdated} + /> + ); +}); diff --git a/apps/web/ce/features/media-library/components/detail-peek-overview/view.tsx b/apps/web/ce/features/media-library/components/detail-peek-overview/view.tsx new file mode 100644 index 00000000000..c550b49af56 --- /dev/null +++ b/apps/web/ce/features/media-library/components/detail-peek-overview/view.tsx @@ -0,0 +1,370 @@ +import type { FC } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { createPortal } from "react-dom"; +// plane imports +import type { EditorRefApi } from "@plane/editor"; +import type { TNameDescriptionLoader } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; +import { cn, getEditorAssetSrc, getFileURL } from "@plane/utils"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import useKeypress from "@/hooks/use-keypress"; +import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click"; +// local imports + +import { IssuePeekOverviewError } from "./error"; +import type { TPeekModes } from "./header"; +import { IssuePeekOverviewHeader } from "./header"; +import { PeekOverviewIssueDetails } from "./issue-detail"; +import { IssuePeekOverviewLoader } from "./loader"; +import { PeekOverviewProperties } from "./properties"; +import type { TIssueOperations } from "@/components/issues/issue-detail"; +import { IssueActivity } from "@/components/issues/issue-detail/issue-activity"; +import { IssueDetailWidgets } from "@/components/issues/issue-detail-widgets"; +import type { TMediaItem } from "../../types/media-library.types"; + +const resolveDescriptionImageSrc = (value: string, workspaceSlug: string, projectId: string) => { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("data:") || trimmed.startsWith("blob:") || trimmed.startsWith("http")) { + return trimmed; + } + if (!trimmed.includes("/") && workspaceSlug) { + return ( + getEditorAssetSrc({ + assetId: trimmed, + workspaceSlug, + projectId, + }) ?? trimmed + ); + } + return getFileURL(trimmed) ?? trimmed; +}; + +const extractDescriptionImageUrls = (descriptionHtml: string | null | undefined, workspaceSlug: string, projectId: string) => { + if (!descriptionHtml) return []; + const sources = new Set<string>(); + + if (typeof window !== "undefined" && "DOMParser" in window) { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(descriptionHtml, "text/html"); + doc.querySelectorAll("img, image-component").forEach((element) => { + const src = + element.getAttribute("src")?.trim() || + element.getAttribute("data-src")?.trim() || + element.getAttribute("data-source")?.trim(); + if (src) { + const resolved = resolveDescriptionImageSrc(src, workspaceSlug, projectId); + if (resolved) sources.add(resolved); + } + }); + } catch { + // fall back to regex parsing + } + } + + if (sources.size === 0) { + const regex = /<(?:img|image-component)[^>]+src=["']?([^"'>\s]+)["']?/gi; + let match = regex.exec(descriptionHtml); + while (match) { + if (match[1]) { + const resolved = resolveDescriptionImageSrc(match[1], workspaceSlug, projectId); + if (resolved) sources.add(resolved); + } + match = regex.exec(descriptionHtml); + } + } + + return Array.from(sources); +}; + +interface IIssueView { + workspaceSlug: string; + projectId: string; + issueId: string; + isLoading?: boolean; + isError?: boolean; + is_archived: boolean; + disabled?: boolean; + embedIssue?: boolean; + embedRemoveCurrentNotification?: () => void; + issueOperations: TIssueOperations; + mediaItem?: TMediaItem; + onMediaItemUpdated?: (updates?: Partial<TMediaItem>) => void; +} + +export const IssueView: FC<IIssueView> = observer((props) => { + const { + workspaceSlug, + projectId, + issueId, + isLoading, + isError, + is_archived, + disabled = false, + embedIssue = false, + embedRemoveCurrentNotification, + issueOperations, + mediaItem, + onMediaItemUpdated, + } = props; + // states + const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek"); + const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved"); + const [isDeleteIssueModalOpen, setIsDeleteIssueModalOpen] = useState(false); + const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false); + const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false); + const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false); + const [isInlineCleanupModalOpen, setIsInlineCleanupModalOpen] = useState(false); + const [descriptionHtmlOverride, setDescriptionHtmlOverride] = useState<string | null>(null); + // ref + const issuePeekOverviewRef = useRef<HTMLDivElement>(null); + const editorRef = useRef<EditorRefApi>(null); + // store hooks + const { + setPeekIssue, + isAnyModalOpen, + issue: { getIssueById, getIsLocalDBIssueDescription }, + } = useIssueDetail(); + const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS); + const issue = getIssueById(issueId); + useEffect(() => { + setDescriptionHtmlOverride(null); + }, [issueId]); + const descriptionHtmlSource = descriptionHtmlOverride ?? issue?.description_html ?? null; + const descriptionImageUrls = useMemo( + () => extractDescriptionImageUrls(descriptionHtmlSource, workspaceSlug, projectId), + [descriptionHtmlSource, projectId, workspaceSlug] + ); + const handleDescriptionChange = useCallback((value: string) => { + setDescriptionHtmlOverride(value); + }, []); + // remove peek id + const removeRoutePeekId = () => { + setPeekIssue(undefined); + if (embedIssue && embedRemoveCurrentNotification) embedRemoveCurrentNotification(); + }; + + const isLocalDBIssueDescription = getIsLocalDBIssueDescription(issueId); + + const toggleDeleteIssueModal = (value: boolean) => setIsDeleteIssueModalOpen(value); + const toggleArchiveIssueModal = (value: boolean) => setIsArchiveIssueModalOpen(value); + const toggleDuplicateIssueModal = (value: boolean) => setIsDuplicateIssueModalOpen(value); + const toggleEditIssueModal = (value: boolean) => setIsEditIssueModalOpen(value); + + const isAnyLocalModalOpen = + isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen; + const isAnyLocalModalOpenWithInline = isAnyLocalModalOpen || isInlineCleanupModalOpen; + + usePeekOverviewOutsideClickDetector( + issuePeekOverviewRef, + () => { + const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen(); + if (!embedIssue) { + if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpenWithInline && !isAnyDropbarOpen) { + removeRoutePeekId(); + } + } + }, + issueId + ); + + const handleKeyDown = () => { + const editorImageFullScreenModalElement = document.querySelector(".editor-image-full-screen-modal"); + const dropdownElement = document.activeElement?.tagName === "INPUT"; + const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen(); + if (!isAnyModalOpen && !dropdownElement && !isAnyDropbarOpen && !editorImageFullScreenModalElement) { + removeRoutePeekId(); + const issueElement = document.getElementById(`issue-${issueId}`); + if (issueElement) issueElement?.focus(); + } + }; + + useKeypress("Escape", () => !embedIssue && handleKeyDown()); + + const handleRestore = async () => { + if (!issueOperations.restore) return; + await issueOperations.restore(workspaceSlug, projectId, issueId); + removeRoutePeekId(); + }; + + const peekOverviewIssueClassName = cn( + !embedIssue + ? "absolute z-[25] flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300" + : `w-full h-full`, + !embedIssue && { + "top-0 bottom-0 right-0 w-full md:w-[50%] border-0 border-l": peekMode === "side-peek", + "size-5/6 top-[8.33%] left-[8.33%]": peekMode === "modal", + "inset-0 m-4 absolute": peekMode === "full-screen", + } + ); + + const shouldUsePortal = !embedIssue; + + const portalContainer = document.getElementById("full-screen-portal") as HTMLElement; + + const content = ( + <div className="h-full w-full !text-base"> + {issueId && ( + <div + ref={issuePeekOverviewRef} + className={peekOverviewIssueClassName} + style={{ + boxShadow: + "0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)", + }} + > + {isError ? ( + <div className="relative h-screen w-full overflow-hidden"> + <IssuePeekOverviewError removeRoutePeekId={removeRoutePeekId} /> + </div> + ) : ( + isLoading && <IssuePeekOverviewLoader removeRoutePeekId={removeRoutePeekId} /> + )} + {!isLoading && !isError && issue && ( + <> + {!embedIssue && ( + <IssuePeekOverviewHeader + peekMode={peekMode} + setPeekMode={(value) => setPeekMode(value)} + removeRoutePeekId={removeRoutePeekId} + toggleDeleteIssueModal={toggleDeleteIssueModal} + toggleArchiveIssueModal={toggleArchiveIssueModal} + toggleDuplicateIssueModal={toggleDuplicateIssueModal} + toggleEditIssueModal={toggleEditIssueModal} + handleRestoreIssue={handleRestore} + isArchived={is_archived} + issueId={issueId} + workspaceSlug={workspaceSlug} + projectId={projectId} + isSubmitting={isSubmitting} + disabled={disabled} + embedIssue={embedIssue} + descriptionImageUrls={descriptionImageUrls} + onInlineCleanupModalChange={setIsInlineCleanupModalOpen} + /> + )} + {/* content */} + <div className="vertical-scrollbar scrollbar-md relative h-full w-full overflow-hidden overflow-y-auto"> + {["side-peek", "modal"].includes(peekMode) ? ( + <div className="relative flex flex-col gap-3 px-8 py-5 space-y-3"> + <PeekOverviewIssueDetails + editorRef={editorRef} + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + issueOperations={issueOperations} + disabled={disabled || isLocalDBIssueDescription} + isArchived={is_archived} + isSubmitting={isSubmitting} + setIsSubmitting={(value) => setIsSubmitting(value)} + onDescriptionChange={mediaItem ? undefined : handleDescriptionChange} + mediaItem={mediaItem} + onMediaItemUpdated={onMediaItemUpdated} + /> + + {!embedIssue && ( + <div className="py-2"> + <IssueDetailWidgets + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + disabled={disabled || is_archived} + issueServiceType={EIssueServiceType.ISSUES} + hideMediaLibraryButton + confirmManifestOnDelete + /> + </div> + )} + + <PeekOverviewProperties + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + issueOperations={issueOperations} + disabled={disabled || is_archived} + mediaItem={mediaItem} + readOnly={Boolean(mediaItem)} + /> + + {!embedIssue && ( + <IssueActivity + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + disabled={is_archived} + /> + )} + </div> + ) : ( + <div className="vertical-scrollbar flex h-full w-full overflow-auto"> + <div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5"> + <div className="space-y-3"> + <PeekOverviewIssueDetails + editorRef={editorRef} + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + issueOperations={issueOperations} + disabled={disabled || isLocalDBIssueDescription} + isArchived={is_archived} + isSubmitting={isSubmitting} + setIsSubmitting={(value) => setIsSubmitting(value)} + onDescriptionChange={mediaItem ? undefined : handleDescriptionChange} + mediaItem={mediaItem} + onMediaItemUpdated={onMediaItemUpdated} + /> + + {!embedIssue && ( + <div className="py-2"> + <IssueDetailWidgets + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + disabled={disabled} + issueServiceType={EIssueServiceType.ISSUES} + hideMediaLibraryButton + confirmManifestOnDelete + /> + </div> + )} + + {!embedIssue && ( + <IssueActivity + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + disabled={is_archived} + /> + )} + </div> + </div> + <div + className={`h-full !w-[400px] flex-shrink-0 border-l border-custom-border-200 p-4 py-5 overflow-hidden vertical-scrollbar scrollbar-sm ${ + is_archived ? "pointer-events-none" : "" + }`} + > + <PeekOverviewProperties + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + issueOperations={issueOperations} + disabled={disabled || is_archived} + mediaItem={mediaItem} + readOnly={Boolean(mediaItem)} + /> + </div> + </div> + )} + </div> + </> + )} + </div> + )} + </div> + ); + + return <>{shouldUsePortal && portalContainer ? createPortal(content, portalContainer) : content}</>; +}); diff --git a/apps/web/ce/features/media-library/components/hls-video.tsx b/apps/web/ce/features/media-library/components/hls-video.tsx new file mode 100644 index 00000000000..51a60cf8b34 --- /dev/null +++ b/apps/web/ce/features/media-library/components/hls-video.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { RefObject } from "react"; +import { useEffect, useRef } from "react"; +import Hls from "hls.js"; + +type THlsVideoProps = { + src: string; + poster?: string; + className?: string; + autoPlay?: boolean; + controls?: boolean; + videoRef?: RefObject<HTMLVideoElement>; +}; + +export const HlsVideo = ({ src, poster, className, autoPlay = false, controls = true, videoRef }: THlsVideoProps) => { + const fallbackRef = useRef<HTMLVideoElement | null>(null); + const targetRef = videoRef ?? fallbackRef; + + useEffect(() => { + const video = targetRef.current; + if (!video || !src) return; + + if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = src; + video.load(); + return; + } + + if (Hls.isSupported()) { + const hls = new Hls(); + hls.loadSource(src); + hls.attachMedia(video); + return () => { + hls.destroy(); + }; + } + + video.src = src; + video.load(); + }, [src, targetRef]); + + return ( + <video + ref={targetRef} + poster={poster} + autoPlay={autoPlay} + controls={controls} + playsInline + preload="metadata" + className={className} + /> + ); +}; diff --git a/apps/web/ce/features/media-library/components/media-card.tsx b/apps/web/ce/features/media-library/components/media-card.tsx new file mode 100644 index 00000000000..6aba56ca8d7 --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-card.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { MouseEvent } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { + AlertTriangle, + Calendar, + CheckCircle2, + Clock, + File, + Image as ImageIcon, + ImageOff, + LoaderCircle, + Video, +} from "lucide-react"; +import { API_BASE_URL } from "@plane/constants"; +import { ETagSize, ETagVariant, Tag } from "@plane/ui"; + +import { useVideoDuration } from "../hooks/use-video-duration"; +import type { TMediaItem } from "../types/media-library.types"; +import { getDisplayMediaTitle } from "../utils/media-detail-utils"; +import { + getEventMediaContextLabel, + getEventMediaDateLabel, + getEventMediaDetails, + getEventMediaMetrics, + isEventMediaItem, +} from "../utils/media-event"; + +const clampProgress = (value: unknown) => { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return Math.min(100, Math.max(0, Math.round(value))); +}; + +export const MediaCard = ({ + item, + href, + className, + forceThumbnail: _forceThumbnail, + label: _label, + onClick, +}: { + item: TMediaItem; + href: string; + className?: string; + forceThumbnail?: boolean; + label?: string; + onClick?: (event: MouseEvent<HTMLAnchorElement>, item: TMediaItem) => void; +}) => { + // console.log("Rendering MediaCard for item:", item); + const isHls = item.mediaType === "video" && item.format.toLowerCase() === "m3u8"; + const [isThumbnailUnavailable, setIsThumbnailUnavailable] = useState(!item.thumbnail); + const isEventItem = isEventMediaItem(item); + const eventDetails = getEventMediaDetails(item); + const eventDateLabel = getEventMediaDateLabel(item); + const eventMetrics = getEventMediaMetrics(item); + const eventContextLabel = getEventMediaContextLabel(item); + const itemDescription = item.description || eventContextLabel || ""; + const displayTitle = getDisplayMediaTitle(item.title); + + useEffect(() => { + setIsThumbnailUnavailable(!item.thumbnail); + }, [item.thumbnail]); + + const durationLabel = useVideoDuration(item); + const isExternal = /^https?:\/\//i.test(href); + const showLinkedTypeIndicator = item.mediaType === "image" && Boolean(item.link) && Boolean(item.linkedMediaType); + const isLinkedDocumentThumbnail = item.mediaType === "image" && item.linkedMediaType === "document"; + const linkedTypeLabel = showLinkedTypeIndicator + ? isEventItem + ? "Video" + : item.linkedMediaType === "video" + ? "Video" + : item.linkedMediaType === "image" + ? "Image" + : "Document" + : ""; + const shouldUseCredentials = (src: string) => { + if (!src) return false; + if (src.startsWith("/")) return true; + if (!/^https?:\/\//i.test(src)) return true; + try { + const url = new URL(src); + if (typeof window !== "undefined" && url.origin === window.location.origin) return true; + if (API_BASE_URL) { + try { + return url.origin === new URL(API_BASE_URL).origin; + } catch { + return false; + } + } + } catch { + return false; + } + return false; + }; + const useCredentials = shouldUseCredentials(item.videoSrc ?? ""); + const crossOrigin = useCredentials ? "use-credentials" : "anonymous"; + const isVideoLike = item.mediaType === "video" || item.linkedMediaType === "video"; + const LinkedTypeIcon = showLinkedTypeIndicator + ? isEventItem + ? Video + : item.linkedMediaType === "video" + ? Video + : item.linkedMediaType === "image" + ? ImageIcon + : File + : null; + const showTranscodeBadge = + isVideoLike && + Boolean(item.transcodeStatus) && + (item.isTranscodeActive || item.isTranscodeFailed || item.isTranscodeComplete); + const transcodeProgress = clampProgress(item.transcodeProgress); + const TranscodeIcon = item.isTranscodeFailed ? AlertTriangle : item.isTranscodeComplete ? CheckCircle2 : LoaderCircle; + const transcodeBadgeClass = item.isTranscodeFailed + ? "bg-red-500/15 text-red-500" + : item.isTranscodeComplete + ? "bg-green-500/15 text-green-500" + : "bg-custom-primary-100/15 text-custom-primary-100"; + const transcodeBadgeLabel = item.isTranscodeActive + ? `${item.transcodeLabel ?? "Uploading"} ${transcodeProgress > 0 ? `${transcodeProgress}%` : ""}`.trim() + : item.transcodeLabel; + const isDetailDisabled = Boolean(item.isTranscodeActive); + const handleLinkClick = (event: MouseEvent<HTMLAnchorElement>) => { + if (isDetailDisabled) { + event.preventDefault(); + event.stopPropagation(); + return; + } + onClick?.(event, item); + }; + + const thumbnailUnavailableFallback = ( + <div className="flex h-full w-full flex-col items-center justify-center gap-1 text-custom-text-300"> + <ImageOff className="h-16 w-16" strokeWidth={2.5} /> + <span className="sr-only">Thumbnail unavailable</span> + </div> + ); + + const cardBody = ( + <div + className={`group w-[220px] flex-shrink-0 sm:w-[240px] md:w-[260px] lg:w-[280px] xl:w-[300px] ${ + className ?? "" + }`.trim()} + > + <div className="relative aspect-[16/9] w-full overflow-hidden rounded-lg bg-custom-background-90"> + {showLinkedTypeIndicator && LinkedTypeIcon ? ( + <span className="absolute right-2 bottom-1 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-100/80 text-custom-text-200 backdrop-blur"> + <span className="sr-only">{linkedTypeLabel}</span> + <LinkedTypeIcon className="h-4 w-4" strokeWidth={3.5} /> + </span> + ) : null} + {item.mediaType === "image" ? ( + isThumbnailUnavailable ? ( + thumbnailUnavailableFallback + ) : ( + <Image + src={item.thumbnail} + alt={displayTitle} + width={100} + height={100} + loading="lazy" + onError={() => setIsThumbnailUnavailable(true)} + className={`h-full w-full transition-transform duration-300 ${ + isLinkedDocumentThumbnail ? "object-contain p-6" : "object-cover" + }`} + /> + ) + ) : item.mediaType === "video" ? ( + isHls ? ( + isThumbnailUnavailable ? ( + thumbnailUnavailableFallback + ) : ( + <Image + src={item.thumbnail} + alt={displayTitle} + width={100} + height={100} + loading="lazy" + onError={() => setIsThumbnailUnavailable(true)} + className="h-full w-full object-cover transition-transform duration-300 " + /> + ) + ) : ( + <video + src={item.videoSrc ?? ""} + poster={item.thumbnail} + muted + loop + playsInline + preload="metadata" + crossOrigin={crossOrigin} + className="h-full w-full object-cover transition-transform duration-300 " + /> + ) + ) : ( + <> + {isThumbnailUnavailable ? ( + <div className="flex h-full w-full items-center justify-center text-custom-text-300"> + <File className="h-6 w-6" strokeWidth={3.5} /> + </div> + ) : ( + <Image + src={item.thumbnail} + alt={displayTitle} + width={100} + height={100} + loading="lazy" + onError={() => setIsThumbnailUnavailable(true)} + className="h-full w-full object-contain p-6 transition-transform duration-300" + /> + )} + </> + )} + {item.isTranscodeActive ? ( + <div className="absolute inset-x-0 bottom-0 z-10 h-1 bg-custom-background-100/70"> + <div + className="h-full bg-custom-primary-100 transition-all duration-300" + style={{ width: `${transcodeProgress}%` }} + /> + </div> + ) : null} + </div> + <div className="mt-2 space-y-1"> + <div className="flex items-center justify-between gap-2"> + <div className="line-clamp-1 text-sm font-semibold text-custom-text-100">{displayTitle}</div> + </div> + {itemDescription ? ( + <div className="line-clamp-2 text-[11px] text-custom-text-300">{itemDescription}</div> + ) : null} + <div className="flex flex-wrap items-center gap-3 text-[11px] text-custom-text-300"> + <span className="inline-flex items-center gap-1"> + <Calendar className="h-3.5 w-3.5 text-custom-text-300" /> + {isEventItem ? eventDateLabel || item.createdAt : item.createdAt} + </span> + {item.mediaType === "video" && !isEventItem ? ( + <span className="inline-flex items-center gap-1"> + <Clock className="h-3.5 w-3.5 text-custom-text-300" /> + {durationLabel} + </span> + ) : null} + {isEventItem ? ( + eventMetrics.map((metric) => <span key={metric}>{metric}</span>) + ) : ( + <span>Views {item.views}</span> + )} + </div> + <div className="flex flex-wrap items-center gap-2 text-[11px]"> + <Tag + variant={ETagVariant.OUTLINED} + size={ETagSize.SM} + className="min-h-0 rounded-full border-0 bg-custom-primary-100/20 px-2 py-1 text-[11px] font-medium text-custom-primary-100 cursor-default hover:text-custom-primary-100" + > + {item.primaryTag} + </Tag> + {isEventItem && eventDetails?.status ? ( + <span className="rounded-full border border-custom-border-200 bg-custom-background-100 px-2 py-1 text-[11px] font-medium text-custom-text-300"> + {eventDetails.status} + </span> + ) : null} + {showTranscodeBadge ? ( + <span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium ${transcodeBadgeClass}`}> + <TranscodeIcon className={`h-3 w-3 ${item.isTranscodeActive ? "animate-spin" : ""}`} /> + {transcodeBadgeLabel} + </span> + ) : null} + {/* <span className="inline-flex items-center gap-1 rounded-full bg-custom-background-90 px-2 py-0.5 text-custom-text-300"> + <MediaTypeIcon className="h-3 w-3" strokeWidth={3.5} /> + {mediaTypeLabel} + </span> */} + {/* <span className="rounded-full border border-custom-border-200 px-2 py-0.5 text-custom-text-300"> + {item.itemsCount} + </span> */} + </div> + </div> + </div> + ); + + if (isDetailDisabled) { + return ( + <div className="cursor-not-allowed text-left opacity-95" aria-disabled="true" title="Transcoding in progress"> + {cardBody} + </div> + ); + } + + return isExternal ? ( + <a href={href} onClick={handleLinkClick} className="text-left"> + {cardBody} + </a> + ) : ( + <Link href={href} onClick={handleLinkClick} className="text-left"> + {cardBody} + </Link> + ); +}; diff --git a/apps/web/ce/features/media-library/components/media-detail-page.tsx b/apps/web/ce/features/media-library/components/media-detail-page.tsx new file mode 100644 index 00000000000..431b6e35111 --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-detail-page.tsx @@ -0,0 +1,1222 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import DOMPurify from "dompurify"; +import Link from "next/link"; +import { useParams, useSearchParams } from "next/navigation"; +import videojs from "video.js"; +import { ArrowLeft } from "lucide-react"; +// import "video.js/dist/video-js.css"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { + buildSgEventAnnotationDisplayMeta, + buildSgEventAnnotationVideoItem, + buildSgEventAnnotationViewKey, + getSgEventMediaReferenceAnnotations, + VideoAnnotationEditor, +} from "@/components/annotation"; +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { SgEventDetailPage } from "@/components/issues/issue-detail/sg-event-detail-page"; +import { useMember } from "@/hooks/store/use-member"; +import { useAppRouter } from "@/hooks/use-app-router"; +import type { TCustomPlaylistAnnotation } from "@/services/media-library.service"; +import { MediaLibraryService } from "@/services/media-library.service"; +import { PLAYER_STYLE } from "../constants/player-styles"; +import { useDocumentPreview, useResolvedMediaSources } from "../hooks/media-detail-hooks"; +import { useMediaLibraryItem } from "../hooks/use-media-library-item"; +import type { TMediaItem } from "../types/media-library.types"; +import { + getCaptionTracks, + getMetaString, + getQualitySelection, + getVideoMimeType, + getVideoRepresentations, +} from "../utils/media-detail-utils"; +import { isEventMediaItem } from "../utils/media-event"; +import { MediaDetailPreview } from "./media-detail-preview"; +import { MediaDetailSidebar } from "./media-detail-sidebar"; +import { TagsSection } from "./tags-section"; + +type TPipCaptionMode = "disabled" | "hidden" | "showing"; + +const MediaDetailPage = () => { + const { mediaId, workspaceSlug, projectId } = useParams() as { + mediaId: string; + workspaceSlug: string; + projectId: string; + }; + const router = useAppRouter(); + const { getUserDetails } = useMember(); + const searchParams = useSearchParams(); + const fromParam = searchParams.get("from") ?? ""; + const annotationParam = (searchParams.get("annotation") ?? searchParams.get("annotate") ?? "").toLowerCase(); + const shouldOpenVideoAnnotationWorkspaceFromQuery = ["1", "true", "open", "video"].includes(annotationParam); + const annotationStreamParam = searchParams.get("stream") ?? ""; + const annotationStreamIdParam = searchParams.get("streamId") ?? ""; + const annotationDeviceIdParam = searchParams.get("deviceId") ?? ""; + const annotationViewKeyParam = searchParams.get("viewKey") ?? ""; + const annotationVideoSrcParam = searchParams.get("videoSrc") ?? ""; + const annotationViewParam = searchParams.get("view") ?? ""; + const backHref = useMemo(() => { + const defaultHref = `/${workspaceSlug}/projects/${projectId}/media-library`; + const projectHrefPrefix = `/${workspaceSlug}/projects/${projectId}`; + if (!fromParam || !fromParam.startsWith("/") || fromParam.startsWith("//")) return defaultHref; + if (fromParam !== projectHrefPrefix && !fromParam.startsWith(`${projectHrefPrefix}/`)) return defaultHref; + return fromParam; + }, [fromParam, projectId, workspaceSlug]); + const { item: rawItem, isLoading } = useMediaLibraryItem(workspaceSlug, projectId, mediaId); + const [mediaItemOverrides, setMediaItemOverrides] = useState<Partial<TMediaItem> | null>(null); + const [isTagsSaving, setIsTagsSaving] = useState(false); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + const annotationVideoItem = useMemo( + () => + shouldOpenVideoAnnotationWorkspaceFromQuery + ? buildSgEventAnnotationVideoItem(rawItem, { + deviceId: annotationDeviceIdParam, + streamId: annotationStreamIdParam, + streamName: annotationStreamParam, + title: annotationViewParam, + viewKey: annotationViewKeyParam, + videoSrc: annotationVideoSrcParam, + }) + : null, + [ + annotationDeviceIdParam, + annotationStreamParam, + annotationStreamIdParam, + annotationVideoSrcParam, + annotationViewKeyParam, + annotationViewParam, + rawItem, + shouldOpenVideoAnnotationWorkspaceFromQuery, + ] + ); + const baseItem = annotationVideoItem ?? rawItem; + const item = useMemo( + () => (baseItem ? { ...baseItem, ...(mediaItemOverrides ?? {}) } : baseItem), + [baseItem, mediaItemOverrides] + ); + const isSgEventAsset = useMemo(() => (item ? isEventMediaItem(item) : false), [item]); + const handleMediaItemUpdated = useCallback((updates?: Partial<TMediaItem>) => { + if (!updates || Object.keys(updates).length === 0) return; + setMediaItemOverrides((prev) => ({ ...(prev ?? {}), ...updates })); + }, []); + + const handleTagsUpdate = useCallback( + async (nextTags: string[]) => { + if (!item?.packageId || !item?.id) return; + const nextMeta = { ...(item.meta ?? {}), tags: nextTags }; + setIsTagsSaving(true); + try { + await mediaLibraryService.updateManifestArtifacts(workspaceSlug, projectId, item.packageId, { + artifact_id: item.id, + artifact: { + meta: nextMeta, + }, + }); + handleMediaItemUpdated({ meta: nextMeta }); + } finally { + setIsTagsSaving(false); + } + }, + [handleMediaItemUpdated, item?.id, item?.meta, item?.packageId, mediaLibraryService, projectId, workspaceSlug] + ); + const videoRef = useRef<HTMLVideoElement | null>(null); + const playerRef = useRef<ReturnType<typeof videojs> | null>(null); + const [isImageZoomOpen, setIsImageZoomOpen] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isVideoAnnotationMode, setIsVideoAnnotationMode] = useState(false); + const [isVideoAnnotationWorkspaceOpen, setIsVideoAnnotationWorkspaceOpen] = useState(false); + const [videoAnnotationWorkspaceActivationKey, setVideoAnnotationWorkspaceActivationKey] = useState(0); + const [currentVideoSeconds, setCurrentVideoSeconds] = useState(0); + const [currentVideoDurationSeconds, setCurrentVideoDurationSeconds] = useState<number | null>(null); + const [videoAnnotationPropertiesElement, setVideoAnnotationPropertiesElement] = useState<HTMLDivElement | null>(null); + const [videoAnnotationToolbarElement, setVideoAnnotationToolbarElement] = useState<HTMLDivElement | null>(null); + const [videoTimelineElement, setVideoTimelineElement] = useState<HTMLDivElement | null>(null); + const [playerTick, setPlayerTick] = useState(0); + const [qualitySelection, setQualitySelection] = useState<string | null>(null); + const [playerElement, setPlayerElement] = useState<HTMLElement | null>(null); + const videoAnnotationSaveBeforeCloseRef = useRef<(() => Promise<boolean>) | null>(null); + const settingsPanelRef = useRef<HTMLDivElement | null>(null); + const pipCaptionModesRef = useRef<Array<{ track: TextTrack; mode: TPipCaptionMode }>>([]); + const inactivityTimeoutRef = useRef<number | null>(null); + const annotationEventJsonSource = rawItem?.fileSrc || rawItem?.downloadSrc || ""; + useEffect(() => { + setMediaItemOverrides(null); + }, [rawItem?.id]); + + useEffect(() => { + setCurrentVideoSeconds(0); + setCurrentVideoDurationSeconds(null); + setIsVideoAnnotationMode(false); + setIsVideoAnnotationWorkspaceOpen(false); + }, [item?.id]); + + useEffect(() => { + const sourceItem = rawItem; + if (!shouldOpenVideoAnnotationWorkspaceFromQuery || !annotationEventJsonSource || !sourceItem) return; + + let isCancelled = false; + const loadEventViewAnnotations = async () => { + for (const credentials of ["include", "omit"] as const) { + try { + const response = await fetch(annotationEventJsonSource, { credentials }); + if (!response.ok) continue; + + const payload = await response.json().catch(() => null); + const eventPayload = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record<string, unknown>) + : null; + if (!eventPayload || isCancelled) return; + + const annotationVideoSource = + annotationVideoSrcParam || + (typeof annotationVideoItem?.videoSrc === "string" ? annotationVideoItem.videoSrc : "") || + (typeof annotationVideoItem?.fileSrc === "string" ? annotationVideoItem.fileSrc : ""); + const nextMeta = buildSgEventAnnotationDisplayMeta(sourceItem.meta ?? {}, { + deviceId: annotationDeviceIdParam, + eventPayload, + streamId: annotationStreamIdParam, + streamName: annotationStreamParam, + title: annotationViewParam, + viewKey: annotationViewKeyParam, + videoSrc: annotationVideoSource, + }); + handleMediaItemUpdated({ + meta: { + ...(annotationVideoItem?.meta ?? {}), + ...nextMeta, + }, + }); + return; + } catch { + continue; + } + } + }; + + void loadEventViewAnnotations(); + + return () => { + isCancelled = true; + }; + }, [ + annotationDeviceIdParam, + annotationStreamIdParam, + annotationStreamParam, + annotationVideoSrcParam, + annotationViewKeyParam, + annotationViewParam, + annotationVideoItem?.fileSrc, + annotationVideoItem?.meta, + annotationVideoItem?.videoSrc, + annotationEventJsonSource, + handleMediaItemUpdated, + rawItem, + shouldOpenVideoAnnotationWorkspaceFromQuery, + ]); + + const meta = (item?.meta ?? {}) as Record<string, unknown>; + const normalizedAction = (item?.action ?? "").toLowerCase(); + const documentFormat = item?.format?.toLowerCase() ?? ""; + const { + resolvedVideoFormat, + isVideoAction, + isVideoFormat, + isVideo, + isHls, + proxiedVideoSrc, + effectiveVideoSrc, + effectiveImageSrc, + effectiveDocumentSrc, + useCredentials, + crossOrigin, + useDocumentCredentials, + } = useResolvedMediaSources({ + item, + meta, + documentFormat, + normalizedAction, + }); + console.log("Resolved Media Sources:", crossOrigin, useCredentials); + const isPdf = item?.mediaType === "document" && documentFormat === "pdf"; + const isTextDocument = + item?.mediaType === "document" && new Set(["txt", "json", "md", "log", "yaml", "yml", "xml"]).has(documentFormat); + const isDocx = item?.mediaType === "document" && documentFormat === "docx"; + const isSpreadsheet = item?.mediaType === "document" && new Set(["xlsx", "xls", "csv"]).has(documentFormat); + const isPptx = item?.mediaType === "document" && documentFormat === "pptx"; + const isBinaryDocument = item?.mediaType === "document" && !isTextDocument; + const isSupportedDocument = item?.mediaType === "document" && (isPdf || isDocx || isSpreadsheet || isTextDocument); + const isUnsupportedDocument = item?.mediaType === "document" && !isSupportedDocument; + + const { + textPreview, + textPreviewError, + isTextPreviewLoading, + documentPreviewUrl, + documentPreviewHtml, + documentPreviewError, + isDocumentPreviewLoading, + } = useDocumentPreview({ + item, + documentFormat, + effectiveDocumentSrc, + isTextDocument, + isBinaryDocument, + isUnsupportedDocument, + isDocx, + isSpreadsheet, + isPptx, + useDocumentCredentials, + }); + + const sanitizedDocumentPreviewHtml = useMemo( + () => (documentPreviewHtml ? DOMPurify.sanitize(documentPreviewHtml, { USE_PROFILES: { html: true } }) : ""), + [documentPreviewHtml] + ); + + const handleTogglePip = useCallback(async () => { + const video = videoRef.current as HTMLVideoElement | null; + if (!video || typeof document === "undefined") return; + try { + if (document.pictureInPictureElement) { + await document.exitPictureInPicture(); + } else if ((video as any).requestPictureInPicture) { + await (video as any).requestPictureInPicture(); + } + } catch (error) { + console.error("Picture-in-Picture error:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Picture-in-Picture failed", + message: "Your browser blocked Picture-in-Picture or it isn't supported for this media.", + }); + } + }, []); + + useEffect(() => { + if (!isVideo) { + if (playerRef.current) { + playerRef.current.dispose(); + playerRef.current = null; + } + return; + } + + const videoElement = videoRef.current; + if (!videoElement || !videoElement.isConnected) return; + + if (playerRef.current && playerRef.current.el?.() !== videoElement) { + playerRef.current.dispose(); + playerRef.current = null; + } + + if (!playerRef.current) { + const overflowButtonName = "OverflowMenuButton"; + const pipButtonName = "PipToggleButton"; + if (!videojs.getComponent(overflowButtonName)) { + const Button = videojs.getComponent("Button"); + const OverflowMenuButton = class extends (Button as any) { + constructor(playerInstance: any, options: any) { + super(playerInstance, options); + this.controlText("More"); + this.addClass("vjs-overflow-button"); + this.addClass("vjs-menu-button"); + } + + handleClick() { + const playerInstance = this.player(); + playerInstance?.trigger?.("overflowtoggle"); + } + }; + videojs.registerComponent(overflowButtonName, OverflowMenuButton as any); + } + if (!videojs.getComponent(pipButtonName)) { + const Button = videojs.getComponent("Button"); + const PipToggleButton = class extends (Button as any) { + constructor(playerInstance: any, options: any) { + super(playerInstance, options); + this.controlText("Picture in Picture"); + this.addClass("vjs-pip-toggle"); + } + + handleClick() { + const playerInstance = this.player(); + playerInstance?.trigger?.("piptoggle"); + } + }; + videojs.registerComponent(pipButtonName, PipToggleButton as any); + } + + playerRef.current = videojs(videoElement, { + controls: true, + autoplay: true, + preload: "auto", + playsinline: true, + crossOrigin, + nativeTextTracks: false, + playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2], + html5: { + vhs: { + withCredentials: useCredentials, + overrideNative: true, + }, + nativeTextTracks: false, + }, + controlBar: { + children: [ + "currentTimeDisplay", + "progressControl", + "durationDisplay", + "volumePanel", + "subsCapsButton", + "fullscreenToggle", + "PipToggleButton", + "OverflowMenuButton", + ], + }, + }); + + const player = playerRef.current as any; + if (!player) return; + const resolvedPlayerElement = (() => { + const element = player?.el?.() as HTMLElement | undefined; + if (!element) return null; + if (element.tagName.toLowerCase() === "video") return element.parentElement; + return element; + })(); + setPlayerElement(resolvedPlayerElement ?? null); + + const Button = videojs.getComponent("Button"); + const MenuButton = videojs.getComponent("MenuButton"); + const MenuItem = videojs.getComponent("MenuItem"); + const controlBar = player.controlBar; + if (controlBar && !controlBar.getChild("PipToggleButton")) { + controlBar.addChild("PipToggleButton", {}); + } + if (controlBar && !controlBar.getChild("OverflowMenuButton")) { + controlBar.addChild("OverflowMenuButton", {}); + } + + let qualityButton: any = null; + let qualityRetryId: ReturnType<typeof setTimeout> | null = null; + const qualityButtonName = "QualityMenuButton"; + + const ensureQualityMenu = () => { + const representations = getVideoRepresentations(player); + const hasRealQualityInfo = representations.some((rep) => { + const height = typeof rep?.height === "number" ? rep.height : 0; + const bandwidth = + typeof rep?.bandwidth === "number" ? rep.bandwidth : typeof rep?.bitrate === "number" ? rep.bitrate : 0; + return height > 0 || bandwidth > 0; + }); + if (representations.length === 0 && isHls && !qualityRetryId) { + qualityRetryId = setTimeout(() => { + qualityRetryId = null; + if (playerRef.current === player) ensureQualityMenu(); + }, 500); + } + if (!hasRealQualityInfo) { + if (qualityButton && player.controlBar) { + player.controlBar.removeChild(qualityButton); + qualityButton = null; + } + return; + } + + if (!videojs.getComponent(qualityButtonName)) { + const QualityMenuItem = class extends (MenuItem as any) { + rep?: any; + isAuto: boolean; + + constructor(playerInstance: any, options: any) { + super(playerInstance, options); + this.rep = options?.rep; + this.isAuto = Boolean(options?.isAuto); + this.on("click", this.handleClick); + } + + handleClick() { + const playerInstance = this.player(); + const reps = getVideoRepresentations(playerInstance); + if (!reps.length) return; + if (this.isAuto) { + reps.forEach((rep) => rep?.enabled?.(true)); + } else { + reps.forEach((rep) => rep?.enabled?.(rep === this.rep)); + } + playerInstance.trigger("qualitychange"); + const button = playerInstance?.controlBar?.getChild?.(qualityButtonName) as any; + button?.update?.(); + } + }; + + const QualityMenuButton = class extends (MenuButton as any) { + items: any[] = []; + constructor(playerInstance: any, options: any) { + super(playerInstance, options); + this.controlText("Quality"); + this.addClass("vjs-quality-selector"); + this.addClass("vjs-icon-cog"); + this.addClass("vjs-menu-button-popup"); + } + + createItems() { + const playerInstance = this.player(); + const reps = getVideoRepresentations(playerInstance); + if (!reps.length) { + return [ + new QualityMenuItem(playerInstance, { + label: "Auto", + selectable: false, + selected: true, + isAuto: true, + }), + ]; + } + const { isAuto, activeRep } = getQualitySelection(reps); + + const sorted = reps + .map((rep, index) => ({ + rep, + height: rep?.height ?? 0, + bandwidth: rep?.bandwidth ?? rep?.bitrate ?? 0, + index, + })) + .sort((left, right) => { + if (left.height !== right.height) return right.height - left.height; + if (left.bandwidth !== right.bandwidth) return right.bandwidth - left.bandwidth; + return left.index - right.index; + }); + + const items = [ + new QualityMenuItem(playerInstance, { + label: "Auto", + selectable: true, + selected: isAuto, + isAuto: true, + }), + ]; + + sorted.forEach(({ rep, height, bandwidth }) => { + const label = height ? `${height}p` : bandwidth ? `${Math.round(bandwidth / 1000)} kbps` : "Source"; + items.push( + new QualityMenuItem(playerInstance, { + label, + selectable: true, + selected: !isAuto && activeRep === rep, + rep, + isAuto: false, + }) + ); + }); + + this.items = items; + return items; + } + + update() { + const reps = getVideoRepresentations(this.player()); + if (!reps.length) return; + const { isAuto, activeRep } = getQualitySelection(reps); + this.items?.forEach((item) => { + if (item?.isAuto) { + item.selected?.(isAuto); + } else if (item?.rep) { + item.selected?.(activeRep === item.rep); + } + }); + } + }; + + videojs.registerComponent(qualityButtonName, QualityMenuButton as any); + } + + if (!qualityButton && player.controlBar) { + qualityButton = player.controlBar.addChild(qualityButtonName, {}); + const fullscreenToggle = player.controlBar.getChild("FullscreenToggle"); + if (fullscreenToggle && qualityButton?.el && player.controlBar.el) { + player.controlBar.el().insertBefore(qualityButton.el(), fullscreenToggle.el()); + } + } + + qualityButton?.update?.(); + }; + + player.ready(() => { + ensureQualityMenu(); + }); + player.on("loadedmetadata", ensureQualityMenu); + player.on("loadeddata", ensureQualityMenu); + player.on("canplay", ensureQualityMenu); + player.on("play", ensureQualityMenu); + player.on("qualitychange", ensureQualityMenu); + player.on("overflowtoggle", () => { + setIsSettingsOpen((prev) => !prev); + }); + player.on("piptoggle", () => { + void handleTogglePip(); + }); + } + + return () => { + if (playerRef.current) { + playerRef.current.dispose(); + playerRef.current = null; + } + setPlayerElement(null); + }; + }, [handleTogglePip, isHls, isVideo]); + + useEffect(() => { + const player = playerRef.current; + if (!player) return; + const handlePlayState = () => setIsPlaying(!player.paused()); + player.on("play", handlePlayState); + player.on("pause", handlePlayState); + player.on("ended", handlePlayState); + player.on("loadedmetadata", handlePlayState); + handlePlayState(); + return () => { + player.off("play", handlePlayState); + player.off("pause", handlePlayState); + player.off("ended", handlePlayState); + player.off("loadedmetadata", handlePlayState); + }; + }, [isVideo, proxiedVideoSrc]); + + useEffect(() => { + const player = playerRef.current; + if (!player || !isVideo) return; + + const updateVideoTime = () => { + const currentTime = Number(player.currentTime?.() ?? 0); + const duration = Number(player.duration?.() ?? 0); + setCurrentVideoSeconds(Number.isFinite(currentTime) && currentTime > 0 ? currentTime : 0); + setCurrentVideoDurationSeconds(Number.isFinite(duration) && duration > 0 ? duration : null); + }; + const playerEvents = ["durationchange", "loadedmetadata", "seeked", "seeking", "timeupdate"]; + + playerEvents.forEach((eventName) => player.on(eventName, updateVideoTime)); + updateVideoTime(); + + return () => { + playerEvents.forEach((eventName) => player.off(eventName, updateVideoTime)); + }; + }, [effectiveVideoSrc, isVideo, item?.id]); + + useEffect(() => { + const player = playerRef.current; + if (!player) return; + const handleChange = () => setPlayerTick((value) => value + 1); + player.on("qualitychange", handleChange); + player.on("ratechange", handleChange); + return () => { + player.off("qualitychange", handleChange); + player.off("ratechange", handleChange); + }; + }, [isVideo, proxiedVideoSrc]); + + useEffect(() => { + const player = playerRef.current; + if (!player) return; + const handleReady = () => setPlayerTick((value) => value + 1); + player.on("loadedmetadata", handleReady); + player.on("loadeddata", handleReady); + player.on("canplay", handleReady); + player.on("play", handleReady); + return () => { + player.off("loadedmetadata", handleReady); + player.off("loadeddata", handleReady); + player.off("canplay", handleReady); + player.off("play", handleReady); + }; + }, [isVideo, proxiedVideoSrc]); + + useEffect(() => { + const player = playerRef.current as any; + if (!player) return; + if (!isSettingsOpen) { + if (inactivityTimeoutRef.current !== null && typeof player.inactivityTimeout === "function") { + player.inactivityTimeout(inactivityTimeoutRef.current); + inactivityTimeoutRef.current = null; + } + return; + } + + if (typeof player.inactivityTimeout === "function") { + if (inactivityTimeoutRef.current === null) { + inactivityTimeoutRef.current = player.inactivityTimeout(); + } + player.inactivityTimeout(0); + } + + const keepControlsActive = () => { + if (typeof player.userActive === "function") { + player.userActive(true); + } + player.addClass?.("vjs-user-active"); + player.removeClass?.("vjs-user-inactive"); + player.controlBar?.show?.(); + }; + + keepControlsActive(); + player.on?.("userinactive", keepControlsActive); + return () => { + player.off?.("userinactive", keepControlsActive); + }; + }, [isSettingsOpen, isVideo, proxiedVideoSrc]); + + useEffect(() => { + if (!isSettingsOpen) return; + const handlePointer = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (!target) return; + if (settingsPanelRef.current?.contains(target)) return; + if (target.closest(".vjs-overflow-button")) return; + setIsSettingsOpen(false); + }; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setIsSettingsOpen(false); + }; + document.addEventListener("mousedown", handlePointer); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handlePointer); + document.removeEventListener("keydown", handleKey); + }; + }, [isSettingsOpen]); + + useEffect(() => { + const player = playerRef.current; + if (!player || !effectiveVideoSrc) return; + const type = getVideoMimeType(resolvedVideoFormat); + const source = type ? { src: effectiveVideoSrc, type } : { src: effectiveVideoSrc }; + player.src(source); + player.poster(item?.thumbnail ?? ""); + }, [item?.thumbnail, effectiveVideoSrc, resolvedVideoFormat]); + + useEffect(() => { + const player = playerRef.current; + if (!player) return; + const tracks = getCaptionTracks(item?.meta); + const existing = player.remoteTextTracks?.(); + const trackList = existing as { length?: number; item?: (index: number) => TextTrack | null } | undefined; + const trackCount = typeof trackList?.length === "number" ? trackList.length : 0; + if (trackCount && typeof trackList?.item === "function") { + for (let i = trackCount - 1; i >= 0; i -= 1) { + const track = trackList.item(i); + if (track) player.removeRemoteTextTrack(track); + } + } + if (!tracks.length) return; + tracks.forEach((track) => { + player.addRemoteTextTrack( + { + kind: track.kind ?? "captions", + src: track.src, + srclang: track.srclang, + label: track.label ?? "CC", + default: track.default ?? false, + }, + false + ); + }); + }, [item?.meta]); + + useEffect(() => { + if (!isVideo) return; + const video = videoRef.current; + if (!video) return; + + const handleEnterPip = () => { + const tracks = video.textTracks; + if (!tracks || tracks.length === 0) return; + const previousModes: Array<{ track: TextTrack; mode: TPipCaptionMode }> = []; + for (let i = 0; i < tracks.length; i += 1) { + const track = tracks[i]; + if (track && (track.kind === "captions" || track.kind === "subtitles")) { + previousModes.push({ track, mode: track.mode }); + track.mode = "showing"; + } + } + pipCaptionModesRef.current = previousModes; + }; + + const handleLeavePip = () => { + pipCaptionModesRef.current.forEach(({ track, mode }) => { + try { + track.mode = mode; + } catch {} + }); + pipCaptionModesRef.current = []; + }; + + video.addEventListener("enterpictureinpicture", handleEnterPip); + video.addEventListener("leavepictureinpicture", handleLeavePip); + return () => { + video.removeEventListener("enterpictureinpicture", handleEnterPip); + video.removeEventListener("leavepictureinpicture", handleLeavePip); + }; + }, [isVideo]); + + const handleOverlayToggle = useCallback(() => { + const player = playerRef.current; + if (!player) return; + if (player.paused()) { + Promise.resolve(player.play?.()).catch(() => undefined); + } else { + player.pause?.(); + } + }, []); + + const handleOverlaySeek = useCallback((delta: number) => { + const player = playerRef.current; + if (!player) return; + const current = player.currentTime() ?? 0; + const seekable = player.seekable && player.seekable(); + let target = current + delta; + const duration = player.duration?.(); + if (typeof duration === "number" && Number.isFinite(duration) && duration > 0) { + target = Math.min(duration, Math.max(0, target)); + } else if (seekable && seekable.length) { + const start = seekable.start(0); + const end = seekable.end(0); + target = Math.min(end, Math.max(start, target)); + } else { + target = Math.max(0, target); + } + player.currentTime(target); + }, []); + + const qualityOptions = useMemo(() => { + const player = playerRef.current as any; + if (!player) { + return [{ key: "auto", label: "Auto", isAuto: true, selected: true, rep: null }]; + } + const reps = getVideoRepresentations(player); + if (!reps.length) { + return [{ key: "auto", label: "Auto", isAuto: true, selected: true, rep: null, disabled: true }]; + } + const { isAuto, activeRep } = getQualitySelection(reps); + const sorted = reps + .map((rep, index) => ({ + rep, + height: rep?.height ?? 0, + bandwidth: rep?.bandwidth ?? rep?.bitrate ?? 0, + index, + })) + .sort((left, right) => { + if (left.height !== right.height) return right.height - left.height; + if (left.bandwidth !== right.bandwidth) return right.bandwidth - left.bandwidth; + return left.index - right.index; + }); + const items: Array<{ + key: string; + label: string; + isAuto: boolean; + selected: boolean; + rep: any; + disabled?: boolean; + }> = []; + const fallbackSelected = qualitySelection === null ? (isAuto ? "auto" : null) : qualitySelection; + if (sorted.length > 1) { + items.push({ + key: "auto", + label: "Auto", + isAuto: true, + selected: fallbackSelected === "auto" || (qualitySelection === null && isAuto), + rep: null, + }); + } + sorted.forEach(({ rep, height, bandwidth }) => { + const label = height ? `${height}p` : bandwidth ? `${Math.round(bandwidth / 1000)} kbps` : "Source"; + const key = `${label}-${bandwidth}-${height}-${rep?.id ?? ""}`; + const isSelected = qualitySelection === key || (qualitySelection === null && !isAuto && activeRep === rep); + items.push({ + key, + label, + isAuto: false, + selected: isSelected, + rep, + }); + }); + if (sorted.length === 1 && !items.some((item) => item.selected)) { + items[0].selected = true; + } + return items; + }, [playerTick, qualitySelection]); + + const playbackRates = useMemo(() => { + const player = playerRef.current as any; + const rates = player?.playbackRates?.(); + return Array.isArray(rates) && rates.length ? rates : [0.5, 0.75, 1, 1.25, 1.5, 2]; + }, [playerTick]); + + const currentPlaybackRate = useMemo(() => { + const player = playerRef.current as any; + const rate = player?.playbackRate?.(); + return typeof rate === "number" ? rate : 1; + }, [playerTick]); + + const handleQualitySelect = useCallback((option: { isAuto: boolean; rep: any; key?: string }) => { + const player = playerRef.current as any; + if (!player) return; + const reps = getVideoRepresentations(player); + if (!reps.length) return; + if (option.isAuto) { + reps.forEach((rep) => rep?.enabled?.(true)); + setQualitySelection("auto"); + } else { + reps.forEach((rep) => rep?.enabled?.(rep === option.rep)); + if (option.key) setQualitySelection(option.key); + } + player.trigger("qualitychange"); + setPlayerTick((value) => value + 1); + }, []); + + const handlePlaybackRate = useCallback((rate: number) => { + const player = playerRef.current as any; + if (!player) return; + player.playbackRate(rate); + setPlayerTick((value) => value + 1); + }, []); + const handleVideoTimelineSeek = useCallback((seconds: number) => { + const player = playerRef.current; + if (!player || !Number.isFinite(seconds)) return; + + const duration = Number(player.duration?.() ?? 0); + const targetSeconds = + Number.isFinite(duration) && duration > 0 ? Math.min(duration, Math.max(0, seconds)) : Math.max(0, seconds); + + player.currentTime(targetSeconds); + setCurrentVideoSeconds(targetSeconds); + }, []); + const handleAnnotationPause = useCallback(() => { + const player = playerRef.current; + player?.pause?.(); + }, []); + const handleOpenVideoAnnotationWorkspace = useCallback(() => { + const player = playerRef.current; + + player?.pause?.(); + setIsVideoAnnotationWorkspaceOpen(true); + setVideoAnnotationWorkspaceActivationKey((currentValue) => currentValue + 1); + }, []); + const handleRegisterVideoAnnotationSaveHandler = useCallback((saveAnnotations: (() => Promise<boolean>) | null) => { + videoAnnotationSaveBeforeCloseRef.current = saveAnnotations; + }, []); + const handleCloseVideoAnnotationWorkspace = useCallback(async () => { + const saveAnnotations = videoAnnotationSaveBeforeCloseRef.current; + if (saveAnnotations) { + const canClose = await saveAnnotations(); + if (!canClose) return false; + } + + const player = playerRef.current; + + setIsVideoAnnotationMode(false); + player?.controls?.(true); + if (shouldOpenVideoAnnotationWorkspaceFromQuery) { + router.push(backHref); + return true; + } + + setIsVideoAnnotationWorkspaceOpen(false); + return true; + }, [backHref, router, shouldOpenVideoAnnotationWorkspaceFromQuery]); + const handleAnnotationModeChange = useCallback((enabled: boolean) => { + setIsVideoAnnotationMode(enabled); + + const player = playerRef.current; + player?.controls?.(true); + }, []); + const handleSaveVideoAnnotations = useCallback( + async (annotations: TCustomPlaylistAnnotation[]) => { + if (!item?.packageId || !item.id) { + throw new Error("Uploaded video annotations can only be saved on media library videos."); + } + + const annotationViewKey = buildSgEventAnnotationViewKey({ + deviceId: annotationDeviceIdParam, + streamId: annotationStreamIdParam, + streamName: annotationStreamParam, + viewKey: annotationViewKeyParam, + videoSrc: annotationVideoSrcParam, + }); + if (shouldOpenVideoAnnotationWorkspaceFromQuery && annotationViewKey) { + const updatedEvent = await mediaLibraryService.updateEventVideoAnnotations( + workspaceSlug, + projectId, + item.packageId, + item.id, + { + annotations, + device_id: annotationDeviceIdParam, + stream_id: annotationStreamIdParam, + stream_name: annotationStreamParam, + view_key: annotationViewKey, + } + ); + const updatedAnnotations = getSgEventMediaReferenceAnnotations(item.meta ?? {}, { + deviceId: annotationDeviceIdParam, + eventPayload: updatedEvent.eventPayload, + streamId: annotationStreamIdParam, + streamName: annotationStreamParam, + title: annotationViewParam, + viewKey: annotationViewKey, + videoSrc: annotationVideoSrcParam, + }); + handleMediaItemUpdated({ + meta: { + ...(item.meta ?? {}), + annotations: updatedAnnotations.length > 0 ? updatedAnnotations : annotations, + annotationViewKey, + }, + }); + + return updatedAnnotations.length > 0 ? updatedAnnotations : annotations; + } + + const nextMeta = { + ...(item.meta ?? {}), + annotations, + }; + + await mediaLibraryService.updateManifestArtifacts(workspaceSlug, projectId, item.packageId, { + artifact_id: item.id, + artifact: { + meta: nextMeta, + }, + }); + handleMediaItemUpdated({ meta: nextMeta }); + + return annotations; + }, + [ + annotationDeviceIdParam, + annotationStreamIdParam, + annotationStreamParam, + annotationVideoSrcParam, + annotationViewKeyParam, + annotationViewParam, + handleMediaItemUpdated, + item?.id, + item?.meta, + item?.packageId, + mediaLibraryService, + projectId, + shouldOpenVideoAnnotationWorkspaceFromQuery, + workspaceSlug, + ] + ); + const canAnnotateCurrentVideo = isVideo && Boolean(item?.packageId && item.id); + + useEffect(() => { + if (!shouldOpenVideoAnnotationWorkspaceFromQuery || !canAnnotateCurrentVideo) return; + + handleOpenVideoAnnotationWorkspace(); + }, [canAnnotateCurrentVideo, handleOpenVideoAnnotationWorkspace, shouldOpenVideoAnnotationWorkspaceFromQuery]); + + if (!item && isLoading) { + return ( + <div className="rounded-lg border border-custom-border-200 bg-custom-background-100 p-6 text-center text-sm text-custom-text-300"> + <div className="flex flex-col items-center gap-2"> + <LogoSpinner /> + <span>Loading media...</span> + </div> + </div> + ); + } + + if (!item) { + return ( + <div className="rounded-lg border border-dashed border-custom-border-200 bg-custom-background-100 p-6 text-center text-sm text-custom-text-300"> + Media not found. + </div> + ); + } + const createdBy = getMetaString(meta, ["created_by", "createdBy"], ""); + const createdByLabel = (createdBy ? (getUserDetails(createdBy)?.display_name ?? createdBy) : "") || item.author; + const canAnnotateUploadedVideo = canAnnotateCurrentVideo; + const isFocusedVideoAnnotationWorkspace = isVideo && isVideoAnnotationWorkspaceOpen; + + if (isSgEventAsset && !(shouldOpenVideoAnnotationWorkspaceFromQuery && isVideo)) { + return ( + <SgEventDetailPage + enableMatrixView + defaultTagViewMode="list" + showTagListActions={false} + workspaceSlug={workspaceSlug} + projectId={projectId} + mediaItem={item} + fallbackBackHref={backHref} + onBack={() => router.push(backHref)} + /> + ); + } + + return ( + <div className="vertical-scrollbar scrollbar-md relative h-full w-full overflow-x-hidden overflow-y-auto"> + <div + className={[ + "flex min-h-full flex-col", + isFocusedVideoAnnotationWorkspace ? "gap-2 px-2 py-2" : "gap-6 px-3 py-3", + ].join(" ")} + > + {!isFocusedVideoAnnotationWorkspace ? ( + <div className="flex items-center justify-between gap-4"> + <Link + href={backHref} + className="inline-flex items-center gap-2 rounded-full px-4 py-1 text-xs text-custom-text-300 hover:text-custom-text-100" + > + <ArrowLeft className="size-md h-3.2 w-3.2" /> + </Link> + + {/* Currently not using this section */} + + {/* <div className="flex items-center gap-3"> + <div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-100 px-1 py-1 text-[11px] text-custom-text-300"> + <button + type="button" + className="rounded-full border border-custom-border-200 px-3 py-1 hover:text-custom-text-100" + > + View 1 + </button> + <button + type="button" + className="rounded-full border border-custom-border-200 px-3 py-1 hover:text-custom-text-100" + > + View 2 + </button> + <button + type="button" + className="rounded-full border border-custom-border-200 px-3 py-1 hover:text-custom-text-100" + > + View 3 + </button> + </div> + </div> */} + </div> + ) : null} + + <div + className={[ + "grid", + isFocusedVideoAnnotationWorkspace ? "min-h-0 flex-1 gap-0" : "gap-6 lg:grid-cols-[2fr_1fr] lg:gap-0", + ].join(" ")} + > + <div + className={[ + "flex flex-col", + isFocusedVideoAnnotationWorkspace ? "h-full min-h-0 w-full gap-2" : "gap-6", + ].join(" ")} + > + <MediaDetailPreview + item={item} + isVideo={isVideo} + isImageZoomOpen={isImageZoomOpen} + setIsImageZoomOpen={setIsImageZoomOpen} + videoRef={videoRef} + isPlaying={isPlaying} + canAnnotateVideo={canAnnotateUploadedVideo} + isVideoAnnotationMode={isVideoAnnotationMode} + isVideoAnnotationWorkspaceOpen={isFocusedVideoAnnotationWorkspace} + onOverlayToggle={handleOverlayToggle} + onOverlaySeek={handleOverlaySeek} + onOpenVideoAnnotationWorkspace={handleOpenVideoAnnotationWorkspace} + onCloseVideoAnnotationWorkspace={handleCloseVideoAnnotationWorkspace} + isSettingsOpen={isSettingsOpen} + onCloseSettings={() => setIsSettingsOpen(false)} + qualityOptions={qualityOptions} + playbackRates={playbackRates} + currentPlaybackRate={currentPlaybackRate} + onSelectQuality={handleQualitySelect} + onSelectRate={handlePlaybackRate} + settingsPanelRef={settingsPanelRef} + playerElement={playerElement} + crossOrigin={crossOrigin} + onVideoAnnotationPropertiesElementChange={setVideoAnnotationPropertiesElement} + onVideoAnnotationToolbarElementChange={setVideoAnnotationToolbarElement} + onVideoTimelineElementChange={setVideoTimelineElement} + showVideoTimeline={isFocusedVideoAnnotationWorkspace} + videoAnnotationContent={ + isVideo ? ( + <VideoAnnotationEditor + annotationKey={`${item.packageId ?? ""}:${item.id}`} + annotations={item.meta?.annotations} + autoEnableAnnotationModeKey={ + isFocusedVideoAnnotationWorkspace ? videoAnnotationWorkspaceActivationKey : undefined + } + canEdit={isFocusedVideoAnnotationWorkspace && Boolean(item.packageId && item.id)} + currentTime={currentVideoSeconds} + durationSeconds={currentVideoDurationSeconds} + enableAnnotationTransforms + enableTextTool + fitToVideoBounds + isPlaying={isPlaying} + modeResetKey={`${item.id}:${isFocusedVideoAnnotationWorkspace ? "open" : "closed"}`} + onModeChange={handleAnnotationModeChange} + onRegisterSaveHandler={handleRegisterVideoAnnotationSaveHandler} + onRequestPause={handleAnnotationPause} + onSave={handleSaveVideoAnnotations} + onSeek={handleVideoTimelineSeek} + playbackRate={currentPlaybackRate} + propertyHostElement={isFocusedVideoAnnotationWorkspace ? videoAnnotationPropertiesElement : null} + toolbarHostElement={isFocusedVideoAnnotationWorkspace ? videoAnnotationToolbarElement : null} + showTimeline={isFocusedVideoAnnotationWorkspace} + thumbnailUrl={item.thumbnail} + timelineHostElement={isFocusedVideoAnnotationWorkspace ? videoTimelineElement : null} + /> + ) : null + } + effectiveImageSrc={effectiveImageSrc} + isUnsupportedDocument={isUnsupportedDocument} + isBinaryDocument={isBinaryDocument} + isDocumentPreviewLoading={isDocumentPreviewLoading} + documentPreviewError={documentPreviewError} + documentPreviewHtml={documentPreviewHtml} + sanitizedDocumentPreviewHtml={sanitizedDocumentPreviewHtml} + documentPreviewUrl={documentPreviewUrl} + isTextDocument={isTextDocument} + isTextPreviewLoading={isTextPreviewLoading} + textPreviewError={textPreviewError} + textPreview={textPreview} + effectiveDocumentSrc={effectiveDocumentSrc} + description={item.description ?? null} + createdByLabel={createdByLabel} + createdAt={item.createdAt} + /> + {!isFocusedVideoAnnotationWorkspace ? ( + <TagsSection + item={item} + onPlay={handleOverlayToggle} + editable + onTagsChange={handleTagsUpdate} + isSaving={isTagsSaving} + /> + ) : null} + </div> + {isVideo ? ( + <style jsx global> + {PLAYER_STYLE} + </style> + ) : null} + + {!isFocusedVideoAnnotationWorkspace ? ( + <MediaDetailSidebar + workspaceSlug={workspaceSlug} + projectId={projectId} + item={item} + onMediaItemUpdated={handleMediaItemUpdated} + /> + ) : null} + </div> + </div> + </div> + ); +}; + +export default MediaDetailPage; diff --git a/apps/web/ce/features/media-library/components/media-detail-preview.tsx b/apps/web/ce/features/media-library/components/media-detail-preview.tsx new file mode 100644 index 00000000000..c805765c894 --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-detail-preview.tsx @@ -0,0 +1,647 @@ +"use client"; + +import type { CSSProperties, ReactNode, RefObject } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { Check, Download, FileText, FileWarning, Pencil } from "lucide-react"; +import { API_BASE_URL } from "@plane/constants"; +import { ImageFullScreenModal } from "@plane/editor"; +import { Button, EModalWidth, ModalCore } from "@plane/ui"; +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { + buildDownloadUrl, + DOCUMENT_PREVIEW_STYLE, + getDisplayMediaTitle, + getMetaNumber, +} from "../utils/media-detail-utils"; +import { PlayerOverlay, PlayerSettingsPanel } from "./player-ui"; +import type { TQualityOption } from "./player-ui"; + +type TMediaDetailPreviewProps = { + item: any; + isVideo: boolean; + isImageZoomOpen: boolean; + setIsImageZoomOpen: (open: boolean) => void; + videoRef: RefObject<HTMLVideoElement>; + isPlaying: boolean; + canAnnotateVideo?: boolean; + isVideoAnnotationMode?: boolean; + isVideoAnnotationWorkspaceOpen?: boolean; + onOverlayToggle: () => void; + onOverlaySeek: (delta: number) => void; + onOpenVideoAnnotationWorkspace?: () => void; + onCloseVideoAnnotationWorkspace?: () => boolean | Promise<boolean>; + isSettingsOpen: boolean; + onCloseSettings: () => void; + qualityOptions: TQualityOption[]; + playbackRates: number[]; + currentPlaybackRate: number; + onSelectQuality: (option: TQualityOption) => void; + onSelectRate: (rate: number) => void; + settingsPanelRef: RefObject<HTMLDivElement>; + crossOrigin: "anonymous" | "use-credentials" | "" | undefined; + playerElement: HTMLElement | null; + videoAnnotationContent?: ReactNode; + onVideoAnnotationPropertiesElementChange?: (element: HTMLDivElement | null) => void; + onVideoAnnotationToolbarElementChange?: (element: HTMLDivElement | null) => void; + onVideoTimelineElementChange?: (element: HTMLDivElement | null) => void; + showVideoTimeline?: boolean; + effectiveImageSrc: string; + isUnsupportedDocument: boolean; + isBinaryDocument: boolean; + isDocumentPreviewLoading: boolean; + documentPreviewError: string | null; + documentPreviewHtml: string | null; + sanitizedDocumentPreviewHtml: string; + documentPreviewUrl: string | null; + isTextDocument: boolean; + isTextPreviewLoading: boolean; + textPreviewError: string | null; + textPreview: string | null; + effectiveDocumentSrc: string; + description: string | null; + createdByLabel: string; + createdAt: string; +}; + +const VIDEO_ANNOTATION_FLOATING_ACTION_CLASS = + "!absolute !right-4 !top-4 !z-30 !inline-flex !h-10 !w-auto !min-w-[118px] !items-center !justify-center !gap-2 !rounded-[7px] !border !px-3.5 !text-[13px] !font-semibold !leading-none !shadow-[0_14px_34px_rgba(0,0,0,0.38)] !backdrop-blur-md !transition-[background-color,border-color,color,box-shadow,transform] hover:!-translate-y-0.5 focus-visible:!outline-none focus-visible:!ring-2 focus-visible:!ring-custom-primary-100/40 focus-visible:!ring-offset-2 focus-visible:!ring-offset-black active:!translate-y-0"; +const VIDEO_ANNOTATION_HEADER_ACTION_CLASS = + "inline-flex h-9 min-w-[118px] items-center justify-center gap-2 rounded-[7px] border border-custom-primary-100 bg-custom-primary-100 px-3.5 text-[13px] font-semibold leading-none text-white shadow-[0_10px_24px_rgba(0,0,0,0.28)] transition-[background-color,box-shadow,transform] hover:-translate-y-0.5 hover:bg-custom-primary-100/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40 focus-visible:ring-offset-2 focus-visible:ring-offset-custom-background-100 active:translate-y-0"; + +export const MediaDetailPreview = ({ + item, + isVideo, + isImageZoomOpen, + setIsImageZoomOpen, + videoRef, + isPlaying, + canAnnotateVideo = false, + isVideoAnnotationWorkspaceOpen = false, + onOverlayToggle, + onOverlaySeek, + onOpenVideoAnnotationWorkspace, + onCloseVideoAnnotationWorkspace, + isSettingsOpen, + onCloseSettings, + qualityOptions, + playbackRates, + currentPlaybackRate, + onSelectQuality, + onSelectRate, + settingsPanelRef, + playerElement, + crossOrigin, + videoAnnotationContent, + onVideoAnnotationPropertiesElementChange, + onVideoAnnotationToolbarElementChange, + onVideoTimelineElementChange, + showVideoTimeline = false, + effectiveImageSrc, + isUnsupportedDocument, + isBinaryDocument, + isDocumentPreviewLoading, + documentPreviewError, + documentPreviewHtml, + sanitizedDocumentPreviewHtml, + documentPreviewUrl, + isTextDocument, + isTextPreviewLoading, + textPreviewError, + textPreview, + effectiveDocumentSrc, +}: TMediaDetailPreviewProps) => { + const [isTouchDevice, setIsTouchDevice] = useState(false); + const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null); + const [isImagePreviewBroken, setIsImagePreviewBroken] = useState(false); + const [isVideoPreviewBroken, setIsVideoPreviewBroken] = useState(false); + const [isDocumentPreviewBroken, setIsDocumentPreviewBroken] = useState(false); + const [isVideoAnnotationDoneModalOpen, setIsVideoAnnotationDoneModalOpen] = useState(false); + const [isCompletingVideoAnnotation, setIsCompletingVideoAnnotation] = useState(false); + const [viewport, setViewport] = useState(() => { + if (typeof window === "undefined") { + return { width: 0, height: 0 }; + } + return { width: window.innerWidth, height: window.innerHeight }; + }); + const displayTitle = getDisplayMediaTitle(item?.title); + const handleVideoTimelineElement = useCallback( + (element: HTMLDivElement | null) => { + onVideoTimelineElementChange?.(element); + }, + [onVideoTimelineElementChange] + ); + const handleVideoAnnotationToolbarElement = useCallback( + (element: HTMLDivElement | null) => { + onVideoAnnotationToolbarElementChange?.(element); + }, + [onVideoAnnotationToolbarElementChange] + ); + const handleVideoAnnotationPropertiesElement = useCallback( + (element: HTMLDivElement | null) => { + onVideoAnnotationPropertiesElementChange?.(element); + }, + [onVideoAnnotationPropertiesElementChange] + ); + const handleRequestCloseVideoAnnotationWorkspace = useCallback(() => { + setIsVideoAnnotationDoneModalOpen(true); + }, []); + const handleConfirmCloseVideoAnnotationWorkspace = useCallback(async () => { + if (!onCloseVideoAnnotationWorkspace || isCompletingVideoAnnotation) return; + + setIsCompletingVideoAnnotation(true); + try { + const didClose = await onCloseVideoAnnotationWorkspace(); + if (didClose !== false) { + setIsVideoAnnotationDoneModalOpen(false); + } + } finally { + setIsCompletingVideoAnnotation(false); + } + }, [isCompletingVideoAnnotation, onCloseVideoAnnotationWorkspace]); + + useEffect(() => { + if (typeof window === "undefined") return; + setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0); + }, []); + + useEffect(() => { + setImageDimensions(null); + }, [effectiveImageSrc]); + + useEffect(() => { + setIsImagePreviewBroken(false); + setIsVideoPreviewBroken(false); + setIsDocumentPreviewBroken(false); + }, [effectiveDocumentSrc, effectiveImageSrc, item?.id, item?.videoSrc]); + + useEffect(() => { + if (isVideoAnnotationWorkspaceOpen) return; + + setIsVideoAnnotationDoneModalOpen(false); + setIsCompletingVideoAnnotation(false); + }, [isVideoAnnotationWorkspaceOpen]); + + useEffect(() => { + if (typeof window === "undefined") return; + const handleResize = () => { + setViewport({ width: window.innerWidth, height: window.innerHeight }); + }; + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + const overlayContent = ( + <> + <PlayerOverlay isPlaying={isPlaying} onToggle={onOverlayToggle} onSeek={onOverlaySeek} /> + <PlayerSettingsPanel + isOpen={isSettingsOpen} + onClose={onCloseSettings} + qualityOptions={qualityOptions} + playbackRates={playbackRates} + currentPlaybackRate={currentPlaybackRate} + onSelectQuality={onSelectQuality} + onSelectRate={onSelectRate} + panelRef={settingsPanelRef} + /> + </> + ); + const annotationWorkspaceToggleContent = ( + <> + {canAnnotateVideo && !isVideoAnnotationWorkspaceOpen ? ( + <button + type="button" + onClick={onOpenVideoAnnotationWorkspace} + className={`${VIDEO_ANNOTATION_FLOATING_ACTION_CLASS} !border-custom-primary-100 !bg-custom-primary-100 !text-white hover:!bg-custom-primary-100/90`} + aria-label="Open annotation editor" + title="Open annotation editor" + > + <Pencil className="!h-4 !w-4 !shrink-0" /> + <span className="!whitespace-nowrap !leading-none">Annotate</span> + </button> + ) : null} + </> + ); + const playerLayerContent = ( + <> + {overlayContent} + {videoAnnotationContent} + {annotationWorkspaceToggleContent} + </> + ); + + const previewHeight = useMemo(() => { + if (!viewport.height) return 505; + + const isDesktopViewport = viewport.width >= 1025; + const isTabletViewport = viewport.width >= 768; + + if (isDesktopViewport) { + const scaledHeight = Math.round(viewport.height * 0.68); + return Math.min(820, Math.max(520, scaledHeight)); + } + + if (isTabletViewport) { + const scaledHeight = Math.round(viewport.height * 0.56); + return Math.min(640, Math.max(420, scaledHeight)); + } + + const scaledHeight = Math.round(viewport.height * 0.38); + return Math.min(420, Math.max(220, scaledHeight)); + }, [viewport.height, viewport.width]); + const videoPreviewHeight = useMemo(() => { + if (!viewport.height) return 620; + + const isDesktopViewport = viewport.width >= 1025; + const isTabletViewport = viewport.width >= 768; + + if (isVideoAnnotationWorkspaceOpen) { + if (isDesktopViewport) { + return Math.min(860, Math.max(500, viewport.height - 360)); + } + + if (isTabletViewport) { + return Math.min(680, Math.max(380, viewport.height - 330)); + } + + return Math.min(500, Math.max(260, viewport.height - 310)); + } + + if (isDesktopViewport) { + const scaledHeight = Math.round(viewport.height * (showVideoTimeline ? 0.56 : 0.78)); + return Math.min(showVideoTimeline ? 700 : 920, Math.max(showVideoTimeline ? 420 : 620, scaledHeight)); + } + + if (isTabletViewport) { + const scaledHeight = Math.round(viewport.height * (showVideoTimeline ? 0.52 : 0.64)); + return Math.min(showVideoTimeline ? 600 : 740, Math.max(showVideoTimeline ? 380 : 500, scaledHeight)); + } + + const scaledHeight = Math.round(viewport.height * 0.44); + return Math.min(480, Math.max(260, scaledHeight)); + }, [isVideoAnnotationWorkspaceOpen, showVideoTimeline, viewport.height, viewport.width]); + const previewHeightStyle: CSSProperties = { height: `${previewHeight}px` }; + const videoPreviewHeightStyle: CSSProperties = { height: `${videoPreviewHeight}px` }; + const overlayVisibilityClass = [isSettingsOpen ? "is-settings-open" : "", !isPlaying ? "is-paused" : ""] + .filter(Boolean) + .join(" "); + const meta = (item?.meta ?? {}) as Record<string, unknown>; + const metaWidth = getMetaNumber(meta, ["width", "image_width", "imageWidth", "w"]); + const metaHeight = getMetaNumber(meta, ["height", "image_height", "imageHeight", "h"]); + const rawWidth = imageDimensions?.width ?? metaWidth; + const rawHeight = imageDimensions?.height ?? metaHeight; + const resolvedImageWidth = rawWidth && rawWidth > 0 ? rawWidth : 1200; + const resolvedImageHeight = rawHeight && rawHeight > 0 ? rawHeight : 900; + const resolvedAspectRatio = resolvedImageHeight > 0 ? resolvedImageWidth / resolvedImageHeight : 1; + const modalWidth = (() => { + if (!viewport.width || !viewport.height || !Number.isFinite(resolvedAspectRatio) || resolvedAspectRatio <= 0) { + return resolvedImageWidth; + } + const maxWidth = viewport.width * 0.9; + const maxHeight = viewport.height * 0.75; + const fittedWidth = Math.min(maxWidth, maxHeight * resolvedAspectRatio); + return Math.max(320, Math.round(fittedWidth)); + })(); + const rawImageSrc = item?.mediaType === "image" ? item.thumbnail : ""; + const isWorkItemAttachment = meta.source === "work_item_attachment"; + const downloadCandidate = item?.downloadSrc || rawImageSrc || effectiveImageSrc; + const isMediaLibraryDownload = + typeof downloadCandidate === "string" && + (downloadCandidate.includes("/media-library/") || + (downloadCandidate.includes("/packages/") && + downloadCandidate.includes("/artifacts/") && + downloadCandidate.includes("/file"))); + const isApiAssetSrc = + typeof rawImageSrc === "string" && + (rawImageSrc.includes("/api/assets/") || rawImageSrc.includes("/api/assets/v2/")); + const downloadBaseSrc = + isWorkItemAttachment || (isMediaLibraryDownload && isApiAssetSrc) + ? rawImageSrc || effectiveImageSrc + : downloadCandidate; + const isAbsoluteDownloadSrc = /^https?:\/\//i.test(downloadBaseSrc); + const isApiDownloadSrc = + Boolean(downloadBaseSrc) && (!isAbsoluteDownloadSrc || (API_BASE_URL && downloadBaseSrc.startsWith(API_BASE_URL))); + const imageDownloadSrc = downloadBaseSrc + ? isApiDownloadSrc + ? buildDownloadUrl(downloadBaseSrc) + : downloadBaseSrc + : ""; + const documentDownloadCandidate = item?.downloadSrc || item?.fileSrc || effectiveDocumentSrc; + const isAbsoluteDocumentDownloadSrc = /^https?:\/\//i.test(documentDownloadCandidate); + const isApiDocumentDownloadSrc = + Boolean(documentDownloadCandidate) && + (!isAbsoluteDocumentDownloadSrc || (Boolean(API_BASE_URL) && documentDownloadCandidate.startsWith(API_BASE_URL))); + const documentDownloadSrc = documentDownloadCandidate + ? isApiDocumentDownloadSrc + ? buildDownloadUrl(documentDownloadCandidate) + : documentDownloadCandidate + : ""; + const isDocumentCorrupted = + (isBinaryDocument && (Boolean(documentPreviewError) || isDocumentPreviewBroken)) || + (isTextDocument && Boolean(textPreviewError)) || + (!isBinaryDocument && !isTextDocument && Boolean(effectiveDocumentSrc) && isDocumentPreviewBroken); + const imagePreviewAvailable = Boolean(effectiveImageSrc) && !isImagePreviewBroken; + const renderUnavailablePreview = (title: string, message: string, className: string, style?: CSSProperties) => ( + <div + className={`flex ${className} flex-col items-center justify-center gap-2 rounded-lg bg-custom-background-100 px-4 text-center`} + style={style} + role="status" + aria-live="polite" + > + <FileWarning className="h-32 w-32 text-custom-text-300" /> + <span className="text-xl font-medium text-custom-text-100">{title}</span> + </div> + ); + + return ( + <> + <div + className={ + isVideoAnnotationWorkspaceOpen && isVideo + ? "h-full min-h-0 bg-transparent p-0" + : "rounded-lg bg-custom-background-100 p-3 sm:p-4" + } + > + {isVideo ? ( + <> + {isVideoAnnotationWorkspaceOpen ? ( + <div className="mb-2 flex h-11 w-full items-center justify-end rounded-lg border border-custom-border-200 bg-custom-background-100 px-3"> + <button + type="button" + onClick={handleRequestCloseVideoAnnotationWorkspace} + className={VIDEO_ANNOTATION_HEADER_ACTION_CLASS} + aria-label="Close annotation editor" + title="Close annotation editor" + > + <Check className="h-4 w-4 shrink-0" /> + <span className="whitespace-nowrap leading-none">Done</span> + </button> + </div> + ) : null} + <div className="flex w-full max-w-full items-start gap-2"> + {showVideoTimeline ? ( + <div + ref={handleVideoAnnotationToolbarElement} + className="flex w-10 shrink-0 justify-center overflow-y-auto overflow-x-hidden rounded-lg border border-custom-border-200 bg-custom-background-90 py-2" + style={videoPreviewHeightStyle} + aria-label="Annotation tools" + /> + ) : null} + <div + className={`media-player relative min-w-0 flex-1 overflow-hidden rounded-lg border border-custom-border-200 bg-black ${overlayVisibilityClass}`} + style={videoPreviewHeightStyle} + > + <video + ref={videoRef} + className={`video-js vjs-default-skin h-full w-full ${isVideoPreviewBroken ? "opacity-0" : ""}`} + poster={item.thumbnail} + playsInline + preload="auto" + crossOrigin={crossOrigin} + onLoadedData={() => setIsVideoPreviewBroken(false)} + onError={() => setIsVideoPreviewBroken(true)} + /> + {playerElement ? createPortal(playerLayerContent, playerElement) : playerLayerContent} + {isVideoPreviewBroken ? ( + <div className="pointer-events-none absolute inset-0 z-20"> + {renderUnavailablePreview( + "Video is not available", + "This video cannot be previewed right now.", + "h-full w-full border-0" + )} + </div> + ) : null} + </div> + {showVideoTimeline ? ( + <div + ref={handleVideoAnnotationPropertiesElement} + className="flex w-[154px] shrink-0 overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-90 p-2" + style={videoPreviewHeightStyle} + aria-label="Annotation properties" + /> + ) : null} + </div> + {showVideoTimeline ? <div ref={handleVideoTimelineElement} className="mt-3" /> : null} + </> + ) : item.mediaType === "image" ? ( + <div + className="overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-90" + style={previewHeightStyle} + > + <button + type="button" + className={`h-full w-full bg-custom-background-100 ${imagePreviewAvailable ? "cursor-zoom-in" : "cursor-default"}`} + onClick={() => { + if (!imagePreviewAvailable) return; + setIsImageZoomOpen(true); + }} + aria-label={imagePreviewAvailable ? "Zoom image" : "Image preview unavailable"} + > + {effectiveImageSrc ? ( + imagePreviewAvailable ? ( + <img + src={effectiveImageSrc} + alt={displayTitle} + loading="lazy" + decoding="async" + className="h-full w-full object-contain" + onLoad={(event) => { + const target = event.currentTarget; + if (!target.naturalWidth || !target.naturalHeight) return; + setImageDimensions({ width: target.naturalWidth, height: target.naturalHeight }); + setIsImagePreviewBroken(false); + }} + onError={() => setIsImagePreviewBroken(true)} + /> + ) : ( + renderUnavailablePreview( + "Image is not available", + "This image cannot be previewed right now.", + "h-full w-full" + ) + ) + ) : ( + renderUnavailablePreview( + "Image is not available", + "This image cannot be previewed right now.", + "h-full w-full" + ) + )} + </button> + </div> + ) : ( + <div className="rounded-lg border border-custom-border-200 bg-custom-background-90"> + {isUnsupportedDocument ? ( + <div + className="flex items-center justify-center rounded-lg bg-custom-background-100 text-xs text-custom-text-300" + style={previewHeightStyle} + > + Only PDF, DOCX, XLSX, CSV, and text files are supported. + </div> + ) : isBinaryDocument ? ( + isDocumentPreviewLoading ? ( + <div + className="flex flex-col items-center justify-center gap-2 rounded-lg bg-custom-background-100 text-xs text-custom-text-300" + style={previewHeightStyle} + > + <LogoSpinner /> + <span>Loading preview...</span> + </div> + ) : documentPreviewError || isDocumentPreviewBroken ? ( + renderUnavailablePreview( + "Document is not available", + documentPreviewError || "This document cannot be previewed right now.", + "w-full", + previewHeightStyle + ) + ) : documentPreviewHtml ? ( + <div className="overflow-hidden rounded-lg bg-white" style={previewHeightStyle}> + <iframe + title={`${displayTitle}-preview`} + className="h-full w-full" + sandbox="" + srcDoc={`<!doctype html><html><head>${DOCUMENT_PREVIEW_STYLE}</head><body><div class="document-preview">${sanitizedDocumentPreviewHtml}</div></body></html>`} + /> + </div> + ) : documentPreviewUrl ? ( + <iframe + src={documentPreviewUrl} + title={displayTitle} + className="h-full w-full rounded-lg bg-white" + style={previewHeightStyle} + onLoad={() => setIsDocumentPreviewBroken(false)} + onError={() => setIsDocumentPreviewBroken(true)} + /> + ) : ( + <div + className="flex items-center justify-center text-xs text-custom-text-300" + style={previewHeightStyle} + > + No preview available for this file. + </div> + ) + ) : isTextDocument ? ( + isTextPreviewLoading ? ( + <div + className="flex flex-col items-center justify-center gap-2 rounded-lg bg-custom-background-100 text-xs text-custom-text-300" + style={previewHeightStyle} + > + <LogoSpinner /> + <span>Loading preview...</span> + </div> + ) : textPreviewError ? ( + renderUnavailablePreview( + "Document is not available", + textPreviewError || "This document cannot be previewed right now.", + "w-full", + previewHeightStyle + ) + ) : ( + <div + className="overflow-auto rounded-lg bg-custom-background-100 p-4 text-xs text-custom-text-100" + style={previewHeightStyle} + > + <pre className="whitespace-pre-wrap break-words">{textPreview}</pre> + </div> + ) + ) : effectiveDocumentSrc ? ( + isDocumentPreviewBroken ? ( + renderUnavailablePreview( + "Document is not available", + "This document cannot be previewed right now.", + "w-full", + previewHeightStyle + ) + ) : ( + <iframe + src={effectiveDocumentSrc} + title={displayTitle} + className="w-full rounded-lg bg-white" + style={previewHeightStyle} + onLoad={() => setIsDocumentPreviewBroken(false)} + onError={() => setIsDocumentPreviewBroken(true)} + /> + ) + ) : ( + <div + className="flex flex-col items-center justify-center gap-3 rounded-lg text-custom-text-300" + style={previewHeightStyle} + > + <div className="flex flex-col items-center gap-2 text-sm"> + <FileText className="h-8 w-8" /> + <span>Document is not available.</span> + </div> + </div> + )} + {documentDownloadSrc && !isUnsupportedDocument && !isDocumentCorrupted ? ( + <div className="flex justify-end border-t border-custom-border-200 p-3"> + <a + href={documentDownloadSrc} + target="_blank" + rel="noreferrer" + download + className="inline-flex items-center gap-3 rounded-md bg-custom-primary-100 px-2 py-1 text-sm font-medium text-custom-100" + > + <span className="flex h-6 w-6 items-center justify-center"> + <Download className="h-4 w-4" /> + </span> + Download + </a> + </div> + ) : null} + </div> + )} + </div> + + {item.mediaType === "image" && imagePreviewAvailable ? ( + <ImageFullScreenModal + aspectRatio={resolvedAspectRatio} + downloadSrc={imageDownloadSrc} + isFullScreenEnabled={isImageZoomOpen} + isTouchDevice={isTouchDevice} + src={effectiveImageSrc} + toggleFullScreenMode={setIsImageZoomOpen} + width={`${modalWidth}px`} + /> + ) : null} + <ModalCore + isOpen={isVideoAnnotationDoneModalOpen} + handleClose={() => { + if (isCompletingVideoAnnotation) return; + setIsVideoAnnotationDoneModalOpen(false); + }} + width={EModalWidth.XL} + > + <div className="flex flex-col gap-2 px-5 py-4"> + <h3 className="text-lg font-medium text-custom-text-100">Done with annotations?</h3> + <p className="text-sm leading-5 text-custom-text-200"> + Are you sure you are done with the annotated changes? Your annotation changes will be saved before leaving + the editor. + </p> + </div> + <div className="flex flex-col-reverse gap-2 border-t border-custom-border-200 px-5 py-4 sm:flex-row sm:justify-end"> + <Button + variant="neutral-primary" + size="sm" + onClick={() => setIsVideoAnnotationDoneModalOpen(false)} + disabled={isCompletingVideoAnnotation} + > + Cancel + </Button> + <Button + variant="primary" + size="sm" + onClick={() => { + void handleConfirmCloseVideoAnnotationWorkspace(); + }} + loading={isCompletingVideoAnnotation} + > + Done + </Button> + </div> + </ModalCore> + </> + ); +}; diff --git a/apps/web/ce/features/media-library/components/media-detail-sidebar.tsx b/apps/web/ce/features/media-library/components/media-detail-sidebar.tsx new file mode 100644 index 00000000000..c8dd24b6849 --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-detail-sidebar.tsx @@ -0,0 +1,494 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Calendar, CalendarClock, Clock, Handshake, Signal, Tag, User, Volleyball } from "lucide-react"; +import type { EditorRefApi } from "@plane/editor"; +import type { TNameDescriptionLoader } from "@plane/types"; +import { renderFormattedPayloadDate } from "@plane/utils"; +import { CategoryDropdown } from "@/components/dropdowns/category-property"; +import { DateDropdown } from "@/components/dropdowns/date"; +import { LevelDropdown } from "@/components/dropdowns/level-property"; +import { ProgramDropdown } from "@/components/dropdowns/program-property"; +import SportDropdown from "@/components/dropdowns/sport-property"; +import { TimeDropdown } from "@/components/dropdowns/time-picker"; +import { YearRangeDropdown } from "@/components/dropdowns/year-property"; +import type { TIssueOperations } from "@/components/issues/issue-detail"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useMember } from "@/hooks/store/use-member"; +import OppositionTeamProperty from "@/plane-web/components/issues/issue-details/opposition-team-property"; +import { MediaLibraryService } from "@/services/media-library.service"; +import type { TMediaItem } from "../types/media-library.types"; +import { formatFileSize, formatMetaLabel, formatMetaValue } from "../utils/media-detail-utils"; +import { + getEventMediaDateLabel, + getEventMediaDetails, + getEventMediaMetrics, + isEventMediaItem, +} from "../utils/media-event"; +import { DetailIssueOverview } from "./detail-peek-overview"; +import { PeekOverviewIssueDetails } from "./detail-peek-overview/issue-detail"; + +type TMediaDetailSidebarProps = { + workspaceSlug: string; + projectId: string; + item: TMediaItem; + onMediaItemUpdated?: (updates?: Partial<TMediaItem>) => void; +}; + +type TOppositionTeam = { + name: string; + logo: string; +}; + +type TEditableMetaKey = + | "category" + | "sport" + | "program" + | "level" + | "season" + | "start_date" + | "start_time" + | "opposition"; + +const HIDDEN_ADDITIONAL_META_KEYS = new Set([ + "hlspending", + "hlsmasterplaylist", + "hlsrendition", + "hlsrenditions", + "poster", + "posterurl", + "thumbnailartifactid", + "thumbnailartifactpath", + "transcodeassetid", + "transcodecompletedat", + "transcodejobid", + "transcodeprofile", + "transcodeprogress", +]); + +const normalizeAdditionalMetaKey = (key: string) => key.replace(/[-_\s]+/g, "").toLowerCase(); + +export const MediaDetailSidebar = ({ + workspaceSlug, + projectId, + item, + onMediaItemUpdated, +}: TMediaDetailSidebarProps) => { + const { setPeekIssue } = useIssueDetail(); + const { getUserDetails } = useMember(); + const workItemId = item?.workItemId ?? ""; + const hasWorkItemId = Boolean(workItemId); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + const sidebarClassName = + "w-full min-w-[300px] border-l border-custom-border-200 bg-custom-sidebar-background-100 py-5 lg:min-w-80 xl:min-w-96 lg:h-full lg:overflow-hidden lg:overscroll-y-contain"; + const artifactEditorRef = useRef<EditorRefApi>(null); + const artifactFormIssueOperations = useMemo<TIssueOperations>( + () => ({ + fetch: async () => {}, + update: async () => {}, + remove: async () => {}, + }), + [] + ); + const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved"); + const [isSavingMeta, setIsSavingMeta] = useState(false); + const artifactMeta = useMemo(() => (item?.meta ?? {}) as Record<string, unknown>, [item?.meta]); + const isEventItem = useMemo(() => isEventMediaItem(item), [item]); + const eventDetails = useMemo(() => getEventMediaDetails(item), [item]); + const eventDateLabel = useMemo(() => getEventMediaDateLabel(item), [item]); + const eventMetrics = useMemo(() => getEventMediaMetrics(item), [item]); + const getMetaString = useCallback( + (key: string) => { + const value = artifactMeta[key]; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; + }, + [artifactMeta] + ); + const oppositionTeamValue = useMemo(() => { + const value = artifactMeta.opposition; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const name = (value as Record<string, unknown>).name; + const logo = (value as Record<string, unknown>).logo; + if (typeof name !== "string" || !name.trim()) return null; + return { + name: name.trim(), + logo: typeof logo === "string" ? logo : "", + } as TOppositionTeam; + }, [artifactMeta]); + const updateEditableMeta = useCallback( + async (key: TEditableMetaKey, value: string | TOppositionTeam | null) => { + const nextMeta = { ...artifactMeta, [key]: value ?? null }; + setIsSavingMeta(true); + try { + if (item?.packageId) { + await mediaLibraryService.updateManifestArtifacts(workspaceSlug, projectId, item.packageId, { + artifact_id: item.id, + artifact: { + meta: nextMeta, + }, + }); + } + onMediaItemUpdated?.({ meta: nextMeta }); + } finally { + setIsSavingMeta(false); + } + }, + [artifactMeta, item?.id, item?.packageId, mediaLibraryService, onMediaItemUpdated, projectId, workspaceSlug] + ); + const baseMetaKeys = useMemo( + () => + new Set([ + "category", + "sport", + "program", + "level", + "season", + "start_date", + "start_time", + "opposition", + "created_by", + "createdBy", + ]), + [] + ); + const createdByMemberId = useMemo(() => { + const value = artifactMeta.created_by ?? artifactMeta.createdBy; + if (typeof value !== "string") return ""; + return value.trim(); + }, [artifactMeta]); + const createdByLabel = useMemo(() => { + if (createdByMemberId) return getUserDetails(createdByMemberId)?.display_name ?? createdByMemberId; + return formatMetaValue(item.author); + }, [createdByMemberId, getUserDetails, item.author]); + const additionalMetaEntries = useMemo( + () => + Object.entries(artifactMeta).filter(([key, value]) => { + if (baseMetaKeys.has(key)) return false; + const normalizedKey = key.toLowerCase(); + if ( + HIDDEN_ADDITIONAL_META_KEYS.has(normalizeAdditionalMetaKey(key)) || + normalizedKey === "annotations" || + normalizedKey === "kind" || + normalizedKey === "thumbnail" || + normalizedKey === "tags" + ) + return false; + const normalized = formatMetaValue(value); + return normalized && normalized !== "--"; + }), + [artifactMeta, baseMetaKeys] + ); + const getFormattedAdditionalMetaValue = useCallback((key: string, value: unknown) => { + const normalizedKey = key.toLowerCase(); + if (normalizedKey === "file_size" || normalizedKey === "filesize" || normalizedKey === "size_in_bytes") { + const sizeValue = formatFileSize(value); + return sizeValue === "--" ? sizeValue : sizeValue.toLowerCase(); + } + return formatMetaValue(value); + }, []); + const fallbackFields = useMemo( + () => [ + { label: "Format", value: formatMetaValue(item.format) }, + { label: "Created", value: formatMetaValue(item.createdAt) }, + ], + [item.createdAt, item.format] + ); + const eventSummaryFields = useMemo( + () => + !eventDetails + ? [] + : [ + { label: "Status", value: formatMetaValue(eventDetails.status) }, + { label: "Event date", value: formatMetaValue(eventDateLabel) }, + { label: "Sport", value: formatMetaValue(eventDetails.sport) }, + { label: "Program", value: formatMetaValue(eventDetails.program) }, + { label: "Level", value: formatMetaValue(eventDetails.level) }, + { label: "Season", value: formatMetaValue(eventDetails.year) }, + { label: "Stream", value: formatMetaValue(eventDetails.primaryStreamName || eventDetails.primaryStreamId) }, + { label: "Location", value: formatMetaValue(eventDetails.locationLabel) }, + { label: "Metrics", value: formatMetaValue(eventMetrics.join(" · ")) }, + ].filter((field) => field.value && field.value !== "--"), + [eventDateLabel, eventDetails, eventMetrics] + ); + const eventSummarySection = + isEventItem && eventSummaryFields.length > 0 ? ( + <div className="space-y-3"> + <h6 className="text-sm font-medium text-custom-text-100">Event Summary</h6> + <div className="space-y-2"> + {eventSummaryFields.map((field) => ( + <div key={field.label} className="flex items-start justify-between gap-3 text-sm"> + <span className="text-custom-text-300">{field.label}</span> + <span className="ml-auto block max-w-[65%] truncate text-right text-custom-text-100" title={field.value}> + {field.value} + </span> + </div> + ))} + </div> + </div> + ) : null; + + useEffect(() => { + if (!workItemId) { + setPeekIssue(undefined); + return; + } + setPeekIssue({ workspaceSlug, projectId, issueId: workItemId }); + }, [projectId, setPeekIssue, workItemId, workspaceSlug]); + + if (workItemId && isEventItem) { + return ( + <div className={sidebarClassName}> + <div className="vertical-scrollbar scrollbar-md h-full overflow-y-auto px-6"> + <div className="space-y-6"> + <DetailIssueOverview embedIssue mediaItem={item} onMediaItemUpdated={onMediaItemUpdated} /> + {eventSummarySection} + </div> + </div> + </div> + ); + } + + if (!workItemId) { + return ( + <div className={sidebarClassName}> + <div className="vertical-scrollbar scrollbar-md h-full overflow-y-auto px-6"> + <div className="space-y-6"> + <div className="space-y-3"> + <PeekOverviewIssueDetails + editorRef={artifactEditorRef} + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={item.id} + issueOperations={artifactFormIssueOperations} + disabled={false} + isArchived={false} + isSubmitting={isSubmitting} + setIsSubmitting={setIsSubmitting} + mediaItem={item} + onMediaItemUpdated={onMediaItemUpdated} + /> + </div> + + <div className="space-y-3"> + <h6 className="text-sm font-medium text-custom-text-100">Artifact Details</h6> + <div className="space-y-2"> + {fallbackFields + .filter((field) => field.value && field.value !== "--") + .map((field) => ( + <div key={field.label} className="flex items-start justify-between gap-3 text-sm"> + <span className="text-custom-text-300">{field.label}</span> + <span + className="ml-auto block max-w-[65%] truncate text-right text-custom-text-100" + title={field.value} + > + {field.value} + </span> + </div> + ))} + </div> + </div> + + {eventSummarySection} + + <div> + <h6 className="text-sm font-medium">Event Details</h6> + <div className="mt-3 w-full space-y-2"> + <div className="flex h-8 w-full items-center gap-3"> + <div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <User className="h-4 w-4 flex-shrink-0" /> + <span>Created by</span> + </div> + <span className="w-3/4 rounded px-2 py-0.5 text-sm text-custom-text-100">{createdByLabel}</span> + </div> + + {hasWorkItemId ? ( + <> + <div className="flex h-8 w-full items-center gap-3"> + <div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <CalendarClock className="h-4 w-4 flex-shrink-0" /> + <span>Start date</span> + </div> + <DateDropdown + value={getMetaString("start_date")} + onChange={(value) => + void updateEditableMeta( + "start_date", + value ? (renderFormattedPayloadDate(value) ?? null) : null + ) + } + placeholder="Add start date" + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${getMetaString("start_date") ? "" : "text-custom-text-400"}`} + hideIcon + disabled={isSavingMeta} + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + <div className="flex h-8 w-full items-center gap-3"> + <div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <Clock className="h-4 w-4 flex-shrink-0" /> + <span>Start time</span> + </div> + <TimeDropdown + value={getMetaString("start_time")} + onChange={(value) => void updateEditableMeta("start_time", value)} + placeholder="Add start time" + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${getMetaString("start_time") ? "" : "text-custom-text-400"}`} + hideIcon + disabled={isSavingMeta} + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + </> + ) : null} + + <div className="flex h-8 w-full items-center gap-3"> + <div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <Signal className="h-4 w-4 flex-shrink-0" /> + <span>Level</span> + </div> + <LevelDropdown + value={getMetaString("level")} + onChange={(value) => void updateEditableMeta("level", value)} + placeholder="Add level" + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${getMetaString("level") ? "" : "text-custom-text-400"}`} + hideIcon + disabled={isSavingMeta} + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + <div className="flex h-8 w-full items-center gap-3"> + <div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <User className="h-4 w-4 flex-shrink-0" /> + <span>Program</span> + </div> + <ProgramDropdown + value={getMetaString("program")} + onChange={(value) => void updateEditableMeta("program", value)} + placeholder="Add program" + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${getMetaString("program") ? "" : "text-custom-text-400"}`} + hideIcon + disabled={isSavingMeta} + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + <div className="flex h-8 w-full items-center gap-3"> + <div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <Volleyball className="h-4 w-4 flex-shrink-0" /> + <span>Sport</span> + </div> + <SportDropdown + value={getMetaString("sport")} + onChange={(value) => void updateEditableMeta("sport", value)} + placeholder="Add sport" + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${getMetaString("sport") ? "" : "text-custom-text-400"}`} + hideIcon + disabled={isSavingMeta} + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + <div className="flex h-8 w-full items-center gap-3"> + <div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <Handshake className="h-4 w-4 flex-shrink-0" /> + <span>Opposition</span> + </div> + <div className="w-3/4"> + <OppositionTeamProperty + storageKey={`opp-team-media-${item.id}`} + value={oppositionTeamValue} + onChange={(team) => void updateEditableMeta("opposition", team)} + disabled={isSavingMeta} + /> + </div> + </div> + + <div className="flex h-8 w-full items-center gap-3"> + <div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <Tag className="h-4 w-4 flex-shrink-0" /> + <span>Category</span> + </div> + <CategoryDropdown + value={getMetaString("category")} + onChange={(value) => void updateEditableMeta("category", value)} + placeholder="Add category" + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${getMetaString("category") ? "" : "text-custom-text-400"}`} + hideIcon + disabled={isSavingMeta} + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + <div className="flex h-8 w-full items-center gap-3"> + <div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <Calendar className="h-4 w-4 flex-shrink-0" /> + <span>Season</span> + </div> + <YearRangeDropdown + value={getMetaString("season")} + onChange={(value) => void updateEditableMeta("season", value)} + placeholder="Add season" + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${getMetaString("season") ? "" : "text-custom-text-400"}`} + hideIcon + disabled={isSavingMeta} + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + </div> + </div> + + {additionalMetaEntries.length > 0 ? ( + <div className="space-y-3"> + <h6 className="text-sm font-medium text-custom-text-100">Metadata</h6> + <div className="space-y-2"> + {additionalMetaEntries.map(([key, value]) => ( + <div key={key} className="flex items-start justify-between gap-3 text-sm"> + <span className="text-custom-text-300">{formatMetaLabel(key)}</span> + <span + className="ml-auto block max-w-[65%] truncate text-right text-custom-text-100" + title={getFormattedAdditionalMetaValue(key, value)} + > + {getFormattedAdditionalMetaValue(key, value)} + </span> + </div> + ))} + </div> + </div> + ) : null} + </div> + </div> + </div> + ); + } + + return ( + <div className={sidebarClassName}> + <DetailIssueOverview embedIssue mediaItem={item} onMediaItemUpdated={onMediaItemUpdated} /> + </div> + ); +}; diff --git a/apps/web/ce/features/media-library/components/media-library-empty-state.tsx b/apps/web/ce/features/media-library/components/media-library-empty-state.tsx new file mode 100644 index 00000000000..374b7452bd8 --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-empty-state.tsx @@ -0,0 +1,128 @@ +"use client"; + +import type { ChangeEvent, DragEvent } from "react"; +import { useRef, useState } from "react"; +import { ArrowRight, FileImage, FileText, FileVideo, FolderOpen, UploadCloud } from "lucide-react"; +import { Button } from "@plane/propel/button"; +import { useMediaLibrary } from "../store/media-library-context"; + +const SUPPORTED_FORMATS = ["JPEG", "PNG", "MP4", "HLS", "PDF", "CSV", "XLSX", "DOCX", "PPTX", "TXT"]; + +export const MediaLibraryEmptyState = () => { + const { openUpload, setPendingUploadFiles } = useMediaLibrary(); + const formatsRef = useRef<HTMLDivElement | null>(null); + const inputRef = useRef<HTMLInputElement | null>(null); + const [isDragging, setIsDragging] = useState(false); + + const openUploadWithFiles = (files: File[]) => { + if (files.length === 0) return; + setPendingUploadFiles(files); + openUpload(); + }; + + const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => { + openUploadWithFiles(Array.from(event.target.files ?? [])); + event.currentTarget.value = ""; + }; + + const handleDrop = (event: DragEvent<HTMLDivElement>) => { + event.preventDefault(); + setIsDragging(false); + openUploadWithFiles(Array.from(event.dataTransfer.files ?? [])); + }; + + return ( + <div className="flex min-h-[420px] flex-1 items-center justify-center py-6 sm:py-8"> + <div className="flex w-full max-w-3xl flex-col items-center rounded-2xl border border-custom-border-200 bg-custom-background-100 px-6 py-8 text-center shadow-sm sm:px-8 sm:py-10"> + <div className="relative mb-6 flex h-20 w-20 items-center justify-center rounded-3xl border border-custom-border-200 bg-custom-background-90 text-custom-primary-100"> + <FolderOpen className="h-9 w-9" /> + <div className="absolute -left-5 bottom-1 flex h-10 w-10 items-center justify-center rounded-2xl border border-custom-border-200 bg-custom-background-80 text-custom-text-200 shadow-sm"> + <FileImage className="h-4 w-4" /> + </div> + <div className="absolute -right-4 top-1 flex h-10 w-10 items-center justify-center rounded-2xl border border-custom-border-200 bg-custom-background-80 text-custom-text-200 shadow-sm"> + <FileVideo className="h-4 w-4" /> + </div> + <div className="absolute -bottom-4 right-1 flex h-9 w-9 items-center justify-center rounded-2xl border border-custom-border-200 bg-custom-background-80 text-custom-text-200 shadow-sm"> + <FileText className="h-4 w-4" /> + </div> + </div> + + <div className="max-w-2xl"> + <h2 className="text-xl font-semibold text-custom-text-100 sm:text-2xl">No media uploaded yet</h2> + <p className="mt-2 text-sm leading-6 text-custom-text-300 sm:text-base"> + Upload images, videos, or other media files to organize and manage them here for this program. + </p> + </div> + + <div className="mt-6 flex flex-col items-center gap-2 sm:flex-row"> + <Button variant="primary" size="sm" className="w-full sm:w-auto" prependIcon={<UploadCloud />} onClick={openUpload}> + Upload media + </Button> + <Button + variant="link-primary" + size="sm" + className="w-full sm:w-auto" + appendIcon={<ArrowRight />} + onClick={() => formatsRef.current?.scrollIntoView({ behavior: "smooth", block: "center" })} + > + Browse supported formats + </Button> + </div> + + <div ref={formatsRef} className="mt-6 flex max-w-2xl flex-col items-center gap-3" tabIndex={-1}> + <p className="text-xs text-custom-text-300 sm:text-sm"> + Drag and drop files in the upload flow or use the upload button to add your first assets. + </p> + <div className="flex flex-wrap justify-center gap-2"> + {SUPPORTED_FORMATS.map((format) => ( + <span + key={format} + className="rounded-full border border-custom-border-200 bg-custom-background-80 px-2.5 py-1 text-[11px] font-medium uppercase tracking-wide text-custom-text-300" + > + {format} + </span> + ))} + <span className="rounded-full border border-custom-border-200 bg-custom-background-80 px-2.5 py-1 text-[11px] font-medium uppercase tracking-wide text-custom-text-300"> + Up to 1 GB + </span> + </div> + </div> + + <div + className={`mt-6 w-full max-w-xl rounded-2xl border border-dashed px-5 py-6 transition-colors sm:px-6 ${ + isDragging + ? "border-custom-primary-100 bg-custom-primary-100/10" + : "border-custom-border-200 bg-custom-background-90" + }`} + onDragOver={(event) => { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + > + <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-custom-primary-100/10 text-custom-primary-100"> + <UploadCloud className="h-6 w-6" /> + </div> + <div className="mt-4 text-sm font-medium text-custom-text-100">Drag and drop files here</div> + <div className="mt-1 text-xs leading-5 text-custom-text-300 sm:text-sm"> + Drop files to open the uploader with them preselected, or choose files manually. + </div> + <div className="mt-4 flex justify-center"> + <input + ref={inputRef} + type="file" + accept=".mp4,.m3u8,video/mp4,application/vnd.apple.mpegurl,application/x-mpegurl,image/*,application/pdf,text/csv,application/json,.docx,.xlsx,.pptx,.txt" + multiple + className="hidden" + onChange={handleFileChange} + /> + <Button variant="neutral-primary" size="sm" onClick={() => inputRef.current?.click()}> + Choose files + </Button> + </div> + </div> + </div> + </div> + ); +}; diff --git a/apps/web/ce/features/media-library/components/media-library-header.tsx b/apps/web/ce/features/media-library/components/media-library-header.tsx new file mode 100644 index 00000000000..cfa9d37ab8f --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-header.tsx @@ -0,0 +1,521 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; +import { CalendarClock, ChevronDown, Clock3, LayoutGrid, List, ListFilter, Search, Upload, X } from "lucide-react"; + +// UI +import { Button } from "@plane/propel/button"; +import { COMPARISON_OPERATOR, LOGICAL_OPERATOR } from "@plane/types"; +import { Breadcrumbs, Header, Tooltip } from "@plane/ui"; +import { renderFormattedPayloadDate } from "@plane/utils"; + +// Components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { DateRangeDropdown } from "@/components/dropdowns/date-range"; +import { TimeDropdown } from "@/components/dropdowns/time-picker"; +import { FiltersToggle } from "@/components/rich-filters/filters-toggle"; + +// Hooks +import { useProject } from "@/hooks/store/use-project"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +import { useMediaLibrary } from "../store/media-library-context"; +import { MediaLibraryUploadStatus } from "./media-library-upload-status"; + +/* ------------------------------------------------------------------ */ +/* TYPES */ +/* ------------------------------------------------------------------ */ + +export enum MediaLayoutTypes { + LIST = "list", + GRID = "grid", +} + +type LayoutItem = { + key: MediaLayoutTypes; + i18n_title: string; +}; + +type Props = { + layouts?: LayoutItem[]; +}; + +type TUpdateQueryOptions = { + resetPagination?: boolean; +}; + +/* ------------------------------------------------------------------ */ +/* DEFAULTS */ +/* ------------------------------------------------------------------ */ + +const DEFAULT_LAYOUTS: LayoutItem[] = [ + { key: MediaLayoutTypes.GRID, i18n_title: "Grid" }, + { key: MediaLayoutTypes.LIST, i18n_title: "List" }, +]; + +const START_DATE_FILTER_PROPERTY = "meta.start_date"; +const START_TIME_FILTER_PROPERTY = "meta.start_time"; +const LEGACY_QUERY_PARAM_KEY = "q"; +const LEGACY_VIEW_PARAM_KEY = "view"; +const MAIN_QUERY_PARAM_KEY = "q_main"; +const SECTION_QUERY_PARAM_KEY = "q_section"; +const MAIN_VIEW_PARAM_KEY = "view_main"; +const SECTION_VIEW_PARAM_KEY = "view_section"; +const MAIN_GROUP_PARAM_KEY = "group_main"; +const GROUPED_MEDIA_GROUP_VALUE = "grouped"; +const SECTION_PATH_SEGMENT = "/media-library/section/"; +// Temporarily disabled per product requirement; keep code path for future re-enable. +const ENABLE_START_TIME_FILTER = false; + +const toStringArray = (value: unknown): string[] => { + if (Array.isArray(value)) return value.map((entry) => String(entry ?? "").trim()).filter(Boolean); + const normalizedValue = String(value ?? "").trim(); + return normalizedValue ? [normalizedValue] : []; +}; + +const toDateOrUndefined = (value?: string) => { + if (!value) return undefined; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : new Date(parsed); +}; + +const useDebouncedValue = (value: string, delayMs: number) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handle = setTimeout(() => { + setDebouncedValue(value); + }, delayMs); + + return () => clearTimeout(handle); + }, [delayMs, value]); + + return debouncedValue; +}; + +/* ------------------------------------------------------------------ */ +/* COMPONENT */ +/* ------------------------------------------------------------------ */ + +export const MediaLibraryListHeader: React.FC<Props> = observer(({ layouts = DEFAULT_LAYOUTS }) => { + const { isMobile } = usePlatformOS(); + const { openUpload, mediaFilters } = useMediaLibrary(); + const { loader } = useProject(); + + const { workspaceSlug, projectId } = useParams() as { + workspaceSlug: string; + projectId: string; + }; + + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const isSectionScope = useMemo(() => pathname.includes(SECTION_PATH_SEGMENT), [pathname]); + const activeQueryParamKey = isSectionScope ? SECTION_QUERY_PARAM_KEY : MAIN_QUERY_PARAM_KEY; + const activeViewParamKey = isSectionScope ? SECTION_VIEW_PARAM_KEY : MAIN_VIEW_PARAM_KEY; + + const queryParam = searchParams.get(activeQueryParamKey) ?? ""; + const [query, setQuery] = useState(queryParam); + const debouncedQuery = useDebouncedValue(query, 300); + const [isTemporalFiltersOpen, setIsTemporalFiltersOpen] = useState(false); + const temporalFiltersRef = useRef<HTMLDivElement | null>(null); + const previousActiveQueryParamKeyRef = useRef(activeQueryParamKey); + const pendingQuerySyncRef = useRef<Map<string, Set<string>>>(new Map()); + const activeLayout = useMemo(() => { + const viewParam = searchParams.get(activeViewParamKey); + return viewParam === MediaLayoutTypes.LIST ? MediaLayoutTypes.LIST : MediaLayoutTypes.GRID; + }, [activeViewParamKey, searchParams]); + const isAllMediaView = searchParams.get(MAIN_GROUP_PARAM_KEY) !== GROUPED_MEDIA_GROUP_VALUE; + const normalizedLayouts = useMemo( + () => layouts.filter((layout) => Object.values(MediaLayoutTypes).includes(layout.key)), + [layouts] + ); + const hasFilterOptions = + mediaFilters.configManager.allAvailableConfigs.length > 0 || mediaFilters.allConditionsForDisplay.length > 0; + const startDateCondition = mediaFilters.allConditionsForDisplay.find( + (condition) => condition.property === START_DATE_FILTER_PROPERTY && condition.operator === COMPARISON_OPERATOR.RANGE + ); + const startDateValues = toStringArray(startDateCondition?.value).slice(0, 2); + const startDateFrom = toDateOrUndefined(startDateValues[0]); + const startDateTo = toDateOrUndefined(startDateValues[1]); + const startTimeCondition = mediaFilters.allConditionsForDisplay.find( + (condition) => condition.property === START_TIME_FILTER_PROPERTY && condition.operator === COMPARISON_OPERATOR.RANGE + ); + const startTimeValues = toStringArray(startTimeCondition?.value).slice(0, 2); + const startTimeFrom = startTimeValues[0] ?? null; + const startTimeTo = startTimeValues[1] ?? null; + + /* ------------------------------------------------------------------ */ + /* SYNC QUERY */ + /* ------------------------------------------------------------------ */ + + useEffect(() => { + const normalizedQueryParam = queryParam.trim(); + const pendingValues = pendingQuerySyncRef.current.get(activeQueryParamKey); + if (pendingValues?.has(normalizedQueryParam)) { + pendingValues.delete(normalizedQueryParam); + if (!pendingValues.size) pendingQuerySyncRef.current.delete(activeQueryParamKey); + return; + } + + setQuery((currentValue) => (currentValue === queryParam ? currentValue : queryParam)); + }, [activeQueryParamKey, queryParam]); + + const updateSearchParam = useCallback( + (key: string, value?: string, options?: TUpdateQueryOptions) => { + const params = new URLSearchParams(searchParams.toString()); + const normalizedValue = (value ?? "").trim(); + + if (normalizedValue) params.set(key, normalizedValue); + else params.delete(key); + + if (options?.resetPagination) { + params.delete("page"); + params.delete("cursor"); + } + params.delete(LEGACY_QUERY_PARAM_KEY); + params.delete(LEGACY_VIEW_PARAM_KEY); + + const nextQueryString = params.toString(); + const currentQueryString = searchParams.toString(); + if (nextQueryString === currentQueryString) return; + + router.replace(nextQueryString ? `${pathname}?${nextQueryString}` : pathname); + }, + [pathname, router, searchParams] + ); + + useEffect(() => { + const didQueryScopeChange = previousActiveQueryParamKeyRef.current !== activeQueryParamKey; + if (didQueryScopeChange) return; + const normalizedDebouncedQuery = debouncedQuery.trim(); + const normalizedCurrentQuery = query.trim(); + if (normalizedDebouncedQuery !== normalizedCurrentQuery) return; + if (normalizedDebouncedQuery === queryParam) return; + const pendingValues = pendingQuerySyncRef.current.get(activeQueryParamKey) ?? new Set<string>(); + pendingValues.add(normalizedDebouncedQuery); + pendingQuerySyncRef.current.set(activeQueryParamKey, pendingValues); + updateSearchParam(activeQueryParamKey, normalizedDebouncedQuery, { resetPagination: true }); + }, [activeQueryParamKey, debouncedQuery, query, queryParam, updateSearchParam]); + + useEffect(() => { + previousActiveQueryParamKeyRef.current = activeQueryParamKey; + }, [activeQueryParamKey]); + + useEffect(() => { + if (ENABLE_START_TIME_FILTER) return; + + const startTimeConditions = mediaFilters.allConditionsForDisplay.filter( + (condition) => condition.property === START_TIME_FILTER_PROPERTY + ); + + if (!startTimeConditions.length) return; + + for (const condition of startTimeConditions) { + mediaFilters.removeCondition(condition.id); + } + }, [mediaFilters, mediaFilters.allConditionsForDisplay]); + + useEffect(() => { + if (!isTemporalFiltersOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target) return; + if (!temporalFiltersRef.current?.contains(target)) { + setIsTemporalFiltersOpen(false); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + }; + }, [isTemporalFiltersOpen]); + + const handleLayoutChange = (layout: MediaLayoutTypes) => { + updateSearchParam(activeViewParamKey, layout); + }; + const handleGroupModeToggle = () => { + const params = new URLSearchParams(searchParams.toString()); + + if (isAllMediaView) { + params.set(MAIN_GROUP_PARAM_KEY, GROUPED_MEDIA_GROUP_VALUE); + } else { + params.delete(MAIN_GROUP_PARAM_KEY); + } + + params.delete(LEGACY_QUERY_PARAM_KEY); + params.delete(LEGACY_VIEW_PARAM_KEY); + params.delete("page"); + params.delete("cursor"); + + const nextQueryString = params.toString(); + const currentQueryString = searchParams.toString(); + if (nextQueryString === currentQueryString) return; + + router.replace(nextQueryString ? `${pathname}?${nextQueryString}` : pathname); + }; + + const upsertTemporalRangeCondition = useCallback( + (property: string, values: Array<string | null | undefined>) => { + const normalizedValues = values.map((value) => String(value ?? "").trim()).filter(Boolean); + const propertyConditions = mediaFilters.allConditionsForDisplay.filter( + (condition) => condition.property === property + ); + const rangeCondition = propertyConditions.find((condition) => condition.operator === COMPARISON_OPERATOR.RANGE); + + for (const condition of propertyConditions) { + if (!rangeCondition || condition.id !== rangeCondition.id) { + mediaFilters.removeCondition(condition.id); + } + } + + if (normalizedValues.length === 0) { + if (rangeCondition) mediaFilters.removeCondition(rangeCondition.id); + return; + } + + if (rangeCondition) { + mediaFilters.updateConditionValue(rangeCondition.id, normalizedValues); + return; + } + + mediaFilters.addCondition( + LOGICAL_OPERATOR.AND, + { + property, + operator: COMPARISON_OPERATOR.RANGE, + value: normalizedValues, + }, + false + ); + }, + [mediaFilters] + ); + + /* ------------------------------------------------------------------ */ + /* RENDER */ + /* ------------------------------------------------------------------ */ + + return ( + <Header className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-3 sm:grid-cols-[minmax(120px,0.65fr)_minmax(120px,1fr)_auto]"> + {/* LEFT */} + <Header.LeftItem className="min-w-0 max-w-none flex-none overflow-hidden"> + <Breadcrumbs isLoading={loader === "init-loader"}> + <CommonProjectBreadcrumbs workspaceSlug={workspaceSlug} projectId={projectId} /> + <Breadcrumbs.Item component={<BreadcrumbLink label="Media Library" isLast />} /> + </Breadcrumbs> + </Header.LeftItem> + + {/* CENTER SEARCH */} + <div className="pointer-events-auto hidden min-w-0 sm:block"> + <div className="relative"> + <Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-300" /> + <input + type="text" + placeholder="Search media" + className="h-8 w-full rounded-md border border-custom-border-200 bg-custom-background-100 px-8 text-left text-xs text-custom-text-100 placeholder:text-custom-text-300 focus:outline-none" + value={query} + onChange={(e) => { + setQuery(e.target.value); + }} + /> + {query && ( + <button + type="button" + onClick={() => { + setQuery(""); + const pendingValues = pendingQuerySyncRef.current.get(activeQueryParamKey) ?? new Set<string>(); + pendingValues.add(""); + pendingQuerySyncRef.current.set(activeQueryParamKey, pendingValues); + updateSearchParam(activeQueryParamKey, "", { resetPagination: true }); + }} + aria-label="Clear search" + title="Clear search" + className="absolute right-2 top-1/2 -translate-y-1/2 text-custom-text-300 hover:text-custom-text-100" + > + <X className="h-4 w-4" /> + </button> + )} + </div> + </div> + + {/* RIGHT */} + <Header.RightItem className="min-w-0 shrink-0 items-center gap-1"> + <div className="flex min-w-0 items-center gap-1 @lg:gap-1.5"> + <div className="hidden 3xl:flex items-center gap-1 border border-custom-border-200 rounded bg-custom-background-100 px-0"> + <DateRangeDropdown + value={{ from: startDateFrom, to: startDateTo }} + onSelect={(range) => { + const from = range?.from ? renderFormattedPayloadDate(range.from) : null; + const to = range?.to ? renderFormattedPayloadDate(range.to) : null; + upsertTemporalRangeCondition(START_DATE_FILTER_PROPERTY, [from, to]); + }} + mergeDates + renderPlaceholder + placeholder={{ from: "From", to: "To" }} + hideIcon={{ from: false, to: true }} + usePointerOutsideClick + buttonVariant="transparent-with-text" + buttonClassName="h-7 rounded px-2 text-xs" + buttonContainerClassName="w-[180px]" + clearIconClassName="h-3.5 w-3.5" + isClearable + /> + </div> + {ENABLE_START_TIME_FILTER ? ( + <div className="hidden 3xl:flex items-center gap-1 rounded bg-custom-background-80 p-1"> + <TimeDropdown + value={startTimeFrom} + onChange={(value) => { + upsertTemporalRangeCondition(START_TIME_FILTER_PROPERTY, [value, startTimeTo]); + }} + placeholder="From" + useNativePicker + buttonVariant="transparent-with-text" + buttonClassName="h-7 rounded px-2 text-xs" + buttonContainerClassName="w-[90px]" + icon={<Clock3 size={14} className="h-3.5 w-3.5 flex-shrink-0" />} + /> + <span className="text-custom-text-300">-</span> + <TimeDropdown + value={startTimeTo} + onChange={(value) => { + upsertTemporalRangeCondition(START_TIME_FILTER_PROPERTY, [startTimeFrom, value]); + }} + placeholder="To" + useNativePicker + buttonVariant="transparent-with-text" + buttonClassName="h-7 rounded px-2 text-xs" + buttonContainerClassName="w-[90px]" + hideIcon + /> + </div> + ) : null} + <div ref={temporalFiltersRef} className="relative 3xl:hidden"> + <Button + variant="neutral-primary" + size="sm" + className="gap-1 px-2 @4xl:px-3" + onClick={() => { + setIsTemporalFiltersOpen((prev) => !prev); + }} + > + <CalendarClock size={14} className="h-3.5 w-3.5" /> + <span className="hidden @4xl:inline">{ENABLE_START_TIME_FILTER ? "Time filters" : "Date filter"}</span> + <ChevronDown + size={14} + className={`hidden h-3.5 w-3.5 transition-transform @4xl:block ${isTemporalFiltersOpen ? "rotate-180" : ""}`} + /> + </Button> + {isTemporalFiltersOpen ? ( + <div className="absolute right-0 top-full z-50 mt-2 w-[320px] max-w-[calc(100vw-2rem)] rounded-md border border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg"> + <div className="text-[11px] font-medium text-custom-text-300">Start date</div> + <div className="mt-1"> + <DateRangeDropdown + value={{ from: startDateFrom, to: startDateTo }} + onSelect={(range) => { + const from = range?.from ? renderFormattedPayloadDate(range.from) : null; + const to = range?.to ? renderFormattedPayloadDate(range.to) : null; + upsertTemporalRangeCondition(START_DATE_FILTER_PROPERTY, [from, to]); + }} + mergeDates + renderPlaceholder + placeholder={{ from: "From", to: "To" }} + usePointerOutsideClick + buttonVariant="transparent-with-text" + buttonClassName="h-8 rounded border border-custom-border-200 px-2 text-xs" + buttonContainerClassName="w-full text-left" + clearIconClassName="h-3.5 w-3.5" + isClearable + /> + </div> + {ENABLE_START_TIME_FILTER ? ( + <> + <div className="mt-3 text-[11px] font-medium text-custom-text-300">Start time</div> + <div className="mt-1 flex items-center gap-2"> + <TimeDropdown + value={startTimeFrom} + onChange={(value) => { + upsertTemporalRangeCondition(START_TIME_FILTER_PROPERTY, [value, startTimeTo]); + }} + placeholder="From" + useNativePicker + buttonVariant="transparent-with-text" + buttonClassName="h-8 rounded border border-custom-border-200 px-2 text-xs" + buttonContainerClassName="w-full text-left" + hideIcon + /> + <span className="text-custom-text-300">-</span> + <TimeDropdown + value={startTimeTo} + onChange={(value) => { + upsertTemporalRangeCondition(START_TIME_FILTER_PROPERTY, [startTimeFrom, value]); + }} + placeholder="To" + useNativePicker + buttonVariant="transparent-with-text" + buttonClassName="h-8 rounded border border-custom-border-200 px-2 text-xs" + buttonContainerClassName="w-full text-left" + hideIcon + /> + </div> + </> + ) : null} + </div> + ) : null} + </div> + {/* Layout Toggle */} + {!isSectionScope ? ( + <Tooltip tooltipContent={isAllMediaView ? "Group by category" : "Show all media"} isMobile={isMobile}> + <Button + variant="neutral-primary" + size="sm" + className="min-w-0 gap-1 px-2 @4xl:px-3" + onClick={handleGroupModeToggle} + aria-label={isAllMediaView ? "Group by category" : "Show all media"} + > + <ListFilter size={14} className="h-3.5 w-3.5 flex-shrink-0" /> + <span className="hidden max-w-[90px] truncate @4xl:inline"> + {isAllMediaView ? "By category" : "All media"} + </span> + </Button> + </Tooltip> + ) : null} + <div className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 p-1"> + {normalizedLayouts.map((layout) => ( + <Tooltip key={layout.key} tooltipContent={layout.i18n_title} isMobile={isMobile}> + <button + type="button" + onClick={() => handleLayoutChange(layout.key)} + aria-label={`${layout.i18n_title} view`} + className={`grid h-[22px] w-7 place-items-center rounded transition ${ + activeLayout === layout.key + ? "bg-custom-background-100 shadow-custom-shadow-2xs" + : "hover:bg-custom-background-100" + }`} + > + {layout.key === MediaLayoutTypes.GRID ? ( + <LayoutGrid size={14} strokeWidth={2} className="text-custom-text-100" /> + ) : ( + <List size={14} strokeWidth={2} className="text-custom-text-100" /> + )} + </button> + </Tooltip> + ))} + </div> + {hasFilterOptions ? <FiltersToggle filter={mediaFilters} /> : null} + <MediaLibraryUploadStatus /> + {/* Upload */} + <Button variant="primary" size="sm" className="gap-1.5 px-2 @4xl:px-3" onClick={openUpload}> + <Upload size={16} className="h-3.5 w-3.5 flex-shrink-0" /> + <span className="hidden @4xl:inline">Upload</span> + </Button> + </div> + </Header.RightItem> + </Header> + ); +}); diff --git a/apps/web/ce/features/media-library/components/media-library-list-page.tsx b/apps/web/ce/features/media-library/components/media-library-list-page.tsx new file mode 100644 index 00000000000..47c77788659 --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-list-page.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useEffect, useId, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams, useSearchParams } from "next/navigation"; +import type { Swiper as SwiperInstance } from "swiper"; +import { Navigation, Scrollbar } from "swiper/modules"; +import { Swiper, SwiperSlide } from "swiper/react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useFiltersOperatorConfigs } from "@/plane-web/hooks/rich-filters/use-filters-operator-configs"; +import { useMediaLibraryItems } from "../hooks/use-media-library-items"; +import { useMediaLibrary } from "../store/media-library-context"; +import type { TMediaItem, TMediaSection } from "../types/media-library.types"; +import { groupMediaItemsByTag, resolveMediaItemActionHref } from "../utils/media-items"; +import { buildMetaFilterConfigs, collectMetaFilterOptions } from "../utils/media-library-filters"; +import { MediaCard } from "./media-card"; +import { MediaLibraryEmptyState } from "./media-library-empty-state"; +import { MediaListView } from "./media-list-view"; + +const MAIN_QUERY_PARAM_KEY = "q_main"; +const MAIN_VIEW_PARAM_KEY = "view_main"; +const MAIN_GROUP_PARAM_KEY = "group_main"; +const GROUPED_MEDIA_GROUP_VALUE = "grouped"; +const SECTION_VIEW_PARAM_KEY = "view_section"; +const LEGACY_VIEW_PARAM_KEY = "view"; + +const MediaRow = ({ + section, + getItemHref, + getSectionHref, +}: { + section: TMediaSection; + getItemHref: (item: TMediaItem) => string; + getSectionHref?: (section: TMediaSection) => string; +}) => { + const rowId = useId().replace(/:/g, ""); + const prevId = `media-prev-${rowId}`; + const nextId = `media-next-${rowId}`; + const scrollbarId = `media-scrollbar-${rowId}`; + const hasScrollableItems = section.items.length > 1; + const [showNavigation, setShowNavigation] = useState(false); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const handleSwiperUpdate = (swiper: SwiperInstance) => { + const isScrollable = !swiper.isLocked; + setShowNavigation(isScrollable); + setCanScrollLeft(isScrollable && !swiper.isBeginning); + setCanScrollRight(isScrollable && !swiper.isEnd); + }; + + return ( + <section className="flex flex-col gap-3"> + <div className="flex items-center justify-between"> + <div className="text-sm font-semibold text-custom-text-100">{section.title}</div> + {getSectionHref ? ( + <Link + href={getSectionHref(section)} + className="text-xs uppercase tracking-wider text-custom-text-300 hover:text-custom-text-100" + > + View all + </Link> + ) : null} + </div> + <div className="relative"> + <Swiper + modules={[Navigation, Scrollbar]} + slidesPerView="auto" + spaceBetween={16} + navigation={hasScrollableItems ? { prevEl: `#${prevId}`, nextEl: `#${nextId}` } : undefined} + scrollbar={{ el: `#${scrollbarId}`, draggable: true }} + allowTouchMove + watchOverflow + onSwiper={handleSwiperUpdate} + onResize={handleSwiperUpdate} + onSlidesLengthChange={handleSwiperUpdate} + onSlideChange={handleSwiperUpdate} + onTransitionEnd={handleSwiperUpdate} + className="media-swiper pb-3" + > + {section.items.map((item, index) => ( + <SwiperSlide key={`${item.id}-${index}`} className="!w-auto"> + <MediaCard item={item} href={getItemHref(item)} /> + </SwiperSlide> + ))} + </Swiper> + {hasScrollableItems ? ( + <> + <button + id={prevId} + type="button" + className={`absolute left-0 top-[40%] z-10 flex -translate-y-1/2 -translate-x-1/2 rounded-full border border-custom-border-200 bg-custom-background-100 p-2 text-custom-text-300 shadow-sm hover:text-custom-text-100 ${ + showNavigation && canScrollLeft ? "" : "hidden" + }`} + aria-label={`Scroll ${section.title} left`} + > + <ChevronLeft className="h-5 w-5" /> + </button> + <button + id={nextId} + type="button" + className={`absolute right-0 top-[40%] z-10 flex -translate-y-1/2 translate-x-1/2 rounded-full border border-custom-border-200 bg-custom-background-100 p-2 text-custom-text-300 shadow-sm hover:text-custom-text-100 ${ + showNavigation && canScrollRight ? "" : "hidden" + }`} + aria-label={`Scroll ${section.title} right`} + > + <ChevronRight className="h-5 w-5" /> + </button> + </> + ) : null} + </div> + <div id={scrollbarId} className="hidden" /> + <hr className="border-0 border-t border-custom-border-300/60" /> + </section> + ); +}; + +const ALLOWED_DOCUMENT_FORMATS = new Set([ + "docx", + "pdf", + "xls", + "xlsx", + "csv", + "txt", + "json", + "md", + "log", + "yaml", + "yml", + "xml", +]); + +const normalizeDocumentFormat = (value: string) => { + const normalized = value.trim().toLowerCase().replace(/^\./, ""); + if (!normalized) return ""; + if (normalized.includes("/")) { + const [, subtype = ""] = normalized.split("/"); + if (!subtype || subtype === "octet-stream") return ""; + if (subtype === "vnd.openxmlformats-officedocument.wordprocessingml.document") return "docx"; + if (subtype === "msword") return "doc"; + if (subtype === "vnd.ms-excel") return "xls"; + if (subtype === "vnd.openxmlformats-officedocument.spreadsheetml.sheet") return "xlsx"; + if (subtype === "csv") return "csv"; + if (subtype === "plain") return "txt"; + if (subtype === "json") return "json"; + if (subtype === "xml") return "xml"; + if (subtype === "pdf") return "pdf"; + if (subtype === "x-yaml" || subtype === "yaml") return "yaml"; + if (subtype === "x-markdown" || subtype === "markdown") return "md"; + return subtype.replace(/^x-/, ""); + } + return normalized; +}; + +const resolveDocumentFormat = (item: TMediaItem) => { + const linkedFormat = normalizeDocumentFormat(item.linkedFormat ?? ""); + if (linkedFormat) return linkedFormat; + const meta = item.meta as Record<string, unknown> | undefined; + const metaFileType = + typeof meta?.file_type === "string" ? meta.file_type : typeof meta?.fileType === "string" ? meta.fileType : ""; + const normalizedMetaType = normalizeDocumentFormat(metaFileType); + if (normalizedMetaType) return normalizedMetaType; + const format = normalizeDocumentFormat(item.format ?? ""); + return format !== "thumbnail" ? format : ""; +}; + +const MediaLibraryListPage = observer(() => { + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; + const { libraryVersion, mediaFilters, setMediaFilterConfigs, trackTranscodeJob } = useMediaLibrary(); + const searchParams = useSearchParams(); + const query = (searchParams.get(MAIN_QUERY_PARAM_KEY) ?? "").trim(); + const viewMode = searchParams.get(MAIN_VIEW_PARAM_KEY) === "list" ? "list" : "grid"; + const isAllMediaView = searchParams.get(MAIN_GROUP_PARAM_KEY) !== GROUPED_MEDIA_GROUP_VALUE; + const filterConditions = useMemo( + () => + mediaFilters.allConditionsForDisplay.map(({ property, operator, value }) => ({ + property, + operator, + value, + })), + [mediaFilters.allConditionsForDisplay] + ); + const { items: libraryItems, isLoading } = useMediaLibraryItems(workspaceSlug, projectId, libraryVersion, { + query, + filters: filterConditions, + formats: "thumbnail", + onActiveTranscodeJob: trackTranscodeJob, + }); + const operatorConfigs = useFiltersOperatorConfigs({ workspaceSlug }); + const filteredItems = useMemo( + () => + libraryItems.filter((item) => { + const format = item.format?.toLowerCase() ?? ""; + const documentFormat = resolveDocumentFormat(item); + const isDocument = item.mediaType === "document"; + const isDocumentThumbnail = item.mediaType === "image" && item.linkedMediaType === "document"; + const isAllowedDocument = !documentFormat || ALLOWED_DOCUMENT_FORMATS.has(documentFormat); + if (isDocument) return isAllowedDocument; + if (format === "thumbnail" && isDocumentThumbnail) return isAllowedDocument; + return true; + }), + [libraryItems] + ); + const mediaSections = useMemo(() => groupMediaItemsByTag(filteredItems), [filteredItems]); + const visibleSections = useMemo<TMediaSection[]>( + () => (isAllMediaView ? [{ title: "All media", items: filteredItems }] : mediaSections), + [filteredItems, isAllMediaView, mediaSections] + ); + // console.log("Media Sections:", libraryItems); + const filterConfigs = useMemo( + () => buildMetaFilterConfigs(collectMetaFilterOptions(filteredItems), operatorConfigs), + [filteredItems, operatorConfigs] + ); + const hasActiveFilters = query.length > 0 || mediaFilters.allConditionsForDisplay.length > 0; + const hasVisibleItems = filteredItems.length > 0; + + useEffect(() => { + setMediaFilterConfigs(filterConfigs); + }, [filterConfigs, setMediaFilterConfigs]); + + const getItemHref = (item: TMediaItem) => { + if (item.link) { + return `/${workspaceSlug}/projects/${projectId}/media-library/${encodeURIComponent(item.link)}`; + } + const actionHref = resolveMediaItemActionHref(item); + if (actionHref) { + return actionHref; + } + return `/${workspaceSlug}/projects/${projectId}/media-library/${encodeURIComponent(item.id)}`; + }; + + const showSkeleton = isLoading && filteredItems.length === 0; + const getSectionHref = (section: TMediaSection) => { + const params = new URLSearchParams(searchParams.toString()); + params.delete(SECTION_VIEW_PARAM_KEY); + params.delete(LEGACY_VIEW_PARAM_KEY); + const paramsString = params.toString(); + return `./section/${encodeURIComponent(section.title)}${paramsString ? `?${paramsString}` : ""}`; + }; + + return ( + <div className="flex min-h-full flex-col"> + <div className="flex flex-1 flex-col px-6 py-4"> + {showSkeleton ? ( + viewMode === "list" ? ( + <div className="flex flex-col gap-8 animate-pulse"> + {Array.from({ length: 3 }).map((_, index) => ( + <section key={`skeleton-list-${index}`} className="flex flex-col gap-3"> + <div className="h-4 w-32 rounded bg-custom-background-90" /> + <div + className="grid w-full gap-4 rounded-lg border border-custom-border-200 bg-custom-background-90 px-3 py-2" + style={{ gridTemplateColumns: "120px minmax(200px, 2fr) 1fr 1fr 1fr" }} + > + {Array.from({ length: 5 }).map((__, cellIndex) => ( + <div key={`skeleton-list-header-${cellIndex}`} className="h-3 rounded bg-custom-background-80" /> + ))} + </div> + <div className="flex flex-col gap-3"> + {Array.from({ length: 4 }).map((__, rowIndex) => ( + <div + key={`skeleton-list-row-${rowIndex}`} + className="grid items-center gap-4 rounded-lg border border-custom-border-200 bg-custom-background-100 px-3 py-2" + style={{ gridTemplateColumns: "120px minmax(200px, 2fr) 1fr 1fr 1fr" }} + > + <div className="h-16 w-28 rounded bg-custom-background-90" /> + <div className="h-4 w-3/4 rounded bg-custom-background-90" /> + <div className="h-3 w-16 rounded bg-custom-background-90" /> + <div className="h-3 w-20 rounded bg-custom-background-90" /> + <div className="h-3 w-16 rounded bg-custom-background-90" /> + </div> + ))} + </div> + </section> + ))} + </div> + ) : ( + <div className="flex flex-col gap-8 animate-pulse"> + {Array.from({ length: 3 }).map((_, sectionIndex) => ( + <section key={`skeleton-grid-${sectionIndex}`} className="flex flex-col gap-3"> + <div className="flex items-center justify-between"> + <div className="h-4 w-32 rounded bg-custom-background-90" /> + <div className="h-3 w-16 rounded bg-custom-background-90" /> + </div> + <div className="flex gap-4 overflow-hidden pb-3"> + {Array.from({ length: 5 }).map((__, cardIndex) => ( + <div + key={`skeleton-card-${cardIndex}`} + className="w-[220px] flex-shrink-0 sm:w-[240px] md:w-[260px] lg:w-[280px] xl:w-[300px]" + > + <div className="aspect-[16/9] w-full rounded-lg bg-custom-background-90" /> + <div className="mt-2 space-y-2"> + <div className="h-4 w-3/4 rounded bg-custom-background-90" /> + <div className="h-3 w-2/3 rounded bg-custom-background-80" /> + <div className="flex gap-2"> + <div className="h-4 w-14 rounded-full bg-custom-background-90" /> + <div className="h-4 w-20 rounded-full bg-custom-background-90" /> + </div> + </div> + </div> + ))} + </div> + <hr className="border-0 border-t border-custom-border-300/60" /> + </section> + ))} + </div> + ) + ) : !hasVisibleItems ? ( + hasActiveFilters ? ( + <div className="flex flex-1 items-center justify-center py-8"> + <div className="rounded-xl border border-dashed border-custom-border-200 bg-custom-background-100 px-6 py-8 text-center text-sm text-custom-text-300"> + No media matches your current search or filters. + </div> + </div> + ) : ( + <MediaLibraryEmptyState /> + ) + ) : viewMode === "list" ? ( + <MediaListView + sections={visibleSections} + getItemHref={getItemHref} + getSectionHref={isAllMediaView ? undefined : getSectionHref} + /> + ) : isAllMediaView ? ( + <div className="grid gap-5 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> + {filteredItems.map((item) => ( + <MediaCard + key={`all-media-${item.id}`} + item={item} + href={getItemHref(item)} + forceThumbnail + className="!w-full" + /> + ))} + </div> + ) : ( + visibleSections.map((section) => ( + <MediaRow + key={section.title} + section={section} + getItemHref={getItemHref} + getSectionHref={isAllMediaView ? undefined : getSectionHref} + /> + )) + )} + </div> + </div> + ); +}); + +export default MediaLibraryListPage; diff --git a/apps/web/ce/features/media-library/components/media-library-list-route-layout.tsx b/apps/web/ce/features/media-library/components/media-library-list-route-layout.tsx new file mode 100644 index 00000000000..82a949a1273 --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-list-route-layout.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { observer } from "mobx-react"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { FiltersRow } from "@/components/rich-filters/filters-row"; +import { useMediaLibrary } from "../store/media-library-context"; +import { MediaLibraryListHeader } from "./media-library-header"; +import { MediaLibraryUploadModal } from "./media-library-upload-modal"; + +const MediaLibraryFiltersRow = observer(() => { + const { mediaFilters } = useMediaLibrary(); + return <FiltersRow filter={mediaFilters} />; +}); + +export const MediaLibraryListRouteLayout = ({ children }: { children: React.ReactNode }) => ( + <> + <AppHeader header={<MediaLibraryListHeader />} /> + <ContentWrapper> + <MediaLibraryFiltersRow /> + <MediaLibraryUploadModal /> + {children} + </ContentWrapper> + </> +); diff --git a/apps/web/ce/features/media-library/components/media-library-section-page.tsx b/apps/web/ce/features/media-library/components/media-library-section-page.tsx new file mode 100644 index 00000000000..4537fde1a43 --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-section-page.tsx @@ -0,0 +1,491 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams, useSearchParams, usePathname } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { useFiltersOperatorConfigs } from "@/plane-web/hooks/rich-filters/use-filters-operator-configs"; +import { useMediaLibraryItems } from "../hooks/use-media-library-items"; +import { useMediaLibrary } from "../store/media-library-context"; +import type { TMediaItem, TMediaSection } from "../types/media-library.types"; +import { resolveMediaItemActionHref } from "../utils/media-items"; +import { buildMetaFilterConfigs, collectMetaFilterOptions } from "../utils/media-library-filters"; +import { MediaCard } from "./media-card"; +import { MediaListView } from "./media-list-view"; + +const SECTION_QUERY_PARAM_KEY = "q_section"; +const MAIN_QUERY_PARAM_KEY = "q_main"; +const MAIN_VIEW_PARAM_KEY = "view_main"; +const MAIN_GROUP_PARAM_KEY = "group_main"; +const GROUPED_MEDIA_GROUP_VALUE = "grouped"; +const SECTION_VIEW_PARAM_KEY = "view_section"; +const LEGACY_QUERY_PARAM_KEY = "q"; +const LEGACY_VIEW_PARAM_KEY = "view"; + +const ALLOWED_DOCUMENT_FORMATS = new Set([ + "docx", + "pdf", + "xls", + "xlsx", + "csv", + "txt", + "json", + "md", + "log", + "yaml", + "yml", + "xml", +]); + +const normalizeDocumentFormat = (value: string) => { + const normalized = value.trim().toLowerCase().replace(/^\./, ""); + if (!normalized) return ""; + if (normalized.includes("/")) { + const [, subtype = ""] = normalized.split("/"); + if (!subtype || subtype === "octet-stream") return ""; + if (subtype === "vnd.openxmlformats-officedocument.wordprocessingml.document") return "docx"; + if (subtype === "msword") return "doc"; + if (subtype === "vnd.ms-excel") return "xls"; + if (subtype === "vnd.openxmlformats-officedocument.spreadsheetml.sheet") return "xlsx"; + if (subtype === "csv") return "csv"; + if (subtype === "plain") return "txt"; + if (subtype === "json") return "json"; + if (subtype === "xml") return "xml"; + if (subtype === "pdf") return "pdf"; + if (subtype === "x-yaml" || subtype === "yaml") return "yaml"; + if (subtype === "x-markdown" || subtype === "markdown") return "md"; + return subtype.replace(/^x-/, ""); + } + return normalized; +}; + +const resolveDocumentFormat = (item: TMediaItem) => { + const linkedFormat = normalizeDocumentFormat(item.linkedFormat ?? ""); + if (linkedFormat) return linkedFormat; + const meta = item.meta as Record<string, unknown> | undefined; + const metaFileType = + typeof meta?.file_type === "string" ? meta.file_type : typeof meta?.fileType === "string" ? meta.fileType : ""; + const normalizedMetaType = normalizeDocumentFormat(metaFileType); + if (normalizedMetaType) return normalizedMetaType; + const format = normalizeDocumentFormat(item.format ?? ""); + return format !== "thumbnail" ? format : ""; +}; + +const MediaLibrarySectionPage = observer(() => { + const { workspaceSlug, projectId, sectionName } = useParams() as { + workspaceSlug: string; + projectId: string; + sectionName: string; + }; + const { libraryVersion, mediaFilters, setMediaFilterConfigs, trackTranscodeJob } = useMediaLibrary(); + const searchParams = useSearchParams(); + const query = (searchParams.get(SECTION_QUERY_PARAM_KEY) ?? "").trim(); + const viewMode = searchParams.get(SECTION_VIEW_PARAM_KEY) === "grid" ? "grid" : "list"; + const pathname = usePathname(); + const pageParam = Number(searchParams.get("page") ?? "1"); + const listPageSize = 10; + const [gridPageSize, setGridPageSize] = useState(12); + const pageSize = viewMode === "list" ? listPageSize : gridPageSize; + const requestedPage = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1; + const decodedSection = decodeURIComponent(sectionName ?? ""); + const currentPath = useMemo(() => { + const params = searchParams.toString(); + return params ? `${pathname}?${params}` : pathname; + }, [pathname, searchParams]); + const containerRef = useRef<HTMLDivElement | null>(null); + const headerRef = useRef<HTMLDivElement | null>(null); + const gridRef = useRef<HTMLDivElement | null>(null); + const paginationRef = useRef<HTMLDivElement | null>(null); + const filterConditions = useMemo( + () => + mediaFilters.allConditionsForDisplay.map(({ property, operator, value }) => ({ + property, + operator, + value, + })), + [mediaFilters.allConditionsForDisplay] + ); + const { + items: libraryItems, + isLoading, + pagination, + } = useMediaLibraryItems(workspaceSlug, projectId, libraryVersion, { + query, + section: decodedSection, + filters: filterConditions, + formats: "thumbnail", + page: requestedPage, + perPage: pageSize, + onActiveTranscodeJob: trackTranscodeJob, + }); + const filteredItems = useMemo( + () => + libraryItems.filter((item) => { + const format = item.format?.toLowerCase() ?? ""; + const documentFormat = resolveDocumentFormat(item); + const isDocument = item.mediaType === "document"; + const isDocumentThumbnail = item.mediaType === "image" && item.linkedMediaType === "document"; + const isAllowedDocument = !documentFormat || ALLOWED_DOCUMENT_FORMATS.has(documentFormat); + if (isDocument) return isAllowedDocument; + if (format === "thumbnail" && isDocumentThumbnail) return isAllowedDocument; + return true; + }), + [libraryItems] + ); + const lastPaginationRef = useRef<typeof pagination>(null); + const resolvedPagination = pagination ?? lastPaginationRef.current; + const operatorConfigs = useFiltersOperatorConfigs({ workspaceSlug }); + const filterConfigs = useMemo( + () => buildMetaFilterConfigs(collectMetaFilterOptions(filteredItems), operatorConfigs), + [filteredItems, operatorConfigs] + ); + + useEffect(() => { + if (pagination) lastPaginationRef.current = pagination; + }, [pagination]); + + useEffect(() => { + setMediaFilterConfigs(filterConfigs); + }, [filterConfigs, setMediaFilterConfigs]); + + const section = useMemo<TMediaSection>( + () => ({ + title: decodedSection || "Upload", + items: filteredItems, + }), + [decodedSection, filteredItems] + ); + const showSkeleton = isLoading && filteredItems.length === 0; + const totalItems = resolvedPagination?.totalResults ?? filteredItems.length; + const totalPages = resolvedPagination?.totalPages ?? 1; + const currentPage = Math.min(requestedPage, totalPages); + const showPagination = totalPages > 1; + const paginationItems = useMemo(() => { + if (!showPagination) return []; + + const items: Array<{ type: "page" | "ellipsis"; value?: number; key: string }> = []; + const pages = new Set<number>(); + + pages.add(1); + pages.add(totalPages); + + for (let page = currentPage - 1; page <= currentPage + 1; page += 1) { + if (page > 1 && page < totalPages) pages.add(page); + } + + if (currentPage <= 3) { + pages.add(2); + pages.add(3); + pages.add(4); + } + + if (currentPage >= totalPages - 2) { + pages.add(totalPages - 1); + pages.add(totalPages - 2); + pages.add(totalPages - 3); + } + + const sortedPages = Array.from(pages) + .filter((page) => page >= 1 && page <= totalPages) + .sort((a, b) => a - b); + + let previousPage = 0; + for (const page of sortedPages) { + if (previousPage && page - previousPage > 1) { + items.push({ type: "ellipsis", key: `ellipsis-${previousPage}-${page}` }); + } + items.push({ type: "page", value: page, key: `page-${page}` }); + previousPage = page; + } + + return items; + }, [currentPage, showPagination, totalPages]); + const hasSectionItems = section.items.length > 0; + + useEffect(() => { + if (viewMode !== "grid" || !hasSectionItems) return; + + const container = containerRef.current; + const grid = gridRef.current; + if (!container || !grid) return; + const parent = container.parentElement; + if (!parent) return; + + let frame = 0; + const scheduleMeasure = () => { + if (frame) { + cancelAnimationFrame(frame); + } + frame = window.requestAnimationFrame(() => { + const sampleCard = grid.firstElementChild as HTMLElement | null; + if (!sampleCard) return; + + const parentHeight = parent.clientHeight; + if (!parentHeight) return; + + const gridStyles = window.getComputedStyle(grid); + const gridColumns = gridStyles.gridTemplateColumns.split(" ").filter((value) => value.trim().length > 0); + const columnCount = Math.max(1, gridColumns.length); + const rowGap = parseFloat(gridStyles.rowGap || gridStyles.gap || "0") || 0; + + const headerHeight = headerRef.current?.getBoundingClientRect().height ?? 0; + const paginationHeight = paginationRef.current?.getBoundingClientRect().height ?? 0; + const containerStyles = window.getComputedStyle(container); + const paddingTop = parseFloat(containerStyles.paddingTop || "0") || 0; + const paddingBottom = parseFloat(containerStyles.paddingBottom || "0") || 0; + + const availableHeight = parentHeight - headerHeight - paginationHeight - paddingTop - paddingBottom; + if (availableHeight <= 0) return; + + const cardHeight = sampleCard.getBoundingClientRect().height; + if (!cardHeight) return; + + const normalizedAvailableHeight = Math.floor(availableHeight); + const normalizedCardHeight = Math.ceil(cardHeight); + const rowHeight = normalizedCardHeight + rowGap; + const rows = Math.max(1, Math.floor((normalizedAvailableHeight + rowGap + 0.5) / rowHeight)); + const nextPageSize = Math.max(1, columnCount * rows); + + setGridPageSize((prev) => (prev === nextPageSize ? prev : nextPageSize)); + }); + }; + + scheduleMeasure(); + + const observer = new ResizeObserver(scheduleMeasure); + observer.observe(parent); + if (headerRef.current) observer.observe(headerRef.current); + if (paginationRef.current) observer.observe(paginationRef.current); + + window.addEventListener("resize", scheduleMeasure); + + return () => { + if (frame) cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener("resize", scheduleMeasure); + }; + }, [hasSectionItems, showPagination, viewMode]); + + const getPageHref = (page: number) => { + const params = new URLSearchParams(searchParams.toString()); + if (page <= 1) { + params.delete("page"); + } else { + params.set("page", String(page)); + } + const queryString = params.toString(); + return queryString ? `${pathname}?${queryString}` : pathname; + }; + + const getItemHref = (item: TMediaItem) => { + const detailTarget = item.link ?? item.id; + const detailPath = `/${workspaceSlug}/projects/${projectId}/media-library/${encodeURIComponent(detailTarget)}`; + const params = new URLSearchParams(); + if (currentPath) params.set("from", currentPath); + const detailHref = params.toString() ? `${detailPath}?${params}` : detailPath; + + const actionHref = resolveMediaItemActionHref(item); + if (actionHref) { + return actionHref; + } + return detailHref; + }; + const backHref = useMemo(() => { + const params = new URLSearchParams(searchParams.toString()); + const allowedParams = new URLSearchParams(); + const mainQuery = params.get(MAIN_QUERY_PARAM_KEY); + const mainView = params.get(MAIN_VIEW_PARAM_KEY); + const mainGroup = params.get(MAIN_GROUP_PARAM_KEY); + + if (mainQuery) allowedParams.set(MAIN_QUERY_PARAM_KEY, mainQuery); + if (mainView === "list" || mainView === "grid") allowedParams.set(MAIN_VIEW_PARAM_KEY, mainView); + if (mainGroup === GROUPED_MEDIA_GROUP_VALUE) allowedParams.set(MAIN_GROUP_PARAM_KEY, mainGroup); + + params.delete(SECTION_QUERY_PARAM_KEY); + params.delete(SECTION_VIEW_PARAM_KEY); + params.delete("page"); + params.delete("cursor"); + params.delete(LEGACY_QUERY_PARAM_KEY); + params.delete(LEGACY_VIEW_PARAM_KEY); + + const queryString = allowedParams.toString(); + return `/${workspaceSlug}/projects/${projectId}/media-library${queryString ? `?${queryString}` : ""}`; + }, [projectId, searchParams, workspaceSlug]); + + if (showSkeleton) { + return viewMode === "list" ? ( + <div className="flex flex-col gap-6 p-3 animate-pulse"> + <div className="flex items-center gap-3"> + <div className="h-6 w-6 rounded-md bg-custom-background-90" /> + <div className="h-4 w-32 rounded bg-custom-background-90" /> + </div> + <div className="flex flex-col gap-8 p-10"> + <section className="flex flex-col gap-3"> + <div className="h-4 w-32 rounded bg-custom-background-90" /> + <div + className="grid w-full gap-4 rounded-lg border border-custom-border-200 bg-custom-background-90 px-3 py-2" + style={{ gridTemplateColumns: "120px minmax(200px, 2fr) 1fr 1fr 1fr" }} + > + {Array.from({ length: 5 }).map((_, index) => ( + <div key={`section-skeleton-header-${index}`} className="h-3 rounded bg-custom-background-80" /> + ))} + </div> + <div className="flex flex-col gap-3"> + {Array.from({ length: 4 }).map((_, rowIndex) => ( + <div + key={`section-skeleton-row-${rowIndex}`} + className="grid items-center gap-4 rounded-lg border border-custom-border-200 bg-custom-background-100 px-3 py-2" + style={{ gridTemplateColumns: "120px minmax(200px, 2fr) 1fr 1fr 1fr" }} + > + <div className="h-16 w-28 rounded bg-custom-background-90" /> + <div className="h-4 w-3/4 rounded bg-custom-background-90" /> + <div className="h-3 w-16 rounded bg-custom-background-90" /> + <div className="h-3 w-20 rounded bg-custom-background-90" /> + <div className="h-3 w-16 rounded bg-custom-background-90" /> + </div> + ))} + </div> + </section> + </div> + </div> + ) : ( + <div className="flex flex-col gap-6 p-3 animate-pulse"> + <div className="flex items-center gap-3"> + <div className="h-6 w-6 rounded-md bg-custom-background-90" /> + <div className="h-4 w-32 rounded bg-custom-background-90" /> + </div> + <div className="flex flex-wrap gap-4"> + {Array.from({ length: 8 }).map((_, cardIndex) => ( + <div + key={`section-skeleton-card-${cardIndex}`} + className="w-[220px] flex-shrink-0 sm:w-[240px] md:w-[260px] lg:w-[280px] xl:w-[300px]" + > + <div className="aspect-[16/9] w-full rounded-lg bg-custom-background-90" /> + <div className="mt-2 space-y-2"> + <div className="h-4 w-3/4 rounded bg-custom-background-90" /> + <div className="h-3 w-2/3 rounded bg-custom-background-80" /> + <div className="flex gap-2"> + <div className="h-4 w-14 rounded-full bg-custom-background-90" /> + <div className="h-4 w-20 rounded-full bg-custom-background-90" /> + </div> + </div> + </div> + ))} + </div> + </div> + ); + } + + if (libraryItems.length === 0 && !query) { + return ( + <div className="rounded-lg border border-dashed border-custom-border-200 bg-custom-background-100 p-6 text-center text-sm text-custom-text-300"> + Section not found. + </div> + ); + } + + if (libraryItems.length === 0) { + return ( + <div className="rounded-lg border border-dashed border-custom-border-200 bg-custom-background-100 p-6 text-center text-sm text-custom-text-300"> + No media matches your search. + </div> + ); + } + + return ( + <div ref={containerRef} className="flex flex-col gap-6 p-3"> + <div ref={headerRef} className="flex items-center gap-3"> + <Link + href={backHref} + className="rounded-md border border-custom-border-200 bg-custom-background-100 p-0.5 text-custom-text-300 hover:text-custom-text-100" + aria-label="Back to media library" + > + <ArrowLeft className="h-4 w-4" /> + </Link> + <div className="text-sm font-semibold text-custom-text-100">{section.title}</div> + </div> + {viewMode === "list" ? ( + <MediaListView sections={[section]} getItemHref={getItemHref} /> + ) : ( + <div + ref={gridRef} + className="grid gap-5 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5" + > + {section.items.map((item) => ( + <MediaCard + key={`${section.title}-${item.id}`} + item={item} + href={getItemHref(item)} + forceThumbnail + className="!w-full" + /> + ))} + </div> + )} + {showPagination ? ( + <div + ref={paginationRef} + className="flex flex-wrap items-center justify-between gap-3 border-t border-custom-border-200 pt-4 text-xs text-custom-text-300" + > + <div> + Page {currentPage} of {totalPages} · {totalItems} items + </div> + <div className="flex items-center gap-2"> + {currentPage > 1 ? ( + <Link + href={getPageHref(currentPage - 1)} + className="rounded-md border border-custom-border-200 bg-custom-background-100 px-2 py-1 text-custom-text-300 hover:text-custom-text-100" + > + Previous + </Link> + ) : ( + <span className="rounded-md border border-custom-border-200 bg-custom-background-100 px-2 py-1 text-custom-text-300 opacity-50"> + Previous + </span> + )} + <div className="flex items-center gap-1"> + {paginationItems.map((item) => + item.type === "ellipsis" ? ( + <span key={item.key} className="px-2 py-1 text-custom-text-300"> + ... + </span> + ) : item.value === currentPage ? ( + <span + key={item.key} + className="rounded-md border border-custom-border-200 bg-custom-background-90 px-2 py-1 text-custom-text-100" + aria-current="page" + > + {item.value} + </span> + ) : ( + <Link + key={item.key} + href={getPageHref(item.value ?? 1)} + className="rounded-md border border-custom-border-200 bg-custom-background-100 px-2 py-1 text-custom-text-300 hover:text-custom-text-100" + > + {item.value} + </Link> + ) + )} + </div> + {currentPage < totalPages ? ( + <Link + href={getPageHref(currentPage + 1)} + className="rounded-md border border-custom-border-200 bg-custom-background-100 px-2 py-1 text-custom-text-300 hover:text-custom-text-100" + > + Next + </Link> + ) : ( + <span className="rounded-md border border-custom-border-200 bg-custom-background-100 px-2 py-1 text-custom-text-300 opacity-50"> + Next + </span> + )} + </div> + </div> + ) : null} + </div> + ); +}); + +export default MediaLibrarySectionPage; diff --git a/apps/web/ce/features/media-library/components/media-library-upload-meta.tsx b/apps/web/ce/features/media-library/components/media-library-upload-meta.tsx new file mode 100644 index 00000000000..f828663a246 --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-upload-meta.tsx @@ -0,0 +1,186 @@ +"use client"; + +import type { ReactNode } from "react"; +import { X } from "lucide-react"; +import { CategoryDropdown } from "@/components/dropdowns/category-property"; +import { LevelDropdown } from "@/components/dropdowns/level-property"; +import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; +import { ProgramDropdown } from "@/components/dropdowns/program-property"; +import SportDropdown from "@/components/dropdowns/sport-property"; +import { YearRangeDropdown } from "@/components/dropdowns/year-property"; +import { UPLOAD_MODAL_TEXT_CLASS } from "./media-library-upload-style-classes"; +import type { TMetaFieldChange, TMetaFormState, TUploadTarget } from "./media-library-upload-types"; + +type Props = { + projectId: string; + uploadTarget: TUploadTarget; + workItemSelector: ReactNode; + meta: TMetaFormState; + isLocked: boolean; + onFieldChange: TMetaFieldChange; + tagDraft: string; + onTagDraftChange: (value: string) => void; + onAddTag: (value: string) => void; + onRemoveTag: (value: string) => void; +}; + +const FIELD_BUTTON_BASE_CLASS = `h-8 border-custom-border-200 bg-custom-background-100 ${UPLOAD_MODAL_TEXT_CLASS.field} hover:bg-custom-background-90 dark:border-[#303030] dark:bg-[#171717] dark:hover:bg-[#1C1C1C]`; +const getFieldButtonClassName = (_hasValue: boolean) => `${FIELD_BUTTON_BASE_CLASS} text-xs`; +const getFieldButtonContainerClassName = (isLocked: boolean) => `w-full text-left ${isLocked ? "cursor-default" : ""}`; +const FIELD_LABEL_CLASS = `pl-1 ${UPLOAD_MODAL_TEXT_CLASS.label}`; + +export const MediaLibraryUploadMetaForm = ({ + projectId, + uploadTarget, + workItemSelector, + meta, + isLocked, + onFieldChange, + tagDraft, + onTagDraftChange, + onAddTag, + onRemoveTag, +}: Props) => ( + <div className="mb-4 rounded-lg border border-custom-border-200 bg-custom-background-90 p-4 dark:border-[#303030] dark:bg-[#151515]"> + <div className={`text-xs font-bold ${UPLOAD_MODAL_TEXT_CLASS.primary}`}> + Metadata <span className={`font-normal ${UPLOAD_MODAL_TEXT_CLASS.optional}`}>(Optional)</span> + </div> + <div className="mt-2">{workItemSelector}</div> + <div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 xl:grid-cols-6"> + <div className={`flex flex-col gap-1 text-[11px] ${UPLOAD_MODAL_TEXT_CLASS.label}`}> + <span className={FIELD_LABEL_CLASS}>Category</span> + <CategoryDropdown + value={meta.category} + onChange={(val) => onFieldChange("category", val)} + placeholder={uploadTarget === "work-item" ? "Work items" : "Uploads"} + buttonVariant="border-with-text" + className="h-8" + buttonContainerClassName={getFieldButtonContainerClassName(isLocked)} + buttonClassName={getFieldButtonClassName(Boolean(meta.category))} + hideIcon + clearIconClassName="h-3 w-3" + dropdownClassName="z-[70]" + disabled={isLocked} + /> + </div> + <div className={`flex flex-col gap-1 text-[11px] ${UPLOAD_MODAL_TEXT_CLASS.label}`}> + <span className={FIELD_LABEL_CLASS}>Sport</span> + <SportDropdown + value={meta.sport} + onChange={(val) => onFieldChange("sport", val)} + placeholder="Select sport" + buttonVariant="border-with-text" + className="h-8" + buttonContainerClassName={getFieldButtonContainerClassName(isLocked)} + buttonClassName={getFieldButtonClassName(Boolean(meta.sport))} + hideIcon + clearIconClassName="h-3 w-3" + dropdownClassName="z-[70]" + disabled={isLocked} + /> + </div> + <div className={`flex flex-col gap-1 text-[11px] ${UPLOAD_MODAL_TEXT_CLASS.label}`}> + <span className={FIELD_LABEL_CLASS}>Created by</span> + <MemberDropdown + value={meta.createdByMemberId} + onChange={(val) => onFieldChange("createdByMemberId", val)} + projectId={projectId} + multiple={false} + placeholder="Select member" + buttonVariant="border-with-text" + className="h-8" + buttonContainerClassName={getFieldButtonContainerClassName(isLocked)} + buttonClassName={getFieldButtonClassName(Boolean(meta.createdByMemberId))} + optionsClassName="z-[70]" + disabled={isLocked} + showUserDetails + /> + </div> + <div className={`flex flex-col gap-1 text-[11px] ${UPLOAD_MODAL_TEXT_CLASS.label}`}> + <span className={FIELD_LABEL_CLASS}>Program</span> + <ProgramDropdown + value={meta.program} + onChange={(val) => onFieldChange("program", val)} + placeholder="Select program" + buttonVariant="border-with-text" + className="h-8" + buttonContainerClassName={getFieldButtonContainerClassName(isLocked)} + buttonClassName={getFieldButtonClassName(Boolean(meta.program))} + hideIcon + clearIconClassName="h-3 w-3" + dropdownClassName="z-[70]" + disabled={isLocked} + /> + </div> + <div className={`flex flex-col gap-1 text-[11px] ${UPLOAD_MODAL_TEXT_CLASS.label}`}> + <span className={FIELD_LABEL_CLASS}>Level</span> + <LevelDropdown + value={meta.level} + onChange={(val) => onFieldChange("level", val)} + placeholder="Select level" + buttonVariant="border-with-text" + className="h-8" + buttonContainerClassName={getFieldButtonContainerClassName(isLocked)} + buttonClassName={getFieldButtonClassName(Boolean(meta.level))} + hideIcon + clearIconClassName="h-3 w-3" + dropdownClassName="z-[70]" + disabled={isLocked} + /> + </div> + <div className={`flex flex-col gap-1 text-[11px] ${UPLOAD_MODAL_TEXT_CLASS.label}`}> + <span className={FIELD_LABEL_CLASS}>Season</span> + <YearRangeDropdown + value={meta.season} + onChange={(val) => onFieldChange("season", val)} + placeholder="Select season" + buttonVariant="border-with-text" + className="h-8" + buttonContainerClassName={getFieldButtonContainerClassName(isLocked)} + buttonClassName={getFieldButtonClassName(Boolean(meta.season))} + hideIcon + clearIconClassName="h-3 w-3" + dropdownClassName="z-[70]" + disabled={isLocked} + /> + </div> + </div> + <div className={`mt-3 text-[11px] ${UPLOAD_MODAL_TEXT_CLASS.label}`}> + <div>Tags</div> + <div className="mt-1 flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-100 px-2 py-1.5 dark:border-[#303030] dark:bg-[#171717]"> + {meta.tags.map((tag) => ( + <span + key={tag} + className="inline-flex items-center gap-1 rounded-full border border-custom-primary-100/30 bg-custom-primary-100/15 px-2 py-0.5 text-[11px] font-medium text-custom-primary-100 dark:border-[#2D9CDB]/30 dark:bg-[#2D9CDB]/15 dark:text-[#2D9CDB]" + > + {tag} + <button + type="button" + onClick={() => onRemoveTag(tag)} + className="text-custom-primary-100/80 hover:text-custom-primary-100 dark:text-[#2D9CDB]/80 dark:hover:text-[#2D9CDB]" + > + <X className="h-3 w-3" /> + </button> + </span> + ))} + <input + type="text" + value={tagDraft} + onChange={(event) => onTagDraftChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault(); + onAddTag(tagDraft); + } + }} + placeholder={meta.tags.length === 0 ? "Add tags" : ""} + className={`min-w-[140px] flex-1 bg-transparent px-1 py-0.5 text-[11px] ${UPLOAD_MODAL_TEXT_CLASS.input} ${UPLOAD_MODAL_TEXT_CLASS.inputPlaceholder} focus:outline-none`} + /> + </div> + <div className="mt-1 flex items-center justify-between gap-3"> + <div className={`text-[10px] ${UPLOAD_MODAL_TEXT_CLASS.muted}`}>Press comma or Enter to add.</div> + <div className={`text-[11px] ${UPLOAD_MODAL_TEXT_CLASS.muted}`}>Metadata applies to all selected files.</div> + </div> + </div> + </div> +); diff --git a/apps/web/ce/features/media-library/components/media-library-upload-modal.tsx b/apps/web/ce/features/media-library/components/media-library-upload-modal.tsx new file mode 100644 index 00000000000..7cedceb79be --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-upload-modal.tsx @@ -0,0 +1,598 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams } from "next/navigation"; +import { AlertTriangle, FileImage, FileText, FileVideo, Trash2, UploadCloud, X } from "lucide-react"; +import type { ISearchIssueResponse, TIssue } from "@plane/types"; +import { Button, Checkbox } from "@plane/ui"; +import { useInstance } from "@/hooks/store/use-instance"; +import { useUser } from "@/hooks/store/user"; +import { IssueService } from "@/services/issue"; +import { ProjectService } from "@/services/project"; +import { useMediaLibrary } from "../store/media-library-context"; +import { + buildUploadId, + FALLBACK_MEDIA_LIBRARY_MAX_FILE_SIZE, + formatFileSize, + getFileExtension, + readMediaLibraryFileSizeLimit, + resolveArtifactFormat, +} from "../utils/media-library-upload-jobs"; +import { buildUploadTraceId, logMediaUploadLifecycle } from "../utils/upload-progress"; +import { MediaLibraryUploadMetaForm } from "./media-library-upload-meta"; +import { UPLOAD_MODAL_TEXT_CLASS } from "./media-library-upload-style-classes"; +import type { TMetaFieldChange, TMetaFormState, TUploadTarget } from "./media-library-upload-types"; +import { MediaLibraryWorkItemSelector } from "./media-library-work-item-selector"; + +type TPreparedUploadStatus = "selected" | "failed"; + +type TUploadItem = { + id: string; + file: File; + status: TPreparedUploadStatus; + uploadId: string; + error?: string; +}; + +const createDefaultMeta = (createdByMemberId: string | null = null): TMetaFormState => ({ + category: null, + createdByMemberId, + sport: null, + program: null, + level: null, + season: null, + startDate: null, + startTime: null, + tags: [], +}); + +const useDebouncedValue = (value: string, delayMs: number) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => { + setDebouncedValue(value); + }, delayMs); + + return () => clearTimeout(timeout); + }, [delayMs, value]); + + return debouncedValue; +}; + +const normalizeInputValue = (value: string | null | undefined) => (value ?? "").trim(); +const normalizeTagValue = (value: string) => value.trim(); +const buildMetaPayload = ( + metaState: TMetaFormState, + uploadTarget: TUploadTarget, + selectedWorkItem: ISearchIssueResponse | null +) => { + const meta: Record<string, unknown> = {}; + const fallbackCategory = uploadTarget === "work-item" ? "Work items" : "Uploads"; + const category = normalizeInputValue(metaState.category) || normalizeInputValue(selectedWorkItem?.category); + const resolvedCategory = category || fallbackCategory; + if (resolvedCategory) meta.category = resolvedCategory; + + const sport = normalizeInputValue(metaState.sport) || normalizeInputValue(selectedWorkItem?.sport); + if (sport) meta.sport = sport; + + const program = normalizeInputValue(metaState.program) || normalizeInputValue(selectedWorkItem?.program); + if (program) meta.program = program; + + const level = normalizeInputValue(metaState.level) || normalizeInputValue(selectedWorkItem?.level); + if (level) meta.level = level; + + const season = normalizeInputValue(metaState.season) || normalizeInputValue(selectedWorkItem?.year); + if (season) meta.season = season; + + const createdByMemberId = normalizeInputValue(metaState.createdByMemberId); + if (createdByMemberId) meta.created_by = createdByMemberId; + + if (metaState.tags.length > 0) meta.tags = metaState.tags; + + const startDate = + normalizeInputValue(metaState.startDate) || + (uploadTarget === "work-item" ? normalizeInputValue(selectedWorkItem?.start_date) : ""); + const startTime = + normalizeInputValue(metaState.startTime) || + (uploadTarget === "work-item" ? normalizeInputValue(selectedWorkItem?.start_time) : ""); + if (startDate) meta.start_date = startDate; + if (startTime) meta.start_time = startTime; + + meta.source = uploadTarget === "work-item" ? "work_item_upload" : "web"; + + return meta; +}; + +const projectService = new ProjectService(); + +export const MediaLibraryUploadModal = () => { + const { isUploadOpen, closeUpload, pendingUploadFiles, setPendingUploadFiles, enqueueUploadBatch } = + useMediaLibrary(); + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; + const { config } = useInstance(); + const { data: currentUser } = useUser(); + const currentUserId = currentUser?.id ?? null; + const [isDragging, setIsDragging] = useState(false); + const [uploads, setUploads] = useState<TUploadItem[]>([]); + const [selectionError, setSelectionError] = useState<string | null>(null); + const [metaState, setMetaState] = useState<TMetaFormState>(() => createDefaultMeta(currentUserId)); + const [workItemResults, setWorkItemResults] = useState<ISearchIssueResponse[]>([]); + const [workItemQuery, setWorkItemQuery] = useState(""); + const [isWorkItemSelectorEnabled, setIsWorkItemSelectorEnabled] = useState(false); + const [isWorkItemLoading, setIsWorkItemLoading] = useState(false); + const [isWorkItemDetailsLoading, setIsWorkItemDetailsLoading] = useState(false); + const [selectedWorkItem, setSelectedWorkItem] = useState<ISearchIssueResponse | null>(null); + const [tagDraft, setTagDraft] = useState(""); + const debouncedWorkItemQuery = useDebouncedValue(workItemQuery, 300); + const inputRef = useRef<HTMLInputElement | null>(null); + const issueService = useMemo(() => new IssueService(), []); + const envMaxFileSize = readMediaLibraryFileSizeLimit(process.env.NEXT_PUBLIC_MEDIA_LIBRARY_FILE_SIZE_LIMIT); + const instanceMaxFileSize = readMediaLibraryFileSizeLimit( + (config as { media_library_file_size_limit?: number } | undefined)?.media_library_file_size_limit + ); + const maxFileSize = instanceMaxFileSize ?? envMaxFileSize ?? FALLBACK_MEDIA_LIBRARY_MAX_FILE_SIZE; + const maxSizeLabel = formatFileSize(maxFileSize); + const readyToUploadItems = uploads.filter((item) => item.status === "selected"); + const failedUploads = uploads.filter((item) => item.status === "failed"); + const uploadTarget: TUploadTarget = selectedWorkItem ? "work-item" : "library"; + const isWorkItemMetaLocked = Boolean(selectedWorkItem); + + useEffect(() => { + if (!isUploadOpen || !currentUserId || selectedWorkItem) return; + setMetaState((prev) => (prev.createdByMemberId ? prev : { ...prev, createdByMemberId: currentUserId })); + }, [currentUserId, isUploadOpen, selectedWorkItem]); + + useEffect(() => { + if (!isUploadOpen || !workspaceSlug || !projectId || !isWorkItemSelectorEnabled) return; + let isMounted = true; + setIsWorkItemLoading(true); + projectService + .projectIssuesSearch(workspaceSlug, projectId, { + search: debouncedWorkItemQuery.trim(), + workspace_search: false, + }) + .then((res) => { + if (!isMounted) return; + setWorkItemResults(res); + }) + .catch(() => { + if (!isMounted) return; + setWorkItemResults([]); + }) + .finally(() => { + if (!isMounted) return; + setIsWorkItemLoading(false); + }); + + return () => { + isMounted = false; + }; + }, [debouncedWorkItemQuery, isUploadOpen, isWorkItemSelectorEnabled, projectId, workspaceSlug]); + + const mergeIssueIntoMeta = (issueData: Partial<TIssue> | ISearchIssueResponse | null | undefined) => { + if (!issueData) return; + const createdByMemberId = "created_by" in issueData ? normalizeInputValue(issueData.created_by) : ""; + setMetaState({ + category: issueData.category ?? "Work items", + createdByMemberId: createdByMemberId || null, + sport: issueData.sport ?? null, + program: issueData.program ?? null, + level: issueData.level ?? null, + season: issueData.year ?? null, + startDate: issueData.start_date ?? null, + startTime: issueData.start_time ?? null, + tags: [], + }); + }; + + const handleSelectWorkItem = (issue: ISearchIssueResponse) => { + setIsWorkItemSelectorEnabled(true); + setSelectedWorkItem(issue); + mergeIssueIntoMeta(issue); + if (!workspaceSlug || !projectId) return; + void (async () => { + try { + setIsWorkItemDetailsLoading(true); + const details = await issueService.retrieve(workspaceSlug, projectId, issue.id); + mergeIssueIntoMeta(details); + } catch { + // Ignore detail fetch errors; keep search payload values. + } finally { + setIsWorkItemDetailsLoading(false); + } + })(); + }; + + const handleClearWorkItem = () => { + setSelectedWorkItem(null); + setWorkItemQuery(""); + setMetaState(createDefaultMeta(currentUserId)); + setTagDraft(""); + }; + + const handleClose = () => { + setUploads([]); + setIsDragging(false); + setSelectionError(null); + setIsWorkItemSelectorEnabled(false); + setMetaState(createDefaultMeta(currentUserId)); + setSelectedWorkItem(null); + setWorkItemResults([]); + setIsWorkItemDetailsLoading(false); + setWorkItemQuery(""); + setTagDraft(""); + if (inputRef.current) inputRef.current.value = ""; + closeUpload(); + }; + + const handleWorkItemSelectorToggle = (isChecked: boolean) => { + setIsWorkItemSelectorEnabled(isChecked); + if (!isChecked) handleClearWorkItem(); + }; + + const addFiles = useCallback( + (files: File[]) => { + if (files.length === 0) return; + const selectedAtMs = Date.now(); + const incomingFiles = files.map((file, index) => { + const uploadId = buildUploadTraceId({ + fileName: file.name, + fileSize: file.size, + lastModified: file.lastModified, + timestampMs: selectedAtMs + index, + }); + logMediaUploadLifecycle({ + event: "file_selected", + uploadId, + fileName: file.name, + fileSize: file.size, + fileType: file.type || getFileExtension(file.name), + }); + return { + file, + id: buildUploadId(file), + uploadId, + }; + }); + setUploads((prev) => { + const existingIds = new Set(prev.map((item) => item.id)); + const duplicateNames: string[] = []; + const oversizedFiles: Array<{ name: string; size: number }> = []; + const nextItems: TUploadItem[] = []; + + incomingFiles.forEach(({ file, id, uploadId }) => { + if (existingIds.has(id)) { + duplicateNames.push(file.name); + return; + } + + existingIds.add(id); + const tooLarge = file.size > maxFileSize; + const unsupported = !resolveArtifactFormat(file.name); + if (tooLarge) { + oversizedFiles.push({ name: file.name, size: file.size }); + } + nextItems.push({ + id, + file, + uploadId, + status: tooLarge || unsupported ? "failed" : "selected", + error: tooLarge ? `File exceeds ${maxSizeLabel} limit` : unsupported ? "Unsupported file type" : undefined, + }); + }); + + if (oversizedFiles.length > 0) { + const firstOversizedFile = oversizedFiles[0]; + setSelectionError( + oversizedFiles.length === 1 + ? `"${firstOversizedFile.name}" is ${formatFileSize( + firstOversizedFile.size + )}. Maximum allowed size is ${maxSizeLabel}.` + : `${oversizedFiles.length} files exceed the ${maxSizeLabel} media library upload limit.` + ); + } else if (duplicateNames.length > 0) { + setSelectionError( + duplicateNames.length === 1 + ? `"${duplicateNames[0]}" is already selected.` + : `${duplicateNames.length} files are already selected.` + ); + } else { + setSelectionError(null); + } + + return nextItems.length > 0 ? [...prev, ...nextItems] : prev; + }); + }, + [maxFileSize, maxSizeLabel] + ); + + useEffect(() => { + if (!isUploadOpen || pendingUploadFiles.length === 0) return; + + addFiles(pendingUploadFiles); + setPendingUploadFiles([]); + }, [addFiles, isUploadOpen, pendingUploadFiles, setPendingUploadFiles]); + + const resetSelectionForm = () => { + setUploads([]); + setIsDragging(false); + setSelectionError(null); + setIsWorkItemSelectorEnabled(false); + setMetaState(createDefaultMeta(currentUserId)); + setSelectedWorkItem(null); + setWorkItemResults([]); + setIsWorkItemDetailsLoading(false); + setWorkItemQuery(""); + setTagDraft(""); + if (inputRef.current) inputRef.current.value = ""; + }; + + const handleUpload = () => { + const itemsToUpload = uploads.filter((item) => item.status === "selected"); + if (itemsToUpload.length === 0 || !workspaceSlug || !projectId) return; + + enqueueUploadBatch({ + workspaceSlug, + projectId, + files: itemsToUpload.map((item) => item.file), + meta: buildMetaPayload(metaState, uploadTarget, selectedWorkItem), + workItemId: selectedWorkItem?.id ?? null, + }); + resetSelectionForm(); + closeUpload(); + }; + + const removeSelectedUpload = (itemId: string) => { + setUploads((prev) => prev.filter((entry) => entry.id !== itemId)); + }; + + const getFileIcon = (file: File) => { + if (file.type.startsWith("image/")) return <FileImage className={`h-5 w-5 ${UPLOAD_MODAL_TEXT_CLASS.muted}`} />; + if (file.type.startsWith("video/")) return <FileVideo className={`h-5 w-5 ${UPLOAD_MODAL_TEXT_CLASS.muted}`} />; + return <FileText className={`h-5 w-5 ${UPLOAD_MODAL_TEXT_CLASS.muted}`} />; + }; + + const updateMetaField: TMetaFieldChange = (field, value) => { + setMetaState((prev) => ({ ...prev, [field]: value })); + }; + + const updateTagDraft = (value: string) => { + setTagDraft(value); + }; + + const updateMetaTags = (updater: (prev: string[]) => string[]) => { + setMetaState((prev) => ({ ...prev, tags: updater(prev.tags) })); + }; + + const handleAddTag = (rawValue: string) => { + const parts = rawValue + .split(",") + .map((entry) => normalizeTagValue(entry)) + .filter(Boolean); + if (parts.length === 0) return; + updateMetaTags((prev) => { + const next = [...prev]; + for (const part of parts) { + const exists = next.some((tag) => tag.toLowerCase() === part.toLowerCase()); + if (!exists) next.push(part); + } + return next; + }); + updateTagDraft(""); + }; + + const handleRemoveTag = (value: string) => { + updateMetaTags((prev) => prev.filter((tag) => tag.toLowerCase() !== value.toLowerCase())); + }; + + const queueSummaryLabel = + uploads.length === 0 + ? "No file selected" + : uploads.length === 1 + ? "1 file selected" + : `${uploads.length} files selected`; + + if (!isUploadOpen) return null; + + return ( + <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-custom-backdrop p-4 dark:bg-[#0F0F0F]/80 sm:items-center"> + <div className="flex max-h-[calc(100dvh-2rem)] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-md dark:border-[#353535] dark:bg-[#151515] dark:shadow-[0_24px_80px_rgba(15,15,15,0.45)]"> + <div className="flex items-center justify-between border-b border-custom-border-200 px-5 py-3.5 dark:border-[#2A2A2A]"> + <h2 className={`text-lg font-bold ${UPLOAD_MODAL_TEXT_CLASS.primary}`}>Upload Files</h2> + <button + type="button" + onClick={handleClose} + className={UPLOAD_MODAL_TEXT_CLASS.mutedAction} + aria-label="Close upload" + > + <X className="h-5 w-5" /> + </button> + </div> + + <div className="flex-1 overflow-y-auto p-5"> + <MediaLibraryUploadMetaForm + projectId={projectId} + uploadTarget={uploadTarget} + workItemSelector={ + <div className="space-y-2"> + <label + className={`inline-flex cursor-pointer items-center gap-2 text-xs ${UPLOAD_MODAL_TEXT_CLASS.label}`} + > + <Checkbox + checked={isWorkItemSelectorEnabled} + onClick={() => handleWorkItemSelectorToggle(!isWorkItemSelectorEnabled)} + className="size-3.5 !outline-none" + iconClassName="size-3" + /> + <span>Import Metadata from the work item</span> + </label> + {isWorkItemSelectorEnabled ? ( + <MediaLibraryWorkItemSelector + selectedWorkItem={selectedWorkItem} + results={workItemResults} + isLoading={isWorkItemLoading} + isDetailsLoading={isWorkItemDetailsLoading} + workItemQuery={workItemQuery} + showCard={false} + onSelect={handleSelectWorkItem} + onQueryChange={setWorkItemQuery} + onClear={handleClearWorkItem} + /> + ) : null} + </div> + } + meta={metaState} + isLocked={isWorkItemMetaLocked} + onFieldChange={updateMetaField} + tagDraft={tagDraft} + onTagDraftChange={updateTagDraft} + onAddTag={handleAddTag} + onRemoveTag={handleRemoveTag} + /> + + <div + className={`flex min-h-[214px] flex-col items-center justify-center rounded-lg border border-dashed px-4 py-8 text-center transition ${ + isDragging + ? "border-custom-primary-100 bg-custom-primary-100/10 dark:border-[#2D9CDB] dark:bg-[#2D9CDB]/10" + : "border-custom-border-200 bg-custom-background-90 dark:border-[#303030] dark:bg-[#171717]" + }`} + onDragOver={(event) => { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(event) => { + event.preventDefault(); + setIsDragging(false); + addFiles(Array.from(event.dataTransfer.files)); + }} + > + <UploadCloud className={`mx-auto h-10 w-10 ${UPLOAD_MODAL_TEXT_CLASS.muted}`} /> + <div className={`mt-2 text-sm font-normal ${UPLOAD_MODAL_TEXT_CLASS.body}`}>Drag and drop files here</div> + <div className={`mt-1 text-xs ${UPLOAD_MODAL_TEXT_CLASS.muted}`}>or</div> + <div className="flex items-center justify-center"> + <Button + variant="primary" + size="sm" + className="mt-3 flex items-center" + onClick={() => inputRef.current?.click()} + > + Browse files + </Button> + </div> + {selectionError ? ( + <div className="mt-3 inline-flex max-w-full items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-left text-xs font-medium text-red-500 dark:border-[#FF3434]/30 dark:bg-[#FF3434]/10 dark:text-[#FF3434]"> + <AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" /> + <span>{selectionError}</span> + </div> + ) : null} + <input + ref={inputRef} + type="file" + accept=".mp4,.m3u8,video/mp4,application/vnd.apple.mpegurl,application/x-mpegurl,image/*,application/pdf,text/csv,application/json,.docx,.xlsx,.pptx,.txt" + multiple + className="hidden" + aria-label="Upload files" + onChange={(event) => { + addFiles(Array.from(event.target.files ?? [])); + event.currentTarget.value = ""; + }} + /> + </div> + + <div className="mt-4 border-t border-custom-border-200/60 pt-4 dark:border-[#2A2A2A]"> + <div className="rounded-lg border border-custom-border-200 bg-custom-background-100 dark:border-[#303030] dark:bg-[#151515]"> + <div className="flex flex-wrap items-center gap-3 border-b border-custom-border-200 px-4 py-3 dark:border-[#2A2A2A]"> + <div className={`min-w-[130px] text-xs font-normal ${UPLOAD_MODAL_TEXT_CLASS.muted}`}> + {queueSummaryLabel} + </div> + <div className="min-w-[180px] flex-1" /> + {failedUploads.length > 0 ? ( + <div className="inline-flex items-center gap-1 text-xs font-medium text-red-500 dark:text-[#FF3434]"> + <AlertTriangle className="h-3.5 w-3.5" /> + {failedUploads.length} invalid + </div> + ) : null} + </div> + + <div className="max-h-[32vh] overflow-y-auto sm:max-h-[40vh]"> + {uploads.length === 0 ? ( + <div className={`px-4 py-5 text-center text-xs ${UPLOAD_MODAL_TEXT_CLASS.muted}`}> + No file selected + </div> + ) : ( + uploads.map((item) => { + const isFailed = item.status === "failed"; + return ( + <div + key={item.id} + className="flex items-center gap-3 border-b border-custom-border-200 px-4 py-3 last:border-b-0 dark:border-[#2A2A2A]" + > + <div + className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-md border ${ + isFailed + ? "border-red-500/40 text-red-500 dark:border-[#FF3434]/40 dark:text-[#FF3434]" + : `border-custom-border-200 ${UPLOAD_MODAL_TEXT_CLASS.muted} dark:border-[#303030]` + }`} + > + {getFileIcon(item.file)} + </div> + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> + <div className={`min-w-0 truncate text-xs font-semibold ${UPLOAD_MODAL_TEXT_CLASS.body}`}> + {item.file.name} + </div> + <div className={`shrink-0 text-[11px] ${UPLOAD_MODAL_TEXT_CLASS.muted}`}> + {formatFileSize(item.file.size)} + </div> + </div> + {isFailed ? ( + <div className="mt-1 flex items-center gap-1.5 text-xs text-red-500 dark:text-[#FF3434]"> + <AlertTriangle className="h-3.5 w-3.5" /> + <span className="truncate">{item.error ?? "Invalid file"}</span> + </div> + ) : null} + </div> + <div className="flex shrink-0 items-center gap-2"> + <button + type="button" + className={`inline-flex h-7 w-7 items-center justify-center rounded border border-transparent ${UPLOAD_MODAL_TEXT_CLASS.mutedAction} hover:border-custom-border-200 dark:hover:border-[#303030]`} + aria-label={`Remove ${item.file.name}`} + onClick={() => removeSelectedUpload(item.id)} + > + <Trash2 className="h-3.5 w-3.5" /> + </button> + </div> + </div> + ); + }) + )} + </div> + </div> + </div> + </div> + + <div + className={`flex flex-wrap items-center justify-between gap-3 border-t border-custom-border-200 px-5 py-3 text-xs ${UPLOAD_MODAL_TEXT_CLASS.muted} dark:border-[#2A2A2A]`} + > + <span>Supported formats: MP4, HLS, JPEG, PNG, PDF, CSV, XLSX (Max size: {maxSizeLabel})</span> + <div className="flex items-center gap-2"> + <Button variant="neutral-primary" size="sm" onClick={handleClose}> + Cancel + </Button> + <Button + variant="primary" + size="sm" + className="disabled:!cursor-default disabled:opacity-70" + onClick={handleUpload} + disabled={readyToUploadItems.length === 0} + > + Save & Upload + </Button> + </div> + </div> + </div> + </div> + ); +}; diff --git a/apps/web/ce/features/media-library/components/media-library-upload-status.tsx b/apps/web/ce/features/media-library/components/media-library-upload-status.tsx new file mode 100644 index 00000000000..d7d65c8740b --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-upload-status.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { Fragment, useMemo } from "react"; +import { Popover, Transition } from "@headlessui/react"; +import { AlertTriangle, CheckCircle2, Clock3, Loader2, RefreshCw, Trash2, UploadCloud, X } from "lucide-react"; +import { useMediaLibrary } from "../store/media-library-context"; +import { + formatFileSize, + getUploadStatusLabel, + getVisibleUploadProgress, + isActiveUploadStatus, + type TMediaLibraryUploadJob, +} from "../utils/media-library-upload-jobs"; +import { formatUploadEta, formatUploadSpeed } from "../utils/upload-progress"; + +const statusToneClass = (status: TMediaLibraryUploadJob["status"]) => { + if (status === "failed") return "text-red-500 dark:text-[#FF3434]"; + if (status === "completed") return "text-green-500 dark:text-[#12D8A0]"; + if (status === "cancelled") return "text-custom-text-400"; + return "text-custom-primary-100 dark:text-[#2D9CDB]"; +}; + +const statusProgressClass = (status: TMediaLibraryUploadJob["status"]) => { + if (status === "failed") return "bg-red-500 dark:bg-[#FF3434]"; + if (status === "completed") return "bg-green-500 dark:bg-[#12D8A0]"; + if (status === "cancelled") return "bg-custom-text-400"; + return "bg-custom-primary-100 dark:bg-[#2D9CDB]"; +}; + +const UploadStatusIcon = ({ status }: { status: TMediaLibraryUploadJob["status"] }) => { + if (status === "failed") return <AlertTriangle className="h-3.5 w-3.5" />; + if (status === "completed") return <CheckCircle2 className="h-3.5 w-3.5" />; + if (status === "cancelled") return <X className="h-3.5 w-3.5" />; + if (status === "uploading" || status === "processing") return <Loader2 className="h-3.5 w-3.5 animate-spin" />; + return <Clock3 className="h-3.5 w-3.5" />; +}; + +const UploadJobRow = ({ job }: { job: TMediaLibraryUploadJob }) => { + const { cancelUploadJob, retryUploadJob, dismissUploadJob } = useMediaLibrary(); + const progress = getVisibleUploadProgress(job); + const isActive = isActiveUploadStatus(job.status); + const isFinished = job.status === "completed" || job.status === "failed" || job.status === "cancelled"; + const uploadDetail = + job.status === "uploading" + ? `${formatUploadSpeed(job.uploadSpeedBytesPerSecond ?? 0)} / ${formatUploadEta(job.uploadEtaSeconds)}` + : null; + + return ( + <div className="border-b border-custom-border-200 px-3 py-3 last:border-b-0 dark:border-[#2A2A2A]"> + <div className="flex items-start gap-3"> + <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded border border-custom-border-200 text-custom-text-400 dark:border-[#303030]"> + <UploadCloud className="h-4 w-4" /> + </div> + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 items-center gap-2"> + <div className="min-w-0 truncate text-xs font-semibold text-custom-text-100">{job.file.name}</div> + <div className="shrink-0 text-[11px] text-custom-text-400">{formatFileSize(job.file.size)}</div> + </div> + <div className="mt-2 flex items-center gap-2"> + <div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-custom-border-200 dark:bg-[#242424]"> + <div + className={`h-full rounded-full transition-[width] ${statusProgressClass(job.status)}`} + style={{ width: `${progress}%` }} + /> + </div> + <div className={`w-9 text-right text-[11px] font-medium ${statusToneClass(job.status)}`}>{progress}%</div> + </div> + <div className={`mt-1 flex min-w-0 items-center gap-1.5 text-xs ${statusToneClass(job.status)}`}> + <UploadStatusIcon status={job.status} /> + <span className="shrink-0">{getUploadStatusLabel(job.status)}</span> + {uploadDetail ? <span className="min-w-0 truncate text-custom-text-400">/ {uploadDetail}</span> : null} + </div> + {job.error ? ( + <div className="mt-1 truncate text-[11px] text-red-500 dark:text-[#FF3434]">{job.error}</div> + ) : null} + </div> + <div className="flex shrink-0 items-center gap-1"> + {job.status === "queued" || job.status === "uploading" ? ( + <button + type="button" + className="rounded px-2 py-1 text-[11px] font-medium text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-100" + onClick={() => cancelUploadJob(job.id)} + > + Cancel + </button> + ) : null} + {job.status === "failed" || job.status === "cancelled" ? ( + <button + type="button" + className="inline-flex h-7 w-7 items-center justify-center rounded text-custom-primary-100 hover:bg-custom-background-80" + aria-label={`Retry ${job.file.name}`} + onClick={() => retryUploadJob(job.id)} + > + <RefreshCw className="h-3.5 w-3.5" /> + </button> + ) : null} + {isFinished ? ( + <button + type="button" + className="inline-flex h-7 w-7 items-center justify-center rounded text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-100" + aria-label={`Dismiss ${job.file.name}`} + onClick={() => dismissUploadJob(job.id)} + > + <Trash2 className="h-3.5 w-3.5" /> + </button> + ) : null} + </div> + </div> + </div> + ); +}; + +export const MediaLibraryUploadStatus = () => { + const { uploadJobs, clearCompletedUploadJobs } = useMediaLibrary(); + const summary = useMemo(() => { + const activeJobs = uploadJobs.filter((job) => isActiveUploadStatus(job.status)); + const uploadingJobs = uploadJobs.filter((job) => job.status === "uploading"); + const processingJobs = uploadJobs.filter((job) => job.status === "processing"); + const failedJobs = uploadJobs.filter((job) => job.status === "failed"); + const completedJobs = uploadJobs.filter((job) => job.status === "completed"); + const cancelledJobs = uploadJobs.filter((job) => job.status === "cancelled"); + const aggregateProgress = uploadJobs.length + ? Math.round(uploadJobs.reduce((total, job) => total + getVisibleUploadProgress(job), 0) / uploadJobs.length) + : 0; + + let label = ""; + if (uploadingJobs.length > 0) label = `${uploadingJobs.length} Uploading`; + else if (processingJobs.length > 0) label = `${processingJobs.length} Processing`; + else if (activeJobs.length > 0) label = `${activeJobs.length} Queued`; + else if (failedJobs.length > 0) label = `${failedJobs.length} Failed`; + else if (completedJobs.length > 0) label = `${completedJobs.length} Completed`; + else if (cancelledJobs.length > 0) label = `${cancelledJobs.length} Cancelled`; + + return { + activeCount: activeJobs.length, + finishedCount: completedJobs.length + failedJobs.length + cancelledJobs.length, + label, + aggregateProgress, + }; + }, [uploadJobs]); + + if (uploadJobs.length === 0) return null; + + return ( + <Popover className="relative"> + <Popover.Button + type="button" + className="inline-flex h-8 items-center gap-1.5 rounded border border-custom-border-200 bg-custom-background-100 px-2 text-xs font-medium text-custom-text-200 transition hover:bg-custom-background-80 dark:border-[#303030] dark:bg-[#151515]" + aria-label="Open upload progress" + > + <UploadCloud + className={`h-3.5 w-3.5 ${summary.activeCount > 0 ? "text-custom-primary-100 dark:text-[#2D9CDB]" : ""}`} + /> + <span className="hidden @4xl:inline">{summary.label}</span> + {summary.activeCount > 0 ? ( + <span className="hidden h-1.5 w-10 overflow-hidden rounded-full bg-custom-border-200 dark:bg-[#242424] @4xl:inline-flex"> + <span + className="h-full rounded-full bg-custom-primary-100 dark:bg-[#2D9CDB]" + style={{ width: `${summary.aggregateProgress}%` }} + /> + </span> + ) : null} + </Popover.Button> + <Transition + as={Fragment} + enter="transition ease-out duration-100" + enterFrom="opacity-0 translate-y-1" + enterTo="opacity-100 translate-y-0" + leave="transition ease-in duration-75" + leaveFrom="opacity-100 translate-y-0" + leaveTo="opacity-0 translate-y-1" + > + <Popover.Panel className="absolute right-0 z-30 mt-2 w-96 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-md dark:border-[#303030] dark:bg-[#151515]"> + <div className="flex items-center justify-between border-b border-custom-border-200 px-3 py-2.5 dark:border-[#2A2A2A]"> + <div> + <div className="text-sm font-semibold text-custom-text-100">Uploads</div> + <div className="text-[11px] text-custom-text-400">{summary.label}</div> + </div> + {summary.finishedCount > 0 ? ( + <button + type="button" + className="text-xs font-medium text-custom-text-400 hover:text-custom-text-100" + onClick={clearCompletedUploadJobs} + > + Clear finished + </button> + ) : null} + </div> + <div className="max-h-80 overflow-y-auto"> + {uploadJobs.map((job) => ( + <UploadJobRow key={job.id} job={job} /> + ))} + </div> + </Popover.Panel> + </Transition> + </Popover> + ); +}; diff --git a/apps/web/ce/features/media-library/components/media-library-upload-style-classes.ts b/apps/web/ce/features/media-library/components/media-library-upload-style-classes.ts new file mode 100644 index 00000000000..af517d0208e --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-upload-style-classes.ts @@ -0,0 +1,11 @@ +export const UPLOAD_MODAL_TEXT_CLASS = { + primary: "text-[rgb(var(--media-library-upload-text-primary))]", + body: "text-[rgb(var(--media-library-upload-text-body))]", + label: "text-[rgb(var(--media-library-upload-text-label))]", + muted: "text-[rgb(var(--media-library-upload-text-muted))]", + mutedAction: "text-[rgb(var(--media-library-upload-text-muted))] hover:text-custom-text-100", + optional: "text-[rgb(var(--media-library-upload-text-optional))]", + field: "text-[rgb(var(--media-library-upload-text-primary))]", + input: "text-[rgb(var(--media-library-upload-text-body))]", + inputPlaceholder: "placeholder:text-[rgb(var(--media-library-upload-placeholder-text))]", +} as const; diff --git a/apps/web/ce/features/media-library/components/media-library-upload-tabs.tsx b/apps/web/ce/features/media-library/components/media-library-upload-tabs.tsx new file mode 100644 index 00000000000..39e891844e9 --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-upload-tabs.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { TUploadTarget } from "./media-library-upload-types"; + +type Props = { + value: TUploadTarget; + onChange: (value: TUploadTarget) => void; +}; + +const TAB_OPTIONS: Array<{ key: TUploadTarget; label: string; helper: string }> = [ + { + key: "library", + label: "Upload to library", + helper: "Upload files to the program media library.", + }, + { + key: "work-item", + label: "Add media to work item", + helper: "Link uploaded media to a work item in this program.", + }, +]; + +export const MediaLibraryUploadTabs = ({ value, onChange }: Props) => ( + <div className="mb-4"> + <div + role="tablist" + aria-label="Upload options" + className="flex items-center gap-1 rounded-lg border border-custom-border-200 bg-custom-background-90 p-1" + > + {TAB_OPTIONS.map((tab) => { + const isActive = value === tab.key; + return ( + <button + key={tab.key} + type="button" + role="tab" + aria-selected={isActive} + onClick={() => onChange(tab.key)} + className={`flex-1 rounded-md px-3 py-2 text-xs font-semibold transition ${ + isActive + ? "bg-custom-primary-100 text-custom-text-100 shadow-custom-shadow-2xs" + : "text-custom-text-300 hover:text-custom-text-100" + }`} + > + {tab.label} + </button> + ); + })} + </div> + <div className="mt-2 text-[11px] text-custom-text-300"> + {TAB_OPTIONS.find((tab) => tab.key === value)?.helper} + </div> + </div> +); diff --git a/apps/web/ce/features/media-library/components/media-library-upload-types.ts b/apps/web/ce/features/media-library/components/media-library-upload-types.ts new file mode 100644 index 00000000000..f431528850d --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-upload-types.ts @@ -0,0 +1,15 @@ +export type TUploadTarget = "library" | "work-item"; + +export type TMetaFormState = { + category: string | null; + createdByMemberId: string | null; + sport: string | null; + program: string | null; + level: string | null; + season: string | null; + startDate: string | null; + startTime: string | null; + tags: string[]; +}; + +export type TMetaFieldChange = <K extends keyof TMetaFormState>(field: K, value: TMetaFormState[K]) => void; diff --git a/apps/web/ce/features/media-library/components/media-library-work-item-selector.tsx b/apps/web/ce/features/media-library/components/media-library-work-item-selector.tsx new file mode 100644 index 00000000000..571241e2f72 --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-library-work-item-selector.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ChevronDown, Search, X } from "lucide-react"; +import type { ISearchIssueResponse } from "@plane/types"; +import { Loader } from "@plane/ui"; +import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; + +type Props = { + selectedWorkItem: ISearchIssueResponse | null; + results: ISearchIssueResponse[]; + isLoading: boolean; + isDetailsLoading: boolean; + workItemQuery: string; + showCard?: boolean; + error?: string | null; + onSelect: (issue: ISearchIssueResponse) => void; + onQueryChange: (value: string) => void; + onClear: () => void; +}; + +export const MediaLibraryWorkItemSelector = ({ + selectedWorkItem, + results, + isLoading, + isDetailsLoading, + workItemQuery, + showCard = true, + error, + onSelect, + onQueryChange, + onClear, +}: Props) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + if (!isOpen) return; + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target) return; + if (!containerRef.current?.contains(target)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [isOpen]); + + const selectorContent = ( + <> + <div ref={containerRef} className={`relative ${showCard ? "mt-2" : ""}`}> + <button + type="button" + onClick={() => setIsOpen((prev) => !prev)} + aria-expanded={isOpen} + className={`flex h-9 w-full items-center justify-between rounded-md border px-3 text-left ${ + isOpen ? "border-white bg-custom-background-100" : "border-custom-border-200 bg-custom-background-100" + }`} + > + {selectedWorkItem ? ( + <div className="flex min-w-0 items-center gap-2"> + <span + className="h-2.5 w-2.5 flex-shrink-0 rounded-full" + style={{ backgroundColor: selectedWorkItem.state__color }} + /> + <IssueIdentifier + projectId={selectedWorkItem.project_id} + issueTypeId={selectedWorkItem.type_id} + projectIdentifier={selectedWorkItem.project__identifier} + issueSequenceId={selectedWorkItem.sequence_id} + textContainerClassName="text-xs text-custom-text-200" + /> + <span className="truncate text-xs text-custom-text-100">{selectedWorkItem.name}</span> + </div> + ) : ( + <span className="text-xs text-[#E5E7EB]">Select work item</span> + )} + <span className="flex items-center gap-1"> + {selectedWorkItem ? ( + <span + role="button" + tabIndex={0} + aria-label="Clear selected work item" + className="grid h-5 w-5 place-items-center rounded text-custom-text-300 hover:bg-custom-background-90 hover:text-custom-text-100" + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onClear(); + setIsOpen(false); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + onClear(); + setIsOpen(false); + }} + > + <X className="h-3.5 w-3.5" /> + </span> + ) : null} + <ChevronDown + className={`h-4 w-4 flex-shrink-0 text-custom-text-300 transition-transform ${isOpen ? "rotate-180" : ""}`} + /> + </span> + </button> + + {isOpen ? ( + <div className="absolute left-0 top-full z-[80] mt-1 w-full rounded-md border border-custom-border-200 bg-custom-background-100 p-2 shadow-lg"> + <div className="relative mb-2"> + <Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-custom-text-400" /> + <input + type="text" + value={workItemQuery} + onChange={(event) => onQueryChange(event.target.value)} + placeholder="Search work items" + className="h-8 w-full rounded-md border border-custom-border-200 bg-custom-background-90 pl-8 pr-2 text-xs text-custom-text-100 placeholder:text-[#E5E7EB] focus:outline-none" + /> + </div> + <div className="max-h-40 overflow-y-auto rounded-md border border-custom-border-200 bg-custom-background-100"> + {isLoading ? ( + <Loader className="space-y-2 p-3"> + <Loader.Item height="24px" /> + <Loader.Item height="24px" /> + <Loader.Item height="24px" /> + </Loader> + ) : results.length === 0 ? ( + <div className="px-3 py-2 text-xs text-custom-text-300">No work items found.</div> + ) : ( + results.map((issue) => { + const isSelected = selectedWorkItem?.id === issue.id; + + return ( + <button + key={issue.id} + type="button" + onClick={() => { + onSelect(issue); + setIsOpen(false); + }} + className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs ${ + isSelected + ? "bg-custom-background-80 text-custom-text-100" + : "text-custom-text-200 hover:bg-custom-background-80" + }`} + > + <span + className="h-2.5 w-2.5 flex-shrink-0 rounded-full" + style={{ backgroundColor: issue.state__color }} + /> + <IssueIdentifier + projectId={issue.project_id} + issueTypeId={issue.type_id} + projectIdentifier={issue.project__identifier} + issueSequenceId={issue.sequence_id} + textContainerClassName={ + isSelected ? "text-xs text-custom-text-100" : "text-xs text-custom-text-200" + } + /> + <span className="truncate">{issue.name}</span> + </button> + ); + }) + )} + </div> + </div> + ) : null} + </div> + {error ? <div className={`${showCard ? "mt-2" : "mt-1"} text-xs text-red-500`}>{error}</div> : null} + {isDetailsLoading ? ( + <div className={`${showCard ? "mt-2" : "mt-1"} text-[11px] text-custom-text-300`}> + Loading work item details… + </div> + ) : null} + </> + ); + + if (!showCard) return selectorContent; + + return ( + <div className="mb-4 rounded-lg border border-custom-border-200 bg-custom-background-90 p-4"> + <div className="text-xs font-semibold text-custom-text-100">Work item (optional)</div> + {selectorContent} + </div> + ); +}; diff --git a/apps/web/ce/features/media-library/components/media-list-view.tsx b/apps/web/ce/features/media-library/components/media-list-view.tsx new file mode 100644 index 00000000000..dcbb48aa7cc --- /dev/null +++ b/apps/web/ce/features/media-library/components/media-list-view.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { MouseEvent, ReactNode } from "react"; +import Link from "next/link"; +import { AlertTriangle, CheckCircle2, File, Image, ImageOff, LoaderCircle, Video } from "lucide-react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table"; +import type { TMediaItem, TMediaSection } from "../types/media-library.types"; +import { getDisplayMediaTitle } from "../utils/media-detail-utils"; +import { getEventMediaDateLabel, isEventMediaItem } from "../utils/media-event"; + +const clampProgress = (value: unknown) => { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return Math.min(100, Math.max(0, Math.round(value))); +}; + +const MediaListRow = ({ + item, + getItemHref, + onItemClick, + getItemTypeLabel, +}: { + item: TMediaItem; + getItemHref?: (item: TMediaItem) => string; + onItemClick?: (event: MouseEvent<HTMLAnchorElement>, item: TMediaItem) => void; + getItemTypeLabel?: (item: TMediaItem) => string; +}) => { + const [isThumbnailUnavailable, setIsThumbnailUnavailable] = useState(!item.thumbnail); + const isEventItem = isEventMediaItem(item); + const displayTitle = getDisplayMediaTitle(item.title); + + useEffect(() => { + setIsThumbnailUnavailable(!item.thumbnail); + }, [item.thumbnail]); + + // console.log("Rendering MediaListRow for item:", item); + + const typeLabel = getItemTypeLabel + ? getItemTypeLabel(item) + : isEventItem + ? "event" + : (item.linkedMediaType ?? item.mediaType); + const dateLabel = isEventItem ? getEventMediaDateLabel(item) || item.createdAt : item.createdAt; + const showLinkedTypeIndicator = item.mediaType === "image" && Boolean(item.link) && Boolean(item.linkedMediaType); + const isLinkedDocumentThumbnail = item.mediaType === "image" && item.linkedMediaType === "document"; + const linkedTypeLabel = showLinkedTypeIndicator + ? isEventItem + ? "Video" + : item.linkedMediaType === "video" + ? "Video" + : item.linkedMediaType === "image" + ? "Image" + : "Document" + : ""; + const LinkedTypeIcon = showLinkedTypeIndicator + ? isEventItem + ? Video + : item.linkedMediaType === "video" + ? Video + : item.linkedMediaType === "image" + ? Image + : File + : null; + const isVideoLike = item.mediaType === "video" || item.linkedMediaType === "video"; + const showTranscodeBadge = + isVideoLike && + Boolean(item.transcodeStatus) && + (item.isTranscodeActive || item.isTranscodeFailed || item.isTranscodeComplete); + const TranscodeIcon = item.isTranscodeFailed ? AlertTriangle : item.isTranscodeComplete ? CheckCircle2 : LoaderCircle; + const transcodeBadgeClass = item.isTranscodeFailed + ? "bg-red-500/15 text-red-500" + : item.isTranscodeComplete + ? "bg-green-500/15 text-green-500" + : "bg-custom-primary-100/15 text-custom-primary-100"; + const transcodeProgress = clampProgress(item.transcodeProgress); + const transcodeBadgeLabel = item.isTranscodeActive + ? `${item.transcodeLabel ?? "Processing"} ${transcodeProgress > 0 ? `${transcodeProgress}%` : ""}`.trim() + : item.transcodeLabel; + const itemHref = getItemHref ? getItemHref(item) : `./${encodeURIComponent(item.id)}`; + const isDetailDisabled = Boolean(item.isTranscodeActive); + const handleItemClick = (event: MouseEvent<HTMLAnchorElement>) => { + if (isDetailDisabled) { + event.preventDefault(); + event.stopPropagation(); + return; + } + onItemClick?.(event, item); + }; + const renderItemLink = (children: ReactNode, className: string) => + isDetailDisabled ? ( + <div className={`${className} cursor-not-allowed opacity-95`} aria-disabled="true" title="Transcoding in progress"> + {children} + </div> + ) : ( + <Link href={itemHref} onClick={handleItemClick} className={className}> + {children} + </Link> + ); + const thumbnailUnavailableFallback = ( + <div className="flex h-full w-full flex-col items-center justify-center gap-1 text-custom-text-300"> + <ImageOff className="h-6 w-6" strokeWidth={2.5} /> + <span className="sr-only">Thumbnail unavailable</span> + </div> + ); + + return ( + <TableRow className="border-b border-custom-border-200 last:border-b-0 hover:bg-custom-background-80/50"> + <TableCell className="w-[140px] min-w-[140px] border-r border-custom-border-200"> + {renderItemLink( + <div className="relative h-16 w-28 overflow-hidden rounded-md bg-custom-background-90"> + {!isThumbnailUnavailable ? ( + <img + src={item.thumbnail} + alt={displayTitle} + onError={() => setIsThumbnailUnavailable(true)} + className={`h-full w-full ${isLinkedDocumentThumbnail ? "object-contain p-3" : "object-cover"}`} + /> + ) : ( + thumbnailUnavailableFallback + )} + {showLinkedTypeIndicator && LinkedTypeIcon ? ( + <span className="absolute right-2 bottom-2 flex h-6 w-6 items-center justify-center rounded-full bg-custom-background-100/80 text-custom-text-300 backdrop-blur"> + <span className="sr-only">{linkedTypeLabel}</span> + <LinkedTypeIcon className="h-3.5 w-3.5" strokeWidth={3.5} /> + </span> + ) : null} + {item.isTranscodeActive ? ( + <div className="absolute inset-x-0 bottom-0 h-1 bg-custom-background-100/70"> + <div + className="h-full bg-custom-primary-100 transition-all duration-300" + style={{ width: `${transcodeProgress}%` }} + /> + </div> + ) : null} + </div>, + "block" + )} + </TableCell> + <TableCell className="min-w-[240px] border-r border-custom-border-200"> + {renderItemLink( + <> + <div className="flex min-w-0 items-center gap-2"> + <div className="line-clamp-1 min-w-0 text-sm font-semibold text-custom-text-100">{displayTitle}</div> + {showTranscodeBadge ? ( + <span className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${transcodeBadgeClass}`}> + {transcodeBadgeLabel} + </span> + ) : null} + </div> + {item.description ? ( + <div className="line-clamp-1 text-[11px] text-custom-text-300">{item.description}</div> + ) : null} + </>, + "block min-w-0" + )} + </TableCell> + <TableCell className="min-w-[120px] border-r border-custom-border-200 text-xs text-custom-text-300"> + {renderItemLink(typeLabel, "block capitalize")} + </TableCell> + <TableCell className="min-w-[160px] border-r border-custom-border-200 text-xs text-custom-text-300"> + {renderItemLink(dateLabel, "block")} + </TableCell> + <TableCell className="min-w-[120px] text-xs text-custom-text-300"> + {renderItemLink(item.primaryTag, "block")} + </TableCell> + </TableRow> + ); +}; + +const MediaListSection = ({ + section, + getItemHref, + onItemClick, + getItemTypeLabel, + getSectionHref, +}: { + section: TMediaSection; + getItemHref?: (item: TMediaItem) => string; + onItemClick?: (event: MouseEvent<HTMLAnchorElement>, item: TMediaItem) => void; + getItemTypeLabel?: (item: TMediaItem) => string; + getSectionHref?: (section: TMediaSection) => string; +}) => ( + <section className="flex flex-col gap-3"> + <div className="flex items-center justify-between"> + {getSectionHref ? ( + <> + <div className="text-sm font-semibold text-custom-text-100">{section.title}</div> + + <Link + href={getSectionHref(section)} + className="text-xs uppercase tracking-wider text-custom-text-300 hover:text-custom-text-100" + > + View all + </Link> + </> + ) : null} + </div> + <div className="overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100"> + <Table className="min-w-[860px]"> + <TableHeader> + <TableRow> + <TableHead className="w-[140px] min-w-[140px] border-r border-custom-border-200">Media</TableHead> + <TableHead className="min-w-[240px] border-r border-custom-border-200">Name</TableHead> + <TableHead className="min-w-[120px] border-r border-custom-border-200">Type</TableHead> + <TableHead className="min-w-[160px] border-r border-custom-border-200">Date</TableHead> + <TableHead className="min-w-[120px]">Category</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {section.items.map((item, index) => ( + <MediaListRow + key={`${section.title}-${item.id}-${index}`} + item={item} + getItemHref={getItemHref} + onItemClick={onItemClick} + getItemTypeLabel={getItemTypeLabel} + /> + ))} + </TableBody> + </Table> + </div> + </section> +); + +export const MediaListView = ({ + sections, + getItemHref, + onItemClick, + getItemTypeLabel, + getSectionHref, +}: { + sections: TMediaSection[]; + getItemHref?: (item: TMediaItem) => string; + onItemClick?: (event: MouseEvent<HTMLAnchorElement>, item: TMediaItem) => void; + getItemTypeLabel?: (item: TMediaItem) => string; + getSectionHref?: (section: TMediaSection) => string; +}) => ( + <div className="flex flex-col gap-8"> + {sections.map((section) => ( + <MediaListSection + key={section.title} + section={section} + getItemHref={getItemHref} + onItemClick={onItemClick} + getItemTypeLabel={getItemTypeLabel} + getSectionHref={getSectionHref} + /> + ))} + </div> +); diff --git a/apps/web/ce/features/media-library/components/player-ui.tsx b/apps/web/ce/features/media-library/components/player-ui.tsx new file mode 100644 index 00000000000..996aa345b97 --- /dev/null +++ b/apps/web/ce/features/media-library/components/player-ui.tsx @@ -0,0 +1,204 @@ +"use client"; + +import type { RefObject } from "react"; +import { useState } from "react"; +import { ChevronDown, Pause, Play } from "lucide-react"; + +type TOverlayProps = { + isPlaying: boolean; + onToggle: () => void; + onSeek: (delta: number) => void; +}; + +const SkipIcon = ({ direction }: { direction: "back" | "forward" }) => ( + <span className="player-skip-icon" aria-hidden="true"> + {direction === "forward" ? ( + <svg viewBox="0 0 24 24" className="player-skip-icon__svg" fill="none"> + <path d="M13.98 4.46997L12 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> + <path + d="M19.0899 7.79999C20.1999 9.27999 20.8899 11.11 20.8899 13.11C20.8899 18.02 16.9099 22 11.9999 22C7.08988 22 3.10986 18.02 3.10986 13.11C3.10986 8.19999 7.08988 4.21997 11.9999 4.21997C12.6799 4.21997 13.3399 4.31002 13.9799 4.46002" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M13.91 10.8301H10.85L10.0901 13.1201H12.3801C13.2201 13.1201 13.91 13.8001 13.91 14.6501C13.91 15.4901 13.2301 16.1801 12.3801 16.1801H10.0901" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ) : ( + <svg viewBox="0 0 24 24" className="player-skip-icon__svg" fill="none"> + <path + d="M13.91 10.8301H10.85L10.09 13.1201H12.38C13.22 13.1201 13.91 13.8001 13.91 14.6501C13.91 15.4901 13.23 16.1801 12.38 16.1801H10.09" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path d="M10.02 4.46997L12 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> + <path + d="M4.91 7.79999C3.8 9.27999 3.10999 11.11 3.10999 13.11C3.10999 18.02 7.09 22 12 22C16.91 22 20.89 18.02 20.89 13.11C20.89 8.19999 16.91 4.21997 12 4.21997C11.32 4.21997 10.66 4.31002 10.02 4.46002" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + )} + </span> +); + +export const PlayerOverlay = ({ isPlaying, onToggle, onSeek }: TOverlayProps) => ( + <div className="player-overlay-controls is-visible"> + <div className="player-overlay-box"> + <button + type="button" + className="player-overlay-button" + onClick={() => onSeek(-5)} + aria-label="Skip back 5 seconds" + > + <SkipIcon direction="back" /> + </button> + <button + type="button" + className="player-overlay-button player-overlay-button--primary" + onClick={onToggle} + aria-label={isPlaying ? "Pause video" : "Play video"} + > + {isPlaying ? ( + <Pause className="player-overlay-icon" aria-hidden="true" /> + ) : ( + <Play className="player-overlay-icon" aria-hidden="true" /> + )} + </button> + <button + type="button" + className="player-overlay-button" + onClick={() => onSeek(5)} + aria-label="Skip forward 5 seconds" + > + <SkipIcon direction="forward" /> + </button> + </div> + </div> +); + +export type TQualityOption = { + key: string; + label: string; + isAuto: boolean; + selected: boolean; + rep: any; + disabled?: boolean; +}; + +type TSettingsPanelProps = { + isOpen: boolean; + onClose: () => void; + qualityOptions: TQualityOption[]; + playbackRates: number[]; + currentPlaybackRate: number; + onSelectQuality: (option: TQualityOption) => void; + onSelectRate: (rate: number) => void; + panelRef: RefObject<HTMLDivElement>; +}; + +export const PlayerSettingsPanel = ({ + isOpen, + onClose, + qualityOptions, + playbackRates, + currentPlaybackRate, + onSelectQuality, + onSelectRate, + panelRef, +}: TSettingsPanelProps) => { + const [openSection, setOpenSection] = useState<"quality" | "speed" | null>(null); + + if (!isOpen) return null; + + const hasRealQualityOption = qualityOptions.some( + (option) => !option.disabled && !option.isAuto && option.label.toLowerCase() !== "source" + ); + const showQualityRow = openSection !== "speed" && hasRealQualityOption; + const showSpeedRow = !hasRealQualityOption || openSection !== "quality"; + + return ( + <div ref={panelRef} className="player-settings-panel" role="dialog" aria-label="Settings"> + {showQualityRow ? ( + <> + <button + type="button" + className="player-settings-row" + onClick={() => setOpenSection((prev) => (prev === "quality" ? null : "quality"))} + > + <span>Video Quality</span> + <span className="player-settings-value"> + {qualityOptions.find((option) => option.selected)?.label ?? "Auto"} + </span> + <ChevronDown className="player-settings-chevron" aria-hidden="true" /> + </button> + {openSection === "quality" ? ( + <div className="player-settings-dropdown"> + {qualityOptions.map((option) => ( + <button + key={option.key} + type="button" + className={`player-settings-dropdown-item ${option.selected ? "is-active" : ""} ${ + option.disabled ? "is-disabled" : "" + }`} + onClick={() => { + onSelectQuality(option); + setOpenSection("quality"); + }} + disabled={option.disabled} + aria-disabled={option.disabled} + > + <span className="player-settings-check">✓</span> + <span>{option.label}</span> + </button> + ))} + </div> + ) : null} + </> + ) : null} + {showSpeedRow ? ( + <> + <button + type="button" + className="player-settings-row" + onClick={() => setOpenSection((prev) => (prev === "speed" ? null : "speed"))} + > + <span>Speed</span> + <span className="player-settings-value"> + {currentPlaybackRate === 1 ? "Normal" : `${currentPlaybackRate}x`} + </span> + <ChevronDown className="player-settings-chevron" aria-hidden="true" /> + </button> + {openSection === "speed" ? ( + <div className="player-settings-dropdown"> + {playbackRates.map((rate) => ( + <button + key={rate} + type="button" + className={`player-settings-dropdown-item ${currentPlaybackRate === rate ? "is-active" : ""}`} + onClick={() => { + onSelectRate(rate); + setOpenSection("speed"); + }} + > + <span className="player-settings-check">✓</span> + <span>{rate === 1 ? "Normal" : `${rate}x`}</span> + </button> + ))} + </div> + ) : null} + </> + ) : null} + </div> + ); +}; diff --git a/apps/web/ce/features/media-library/components/tags-section.tsx b/apps/web/ce/features/media-library/components/tags-section.tsx new file mode 100644 index 00000000000..bb6a7cb8af4 --- /dev/null +++ b/apps/web/ce/features/media-library/components/tags-section.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useState } from "react"; +import type { ReactNode } from "react"; +import { FileText, Image as ImageIcon, Video, X } from "lucide-react"; +import { useMember } from "@/hooks/store/use-member"; +import type { TMediaItem } from "../types/media-library.types"; +import { getStructuredEventTags, isEventMediaItem } from "../utils/media-event"; + +type TagsSectionProps = { + item: TMediaItem; + onPlay: () => void; + editable?: boolean; + isSaving?: boolean; + onTagsChange?: (nextTags: string[]) => void; +}; + +const normalizeTagValue = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (Array.isArray(value)) { + return value + .map((entry) => normalizeTagValue(entry)) + .filter(Boolean) + .join(", "); + } + if (typeof value === "string") return value.trim(); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const maybeName = (value as Record<string, unknown>)?.name; + if (typeof maybeName === "string" && maybeName.trim()) return maybeName.trim(); + } + return ""; +}; + +const TagPill = ({ label, value }: { label: string; value: string }) => ( + <span className="inline-flex items-center gap-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-2.5 py-1 text-[11px] text-custom-text-300"> + <span className="text-custom-text-400">{label}:</span> + <span className="text-custom-text-100">{value}</span> + </span> +); + +const buildPreview = (item: TMediaItem, onPlay: () => void): ReactNode => { + if (item.mediaType === "video") { + return ( + <button + type="button" + onClick={onPlay} + className="flex h-24 w-full items-center justify-center overflow-hidden rounded-md border border-custom-border-200 bg-custom-background-100 text-left" + > + {item.thumbnail ? ( + <img src={item.thumbnail} alt={item.title} className="h-full w-full object-cover" /> + ) : ( + <div className="flex flex-col items-center gap-1 text-[10px] text-custom-text-300"> + <Video className="h-4 w-4" /> + <span>Play preview</span> + </div> + )} + </button> + ); + } + + if (item.mediaType === "image") { + return item.thumbnail ? ( + <div className="flex h-24 w-full items-center justify-center overflow-hidden rounded-md border border-custom-border-200 bg-custom-background-100"> + <img src={item.thumbnail} alt={item.title} className="h-full w-full object-cover" /> + </div> + ) : ( + <div className="flex h-24 w-full flex-col items-center justify-center gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 text-[10px] text-custom-text-300"> + <ImageIcon className="h-4 w-4" /> + <span>No preview</span> + </div> + ); + } + + return item.thumbnail ? ( + <div className="flex h-24 w-full items-center justify-center overflow-hidden rounded-md border border-custom-border-200 bg-custom-background-100"> + <img src={item.thumbnail} alt={item.title} className="h-14 w-14 object-contain" /> + </div> + ) : ( + <div className="flex h-24 w-full flex-col items-center justify-center gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 text-[10px] text-custom-text-300"> + <FileText className="h-4 w-4" /> + <span>No preview</span> + </div> + ); +}; + +export const TagsSection = ({ item, onPlay, editable = false, isSaving = false, onTagsChange }: TagsSectionProps) => { + const meta = item.meta ?? {}; + const oppositionName = normalizeTagValue(meta.opposition); + const { getUserDetails } = useMember(); + const [tagDraft, setTagDraft] = useState(""); + const rawTags = Array.isArray(meta.tags) ? meta.tags : []; + const isEventItem = isEventMediaItem(item); + const structuredEventTags = getStructuredEventTags(item); + const tagsList = rawTags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0); + const tagMap = new Map<string, string>(); + + const addTag = (label: string, value: unknown) => { + const normalized = normalizeTagValue(value); + if (!normalized) return; + tagMap.set(label, normalized); + }; + + const handleAddTag = (rawValue: string) => { + if (!onTagsChange) return; + const parts = rawValue + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + if (parts.length === 0) return; + const next = [...tagsList]; + for (const part of parts) { + const exists = next.some((tag) => tag.toLowerCase() === part.toLowerCase()); + if (!exists) next.push(part); + } + onTagsChange(next); + setTagDraft(""); + }; + + const handleRemoveTag = (value: string) => { + if (!onTagsChange) return; + const next = tagsList.filter((tag) => tag.toLowerCase() !== value.toLowerCase()); + onTagsChange(next); + }; + + if (editable) { + return ( + <div className="w-full self-start lg:max-w-[720px]"> + <div className="text-[11px] text-custom-text-300"> + <div className="mb-1">{isEventItem && structuredEventTags.length > 0 ? "Event tags" : "Tags"}</div> + <div className="flex min-h-[34px] w-full flex-wrap items-center gap-2 rounded-md px-2 py-1.5"> + {structuredEventTags.length > 0 + ? structuredEventTags.map((tag, index) => ( + <span + key={`${tag.label}-${index}`} + className="inline-flex items-center gap-1 rounded-full border border-custom-primary-100/30 bg-custom-primary-100/15 px-2 py-0.5 text-[11px] font-medium text-custom-primary-100" + title={tag.label} + > + {tag.label} + </span> + )) + : null} + {tagsList.map((tag) => ( + <span + key={tag} + className="inline-flex items-center gap-1 rounded-full border border-custom-primary-100/30 bg-custom-primary-100/15 px-2 py-0.5 text-[11px] font-medium text-custom-primary-100" + > + {tag} + <button + type="button" + onClick={() => handleRemoveTag(tag)} + className="text-custom-primary-100/80 hover:text-custom-primary-100" + disabled={isSaving} + aria-label={`Remove ${tag}`} + > + <X className="h-3 w-3" /> + </button> + </span> + ))} + {!structuredEventTags.length ? ( + <input + type="text" + value={tagDraft} + onChange={(event) => setTagDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault(); + handleAddTag(tagDraft); + } + }} + placeholder={tagsList.length === 0 ? "Add tags" : ""} + className="min-w-[140px] flex-1 bg-transparent px-1 py-0.5 text-[11px] text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none" + disabled={isSaving} + /> + ) : null} + </div> + <div className="mt-1 text-[10px] text-custom-text-300"> + {structuredEventTags.length > 0 + ? "These tags were captured during the live event." + : "Press comma or Enter to add."} + </div> + </div> + </div> + ); + } + + addTag("Category", meta.category); + addTag("Sport", meta.sport); + addTag("Program", meta.program); + addTag("Level", meta.level); + addTag("Season", meta.season); + addTag("Opposition", oppositionName); + addTag("Source", meta.source); + const createdByValue = meta.created_by ?? meta.createdBy ?? item.author; + const createdByLabel = + typeof createdByValue === "string" + ? (getUserDetails(createdByValue)?.display_name ?? createdByValue) + : createdByValue; + addTag("Created by", createdByLabel); + addTag("File type", meta.file_type ?? meta.fileType ?? item.format); + + if (tagsList.length > 0 && !editable) { + addTag("Tags", tagsList); + } + + const tags = Array.from(tagMap.entries()); + + return ( + <div className="rounded-xl border border-custom-border-200 bg-custom-background-90"> + <div className="border-b border-custom-border-200 px-4 py-2 text-xs font-semibold text-custom-text-100">Tags</div> + <div className="grid gap-4 px-4 py-3 sm:grid-cols-[160px_1fr]"> + {buildPreview(item, onPlay)} + <div className="flex flex-wrap gap-2"> + {tags.length > 0 ? ( + tags.map(([label, value]) => <TagPill key={label} label={label} value={value} />) + ) : ( + <div className="text-xs text-custom-text-300">No tags available.</div> + )} + </div> + </div> + </div> + ); +}; diff --git a/apps/web/ce/features/media-library/constants/player-styles.ts b/apps/web/ce/features/media-library/constants/player-styles.ts new file mode 100644 index 00000000000..32b827c64a8 --- /dev/null +++ b/apps/web/ce/features/media-library/constants/player-styles.ts @@ -0,0 +1,446 @@ +export const PLAYER_STYLE = ` + .media-player { + position: relative; + isolation: isolate; + } + .media-player .video-js { + position: relative; + z-index: 1; + } + .media-player .video-js .vjs-tech { + object-fit: contain; + } + .media-player .video-js .vjs-control-bar { + display: flex; + align-items: center; + flex-wrap: nowrap; + background: rgba(0, 0, 0, 0.65); + border-radius: 0 0 12px 12px; + height: 40px; + left: 0; + right: 0; + bottom: 0; + padding: 0 12px; + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.35); + gap: 8px; + z-index: 30; + } + .media-player.is-settings-open .video-js .vjs-control-bar, + .media-player.is-settings-open .video-js.vjs-user-inactive .vjs-control-bar { + opacity: 1 !important; + visibility: visible !important; + transform: none !important; + pointer-events: auto !important; + } + .media-player .video-js .vjs-control { + display: flex; + align-items: center; + justify-content: center; + height: 69%; + padding: 0; + line-height: 1; + } + .media-player .video-js .vjs-control-bar, + .media-player .video-js .vjs-time-control { + color: #f9fafb; + font-size: 12px; + font-weight: 400; + line-height: 1; + } + .media-player .video-js .vjs-time-control { + min-width: 40px; + padding: 0 2px; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + } + .media-player .video-js .vjs-time-divider { + padding: 0 2px; + } + .media-player .video-js .vjs-progress-control { + flex: 1; + margin: 0 12px 0 6px; + display: flex; + align-items: center; + min-width: 0; + height: 100%; + } + .media-player .video-js .vjs-progress-control .vjs-progress-holder { + width: 100%; + align-self: center; + } + .media-player .video-js .vjs-time-control { + flex: 0 0 auto; + white-space: nowrap; + } + .media-player .video-js .vjs-progress-holder { + height: 4px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); + margin: 0; + } + .media-player .video-js .vjs-load-progress { + background: rgba(255, 255, 255, 0.35); + border-radius: 999px; + } + .media-player .video-js .vjs-play-progress { + background: #ffffff; + border-radius: 999px; + } + .media-player .video-js .vjs-progress-holder .vjs-play-progress:before { + border-radius: 999px; + height: 4px; + width: 4px; + top: 0; + transform: none; + } + .media-player .video-js .vjs-button { + width: 26px; + height: 24px; + } + .media-player .video-js .vjs-button .vjs-icon-placeholder:before { + font-size: 22px; + line-height: 1; + display: block; + } + .media-player .video-js .vjs-volume-panel { + margin-left: 6px; + flex: 0 0 auto; + } + .media-player .video-js .vjs-volume-panel .vjs-volume-control { + display: flex; + align-items: center; + width: 36px; + margin-left: 4px; + overflow: hidden; + transition: width 160ms ease; + } + .media-player .video-js .vjs-volume-panel:hover .vjs-volume-control, + .media-player .video-js .vjs-volume-panel:focus-within .vjs-volume-control, + .media-player .video-js .vjs-volume-panel.vjs-hover .vjs-volume-control { + width: 72px; + } + .media-player .video-js .vjs-volume-bar { + background: rgba(255, 255, 255, 0.2); + border-radius: 999px; + height: 3px; + } + .media-player .video-js .vjs-volume-level { + background: #ffffff; + border-radius: 999px; + } + .media-player .video-js .vjs-control .vjs-icon-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + } + .media-player .video-js .vjs-quality-selector, + .media-player .video-js .vjs-hls-quality-selector, + .media-player .video-js .vjs-quality-menu, + .media-player .video-js .vjs-menu-button.vjs-icon-cog { + display: none !important; + } + .media-player .video-js .vjs-live-control, + .media-player .video-js .vjs-live-display, + .media-player .video-js .vjs-live, + .media-player .video-js .vjs-live-button { + display: none !important; + } + .media-player .video-js .vjs-control-bar [class*="live"] { + display: none !important; + } + .media-player .video-js .vjs-play-control, + .media-player .video-js .vjs-replay-control, + .media-player .video-js .vjs-skip-backward, + .media-player .video-js .vjs-skip-forward, + .media-player .video-js .vjs-prev-control, + .media-player .video-js .vjs-next-control, + .media-player .video-js .vjs-playback-rate, + .media-player .video-js .vjs-subs-caps-button, + .media-player .video-js .vjs-remaining-time, + .media-player .video-js .vjs-picture-in-picture-control { + display: none !important; + } + .media-player .video-js .vjs-big-play-button { + display: none; + } + .media-player .video-js .vjs-overflow-button .vjs-icon-placeholder:before { + content: ""; + } + .media-player .video-js .vjs-overflow-button .vjs-icon-placeholder { + position: relative; + width: 16px; + height: 16px; + display: block; + opacity: 1; + background: no-repeat center / contain; + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='3.2'/><path d='M19.4 15a1.7 1.7 0 0 0 .34 1.87l.09.09a2.1 2.1 0 1 1-2.97 2.97l-.09-.09A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2.1 2.1 0 1 1-4.2 0v-.05a1.7 1.7 0 0 0-1-1.55 1.7 1.7 0 0 0-1.83.44l-.09.09a2.1 2.1 0 1 1-2.97-2.97l.09-.09A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2.1 2.1 0 1 1 0-4.2h.05A1.7 1.7 0 0 0 4.6 8a1.7 1.7 0 0 0-.44-1.83l-.09-.09A2.1 2.1 0 1 1 6.99 3.1l.09.09A1.7 1.7 0 0 0 8.9 3.6a1.7 1.7 0 0 0 1-1.55V2a2.1 2.1 0 1 1 4.2 0v.05a1.7 1.7 0 0 0 1 1.55 1.7 1.7 0 0 0 1.83-.44l.09-.09A2.1 2.1 0 1 1 20.9 6.08l-.09.09A1.7 1.7 0 0 0 19.4 8c0 .7.42 1.34 1.05 1.55H21a2.1 2.1 0 1 1 0 4.2h-.05A1.7 1.7 0 0 0 19.4 15Z'/></svg>"); + } + .media-player .video-js .vjs-overflow-button .vjs-icon-placeholder:after { + content: none; + } + .media-player .video-js .vjs-overflow-button { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + } + .media-player .video-js .vjs-pip-toggle .vjs-icon-placeholder:before { + content: ""; + } + .media-player .video-js .vjs-pip-toggle .vjs-icon-placeholder { + position: relative; + width: 19px; + height: 20px; + display: block !important; + opacity: 1 !important; + background: no-repeat center / contain; + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M21 15V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h6'/><path d='M21 15h-6a2 2 0 0 0-2 2v4h8z'/></svg>"); + } + .media-player .video-js .vjs-pip-toggle .vjs-icon-placeholder:after { + content: none; + } + .media-player .video-js .vjs-pip-toggle { + display: flex !important; + align-items: center; + justify-content: center; + width: 26px; + } + .media-player .video-js .vjs-overflow-button { + display: flex !important; + } + .media-player .video-js .vjs-pip-toggle, + .media-player .video-js .vjs-overflow-button { + visibility: visible !important; + opacity: 1 !important; + } + .media-player .video-js .vjs-pip-toggle .vjs-icon-placeholder { + display: block !important; + } + .media-player .video-js .vjs-volume-panel, + .media-player .video-js .vjs-pip-toggle, + .media-player .video-js .vjs-fullscreen-control, + .media-player .video-js .vjs-overflow-button { + margin-left: 17px; + } + .media-player .video-js .vjs-overflow-button { + margin-left: 6px; + } + .media-player .player-overlay-controls { + position: absolute; + left: 0; + right: 0; + top: 50%; + transform: translateY(-50%); + display: flex !important; + align-items: center; + justify-content: center; + gap: 18px; + pointer-events: none; + opacity: 0; + visibility: hidden; + z-index: 30; + transition: opacity 160ms ease, visibility 160ms ease; + } + .media-player:hover .player-overlay-controls { + opacity: 1; + visibility: visible; + } + .media-player.is-paused .player-overlay-controls { + opacity: 1; + visibility: visible; + } + .media-player .video-js.vjs-fullscreen.vjs-user-inactive .player-overlay-controls { + opacity: 0 !important; + visibility: hidden !important; + } + .media-player .player-overlay-box { + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 18px; + padding: 10px 18px; + border-radius: 12px; + } + .media-player .player-overlay-button { + pointer-events: auto; + height: 44px; + width: 44px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.55); + border: none; + color: #ffffff; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 160ms ease, background 160ms ease; + } + .media-player .player-overlay-button--primary { + height: 56px; + width: 56px; + background: rgba(0, 0, 0, 0.65); + } + .media-player .player-overlay-icon { + height: 22px; + width: 22px; + } + .media-player .player-overlay-button--primary .player-overlay-icon { + height: 26px; + width: 26px; + } + .media-player .player-skip-icon { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + width: 24px; + color: #ffffff; + } + .media-player .player-skip-icon__svg { + height: 24px; + width: 24px; + } + .media-player .player-overlay-icon--back { + transform: rotate(180deg); + } + .media-player .player-overlay-button:hover { + transform: scale(1.04); + background: rgba(0, 0, 0, 0.8); + } + .media-player .player-settings-panel { + position: absolute; + right: 18px; + bottom: 52px; + width: 260px; + background: rgba(20, 20, 20, 0.95); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.4); + color: #f9fafb; + z-index: 31; + padding: 8px 12px; + } + .media-player .player-settings-row { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 0; + font-size: 12px; + font-weight: 500; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: transparent; + color: inherit; + cursor: pointer; + } + .media-player .player-settings-row:last-child { + border-bottom: none; + } + .media-player .player-settings-value { + margin-left: auto; + color: #4fc3ff; + font-weight: 500; + } + .media-player .player-settings-chevron { + color: #4fc3ff; + font-size: 12px; + } + .media-player .player-settings-dropdown { + margin: 6px 0 10px; + padding: 8px 0; + background: rgba(18, 18, 18, 0.98); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + } + .media-player .player-settings-dropdown-item { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: transparent; + border: none; + color: #4fc3ff; + font-size: 12px; + text-align: left; + cursor: pointer; + } + .media-player .player-settings-dropdown-item.is-active { + color: #ffffff; + } + .media-player .player-settings-check { + width: 14px; + color: #ffffff; + opacity: 0; + } + .media-player .player-settings-dropdown-item.is-active .player-settings-check { + opacity: 1; + } + @media (max-width: 768px) { + .media-player .video-js .vjs-control-bar { + height: 36px; + padding: 0 8px; + gap: 6px; + } + .media-player .video-js .vjs-time-control { + font-size: 11px; + min-width: 34px; + } + .media-player .video-js .vjs-progress-control { + margin: 0 8px 0 4px; + } + .media-player .video-js .vjs-button { + width: 24px; + height: 22px; + } + .media-player .player-overlay-button { + height: 40px; + width: 40px; + } + .media-player .player-overlay-button--primary { + height: 48px; + width: 48px; + } + .media-player .player-skip-icon { + height: 22px; + width: 22px; + } + .media-player .player-skip-icon__svg { + height: 22px; + width: 22px; + } + .media-player .player-settings-panel { + width: 220px; + right: 12px; + bottom: 46px; + } + } + @media (max-width: 480px) { + .media-player .video-js .vjs-control-bar { + height: 34px; + padding: 0 6px; + } + .media-player .video-js .vjs-volume-panel { + margin-left: 8px; + } + .media-player .video-js .vjs-volume-panel .vjs-volume-control { + width: 24px; + } + .media-player .video-js .vjs-volume-panel:hover .vjs-volume-control, + .media-player .video-js .vjs-volume-panel:focus-within .vjs-volume-control, + .media-player .video-js .vjs-volume-panel.vjs-hover .vjs-volume-control { + width: 56px; + } + .media-player .player-overlay-box { + gap: 12px; + padding: 8px 12px; + } + .media-player .player-settings-panel { + width: 200px; + } + } +`; diff --git a/apps/web/ce/features/media-library/hooks/media-detail-hooks.ts b/apps/web/ce/features/media-library/hooks/media-detail-hooks.ts new file mode 100644 index 00000000000..6ae945ab7e1 --- /dev/null +++ b/apps/web/ce/features/media-library/hooks/media-detail-hooks.ts @@ -0,0 +1,498 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { API_BASE_URL } from "@plane/constants"; +import { resolveAttachmentDownloadUrl } from "@/components/issues/issue-detail-widgets/media-library-utils"; +import { addInlineDisposition, getVideoFormatFromSrc } from "../utils/media-detail-utils"; + +type TUseResolvedMediaSourcesArgs = { + item: any; + meta: Record<string, unknown>; + documentFormat: string; + normalizedAction: string; +}; + +export const useResolvedMediaSources = ({ + item, + meta, + documentFormat, + normalizedAction, +}: TUseResolvedMediaSourcesArgs) => { + const rawVideoSrc = item?.videoSrc ?? item?.fileSrc ?? ""; + const annotationVideoSource = + typeof meta?.annotationVideoSource === "string" ? meta.annotationVideoSource.trim() : ""; + const shouldUseAnnotationVideoSource = Boolean( + annotationVideoSource && (meta?.hls_direct === true || meta?.hlsDirect === true) + ); + const videoSrc = shouldUseAnnotationVideoSource ? annotationVideoSource : rawVideoSrc; + const rawImageSrc = item?.mediaType === "image" ? item.imageSrc || item.thumbnail : ""; + const [resolvedVideoSrc, setResolvedVideoSrc] = useState<string>(""); + const [resolvedDocumentSrc, setResolvedDocumentSrc] = useState<string>(""); + const [resolvedImageSrc, setResolvedImageSrc] = useState<string>(""); + + const isVideoAssetApiUrl = useMemo( + () => + Boolean(API_BASE_URL) && + typeof videoSrc === "string" && + videoSrc.startsWith(API_BASE_URL) && + videoSrc.includes("/api/assets/v2/"), + [videoSrc] + ); + const isImageAssetApiUrl = useMemo( + () => + Boolean(API_BASE_URL) && + typeof rawImageSrc === "string" && + rawImageSrc.startsWith(API_BASE_URL) && + rawImageSrc.includes("/api/assets/v2/"), + [rawImageSrc] + ); + const isDocumentAssetApiUrl = useMemo( + () => + Boolean(API_BASE_URL) && + typeof item?.fileSrc === "string" && + item.fileSrc.startsWith(API_BASE_URL) && + item.fileSrc.includes("/api/assets/v2/"), + [item?.fileSrc] + ); + + const detectedVideoFormat = documentFormat || getVideoFormatFromSrc(videoSrc); + const isVideoAction = new Set(["play", "play_hls", "play_streaming", "open_mp4"]).has(normalizedAction); + const isVideoFormat = new Set(["mp4", "m4v", "m3u8", "mov", "webm", "avi", "mkv", "mpeg", "mpg", "stream"]).has( + detectedVideoFormat + ); + const isVideo = item?.mediaType === "video" || item?.linkedMediaType === "video" || isVideoAction || isVideoFormat; + const isHls = + isVideo && + (detectedVideoFormat === "m3u8" || + detectedVideoFormat === "stream" || + videoSrc.toLowerCase().includes(".m3u8") || + normalizedAction === "play_streaming" || + Boolean(meta?.hls)); + const resolvedVideoFormat = isHls ? "m3u8" : detectedVideoFormat; + + const hlsProxyOverride = useMemo(() => { + const rawMeta = meta as Record<string, unknown> | undefined; + if (!rawMeta) return null; + const direct = rawMeta.hls_direct ?? rawMeta.hlsDirect; + if (typeof direct === "boolean") return direct ? false : true; + const proxy = rawMeta.hls_proxy ?? rawMeta.hlsProxy ?? rawMeta.use_hls_proxy ?? rawMeta.useHlsProxy; + if (typeof proxy === "boolean") return proxy; + return null; + }, [meta]); + + const isCrossOriginHls = useMemo(() => { + if (!isHls || !videoSrc) return false; + if (videoSrc.startsWith("/")) return false; + if (!/^https?:\/\//i.test(videoSrc)) return false; + try { + const base = typeof window !== "undefined" ? window.location.origin : "http://localhost"; + const url = new URL(videoSrc, base); + return url.origin !== base; + } catch { + return true; + } + }, [isHls, videoSrc]); + + const shouldProxyHls = useMemo(() => { + if (!isHls) return false; + if (hlsProxyOverride === true) return true; + if (hlsProxyOverride === false) return false; + return isCrossOriginHls; + }, [hlsProxyOverride, isCrossOriginHls, isHls]); + + const proxiedVideoSrc = useMemo(() => { + if (!videoSrc) return videoSrc; + if (!isHls || !shouldProxyHls) return videoSrc; + try { + const base = typeof window !== "undefined" ? window.location.origin : "http://localhost"; + const url = new URL(videoSrc, base); + if (url.origin === base) return videoSrc; + return `/api/hls?url=${encodeURIComponent(url.toString())}`; + } catch { + return videoSrc; + } + }, [isHls, shouldProxyHls, videoSrc]); + + const effectiveVideoSrc = isVideoAssetApiUrl ? resolvedVideoSrc : resolvedVideoSrc || proxiedVideoSrc || videoSrc; + const effectiveImageSrc = isImageAssetApiUrl ? resolvedImageSrc : resolvedImageSrc || rawImageSrc; + const effectiveDocumentSrc = isDocumentAssetApiUrl ? resolvedDocumentSrc : resolvedDocumentSrc || item?.fileSrc || ""; + + const credentialOrigins = useMemo(() => { + const origins = new Set<string>(); + if (typeof window !== "undefined") { + origins.add(window.location.origin); + } + if (API_BASE_URL) { + try { + origins.add(new URL(API_BASE_URL).origin); + } catch { + // ignore invalid API base URL + } + } + return origins; + }, []); + + const shouldUseCredentials = useCallback( + (src: string) => { + if (!src) return true; + if (src.startsWith("/")) return true; + if (!/^https?:\/\//i.test(src)) return true; + try { + const url = new URL(src); + return credentialOrigins.has(url.origin); + } catch { + return true; + } + }, + [credentialOrigins] + ); + + const useCredentials = useMemo( + () => shouldUseCredentials(effectiveVideoSrc), + [effectiveVideoSrc, shouldUseCredentials] + ); + const crossOrigin: "use-credentials" | "anonymous" | "" | undefined = useCredentials + ? "use-credentials" + : "anonymous"; + + const useDocumentCredentials = useMemo( + () => shouldUseCredentials(effectiveDocumentSrc), + [effectiveDocumentSrc, shouldUseCredentials] + ); + useEffect(() => { + let isMounted = true; + if (!isVideo || !videoSrc) { + setResolvedVideoSrc(""); + return () => { + isMounted = false; + }; + } + + if (!isVideoAssetApiUrl) { + // Keep empty so HLS streams can still flow through `proxiedVideoSrc` when needed. + setResolvedVideoSrc(""); + return () => { + isMounted = false; + }; + } + + setResolvedVideoSrc(""); + const resolveUrl = async () => { + try { + const resolved = await resolveAttachmentDownloadUrl(addInlineDisposition(videoSrc)); + if (isMounted) setResolvedVideoSrc(resolved || videoSrc); + } catch { + if (isMounted) setResolvedVideoSrc(videoSrc); + } + }; + + void resolveUrl(); + + return () => { + isMounted = false; + }; + }, [isVideo, isVideoAssetApiUrl, videoSrc]); + + useEffect(() => { + let isMounted = true; + if (!rawImageSrc || item?.mediaType !== "image") { + setResolvedImageSrc(""); + return () => { + isMounted = false; + }; + } + + if (!isImageAssetApiUrl) { + setResolvedImageSrc(rawImageSrc); + return () => { + isMounted = false; + }; + } + + setResolvedImageSrc(""); + const resolveUrl = async () => { + try { + const resolved = await resolveAttachmentDownloadUrl(addInlineDisposition(rawImageSrc)); + if (isMounted) setResolvedImageSrc(resolved || rawImageSrc); + } catch { + if (isMounted) setResolvedImageSrc(rawImageSrc); + } + }; + + void resolveUrl(); + + return () => { + isMounted = false; + }; + }, [isImageAssetApiUrl, item?.mediaType, rawImageSrc]); + + useEffect(() => { + let isMounted = true; + const fileSrc = item?.fileSrc; + if (!item || item.mediaType !== "document" || !fileSrc) { + setResolvedDocumentSrc(""); + return () => { + isMounted = false; + }; + } + + if (!isDocumentAssetApiUrl) { + setResolvedDocumentSrc(fileSrc); + return () => { + isMounted = false; + }; + } + + setResolvedDocumentSrc(""); + const resolveUrl = async () => { + try { + const resolved = await resolveAttachmentDownloadUrl(addInlineDisposition(fileSrc)); + if (isMounted) setResolvedDocumentSrc(resolved || fileSrc); + } catch { + if (isMounted) setResolvedDocumentSrc(fileSrc); + } + }; + + void resolveUrl(); + + return () => { + isMounted = false; + }; + }, [isDocumentAssetApiUrl, item?.fileSrc, item?.mediaType]); + + return { + videoSrc, + rawImageSrc, + resolvedVideoFormat, + isVideoAction, + isVideoFormat, + isVideo, + isHls, + proxiedVideoSrc, + effectiveVideoSrc, + effectiveImageSrc, + effectiveDocumentSrc, + useCredentials, + crossOrigin, + useDocumentCredentials, + }; +}; + +type TUseDocumentPreviewArgs = { + item: any; + documentFormat: string; + effectiveDocumentSrc: string; + isTextDocument: boolean; + isBinaryDocument: boolean; + isUnsupportedDocument: boolean; + isDocx: boolean; + isSpreadsheet: boolean; + isPptx: boolean; + useDocumentCredentials: boolean; +}; + +export const useDocumentPreview = ({ + item, + documentFormat, + effectiveDocumentSrc, + isTextDocument, + isBinaryDocument, + isUnsupportedDocument, + isDocx, + isSpreadsheet, + isPptx, + useDocumentCredentials, +}: TUseDocumentPreviewArgs) => { + const [textPreview, setTextPreview] = useState<string | null>(null); + const [textPreviewError, setTextPreviewError] = useState<string | null>(null); + const [isTextPreviewLoading, setIsTextPreviewLoading] = useState(false); + const [documentPreviewUrl, setDocumentPreviewUrl] = useState<string | null>(null); + const [documentPreviewHtml, setDocumentPreviewHtml] = useState<string | null>(null); + const [documentPreviewError, setDocumentPreviewError] = useState<string | null>(null); + const [isDocumentPreviewLoading, setIsDocumentPreviewLoading] = useState(false); + const csvPreviewBytes = 512 * 1024; + const csvPreviewRows = 500; + + useEffect(() => { + let isMounted = true; + const fileSrc = effectiveDocumentSrc; + if (!item || item.mediaType !== "document" || !fileSrc || !isTextDocument || isUnsupportedDocument) { + setTextPreview(null); + setTextPreviewError(null); + setIsTextPreviewLoading(false); + return () => { + isMounted = false; + }; + } + + const loadTextPreview = async () => { + try { + setIsTextPreviewLoading(true); + const response = await fetch(fileSrc, { credentials: useDocumentCredentials ? "include" : "omit" }); + if (!response.ok) { + throw new Error(`Failed to load document preview (status ${response.status}).`); + } + const rawText = await response.text(); + let formattedText = rawText; + if (documentFormat === "json") { + try { + formattedText = JSON.stringify(JSON.parse(rawText), null, 2); + } catch { + formattedText = rawText; + } + } + if (isMounted) setTextPreview(formattedText); + } catch (error) { + if (isMounted) { + setTextPreviewError(error instanceof Error ? error.message : "Unable to load preview."); + setTextPreview(null); + } + } finally { + if (isMounted) setIsTextPreviewLoading(false); + } + }; + + void loadTextPreview(); + + return () => { + isMounted = false; + }; + }, [ + documentFormat, + effectiveDocumentSrc, + isTextDocument, + item?.mediaType, + isUnsupportedDocument, + useDocumentCredentials, + ]); + + useEffect(() => { + let isMounted = true; + let objectUrl: string | null = null; + const fileSrc = effectiveDocumentSrc; + + if (!item || !fileSrc || !isBinaryDocument || isUnsupportedDocument) { + setDocumentPreviewUrl(null); + setDocumentPreviewHtml(null); + setDocumentPreviewError(null); + setIsDocumentPreviewLoading(false); + return () => { + isMounted = false; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + } + + const loadBinaryPreview = async () => { + try { + setIsDocumentPreviewLoading(true); + setDocumentPreviewError(null); + setDocumentPreviewHtml(null); + if (isPptx) { + throw new Error("Preview is not available for PowerPoint files."); + } + if (isDocx) { + const response = await fetch(fileSrc, { credentials: useDocumentCredentials ? "include" : "omit" }); + if (!response.ok) { + throw new Error(`Failed to load document preview (status ${response.status}).`); + } + const blob = await response.blob(); + const mammothModule = await import("mammoth"); + const convertToHtml = mammothModule.convertToHtml ?? mammothModule.default?.convertToHtml; + if (!convertToHtml) throw new Error("Document preview is unavailable."); + const arrayBuffer = await blob.arrayBuffer(); + const result = await convertToHtml({ arrayBuffer }); + if (isMounted) setDocumentPreviewHtml(result.value); + return; + } + if (isSpreadsheet) { + const xlsxModule = await import("xlsx"); + const XLSX = "default" in xlsxModule ? xlsxModule.default : xlsxModule; + let workbook; + if (documentFormat === "csv") { + const headers = { Range: `bytes=0-${csvPreviewBytes - 1}` }; + let response = await fetch(fileSrc, { + credentials: useDocumentCredentials ? "include" : "omit", + headers, + }); + if (!response.ok && response.status !== 206) { + response = await fetch(fileSrc, { credentials: useDocumentCredentials ? "include" : "omit" }); + } + if (!response.ok) { + throw new Error(`Failed to load document preview (status ${response.status}).`); + } + const csvText = await response.text(); + const csvBuffer = new TextEncoder().encode(csvText).buffer; + workbook = XLSX.read(csvBuffer, { type: "array", sheetRows: csvPreviewRows }); + } else { + const response = await fetch(fileSrc, { credentials: useDocumentCredentials ? "include" : "omit" }); + if (!response.ok) { + throw new Error(`Failed to load document preview (status ${response.status}).`); + } + const blob = await response.blob(); + const arrayBuffer = await blob.arrayBuffer(); + workbook = XLSX.read(arrayBuffer, { type: "array" }); + } + const sheetName = workbook.SheetNames[0]; + const sheet = sheetName ? workbook.Sheets[sheetName] : undefined; + if (!sheet) throw new Error("Spreadsheet preview is unavailable."); + const html = XLSX.utils.sheet_to_html(sheet); + if (isMounted) setDocumentPreviewHtml(html); + return; + } + const response = await fetch(fileSrc, { credentials: useDocumentCredentials ? "include" : "omit" }); + if (!response.ok) { + throw new Error(`Failed to load document preview (status ${response.status}).`); + } + const blob = await response.blob(); + objectUrl = URL.createObjectURL(blob); + if (isMounted) setDocumentPreviewUrl(objectUrl); + } catch (error) { + if (isMounted) { + setDocumentPreviewError(error instanceof Error ? error.message : "Unable to load preview."); + setDocumentPreviewUrl(null); + setDocumentPreviewHtml(null); + } + } finally { + if (isMounted) setIsDocumentPreviewLoading(false); + } + }; + + void loadBinaryPreview(); + + return () => { + isMounted = false; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [ + effectiveDocumentSrc, + isBinaryDocument, + isDocx, + isPptx, + isUnsupportedDocument, + isSpreadsheet, + item?.mediaType, + useDocumentCredentials, + documentFormat, + ]); + + useEffect(() => { + if (!isUnsupportedDocument) return; + setTextPreview(null); + setTextPreviewError(null); + setIsTextPreviewLoading(false); + setDocumentPreviewUrl(null); + setDocumentPreviewHtml(null); + setIsDocumentPreviewLoading(false); + setDocumentPreviewError("Only PDF, DOCX, XLSX, CSV, and text files are supported."); + }, [isUnsupportedDocument]); + + return { + textPreview, + textPreviewError, + isTextPreviewLoading, + documentPreviewUrl, + documentPreviewHtml, + documentPreviewError, + isDocumentPreviewLoading, + }; +}; diff --git a/apps/web/ce/features/media-library/hooks/use-media-library-item.ts b/apps/web/ce/features/media-library/hooks/use-media-library-item.ts new file mode 100644 index 00000000000..259a026c6e2 --- /dev/null +++ b/apps/web/ce/features/media-library/hooks/use-media-library-item.ts @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { MediaLibraryService } from "@/services/media-library.service"; +import type { TMediaItem } from "../types/media-library.types"; +import { mapArtifactsToMediaItems } from "../utils/media-items"; + +export const useMediaLibraryItem = ( + workspaceSlug?: string, + projectId?: string, + mediaId?: string, + refreshKey?: number +) => { + const [item, setItem] = useState<TMediaItem | null>(null); + const [isLoading, setIsLoading] = useState(false); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + const normalizedId = useMemo(() => { + if (!mediaId) return ""; + try { + return decodeURIComponent(mediaId); + } catch { + return mediaId; + } + }, [mediaId]); + + useEffect(() => { + if (!workspaceSlug || !projectId || !normalizedId) return; + let isMounted = true; + setIsLoading(true); + setItem(null); + + const load = async () => { + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + const metadataMap = + manifest && typeof manifest === "object" && manifest.metadata && typeof manifest.metadata === "object" + ? (manifest.metadata as Record<string, Record<string, unknown>>) + : undefined; + if (!packageId) { + if (isMounted) setItem(null); + return; + } + const artifacts = await mediaLibraryService.getArtifactDetail( + workspaceSlug, + projectId, + packageId, + normalizedId + ); + const mappedItems = mapArtifactsToMediaItems(Array.isArray(artifacts) ? artifacts : [], { + workspaceSlug, + projectId, + packageId, + metadata: metadataMap, + }); + const resolved = mappedItems.find((entry) => entry.id === normalizedId) ?? null; + if (isMounted) setItem(resolved); + } catch { + if (isMounted) setItem(null); + } finally { + if (isMounted) setIsLoading(false); + } + }; + + void load(); + + return () => { + isMounted = false; + }; + }, [mediaLibraryService, normalizedId, projectId, refreshKey, workspaceSlug]); + + return { item, isLoading }; +}; diff --git a/apps/web/ce/features/media-library/hooks/use-media-library-items.ts b/apps/web/ce/features/media-library/hooks/use-media-library-items.ts new file mode 100644 index 00000000000..9bacdae413c --- /dev/null +++ b/apps/web/ce/features/media-library/hooks/use-media-library-items.ts @@ -0,0 +1,306 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import type { TMediaArtifactsPaginatedResponse, TMediaTranscodeJobResponse } from "@/services/media-library.service"; +import { MediaLibraryService } from "@/services/media-library.service"; +import type { TMediaTranscodeJobTrackerInput } from "../store/media-library-context"; +import type { TMediaItem } from "../types/media-library.types"; +import { mapArtifactsToMediaItems } from "../utils/media-items"; + +type TMediaLibraryFilterCondition = { + property: string; + operator: string; + value: unknown; +}; + +type TMediaLibraryQueryOptions = { + query?: string; + filters?: TMediaLibraryFilterCondition[]; + formats?: string; + section?: string; + page?: number; + perPage?: number; + cursor?: string; + onActiveTranscodeJob?: (job: TMediaTranscodeJobTrackerInput) => void; +}; + +type TMediaLibraryPagination = { + totalResults: number; + totalPages: number; + nextCursor?: string; + prevCursor?: string; + nextPageResults?: boolean; + prevPageResults?: boolean; +}; + +const isRequestCanceled = (error: unknown) => { + if (!error || typeof error !== "object") return false; + const maybeCanceledError = error as { code?: string; name?: string }; + return ( + maybeCanceledError.code === "ERR_CANCELED" || + maybeCanceledError.name === "CanceledError" || + maybeCanceledError.name === "AbortError" + ); +}; + +const ACTIVE_TRANSCODE_STATUSES = new Set([ + "UPLOAD_COMPLETE", + "QUEUED", + "CLAIMED", + "PROBING", + "PROCESSING", + "TRANSCODING", + "PACKAGING", + "VALIDATING", + "RETRY_PENDING", + "CANCEL_REQUESTED", +]); +const FAILED_TRANSCODE_STATUSES = new Set(["FAILED", "QUEUE_FAILED", "CANCELLED"]); + +const getTranscodeLabel = (status: string) => { + switch (status) { + case "UPLOAD_COMPLETE": + case "QUEUED": + return "Queued"; + case "CLAIMED": + case "PROBING": + case "PROCESSING": + case "TRANSCODING": + case "PACKAGING": + case "VALIDATING": + case "RETRY_PENDING": + return "Uploading"; + case "CANCEL_REQUESTED": + return "Cancelling"; + case "COMPLETED": + case "READY": + case "UPLOADED": + return "Uploaded"; + case "FAILED": + case "QUEUE_FAILED": + return "Failed"; + case "CANCELLED": + return "Cancelled"; + default: + return status.replace(/_/g, " ").toLowerCase(); + } +}; + +const mergeTranscodeJob = (item: TMediaItem, job: TMediaTranscodeJobResponse): TMediaItem => { + const status = job.status; + const progress = Math.min(100, Math.max(0, Math.round(job.progress ?? item.transcodeProgress ?? 0))); + const isComplete = status === "COMPLETED" || status === "READY" || status === "UPLOADED"; + const isFailed = FAILED_TRANSCODE_STATUSES.has(status); + const isActive = ACTIVE_TRANSCODE_STATUSES.has(status); + return { + ...item, + transcodeStatus: status, + transcodeProgress: isComplete ? 100 : progress, + transcodeLabel: getTranscodeLabel(status), + transcodeError: job.error?.message || job.error?.code || item.transcodeError, + isTranscodeActive: isActive, + isTranscodeFailed: isFailed, + isTranscodeComplete: isComplete, + }; +}; + +const shouldIncludeForFormats = (item: TMediaItem, desiredFormats: string[], thumbnailTargets: Set<string>) => { + if (desiredFormats.length === 0) return true; + if (desiredFormats.includes(item.format)) return true; + if ( + desiredFormats.includes("thumbnail") && + (item.isTranscodeActive || + item.isTranscodeFailed || + (item.mediaType === "video" && item.isTranscodeComplete && !thumbnailTargets.has(item.id))) + ) { + return true; + } + return false; +}; + +export const useMediaLibraryItems = ( + workspaceSlug?: string, + projectId?: string, + refreshKey?: number, + options?: TMediaLibraryQueryOptions +) => { + const [items, setItems] = useState<TMediaItem[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [pagination, setPagination] = useState<TMediaLibraryPagination | null>(null); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + const filtersParam = useMemo(() => { + if (!options?.filters?.length) return ""; + try { + return JSON.stringify(options.filters); + } catch { + return ""; + } + }, [options?.filters]); + const queryParam = options?.query?.trim() ?? ""; + const formatsParam = options?.formats?.trim() ?? ""; + const sectionParam = options?.section?.trim() ?? ""; + const perPageParam = options?.perPage; + const pageParam = options?.page; + const onActiveTranscodeJob = options?.onActiveTranscodeJob; + const cursorParam = useMemo(() => { + if (options?.cursor) return options.cursor; + if (!perPageParam) return ""; + const pageIndex = Number.isFinite(pageParam) && pageParam && pageParam > 0 ? pageParam - 1 : 0; + return `${perPageParam}:${pageIndex}:0`; + }, [options?.cursor, pageParam, perPageParam]); + const desiredFormats = useMemo( + () => + formatsParam + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean), + [formatsParam] + ); + const shouldPaginate = Boolean(perPageParam || cursorParam); + + useEffect(() => { + if (!workspaceSlug || !projectId) return; + let isMounted = true; + const abortController = new AbortController(); + setIsLoading(true); + setPagination(null); + + const load = async () => { + try { + const requestConfig = { signal: abortController.signal }; + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId, requestConfig); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + const metadataMap = + manifest && typeof manifest === "object" && manifest.metadata && typeof manifest.metadata === "object" + ? (manifest.metadata as Record<string, Record<string, unknown>>) + : undefined; + if (!packageId) { + if (isMounted) setItems([]); + return; + } + const params: Record<string, string> = {}; + if (queryParam) params.q = queryParam; + if (filtersParam) params.filters = filtersParam; + if (formatsParam && (!desiredFormats.includes("thumbnail") || shouldPaginate)) { + params.formats = formatsParam; + } + if (sectionParam) params.section = sectionParam; + if (cursorParam) params.cursor = cursorParam; + if (perPageParam) params.per_page = String(perPageParam); + const artifactsResponse = await mediaLibraryService.getArtifacts( + workspaceSlug, + projectId, + packageId, + params, + requestConfig + ); + const paginatedResponse = + artifactsResponse && !Array.isArray(artifactsResponse) && Array.isArray(artifactsResponse.results) + ? (artifactsResponse as TMediaArtifactsPaginatedResponse) + : null; + const artifacts = paginatedResponse + ? paginatedResponse.results + : Array.isArray(artifactsResponse) + ? artifactsResponse + : []; + if (isMounted) { + const mappedItems = mapArtifactsToMediaItems(artifacts, { + workspaceSlug, + projectId, + packageId, + metadata: metadataMap, + }); + const activeTranscodeItems = mappedItems.filter( + (item) => item.packageId && item.transcodeJobId && item.isTranscodeActive + ); + const jobResults = await Promise.all( + activeTranscodeItems.map(async (item) => { + try { + const job = await mediaLibraryService.getArtifactTranscodeJob( + workspaceSlug, + projectId, + item.packageId ?? packageId, + item.id, + item.transcodeJobId ?? "" + ); + return [item.id, job] as const; + } catch { + return [item.id, null] as const; + } + }) + ); + const jobByItemId = new Map<string, TMediaTranscodeJobResponse>(); + for (const [itemId, job] of jobResults) { + if (job) jobByItemId.set(itemId, job); + } + if (!isMounted || abortController.signal.aborted) return; + const hydratedItems = mappedItems.map((item) => { + const job = jobByItemId.get(item.id); + return job ? mergeTranscodeJob(item, job) : item; + }); + hydratedItems.forEach((item) => { + if (!item.packageId || !item.transcodeJobId || !item.isTranscodeActive) return; + onActiveTranscodeJob?.({ + workspaceSlug, + projectId, + packageId: item.packageId, + artifactId: item.id, + jobId: item.transcodeJobId, + }); + }); + const thumbnailTargets = new Set( + hydratedItems + .filter((item) => item.format === "thumbnail" && typeof item.link === "string" && item.link.trim()) + .map((item) => item.link?.trim() ?? "") + ); + const filteredItems = desiredFormats.length + ? hydratedItems.filter((item) => shouldIncludeForFormats(item, desiredFormats, thumbnailTargets)) + : hydratedItems; + setItems(filteredItems); + if (paginatedResponse) { + setPagination({ + totalResults: paginatedResponse.total_results ?? paginatedResponse.total_count ?? filteredItems.length, + totalPages: paginatedResponse.total_pages ?? 1, + nextCursor: paginatedResponse.next_cursor, + prevCursor: paginatedResponse.prev_cursor, + nextPageResults: paginatedResponse.next_page_results, + prevPageResults: paginatedResponse.prev_page_results, + }); + } else { + setPagination(null); + } + } + } catch (error) { + if (abortController.signal.aborted || isRequestCanceled(error)) return; + if (isMounted) setItems([]); + if (isMounted) setPagination(null); + } finally { + if (isMounted && !abortController.signal.aborted) setIsLoading(false); + } + }; + + void load(); + + return () => { + isMounted = false; + abortController.abort(); + }; + }, [ + cursorParam, + desiredFormats, + filtersParam, + formatsParam, + mediaLibraryService, + onActiveTranscodeJob, + perPageParam, + projectId, + queryParam, + refreshKey, + sectionParam, + shouldPaginate, + workspaceSlug, + ]); + + return { items, isLoading, pagination }; +}; diff --git a/apps/web/ce/features/media-library/hooks/use-video-duration.ts b/apps/web/ce/features/media-library/hooks/use-video-duration.ts new file mode 100644 index 00000000000..525f4070131 --- /dev/null +++ b/apps/web/ce/features/media-library/hooks/use-video-duration.ts @@ -0,0 +1,5 @@ +"use client"; + +import type { TMediaItem } from "../types/media-library.types"; + +export const useVideoDuration = (item: TMediaItem) => (item.duration ? item.duration : "-"); diff --git a/apps/web/ce/features/media-library/index.ts b/apps/web/ce/features/media-library/index.ts new file mode 100644 index 00000000000..63ae44ee69b --- /dev/null +++ b/apps/web/ce/features/media-library/index.ts @@ -0,0 +1,6 @@ +export { default as MediaDetailPage } from "./components/media-detail-page"; +export { MediaLibraryListRouteLayout } from "./components/media-library-list-route-layout"; +export { default as MediaLibraryListPage } from "./components/media-library-list-page"; +export { default as MediaLibrarySectionPage } from "./components/media-library-section-page"; +export { MediaLibraryProvider, useMediaLibrary } from "./store/media-library-context"; +export type * from "./types/media-library.types"; diff --git a/apps/web/ce/features/media-library/store/media-library-context.tsx b/apps/web/ce/features/media-library/store/media-library-context.tsx new file mode 100644 index 00000000000..253d1bae5f8 --- /dev/null +++ b/apps/web/ce/features/media-library/store/media-library-context.tsx @@ -0,0 +1,723 @@ +"use client"; + +import type { ReactNode } from "react"; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { usePathname } from "next/navigation"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { FilterInstance } from "@plane/shared-state"; +import type { TFilterConfig, TFilterValue } from "@plane/types"; + +import type { TMediaArtifactPayload, TMediaTranscodeJobStatus } from "@/services/media-library.service"; +import { MediaLibraryService } from "@/services/media-library.service"; +import { getDocumentThumbnailPath } from "../utils/media-items"; +import type { TMediaLibraryExternalFilter, TMediaLibraryFilterProperty } from "../utils/media-library-filters"; +import { mediaLibraryFiltersAdapter } from "../utils/media-library-filters"; +import { + buildArtifactName, + buildMediaLibraryUploadJobs, + buildUploadAttemptRequestId, + escapeHtml, + getErrorMessage, + getFileExtension, + getTitleFromFile, + getUploadErrorMessage, + isActiveUploadStatus, + isCompletedUploadStatus, + isDocumentUploadFormat, + isImageUploadFormat, + isMp4Upload, + isVideoUploadFormat, + resolveArtifactFormat, + type TMediaLibraryUploadBatchInput, + type TMediaLibraryUploadJob, +} from "../utils/media-library-upload-jobs"; +import { + calculateUploadProgressMetrics, + logMediaUploadLifecycle, + shouldLogUploadProgress, +} from "../utils/upload-progress"; + +export type TMediaTranscodeJobTrackerInput = { + workspaceSlug: string; + projectId: string; + packageId: string; + artifactId: string; + jobId: string; + uploadJobId?: string; +}; + +type TMediaLibraryContext = { + isUploadOpen: boolean; + pendingUploadFiles: File[]; + uploadJobs: TMediaLibraryUploadJob[]; + openUpload: () => void; + closeUpload: () => void; + setPendingUploadFiles: (files: File[]) => void; + libraryVersion: number; + refreshLibrary: () => void; + trackTranscodeJob: (job: TMediaTranscodeJobTrackerInput) => void; + enqueueUploadBatch: (input: TMediaLibraryUploadBatchInput) => void; + cancelUploadJob: (jobId: string) => void; + retryUploadJob: (jobId: string) => void; + dismissUploadJob: (jobId: string) => void; + clearCompletedUploadJobs: () => void; + mediaFilters: FilterInstance<TMediaLibraryFilterProperty, TMediaLibraryExternalFilter>; + setMediaFilterConfigs: (configs: TFilterConfig<TMediaLibraryFilterProperty, TFilterValue>[]) => void; +}; + +const MediaLibraryContext = createContext<TMediaLibraryContext | null>(null); +const SECTION_PATH_SEGMENT = "/media-library/section/"; +const MEDIA_LIBRARY_PATH_SEGMENT = "/media-library"; +const TRANSCODE_JOB_POLL_INTERVAL_MS = 5000; +const TERMINAL_TRANSCODE_STATUSES = new Set<TMediaTranscodeJobStatus>([ + "COMPLETED", + "READY", + "UPLOADED", + "FAILED", + "QUEUE_FAILED", + "CANCELLED", +]); + +const normalizeTrackedTranscodeJob = (job: TMediaTranscodeJobTrackerInput): TMediaTranscodeJobTrackerInput | null => { + const workspaceSlug = job.workspaceSlug?.trim(); + const projectId = job.projectId?.trim(); + const packageId = job.packageId?.trim(); + const artifactId = job.artifactId?.trim(); + const jobId = job.jobId?.trim(); + + if (!workspaceSlug || !projectId || !packageId || !artifactId || !jobId) return null; + + return { + workspaceSlug, + projectId, + packageId, + artifactId, + jobId, + uploadJobId: job.uploadJobId, + }; +}; + +const getTrackedTranscodeJobKey = (job: TMediaTranscodeJobTrackerInput) => + [job.workspaceSlug, job.projectId, job.packageId, job.artifactId, job.jobId].join(":"); + +export const MediaLibraryProvider = ({ children }: { children: ReactNode }) => { + const pathname = usePathname(); + const [isUploadOpen, setIsUploadOpen] = useState(false); + const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]); + const [libraryVersion, setLibraryVersion] = useState(0); + const [uploadJobs, setUploadJobs] = useState<TMediaLibraryUploadJob[]>([]); + const [trackedTranscodeJobs, setTrackedTranscodeJobs] = useState<Record<string, TMediaTranscodeJobTrackerInput>>({}); + const uploadJobsRef = useRef(uploadJobs); + const trackedTranscodeJobsRef = useRef(trackedTranscodeJobs); + const isMediaLibraryPathRef = useRef(false); + const filterInstancesRef = useRef( + new Map<string, FilterInstance<TMediaLibraryFilterProperty, TMediaLibraryExternalFilter>>() + ); + const filterConfigsRef = useRef(new Map<string, TFilterConfig<TMediaLibraryFilterProperty, TFilterValue>[]>()); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + + const updateUploadJob = useCallback((jobId: string, updates: Partial<TMediaLibraryUploadJob>) => { + setUploadJobs((prev) => + prev.map((job) => (job.id === jobId ? { ...job, ...updates, updatedAtMs: Date.now() } : job)) + ); + }, []); + + const openUpload = useCallback(() => setIsUploadOpen(true), []); + const closeUpload = useCallback(() => { + setPendingUploadFiles([]); + setIsUploadOpen(false); + }, []); + const refreshLibrary = useCallback(() => setLibraryVersion((prev) => prev + 1), []); + const trackTranscodeJob = useCallback((job: TMediaTranscodeJobTrackerInput) => { + const normalizedJob = normalizeTrackedTranscodeJob(job); + if (!normalizedJob) return; + + const jobKey = getTrackedTranscodeJobKey(normalizedJob); + setTrackedTranscodeJobs((prev) => { + if (prev[jobKey]) return prev; + return { ...prev, [jobKey]: normalizedJob }; + }); + }, []); + + const uploadSingleJob = useCallback( + async (job: TMediaLibraryUploadJob, packageId: string, index: number, uploadedAt: number) => { + const latestJob = uploadJobsRef.current.find((entry) => entry.id === job.id); + if (latestJob?.status === "cancelled") return false; + + const file = job.file; + const format = resolveArtifactFormat(file.name); + if (!format) { + logMediaUploadLifecycle({ + level: "warn", + event: "upload_rejected", + uploadId: job.uploadId, + fileName: file.name, + fileSize: file.size, + fileType: file.type || getFileExtension(file.name), + error: "Unsupported file type", + }); + updateUploadJob(job.id, { + status: "failed", + failedPhase: "upload", + error: "Unsupported file type", + }); + return false; + } + + const artifactName = job.artifactName || buildArtifactName(file.name, uploadedAt, index); + const title = getTitleFromFile(file.name) || "Untitled Upload"; + const description = `<p>Uploaded file: ${escapeHtml(title)}</p>`; + const action = isVideoUploadFormat(format) ? "play" : isImageUploadFormat(format) ? "view" : "download"; + const meta = { ...job.meta }; + const requestId = buildUploadAttemptRequestId(job.uploadId, job.retryCount ?? 0); + meta.upload_id = job.uploadId; + meta.request_id = requestId; + meta.upload_client = "plane-web"; + if (isDocumentUploadFormat(format)) { + meta.kind = "document_file"; + meta.file_size = file.size; + meta.file_type = file.type || format; + meta.thumbnail = getDocumentThumbnailPath(format); + } + + let uploadStartedAtMs: number | undefined; + try { + const abortController = new AbortController(); + const startedAtMs = Date.now(); + uploadStartedAtMs = startedAtMs; + let lastLoggedPercent: number | null = null; + let lastLoggedAtMs: number | null = null; + logMediaUploadLifecycle({ + event: "upload_started", + uploadId: job.uploadId, + requestId, + workspaceSlug: job.workspaceSlug, + projectId: job.projectId, + packageId, + artifactName, + fileName: file.name, + fileSize: file.size, + fileType: file.type || format, + }); + updateUploadJob(job.id, { + status: "uploading", + progress: 0, + requestId, + packageId, + artifactName, + uploadedBytes: 0, + totalBytes: file.size, + uploadStartedAtMs: startedAtMs, + uploadCompletedAtMs: undefined, + uploadSpeedBytesPerSecond: undefined, + uploadEtaSeconds: null, + error: undefined, + failedPhase: undefined, + abortController, + }); + + const artifactPayload: TMediaArtifactPayload = { + name: artifactName, + title, + description, + format, + link: null, + action, + meta, + work_item_id: job.workItemId ?? undefined, + }; + const artifact = await mediaLibraryService.uploadArtifact( + job.workspaceSlug, + job.projectId, + packageId, + artifactPayload, + file, + (progressEvent) => { + const total = progressEvent.total ?? 0; + if (!total) return; + const currentJob = uploadJobsRef.current.find((entry) => entry.id === job.id); + if (currentJob?.status === "cancelled") return; + const nowMs = Date.now(); + const metrics = calculateUploadProgressMetrics({ + loadedBytes: progressEvent.loaded, + totalBytes: total, + startedAtMs, + nowMs, + }); + if ( + shouldLogUploadProgress({ + percent: metrics.percent, + lastLoggedPercent, + lastLoggedAtMs, + nowMs, + }) + ) { + logMediaUploadLifecycle({ + event: "upload_progress", + uploadId: job.uploadId, + requestId, + workspaceSlug: job.workspaceSlug, + projectId: job.projectId, + packageId, + artifactName, + percent: metrics.percent, + uploadedBytes: metrics.uploadedBytes, + totalBytes: metrics.totalBytes, + speedBytesPerSecond: metrics.speedBytesPerSecond, + etaSeconds: metrics.etaSeconds, + }); + lastLoggedPercent = metrics.percent; + lastLoggedAtMs = nowMs; + } + updateUploadJob(job.id, { + progress: metrics.percent, + status: "uploading", + uploadedBytes: metrics.uploadedBytes, + totalBytes: metrics.totalBytes, + uploadSpeedBytesPerSecond: metrics.speedBytesPerSecond, + uploadEtaSeconds: metrics.etaSeconds, + }); + }, + { + signal: abortController.signal, + headers: { + "X-Upload-ID": job.uploadId, + "X-Request-ID": requestId, + }, + } + ); + + const uploadCompletedAtMs = Date.now(); + const uploadDurationMs = uploadCompletedAtMs - startedAtMs; + const transcodeJobId = artifact.transcode_job?.job_id; + logMediaUploadLifecycle({ + event: "upload_completed", + uploadId: job.uploadId, + requestId, + workspaceSlug: job.workspaceSlug, + projectId: job.projectId, + packageId, + artifactName, + fileName: file.name, + fileSize: file.size, + durationMs: uploadDurationMs, + transcodeJobId, + }); + updateUploadJob(job.id, { + artifact, + packageId, + abortController: undefined, + progress: 100, + uploadedBytes: file.size, + totalBytes: file.size, + uploadCompletedAtMs, + uploadEtaSeconds: 0, + status: isMp4Upload(file) && transcodeJobId ? "processing" : "completed", + transcodeJobId, + }); + refreshLibrary(); + + if (isMp4Upload(file) && transcodeJobId) { + logMediaUploadLifecycle({ + event: "transcode_tracking_started", + uploadId: job.uploadId, + requestId, + workspaceSlug: job.workspaceSlug, + projectId: job.projectId, + packageId, + artifactName, + transcodeJobId, + }); + trackTranscodeJob({ + workspaceSlug: job.workspaceSlug, + projectId: job.projectId, + packageId, + artifactId: artifact.name, + jobId: transcodeJobId, + uploadJobId: job.id, + }); + } + + if (isMp4Upload(file) && artifact.transcode_job_error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Background transcoding was not queued", + message: getErrorMessage( + artifact.transcode_job_error, + "The MP4 was uploaded, but transcoding was not queued." + ), + }); + } + return true; + } catch (error) { + const wasCancelled = + error && typeof error === "object" && (error as Record<string, unknown>).code === "ERR_CANCELED"; + const errorMessage = wasCancelled ? "Cancelled" : getUploadErrorMessage(error); + logMediaUploadLifecycle({ + level: wasCancelled ? "warn" : "error", + event: wasCancelled ? "upload_cancelled" : "upload_failed", + uploadId: job.uploadId, + requestId: job.requestId, + workspaceSlug: job.workspaceSlug, + projectId: job.projectId, + packageId, + artifactName, + fileName: file.name, + fileSize: file.size, + durationMs: uploadStartedAtMs ? Date.now() - uploadStartedAtMs : undefined, + error: errorMessage, + }); + updateUploadJob(job.id, { + status: wasCancelled ? "cancelled" : "failed", + failedPhase: wasCancelled ? undefined : "upload", + abortController: undefined, + error: errorMessage, + }); + return false; + } + }, + [mediaLibraryService, refreshLibrary, trackTranscodeJob, updateUploadJob] + ); + + const processUploadBatch = useCallback( + async (jobs: TMediaLibraryUploadJob[]) => { + if (jobs.length === 0) return; + const { workspaceSlug, projectId } = jobs[0]; + const uploadedAt = Date.now(); + let packageId: string | null = null; + + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + packageId = typeof manifest?.id === "string" ? manifest.id : null; + } catch { + jobs.forEach((job) => { + const latestJob = uploadJobsRef.current.find((entry) => entry.id === job.id); + if (latestJob?.status === "cancelled") return; + updateUploadJob(job.id, { + status: "failed", + failedPhase: "upload", + error: "Unable to initialize media library", + }); + }); + return; + } + + if (!packageId) { + jobs.forEach((job) => { + const latestJob = uploadJobsRef.current.find((entry) => entry.id === job.id); + if (latestJob?.status === "cancelled") return; + updateUploadJob(job.id, { + status: "failed", + failedPhase: "upload", + error: "Media library package not available", + }); + }); + return; + } + + const uploadableJobs = jobs.filter((job) => { + const latestJob = uploadJobsRef.current.find((entry) => entry.id === job.id); + return latestJob?.status !== "cancelled"; + }); + const results = await Promise.allSettled( + uploadableJobs.map((job, index) => uploadSingleJob(job, packageId, index, uploadedAt)) + ); + const successCount = results.filter( + (result): result is PromiseFulfilledResult<boolean> => result.status === "fulfilled" && result.value + ).length; + + if (successCount > 0) { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Upload started", + message: + successCount === 1 + ? "File uploaded. Background processing will continue automatically." + : `${successCount} files uploaded. Background processing will continue automatically.`, + }); + } + }, + [mediaLibraryService, updateUploadJob, uploadSingleJob] + ); + + const enqueueUploadBatch = useCallback( + (input: TMediaLibraryUploadBatchInput) => { + if (!input.files.length || !input.workspaceSlug || !input.projectId) return; + const jobs = buildMediaLibraryUploadJobs(input); + jobs.forEach((job) => { + logMediaUploadLifecycle({ + event: "upload_queued", + uploadId: job.uploadId, + workspaceSlug: job.workspaceSlug, + projectId: job.projectId, + fileName: job.file.name, + fileSize: job.file.size, + fileType: job.file.type || getFileExtension(job.file.name), + }); + }); + setUploadJobs((prev) => [...prev, ...jobs]); + void processUploadBatch(jobs); + }, + [processUploadBatch] + ); + + const cancelUploadJob = useCallback( + (jobId: string) => { + const job = uploadJobsRef.current.find((entry) => entry.id === jobId); + if (!job) return; + logMediaUploadLifecycle({ + level: "warn", + event: "upload_cancel_requested", + uploadId: job.uploadId, + requestId: job.requestId, + fileName: job.file.name, + fileSize: job.file.size, + percent: job.progress, + uploadedBytes: job.uploadedBytes, + totalBytes: job.totalBytes, + }); + if (job.status === "uploading") { + job.abortController?.abort(); + } + updateUploadJob(jobId, { + status: "cancelled", + abortController: undefined, + error: "Cancelled", + }); + }, + [updateUploadJob] + ); + + const retryUploadJob = useCallback( + (jobId: string) => { + const job = uploadJobsRef.current.find((entry) => entry.id === jobId); + if (!job || isActiveUploadStatus(job.status)) return; + const retryJob: TMediaLibraryUploadJob = { + ...job, + status: "queued", + progress: 0, + requestId: undefined, + artifactName: undefined, + artifact: undefined, + packageId: undefined, + transcodeJobId: undefined, + uploadedBytes: undefined, + totalBytes: job.file.size, + uploadStartedAtMs: undefined, + uploadCompletedAtMs: undefined, + uploadSpeedBytesPerSecond: undefined, + uploadEtaSeconds: undefined, + error: undefined, + failedPhase: undefined, + abortController: undefined, + retryCount: (job.retryCount ?? 0) + 1, + updatedAtMs: Date.now(), + }; + setUploadJobs((prev) => prev.map((entry) => (entry.id === jobId ? retryJob : entry))); + void processUploadBatch([retryJob]); + }, + [processUploadBatch] + ); + + const dismissUploadJob = useCallback((jobId: string) => { + const job = uploadJobsRef.current.find((entry) => entry.id === jobId); + if (job && isActiveUploadStatus(job.status)) return; + setUploadJobs((prev) => prev.filter((entry) => entry.id !== jobId)); + }, []); + + const clearCompletedUploadJobs = useCallback(() => { + setUploadJobs((prev) => + prev.filter( + (job) => !isCompletedUploadStatus(job.status) && job.status !== "cancelled" && job.status !== "failed" + ) + ); + }, []); + + const activeScopeKey = useMemo(() => { + const markerIndex = pathname.indexOf(SECTION_PATH_SEGMENT); + if (markerIndex === -1) return "all"; + const rawSectionName = + pathname + .slice(markerIndex + SECTION_PATH_SEGMENT.length) + .split("/")[0] + ?.trim() ?? ""; + if (!rawSectionName) return "all"; + try { + return `section:${decodeURIComponent(rawSectionName)}`; + } catch { + return `section:${rawSectionName}`; + } + }, [pathname]); + const mediaFilters = useMemo(() => { + const existing = filterInstancesRef.current.get(activeScopeKey); + if (existing) return existing; + const nextInstance = new FilterInstance<TMediaLibraryFilterProperty, TMediaLibraryExternalFilter>({ + adapter: mediaLibraryFiltersAdapter, + }); + filterInstancesRef.current.set(activeScopeKey, nextInstance); + return nextInstance; + }, [activeScopeKey]); + const trackedTranscodeJobSignature = useMemo( + () => Object.keys(trackedTranscodeJobs).sort().join("|"), + [trackedTranscodeJobs] + ); + + useEffect(() => { + uploadJobsRef.current = uploadJobs; + }, [uploadJobs]); + + useEffect(() => { + trackedTranscodeJobsRef.current = trackedTranscodeJobs; + }, [trackedTranscodeJobs]); + + useEffect(() => { + isMediaLibraryPathRef.current = pathname.includes(MEDIA_LIBRARY_PATH_SEGMENT); + }, [pathname]); + + useEffect(() => { + if (!trackedTranscodeJobSignature) return; + + let isDisposed = false; + let isPolling = false; + + const pollJobs = async () => { + if (isPolling) return; + isPolling = true; + + try { + const jobs = Object.entries(trackedTranscodeJobsRef.current); + const terminalJobKeys: string[] = []; + let shouldRefreshLibrary = false; + + await Promise.all( + jobs.map(async ([jobKey, job]) => { + try { + const result = await mediaLibraryService.getArtifactTranscodeJob( + job.workspaceSlug, + job.projectId, + job.packageId, + job.artifactId, + job.jobId + ); + if (isDisposed) return; + + shouldRefreshLibrary = true; + if (TERMINAL_TRANSCODE_STATUSES.has(result.status)) { + terminalJobKeys.push(jobKey); + if (job.uploadJobId) { + if (result.status === "FAILED" || result.status === "QUEUE_FAILED" || result.status === "CANCELLED") { + updateUploadJob(job.uploadJobId, { + status: result.status === "CANCELLED" ? "cancelled" : "failed", + failedPhase: result.status === "CANCELLED" ? undefined : "processing", + error: + result.error?.message ?? result.error?.code ?? `Transcoding ${result.status.toLowerCase()}`, + }); + } else { + updateUploadJob(job.uploadJobId, { + status: "completed", + progress: 100, + error: undefined, + failedPhase: undefined, + }); + } + } + } + } catch { + // Keep tracking. The service can be temporarily unavailable while the worker is still running. + } + }) + ); + + if (isDisposed) return; + + if (terminalJobKeys.length > 0) { + setTrackedTranscodeJobs((prev) => { + let next = prev; + for (const jobKey of terminalJobKeys) { + if (!next[jobKey]) continue; + if (next === prev) next = { ...prev }; + delete next[jobKey]; + } + return next; + }); + } + + if (shouldRefreshLibrary && isMediaLibraryPathRef.current) { + refreshLibrary(); + } + } finally { + isPolling = false; + } + }; + + void pollJobs(); + + const intervalId = window.setInterval(pollJobs, TRANSCODE_JOB_POLL_INTERVAL_MS); + return () => { + isDisposed = true; + window.clearInterval(intervalId); + }; + }, [mediaLibraryService, refreshLibrary, trackedTranscodeJobSignature, updateUploadJob]); + + useEffect(() => { + const configs = filterConfigsRef.current.get(activeScopeKey) ?? []; + mediaFilters.configManager.setAreConfigsReady(true); + mediaFilters.configManager.registerAll(configs); + }, [activeScopeKey, mediaFilters]); + + const setMediaFilterConfigs = useCallback( + (configs: TFilterConfig<TMediaLibraryFilterProperty, TFilterValue>[]) => { + filterConfigsRef.current.set(activeScopeKey, configs); + mediaFilters.configManager.setAreConfigsReady(true); + mediaFilters.configManager.registerAll(configs); + }, + [activeScopeKey, mediaFilters] + ); + + const value = useMemo( + () => ({ + isUploadOpen, + pendingUploadFiles, + uploadJobs, + openUpload, + closeUpload, + setPendingUploadFiles, + libraryVersion, + refreshLibrary, + trackTranscodeJob, + enqueueUploadBatch, + cancelUploadJob, + retryUploadJob, + dismissUploadJob, + clearCompletedUploadJobs, + mediaFilters, + setMediaFilterConfigs, + }), + [ + isUploadOpen, + pendingUploadFiles, + uploadJobs, + openUpload, + closeUpload, + setPendingUploadFiles, + libraryVersion, + refreshLibrary, + trackTranscodeJob, + enqueueUploadBatch, + cancelUploadJob, + retryUploadJob, + dismissUploadJob, + clearCompletedUploadJobs, + mediaFilters, + setMediaFilterConfigs, + ] + ); + + return <MediaLibraryContext.Provider value={value}>{children}</MediaLibraryContext.Provider>; +}; + +export const useMediaLibrary = () => { + const context = useContext(MediaLibraryContext); + if (!context) throw new Error("useMediaLibrary must be used within MediaLibraryProvider"); + return context; +}; diff --git a/apps/web/ce/features/media-library/types/media-library.types.ts b/apps/web/ce/features/media-library/types/media-library.types.ts new file mode 100644 index 00000000000..e802ce94e97 --- /dev/null +++ b/apps/web/ce/features/media-library/types/media-library.types.ts @@ -0,0 +1,42 @@ +export type TMediaItem = { + id: string; + packageId?: string; + title: string; + description?: string; + descriptionHtml?: string; + format: string; + linkedFormat?: string; + action: string; + link?: string | null; + workItemId?: string | null; + author: string; + createdAt: string; + views: number; + duration: string; + primaryTag: string; + secondaryTag: string; + itemsCount: number; + meta: Record<string, unknown>; + mediaType: "video" | "image" | "document"; + linkedMediaType?: "video" | "image" | "document"; + thumbnail: string; + videoSrc?: string; + imageSrc?: string; + fileSrc?: string; + downloadSrc?: string; + docs: string[]; + transcodeStatus?: string; + transcodeJobId?: string; + transcodeAssetId?: string; + transcodeProgress?: number; + transcodeLabel?: string; + transcodeError?: string; + isTranscodeActive?: boolean; + isTranscodeFailed?: boolean; + isTranscodeComplete?: boolean; +}; + +export type TMediaSection = { + title: string; + items: TMediaItem[]; +}; diff --git a/apps/web/ce/features/media-library/utils/__tests__/media-library-upload-jobs.test.mjs b/apps/web/ce/features/media-library/utils/__tests__/media-library-upload-jobs.test.mjs new file mode 100644 index 00000000000..f4d248643b4 --- /dev/null +++ b/apps/web/ce/features/media-library/utils/__tests__/media-library-upload-jobs.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildMediaLibraryUploadJobs, + FALLBACK_MEDIA_LIBRARY_MAX_FILE_SIZE, + formatFileSize, + getUploadStatusLabel, + getVisibleUploadProgress, + isActiveUploadStatus, + readMediaLibraryFileSizeLimit, + resolveArtifactFormat, +} from "../media-library-upload-jobs.ts"; + +const createFile = (name, size, type = "video/mp4") => + new File([new Uint8Array(size)], name, { + type, + lastModified: 1_785_922_733_582, + }); + +test("buildMediaLibraryUploadJobs creates queued background upload jobs", () => { + const file = createFile("clip-01.mp4", 4); + const jobs = buildMediaLibraryUploadJobs({ + workspaceSlug: "workspace-a", + projectId: "project-a", + files: [file], + meta: { category: "Uploads", sport: "Football" }, + workItemId: null, + }); + + assert.equal(jobs.length, 1); + assert.equal(jobs[0].workspaceSlug, "workspace-a"); + assert.equal(jobs[0].projectId, "project-a"); + assert.equal(jobs[0].file, file); + assert.equal(jobs[0].status, "queued"); + assert.equal(jobs[0].progress, 0); + assert.equal(jobs[0].meta.category, "Uploads"); + assert.match(jobs[0].uploadId, /^upload-\d{8}T\d{6}Z-clip-01-mp4-4-1785922733582$/); +}); + +test("upload helpers normalize size limit and display labels", () => { + assert.equal(FALLBACK_MEDIA_LIBRARY_MAX_FILE_SIZE, 5 * 1024 * 1024 * 1024); + assert.equal(readMediaLibraryFileSizeLimit("5368709120"), 5 * 1024 * 1024 * 1024); + assert.equal(readMediaLibraryFileSizeLimit("bad"), null); + assert.equal(formatFileSize(5 * 1024 * 1024 * 1024), "5GB"); + assert.equal(formatFileSize(194 * 1024 * 1024), "194MB"); +}); + +test("resolveArtifactFormat accepts supported media and rejects unknown formats", () => { + assert.equal(resolveArtifactFormat("game.mp4"), "mp4"); + assert.equal(resolveArtifactFormat("clip.m3u8"), "m3u8"); + assert.equal(resolveArtifactFormat("thumbnail.PNG"), "png"); + assert.equal(resolveArtifactFormat("notes.pdf"), "pdf"); + assert.equal(resolveArtifactFormat("archive.zip"), ""); +}); + +test("upload status helpers distinguish active, completed and failed jobs", () => { + assert.equal(isActiveUploadStatus("queued"), true); + assert.equal(isActiveUploadStatus("uploading"), true); + assert.equal(isActiveUploadStatus("processing"), true); + assert.equal(isActiveUploadStatus("completed"), false); + assert.equal(getUploadStatusLabel("processing"), "Processing"); + assert.equal(getUploadStatusLabel("failed"), "Failed"); + assert.equal(getVisibleUploadProgress({ progress: 125 }), 100); + assert.equal(getVisibleUploadProgress({ progress: -10 }), 0); +}); diff --git a/apps/web/ce/features/media-library/utils/__tests__/upload-progress.test.mjs b/apps/web/ce/features/media-library/utils/__tests__/upload-progress.test.mjs new file mode 100644 index 00000000000..024d189fc73 --- /dev/null +++ b/apps/web/ce/features/media-library/utils/__tests__/upload-progress.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildUploadTraceId, + calculateUploadProgressMetrics, + formatUploadEta, + formatUploadSpeed, + shouldLogUploadProgress, +} from "../upload-progress.ts"; + +test("buildUploadTraceId creates a safe upload correlation id", () => { + const uploadId = buildUploadTraceId({ + fileName: "Game Clip 01.Final.mp4", + fileSize: 203_482_999, + lastModified: 1_785_922_733_582, + timestampMs: Date.UTC(2026, 7, 18, 13, 8, 0), + }); + + assert.equal(uploadId, "upload-20260818T130800Z-game-clip-01-final-mp4-203482999-1785922733582"); +}); + +test("calculateUploadProgressMetrics returns percent, speed and eta", () => { + const metrics = calculateUploadProgressMetrics({ + loadedBytes: 50 * 1024 * 1024, + totalBytes: 200 * 1024 * 1024, + startedAtMs: 1_000, + nowMs: 11_000, + }); + + assert.equal(metrics.percent, 25); + assert.equal(metrics.uploadedBytes, 50 * 1024 * 1024); + assert.equal(metrics.totalBytes, 200 * 1024 * 1024); + assert.equal(metrics.speedBytesPerSecond, 5 * 1024 * 1024); + assert.equal(metrics.etaSeconds, 30); +}); + +test("calculateUploadProgressMetrics handles missing total without invalid eta", () => { + const metrics = calculateUploadProgressMetrics({ + loadedBytes: 1024, + totalBytes: 0, + startedAtMs: 1_000, + nowMs: 1_000, + }); + + assert.equal(metrics.percent, 0); + assert.equal(metrics.speedBytesPerSecond, 0); + assert.equal(metrics.etaSeconds, null); +}); + +test("upload speed and eta labels are readable", () => { + assert.equal(formatUploadSpeed(226_293), "221 KB/s"); + assert.equal(formatUploadSpeed(5.2 * 1024 * 1024), "5.2 MB/s"); + assert.equal(formatUploadEta(null), "ETA calculating"); + assert.equal(formatUploadEta(45), "ETA 45s"); + assert.equal(formatUploadEta(90), "ETA 1m 30s"); + assert.equal(formatUploadEta(3_900), "ETA 1h 5m"); +}); + +test("shouldLogUploadProgress logs at 10 percent milestones and time fallback", () => { + assert.equal( + shouldLogUploadProgress({ + percent: 0, + lastLoggedPercent: null, + lastLoggedAtMs: null, + nowMs: 1_000, + }), + true + ); + assert.equal( + shouldLogUploadProgress({ + percent: 9, + lastLoggedPercent: 0, + lastLoggedAtMs: 1_000, + nowMs: 5_000, + }), + false + ); + assert.equal( + shouldLogUploadProgress({ + percent: 10, + lastLoggedPercent: 0, + lastLoggedAtMs: 1_000, + nowMs: 5_000, + }), + true + ); + assert.equal( + shouldLogUploadProgress({ + percent: 12, + lastLoggedPercent: 10, + lastLoggedAtMs: 1_000, + nowMs: 17_000, + }), + true + ); +}); diff --git a/apps/web/ce/features/media-library/utils/media-detail-utils.ts b/apps/web/ce/features/media-library/utils/media-detail-utils.ts new file mode 100644 index 00000000000..aac8ce08e1a --- /dev/null +++ b/apps/web/ce/features/media-library/utils/media-detail-utils.ts @@ -0,0 +1,293 @@ +export const formatMetaValue = (value: unknown): string => { + if (value === null || value === undefined) return "--"; + if (Array.isArray(value)) { + const entries = value + .map((entry) => formatMetaValue(entry)) + .filter((entry) => entry && entry !== "--"); + return entries.length ? entries.join(", ") : "--"; + } + if (typeof value === "string") return value.trim() ? value : "--"; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const namedValue = (value as Record<string, unknown>)?.name; + if (typeof namedValue === "string" && namedValue.trim()) return namedValue.trim(); + } + return JSON.stringify(value) || ""; +}; + +export const getDisplayMediaTitle = (value?: string | null) => { + const normalizedValue = value?.trim() || ""; + + if (!normalizedValue) { + return ""; + } + + return ( + normalizedValue + .replace(/\s+final\s+event\s+json$/i, "") + .replace(/\s+final\s+json$/i, "") + .replace(/\s+json$/i, "") + .trim() || normalizedValue + ); +}; + +export const formatMetaLabel = (value: string) => { + if (!value) return value; + return value + .replace(/[_-]+/g, " ") + .split(" ") + .filter(Boolean) + .map((chunk) => chunk[0]?.toUpperCase() + chunk.slice(1)) + .join(" "); +}; + +export const getMetaString = (meta: Record<string, unknown>, keys: string[], fallback = "") => { + for (const key of keys) { + const value = meta[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return fallback; +}; + +export const getMetaNumber = (meta: Record<string, unknown>, keys: string[]) => { + for (const key of keys) { + const value = meta[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (!Number.isNaN(parsed)) return parsed; + } + } + return null; +}; + +export const getMetaObject = (meta: Record<string, unknown>, keys: string[]) => { + for (const key of keys) { + const value = meta[key]; + if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>; + } + return null; +}; + +export const formatFileSize = (value: unknown) => { + const size = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + if (!Number.isFinite(size) || size <= 0) return "--"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let unitIndex = 0; + let normalized = size; + while (normalized >= 1024 && unitIndex < units.length - 1) { + normalized /= 1024; + unitIndex += 1; + } + const precision = normalized >= 10 || unitIndex === 0 ? 0 : 1; + return `${normalized.toFixed(precision)} ${units[unitIndex]}`; +}; + +const parseDateValue = (value?: string | null) => { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const dateOnlyMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (dateOnlyMatch) { + const year = Number(dateOnlyMatch[1]); + const month = Number(dateOnlyMatch[2]); + const day = Number(dateOnlyMatch[3]); + return { date: new Date(Date.UTC(year, month - 1, day)), isDateOnly: true }; + } + const parsed = Date.parse(trimmed); + if (Number.isNaN(parsed)) return null; + return { date: new Date(parsed), isDateOnly: false }; +}; + +export const formatDateValue = (value?: string | null) => { + const parsed = parseDateValue(value); + if (!parsed) return value?.trim() || "--"; + const baseOptions: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "numeric", + }; + const primaryOptions = parsed.isDateOnly ? { ...baseOptions, timeZone: "UTC" } : baseOptions; + try { + return parsed.date.toLocaleDateString(undefined, primaryOptions); + } catch { + try { + return parsed.date.toLocaleDateString(undefined, baseOptions); + } catch { + return parsed.date.toLocaleDateString(); + } + } +}; + +export const formatTimeValue = (value?: string | null) => { + const parsed = parseDateValue(value); + if (!parsed) return value?.trim() || "--"; + if (parsed.isDateOnly) return "--"; + const primaryOptions: Intl.DateTimeFormatOptions = { + hour: "numeric", + minute: "2-digit", + }; + try { + return parsed.date.toLocaleTimeString(undefined, primaryOptions); + } catch { + try { + return parsed.date.toLocaleTimeString(); + } catch { + return `${parsed.date.getHours().toString().padStart(2, "0")}:${parsed.date + .getMinutes() + .toString() + .padStart(2, "0")}`; + } + } +}; + +export const resolveOppositionLogoUrl = (logo?: string | null) => { + if (!logo) return ""; + if (/^https?:\/\//i.test(logo)) return logo; + const base = process.env.NEXT_PUBLIC_CP_SERVER_URL?.replace(/\/$/, "") ?? ""; + if (!base) return ""; + return `${base}/blobs/${logo.replace(/^\/+/, "")}`; +}; + +export const isMeaningfulValue = (value: unknown) => { + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim() !== "" && value !== "--"; + return true; +}; + +export const DOCUMENT_PREVIEW_STYLE = ` +<style> + .document-preview { + color: #111827; + font-size: 14px; + line-height: 1.6; + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Roboto", "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif; + } + .document-preview h1, + .document-preview h2, + .document-preview h3, + .document-preview h4, + .document-preview h5, + .document-preview h6 { + font-weight: 600; + margin: 0 0 0.75rem; + } + .document-preview p { + margin: 0 0 0.75rem; + } + .document-preview ul, + .document-preview ol { + padding-left: 1.25rem; + margin: 0 0 0.75rem; + } + .document-preview table { + width: 100%; + border-collapse: collapse; + margin: 0.75rem 0; + } + .document-preview th, + .document-preview td { + border: 1px solid #e5e7eb; + padding: 6px 8px; + vertical-align: top; + } + .document-preview tr:nth-child(even) { + background: #f9fafb; + } + .document-preview pre, + .document-preview code { + background: #f3f4f6; + border-radius: 4px; + } + .document-preview pre { + padding: 8px 10px; + overflow: auto; + } + .document-preview code { + padding: 2px 4px; + } +</style> +`; + +export const getVideoMimeType = (format: string) => { + const normalized = format.toLowerCase(); + if (normalized === "mp4" || normalized === "m4v") return "video/mp4"; + if (normalized === "m3u8" || normalized === "stream") return "application/x-mpegURL"; + if (normalized === "mov") return "video/quicktime"; + if (normalized === "webm") return "video/webm"; + if (normalized === "avi") return "video/x-msvideo"; + if (normalized === "mkv") return "video/x-matroska"; + if (normalized === "mpeg" || normalized === "mpg") return "video/mpeg"; + return ""; +}; + +export const getVideoFormatFromSrc = (src: string) => { + const match = src.toLowerCase().match(/\.(mp4|m4v|m3u8|mov|webm|avi|mkv|mpeg|mpg)(\?.*)?$/); + return match?.[1] ?? ""; +}; + +export type TCaptionTrack = { + src: string; + label?: string; + srclang?: string; + kind?: "captions" | "subtitles"; + default?: boolean; +}; + +export const getCaptionTracks = (meta: unknown): TCaptionTrack[] => { + if (!meta || typeof meta !== "object") return []; + const raw = (meta as Record<string, unknown>).captions ?? (meta as Record<string, unknown>).subtitles; + if (!raw) return []; + const tracks = Array.isArray(raw) ? raw : [raw]; + return tracks + .map((entry) => { + if (typeof entry === "string") { + return { src: entry, label: "CC", kind: "captions" } as TCaptionTrack; + } + if (!entry || typeof entry !== "object") return null; + const data = entry as Record<string, unknown>; + const src = typeof data.src === "string" ? data.src : ""; + if (!src) return null; + return { + src, + label: typeof data.label === "string" ? data.label : undefined, + srclang: typeof data.srclang === "string" ? data.srclang : undefined, + kind: data.kind === "subtitles" ? "subtitles" : "captions", + default: Boolean(data.default), + } as TCaptionTrack; + }) + .filter((entry): entry is TCaptionTrack => Boolean(entry?.src)); +}; + +export const getVideoRepresentations = (player: any) => { + const tech = player?.tech?.(true); + const vhs = tech?.vhs; + const reps = typeof vhs?.representations === "function" ? vhs.representations() : []; + return Array.isArray(reps) ? reps : []; +}; + +export const getQualitySelection = (representations: any[]) => { + const enabled = representations.filter((rep) => rep?.enabled?.()); + if (enabled.length === 1) { + return { isAuto: false, activeRep: enabled[0] }; + } + return { isAuto: true, activeRep: null }; +}; + +export const buildDownloadUrl = (src: string) => { + if (!src) return ""; + const separator = src.includes("?") ? "&" : "?"; + return `${src}${separator}download=1`; +}; + +export const addInlineDisposition = (src: string) => { + if (!src) return ""; + try { + const url = new URL(src); + url.searchParams.set("disposition", "inline"); + return url.toString(); + } catch { + const separator = src.includes("?") ? "&" : "?"; + return `${src}${separator}disposition=inline`; + } +}; diff --git a/apps/web/ce/features/media-library/utils/media-event.ts b/apps/web/ce/features/media-library/utils/media-event.ts new file mode 100644 index 00000000000..f95adc57884 --- /dev/null +++ b/apps/web/ce/features/media-library/utils/media-event.ts @@ -0,0 +1,308 @@ +"use client"; + +import type { TMediaItem } from "../types/media-library.types"; +import { formatDateValue, formatTimeValue, getMetaNumber, getMetaString } from "./media-detail-utils"; + +type TMediaEventSource = Pick<TMediaItem, "meta"> | Record<string, unknown> | null | undefined; + +export type TStructuredEventTag = { + action: string | null; + label: string; + quarter: string | null; + result: string | null; + team: string | null; + timeRange: string | null; + timestamp: string | null; +}; + +export type TEventMediaDetails = { + deviceCount: number; + eventDate: string | null; + eventDateTime: string | null; + eventTime: string | null; + level: string | null; + locationLabel: string | null; + primaryStreamId: string | null; + primaryStreamName: string | null; + program: string | null; + projectId: string | null; + sport: string | null; + status: string | null; + structuredTags: TStructuredEventTag[]; + tagCount: number; + title: string | null; + workspaceSlug: string | null; + year: string | null; +}; + +const toMetaRecord = (source: TMediaEventSource): Record<string, unknown> => { + if (!source || typeof source !== "object") { + return {}; + } + + if ("meta" in source) { + const metaValue = source.meta; + if (metaValue && typeof metaValue === "object" && !Array.isArray(metaValue)) { + return metaValue as Record<string, unknown>; + } + } + + return source as Record<string, unknown>; +}; + +const toSourceRecord = (source: TMediaEventSource): Record<string, unknown> => { + if (!source || typeof source !== "object") { + return {}; + } + + return source as Record<string, unknown>; +}; + +const toOptionalText = (value: unknown) => { + if (typeof value !== "string") { + return null; + } + + const normalizedValue = value.trim(); + return normalizedValue || null; +}; + +const isCoachCompletedEventArtifactSource = (source: TMediaEventSource) => { + const meta = toMetaRecord(source); + const sourceRecord = toSourceRecord(source); + const sourceType = toOptionalText(meta.source); + const format = toOptionalText(sourceRecord.format); + const id = toOptionalText(sourceRecord.id); + const title = toOptionalText(sourceRecord.title); + + return ( + sourceType === "plane-coach" && + format === "json" && + (Boolean(id?.startsWith("coach-event-")) || Boolean(title?.toLowerCase().includes("final event json"))) + ); +}; + +const normalizeLooseLabel = (value: string | null) => value?.trim().toLowerCase().replace(/[_-]+/g, " ") ?? ""; + +const isCompletedEventCategorySource = (source: TMediaEventSource) => { + const meta = toMetaRecord(source); + const sourceRecord = toSourceRecord(source); + const category = normalizeLooseLabel(toOptionalText(meta.category)); + const primaryTag = normalizeLooseLabel(toOptionalText(sourceRecord.primaryTag)); + const secondaryTag = normalizeLooseLabel(toOptionalText(sourceRecord.secondaryTag)); + + return [category, primaryTag, secondaryTag].some( + (label) => label === "completed event" || label === "completed events" + ); +}; + +const hasEventTagRows = (value: unknown) => Array.isArray(value) && value.length > 0; + +const hasEventMediaShape = (source: TMediaEventSource) => { + const meta = toMetaRecord(source); + const sourceRecord = toSourceRecord(source); + const nestedEvent = + meta.event && typeof meta.event === "object" && !Array.isArray(meta.event) + ? (meta.event as Record<string, unknown>) + : {}; + const nestedRawEvent = + meta.rawEvent && typeof meta.rawEvent === "object" && !Array.isArray(meta.rawEvent) + ? (meta.rawEvent as Record<string, unknown>) + : {}; + const sources = [meta, nestedEvent, nestedRawEvent, sourceRecord]; + const hasIdentifier = sources.some((entry) => + ["sg_event_id", "event_id", "eventId", "plane_event_id", "planeEventId", "preview_event_id", "previewEventId"].some( + (key) => Boolean(toOptionalText(entry[key])) + ) + ); + const hasEventPayload = + hasEventTagRows(meta.tags) || + hasEventTagRows(meta.event_tags) || + hasEventTagRows(meta.eventTags) || + hasEventTagRows(nestedEvent.tags) || + hasEventTagRows(nestedRawEvent.tags) || + hasEventTagRows(meta.devices) || + hasEventTagRows(meta.mediaReferences); + const hasEventCopy = sources.some((entry) => + ["event_date", "event_date_time", "event_time", "dt_event", "sport", "program", "level", "location_label"].some( + (key) => Boolean(toOptionalText(entry[key])) + ) + ); + + return hasIdentifier && (hasEventPayload || hasEventCopy); +}; + +const formatStatusLabel = (value: string | null) => { + if (!value) return null; + + return value + .trim() + .replace(/[_-]+/g, " ") + .split(" ") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +}; + +const formatLooseTimeValue = (value: string | null) => { + if (!value) return null; + + const formattedValue = formatTimeValue(value); + return formattedValue !== "--" ? formattedValue : value; +}; + +const formatStructuredEventTagLabel = (value: unknown): string => { + if (!Array.isArray(value)) return ""; + + const tagEntries = value + .map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return null; + } + + const tagRecord = entry as Record<string, unknown>; + const tagName = toOptionalText(tagRecord.tag); + const tagValue = toOptionalText(tagRecord.value); + + if (!tagName && !tagValue) return null; + if (tagName && tagValue) return `${tagName}: ${tagValue}`; + return tagName || tagValue; + }) + .filter((entry): entry is string => Boolean(entry)); + + return tagEntries.join(", "); +}; + +export const getStructuredEventTags = (source: TMediaEventSource): TStructuredEventTag[] => { + const meta = toMetaRecord(source); + const rawTags = meta.tags; + + if (!Array.isArray(rawTags)) { + return []; + } + + return rawTags + .map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return null; + } + + const tagRecord = entry as Record<string, unknown>; + const action = toOptionalText(tagRecord.action); + const quarter = toOptionalText(tagRecord.quarter); + const result = toOptionalText(tagRecord.result); + const team = toOptionalText(tagRecord.team); + const timeRange = toOptionalText(tagRecord.timeRange); + const timestamp = toOptionalText(tagRecord.timestamp); + const dataLabel = formatStructuredEventTagLabel(tagRecord.data); + const label = + [team, result, dataLabel || action, quarter, timeRange || timestamp] + .filter((part): part is string => Boolean(part)) + .join(" · ") || + action || + "Event tag"; + + return { + action, + label, + quarter, + result, + team, + timeRange, + timestamp, + } satisfies TStructuredEventTag; + }) + .filter((entry): entry is TStructuredEventTag => Boolean(entry)); +}; + +export const getEventMediaDetails = (source: TMediaEventSource): TEventMediaDetails | null => { + const meta = toMetaRecord(source); + const sourceRecord = toSourceRecord(source); + const artifactType = toOptionalText(meta.artifact_type); + const sourceType = toOptionalText(meta.source); + const hasEventIdentifiers = Boolean(toOptionalText(meta.event_id)) || Boolean(toOptionalText(meta.plane_event_id)); + const isCoachCompletedEventArtifact = isCoachCompletedEventArtifactSource(source); + const isCompletedEventCategory = isCompletedEventCategorySource(source); + const hasRecognizedEventShape = hasEventMediaShape(source); + + if ( + artifactType !== "completed-event-json" && + !(sourceType === "plane-coach" && hasEventIdentifiers) && + !isCompletedEventCategory && + !isCoachCompletedEventArtifact && + !hasRecognizedEventShape + ) { + return null; + } + + const structuredTags = getStructuredEventTags(meta); + const tagCount = getMetaNumber(meta, ["tag_count", "tagCount"]) ?? structuredTags.length; + + return { + deviceCount: getMetaNumber(meta, ["device_count", "deviceCount"]) ?? 0, + eventDate: toOptionalText(meta.event_date), + eventDateTime: toOptionalText(meta.event_date_time), + eventTime: toOptionalText(meta.event_time), + level: getMetaString(meta, ["level"], "") || null, + locationLabel: getMetaString(meta, ["location_label", "locationLabel"], "") || null, + primaryStreamId: getMetaString(meta, ["primary_stream_id", "primaryStreamId"], "") || null, + primaryStreamName: getMetaString(meta, ["primary_stream_name", "primaryStreamName"], "") || null, + program: getMetaString(meta, ["program"], "") || null, + projectId: getMetaString(meta, ["project_id", "projectId"], "") || null, + sport: getMetaString(meta, ["sport"], "") || null, + status: formatStatusLabel(getMetaString(meta, ["status"], "") || (isCompletedEventCategory ? "Completed" : null)), + structuredTags, + tagCount, + title: getMetaString(meta, ["title"], "") || toOptionalText(sourceRecord.title), + workspaceSlug: getMetaString(meta, ["workspace_slug", "workspaceSlug"], "") || null, + year: getMetaString(meta, ["year", "season"], "") || null, + }; +}; + +export const isEventMediaItem = (source: TMediaEventSource) => Boolean(getEventMediaDetails(source)); + +export const getEventMediaDateLabel = (source: TMediaEventSource) => { + const details = getEventMediaDetails(source); + + if (!details) return null; + + const dateSource = details.eventDateTime || details.eventDate; + const timeSource = details.eventDateTime || details.eventTime; + const dateLabel = dateSource ? formatDateValue(dateSource) : null; + const timeLabel = timeSource ? formatLooseTimeValue(timeSource) : null; + + if (dateLabel && timeLabel) return `${dateLabel} · ${timeLabel}`; + return dateLabel || timeLabel; +}; + +export const getEventMediaContextLabel = (source: TMediaEventSource) => { + const details = getEventMediaDetails(source); + + if (!details) return null; + + const parts = [details.sport, details.program, details.level].filter((entry): entry is string => Boolean(entry)); + + return parts.length > 0 ? parts.join(" · ") : null; +}; + +export const getEventMediaMetrics = (source: TMediaEventSource) => { + const details = getEventMediaDetails(source); + + if (!details) return []; + + const metrics: string[] = []; + + if (details.tagCount > 0) { + metrics.push(`${details.tagCount} tag${details.tagCount === 1 ? "" : "s"}`); + } + + if (details.deviceCount > 0) { + metrics.push(`${details.deviceCount} device${details.deviceCount === 1 ? "" : "s"}`); + } + + if (details.primaryStreamName) { + metrics.push(details.primaryStreamName); + } + + return metrics; +}; diff --git a/apps/web/ce/features/media-library/utils/media-items.ts b/apps/web/ce/features/media-library/utils/media-items.ts new file mode 100644 index 00000000000..42cbd55b94f --- /dev/null +++ b/apps/web/ce/features/media-library/utils/media-items.ts @@ -0,0 +1,578 @@ +"use client"; + +import { API_BASE_URL } from "@plane/constants"; + +import type { TMediaArtifact } from "@/services/media-library.service"; +import type { TMediaItem, TMediaSection } from "../types/media-library.types"; +import { getDisplayMediaTitle } from "./media-detail-utils"; +import { getEventMediaContextLabel, getEventMediaDateLabel, getEventMediaDetails } from "./media-event"; + +type TArtifactContext = { + workspaceSlug: string; + projectId: string; + packageId: string; + metadata?: Record<string, Record<string, unknown>>; +}; + +const VIDEO_FORMATS = new Set(["mp4", "m3u8", "mov", "webm", "avi", "mkv", "mpeg", "mpg", "m4v"]); +const IMAGE_FORMATS = new Set([ + "jpg", + "jpeg", + "png", + "svg", + "webp", + "gif", + "bmp", + "tif", + "tiff", + "avif", + "heic", + "heif", + "thumbnail", +]); +const GENERIC_FORMAT_VALUES = new Set([ + "application/octet-stream", + "application", + "video", + "image", + "binary", + "octet-stream", +]); +const FORMAT_OVERRIDES: Record<string, string> = { + "application/vnd.apple.mpegurl": "m3u8", + "application/x-mpegurl": "m3u8", + "video/quicktime": "mov", + "video/x-msvideo": "avi", + "video/x-matroska": "mkv", + "image/svg+xml": "svg", +}; +const VIDEO_ACTIONS = new Set(["play", "play_hls", "play_streaming", "open_mp4"]); +const DOCUMENT_THUMBNAILS: Record<string, string> = { + pdf: "attachment/pdf-icon.png", + doc: "attachment/doc-icon.png", + docx: "attachment/doc-icon.png", + xls: "attachment/excel-icon.png", + xlsx: "attachment/excel-icon.png", + csv: "attachment/csv-icon.png", + txt: "attachment/txt-icon.png", + json: "attachment/txt-icon.png", + md: "attachment/txt-icon.png", + log: "attachment/txt-icon.png", + xml: "attachment/txt-icon.png", + yml: "attachment/txt-icon.png", + yaml: "attachment/txt-icon.png", + html: "attachment/html-icon.png", + css: "attachment/css-icon.png", +}; +const ARTIFACT_NAME_PATTERN = /^[A-Za-z0-9_-]+$/; +const THUMBNAIL_HINT_KEYS = ["thumbnail", "thumbnail_url", "thumbnailUrl", "poster", "poster_url", "posterUrl"]; +const ACTIVE_TRANSCODE_STATUSES = new Set([ + "UPLOAD_COMPLETE", + "QUEUED", + "CLAIMED", + "PROBING", + "PROCESSING", + "TRANSCODING", + "PACKAGING", + "VALIDATING", + "RETRY_PENDING", +]); +const FAILED_TRANSCODE_STATUSES = new Set(["FAILED", "QUEUE_FAILED", "CANCELLED"]); + +const resolveArtifactPath = (path: string) => { + if (!path) return ""; + if (/^https?:\/\//i.test(path)) return path; + return `/${path.replace(/^\/+/, "")}`; +}; + +const joinApiPath = (base: string, path: string) => `${base?.replace(/\/$/, "") ?? ""}${path}`; + +const buildArtifactFileUrl = (context: TArtifactContext, artifactName: string) => + joinApiPath( + API_BASE_URL, + `/api/workspaces/${context.workspaceSlug}/projects/${context.projectId}/media-library/packages/${context.packageId}/artifacts/${encodeURIComponent( + artifactName + )}/file/` + ); + +const resolveArtifactSource = (artifact: TMediaArtifact, context?: TArtifactContext) => { + const rawPath = artifact.path ?? ""; + if (rawPath && /^https?:\/\//i.test(rawPath)) return rawPath; + const action = (artifact.action ?? "").toLowerCase(); + if (rawPath && action === "play_hls") return resolveArtifactPath(rawPath); + if (context && artifact.name) { + return buildArtifactFileUrl(context, artifact.name); + } + return resolveArtifactPath(rawPath); +}; + +const formatDateLabel = (value: string) => { + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + const date = new Date(parsed); + const day = String(date.getDate()).padStart(2, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const year = date.getFullYear(); + return `${day}/${month}/${year}`; +}; + +const getMetaObject = (meta: unknown) => { + if (meta && typeof meta === "object" && !Array.isArray(meta)) { + return meta as Record<string, unknown>; + } + return {}; +}; + +const resolveArtifactMeta = (artifact: TMediaArtifact, metadata?: Record<string, Record<string, unknown>>) => { + const directMeta = getMetaObject(artifact.meta); + if (Object.keys(directMeta).length > 0) return directMeta; + const ref = (artifact.metadata_ref ?? "").trim() || artifact.name; + return getMetaObject(metadata?.[ref]); +}; + +const getMetaString = (meta: Record<string, unknown>, keys: string[], fallback = "") => { + for (const key of keys) { + const value = meta[key]; + if (typeof value === "string" && value.trim()) return value; + } + return fallback; +}; + +const getArtifactWorkItemId = (artifact: TMediaArtifact, meta: Record<string, unknown>) => { + const rawArtifactWorkItemId = artifact.work_item_id; + const artifactWorkItemId = + typeof rawArtifactWorkItemId === "string" + ? rawArtifactWorkItemId.trim() + : rawArtifactWorkItemId + ? String(rawArtifactWorkItemId).trim() + : ""; + if (artifactWorkItemId) return artifactWorkItemId; + return getMetaString(meta, ["work_item_id", "workItemId"], "").trim(); +}; + +const getMetaNumber = (meta: Record<string, unknown>, keys: string[], fallback = 0) => { + for (const key of keys) { + const value = meta[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (!Number.isNaN(parsed)) return parsed; + } + } + return fallback; +}; + +const getMetaBoolean = (meta: Record<string, unknown>, keys: string[], fallback = false) => { + for (const key of keys) { + const value = meta[key]; + if (typeof value === "boolean") return value; + if (typeof value === "string" && value.trim()) { + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes"].includes(normalized)) return true; + if (["0", "false", "no"].includes(normalized)) return false; + } + } + return fallback; +}; + +const getTranscodeLabel = (status: string) => { + switch (status) { + case "UPLOAD_COMPLETE": + case "QUEUED": + return "Queued"; + case "CLAIMED": + case "PROBING": + case "PROCESSING": + case "TRANSCODING": + case "PACKAGING": + case "VALIDATING": + case "RETRY_PENDING": + return "Uploading"; + case "COMPLETED": + case "READY": + case "UPLOADED": + return "Uploaded"; + case "CANCELLED": + return "Cancelled"; + case "QUEUE_FAILED": + case "FAILED": + return "Failed"; + default: + return status ? status.replace(/_/g, " ").toLowerCase() : ""; + } +}; + +const getTranscodeState = (meta: Record<string, unknown>) => { + const status = getMetaString(meta, ["transcode_status"], "").trim().toUpperCase(); + const progress = Math.min(100, Math.max(0, Math.round(getMetaNumber(meta, ["transcode_progress"], 0)))); + const hlsPending = getMetaBoolean(meta, ["hls_pending", "hlsPending"], false); + const isComplete = + status === "COMPLETED" || + status === "READY" || + status === "UPLOADED" || + (!hlsPending && Boolean(getMetaString(meta, ["hls_master_playlist"], ""))); + const isFailed = FAILED_TRANSCODE_STATUSES.has(status); + const isActive = !isComplete && !isFailed && (hlsPending || ACTIVE_TRANSCODE_STATUSES.has(status)); + const error = getMetaString(meta, ["transcode_error"], ""); + return { + transcodeStatus: status || undefined, + transcodeJobId: getMetaString(meta, ["transcode_job_id"], "") || undefined, + transcodeAssetId: getMetaString(meta, ["transcode_asset_id"], "") || undefined, + transcodeProgress: isComplete ? 100 : progress, + transcodeLabel: status ? getTranscodeLabel(status) : hlsPending ? "Uploading" : undefined, + transcodeError: error || undefined, + isTranscodeActive: isActive, + isTranscodeFailed: isFailed, + isTranscodeComplete: isComplete, + }; +}; + +const getMetaStringArray = (meta: Record<string, unknown>, key: string) => { + const value = meta[key]; + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +}; + +const getMetaDuration = (meta: Record<string, unknown>, keys: string[], fallback = "") => { + const stringValue = getMetaString(meta, keys, ""); + if (stringValue) return stringValue; + for (const key of keys) { + const value = meta[key]; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + } + return fallback; +}; + +const getFormatFromPath = (value?: string) => { + const rawValue = value?.trim(); + if (!rawValue) return ""; + const withoutQuery = rawValue.split("?")[0].split("#")[0]; + const fileName = withoutQuery.split("/").pop() ?? ""; + const dotIndex = fileName.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === fileName.length - 1) return ""; + return fileName.slice(dotIndex + 1).toLowerCase(); +}; + +const containsHtmlTags = (value: string) => /<\/?[a-z][^>]*>/i.test(value); + +const decodeHtmlEntities = (value: string) => { + if (!value) return ""; + return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (_, entity: string) => { + const normalized = entity.toLowerCase(); + if (normalized === "nbsp") return " "; + if (normalized === "amp") return "&"; + if (normalized === "lt") return "<"; + if (normalized === "gt") return ">"; + if (normalized === "quot") return '"'; + if (normalized === "apos") return "'"; + if (normalized.startsWith("#x")) { + const code = Number.parseInt(normalized.slice(2), 16); + if (!Number.isFinite(code)) return ""; + try { + return String.fromCodePoint(code); + } catch { + return ""; + } + } + if (normalized.startsWith("#")) { + const code = Number.parseInt(normalized.slice(1), 10); + if (!Number.isFinite(code)) return ""; + try { + return String.fromCodePoint(code); + } catch { + return ""; + } + } + return ""; + }); +}; + +const htmlToPlainText = (value: string) => { + if (!value) return ""; + if (!containsHtmlTags(value)) return value.trim(); + return decodeHtmlEntities( + value + .replace(/<br\s*\/?>/gi, "\n") + .replace(/<\/(p|div|li|ul|ol|h[1-6]|tr|blockquote|pre)>/gi, "\n") + .replace(/<li[^>]*>/gi, "- ") + .replace(/<[^>]+>/g, " ") + ) + .replace(/\r/g, "") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/[ \t]{2,}/g, " ") + .trim(); +}; + +const inferFormatFromPaths = (...paths: Array<string | null | undefined>) => { + for (const path of paths) { + if (!path) continue; + const inferred = getFormatFromPath(path); + if (inferred) return inferred; + } + return ""; +}; + +const normalizeFormat = (value: string | null | undefined, ...fallbackPaths: Array<string | null | undefined>) => { + const rawValue = (value ?? "").trim().toLowerCase(); + const cleaned = rawValue.replace(/^\./, ""); + if (cleaned) { + const override = FORMAT_OVERRIDES[cleaned]; + if (override) return override; + if (GENERIC_FORMAT_VALUES.has(cleaned)) { + return inferFormatFromPaths(...fallbackPaths); + } + if (cleaned.includes("/")) { + const [, subtype = ""] = cleaned.split("/"); + if (subtype === "vnd.apple.mpegurl" || subtype === "x-mpegurl" || subtype === "mpegurl") return "m3u8"; + if (subtype === "quicktime") return "mov"; + if (subtype === "x-matroska") return "mkv"; + if (subtype === "x-msvideo") return "avi"; + if (subtype === "svg+xml") return "svg"; + return subtype.replace(/^x-/, ""); + } + return cleaned; + } + return inferFormatFromPaths(...fallbackPaths); +}; + +const getMediaType = (format: string, rawFormat = "", action = ""): TMediaItem["mediaType"] => { + if (VIDEO_FORMATS.has(format)) return "video"; + if (IMAGE_FORMATS.has(format)) return "image"; + const normalizedRaw = rawFormat.trim().toLowerCase(); + if (normalizedRaw.startsWith("video/") || normalizedRaw === "video" || normalizedRaw.includes("mpegurl")) { + return "video"; + } + if (normalizedRaw.startsWith("image/") || normalizedRaw === "image") return "image"; + if (VIDEO_ACTIONS.has(action.trim().toLowerCase())) return "video"; + return "document"; +}; + +export const getDocumentThumbnailPath = (format?: string) => { + const key = (format ?? "").toLowerCase(); + return DOCUMENT_THUMBNAILS[key] ?? "attachment/default-icon.png"; +}; + +const getPlaneCoachThumbnailPath = () => "attachment/video-icon.png"; + +export const resolveMediaItemActionHref = (item: TMediaItem) => { + const action = (item.action ?? "").toLowerCase(); + + if (item.mediaType === "video" || VIDEO_ACTIONS.has(action)) return null; + if (action === "open_pdf" && item.fileSrc) { + return `/viewer?src=${encodeURIComponent(item.fileSrc)}&type=pdf`; + } + if ((action === "download" || action === "view") && item.fileSrc) { + return item.fileSrc; + } + + return null; +}; + +export const mapArtifactsToMediaItems = (artifacts: TMediaArtifact[], context?: TArtifactContext): TMediaItem[] => { + const formatDisplayTitle = (title: string, format: string, mediaType: TMediaItem["mediaType"]) => { + const normalizedTitle = title.trim(); + + if (!normalizedTitle) { + return ""; + } + + if (mediaType === "document") { + return getDisplayMediaTitle(normalizedTitle); + } + + return normalizedTitle; + }; + + const thumbnailByLink = new Map<string, string>(); + const mediaTypeByName = new Map<string, TMediaItem["mediaType"]>(); + const artifactByName = new Map<string, TMediaArtifact>(); + + const normalizeKey = (value: string) => value.trim().toLowerCase(); + const resolveArtifactNameSource = (value: string) => { + const linkedArtifact = artifactByName.get(normalizeKey(value)); + return linkedArtifact ? resolveArtifactSource(linkedArtifact, context) : ""; + }; + const resolveThumbnailHint = (value: string) => { + const normalizedValue = value.trim(); + if (!normalizedValue) return ""; + const artifactSource = resolveArtifactNameSource(normalizedValue); + if (artifactSource) return artifactSource; + if (context && ARTIFACT_NAME_PATTERN.test(normalizedValue)) return buildArtifactFileUrl(context, normalizedValue); + return resolveArtifactPath(normalizedValue); + }; + const getThumbnailHint = (artifact: TMediaArtifact, meta: Record<string, unknown>) => { + const artifactRecord = artifact as TMediaArtifact & Record<string, unknown>; + for (const key of THUMBNAIL_HINT_KEYS) { + const value = artifactRecord[key] ?? meta[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; + }; + + for (const artifact of artifacts) { + const rawFormat = artifact.format ?? ""; + const normalizedAction = (artifact.action ?? "").toLowerCase(); + const actionFormat = + normalizedAction === "play_hls" || normalizedAction === "play_streaming" + ? "m3u8" + : normalizedAction === "open_mp4" + ? "mp4" + : ""; + const format = normalizeFormat(rawFormat, artifact.path, artifact.name, artifact.link) || actionFormat; + if (artifact.name) { + mediaTypeByName.set(normalizeKey(artifact.name), getMediaType(format, rawFormat, artifact.action ?? "")); + artifactByName.set(normalizeKey(artifact.name), artifact); + } + if (!artifact.link || !IMAGE_FORMATS.has(format)) continue; + const isPreview = artifact.action === "preview" || format === "thumbnail"; + if (isPreview) { + thumbnailByLink.set(normalizeKey(artifact.link), resolveArtifactSource(artifact, context)); + } + } + + const sortedArtifacts = [...artifacts].sort((left, right) => { + const leftTime = Date.parse(left.created_at || left.updated_at || ""); + const rightTime = Date.parse(right.created_at || right.updated_at || ""); + if (Number.isNaN(leftTime) && Number.isNaN(rightTime)) return 0; + if (Number.isNaN(leftTime)) return 1; + if (Number.isNaN(rightTime)) return -1; + return rightTime - leftTime; + }); + + return sortedArtifacts.map((artifact) => { + const rawFormat = artifact.format ?? ""; + const normalizedAction = (artifact.action ?? "").toLowerCase(); + const actionFormat = + normalizedAction === "play_hls" || normalizedAction === "play_streaming" + ? "m3u8" + : normalizedAction === "open_mp4" + ? "mp4" + : ""; + const format = normalizeFormat(rawFormat, artifact.path, artifact.name, artifact.link) || actionFormat; + const mediaType = getMediaType(format, rawFormat, artifact.action ?? ""); + const meta = resolveArtifactMeta(artifact, context?.metadata); + const eventDetails = getEventMediaDetails(meta); + const workItemId = getArtifactWorkItemId(artifact, meta); + const linkedArtifact = artifact.link ? artifactByName.get(normalizeKey(artifact.link)) : undefined; + const linkedTitle = format === "thumbnail" && linkedArtifact?.title ? linkedArtifact.title : artifact.title; + const displayTitle = formatDisplayTitle(eventDetails?.title || linkedTitle, format, mediaType); + // console.log("Display Title:", displayTitle, "Format:", format, "Media Type:", mediaType, "Event Details:", eventDetails, "Linked Artifact:", linkedArtifact); + const baseDescription = (artifact.description ?? getMetaString(meta, ["description", "summary"], "")).trim(); + const eventContextLabel = getEventMediaContextLabel(meta); + const eventDateLabel = getEventMediaDateLabel(meta); + const descriptionSource = + eventDetails && !baseDescription + ? [eventContextLabel, eventDateLabel].filter((entry): entry is string => Boolean(entry)).join(" · ") + : baseDescription; + const description = format === "thumbnail" ? "" : htmlToPlainText(descriptionSource); + const descriptionHtml = + format === "thumbnail" || !containsHtmlTags(descriptionSource) ? undefined : descriptionSource; + + const createdAt = formatDateLabel(artifact.created_at || artifact.updated_at || ""); + const views = getMetaNumber(meta, ["views"], 0); + const duration = getMetaDuration(meta, ["duration"], ""); + + const primaryTag = getMetaString(meta, ["category", "sport", "program"], "Uploads"); + const linkValue = artifact.link ?? getMetaString(meta, ["for"], ""); + const linkTarget = linkValue ? normalizeKey(linkValue) : ""; + const linkFormat = getFormatFromPath(linkValue); + const linkedFormat = linkedArtifact + ? normalizeFormat(linkedArtifact.format, linkedArtifact.path, linkedArtifact.name, linkedArtifact.link) || + linkFormat + : linkFormat || undefined; + const metaKind = getMetaString(meta, ["kind"], "").toLowerCase(); + const metaSource = getMetaString(meta, ["source"], "").toLowerCase(); + const inferredLinkedMediaType = + normalizedAction === "play" || + normalizedAction === "preview" || + normalizedAction === "play_hls" || + normalizedAction === "play_streaming" || + normalizedAction === "open_mp4" + ? "video" + : normalizedAction === "view" || normalizedAction === "open_image" + ? "image" + : format === "thumbnail" && (normalizedAction === "open_pdf" || normalizedAction === "download") + ? "document" + : format === "thumbnail" && metaKind === "thumbnail" + ? "document" + : format === "thumbnail" && metaSource === "generated" + ? "video" + : format === "thumbnail" + ? "image" + : "document"; + const linkedMediaType = linkTarget + ? (mediaTypeByName.get(linkTarget) ?? (linkFormat ? getMediaType(linkFormat) : inferredLinkedMediaType)) + : undefined; + const secondaryTag = + getMetaString(meta, ["season", "level", "status", "coach"], "") || (eventDetails?.status ?? "Media"); + const itemsCount = getMetaNumber(meta, ["itemsCount", "items_count"], 1); + const author = getMetaString(meta, ["coach", "author", "creator"], "Media Library"); + const docs = getMetaStringArray(meta, "docs"); + + const rawPath = artifact.path ?? ""; + const resolvedPath = resolveArtifactSource(artifact, context); + const downloadablePath = context && artifact.name ? buildArtifactFileUrl(context, artifact.name) : ""; + const directDownloadPath = rawPath && /^https?:\/\//i.test(rawPath) ? rawPath : ""; + const preferredDownloadPath = + mediaType === "video" ? downloadablePath || directDownloadPath : directDownloadPath || downloadablePath; + + const metaThumbnail = getThumbnailHint(artifact, meta); + const transcodeState = getTranscodeState(meta); + const artifactThumbnail = artifact.name ? thumbnailByLink.get(normalizeKey(artifact.name)) : ""; + const planeCoachThumbnail = + metaSource === "plane-coach" && mediaType === "document" ? metaThumbnail || getPlaneCoachThumbnailPath() : ""; + const fallbackThumbnail = + mediaType === "image" + ? resolvedPath + : planeCoachThumbnail || (mediaType === "document" ? getDocumentThumbnailPath(format) : ""); + const thumbnail = resolveArtifactPath( + artifactThumbnail || resolveThumbnailHint(metaThumbnail) || planeCoachThumbnail || fallbackThumbnail + ); + + return { + id: artifact.name, + packageId: context?.packageId, + title: displayTitle, + description, + descriptionHtml, + format, + linkedFormat, + action: artifact.action, + link: artifact.link ?? null, + workItemId: workItemId || null, + author, + createdAt, + views, + duration, + primaryTag, + secondaryTag, + itemsCount, + meta, + mediaType, + linkedMediaType, + thumbnail, + videoSrc: mediaType === "video" ? resolvedPath : undefined, + imageSrc: mediaType === "image" ? resolvedPath : undefined, + fileSrc: mediaType === "document" ? resolvedPath : undefined, + downloadSrc: preferredDownloadPath || undefined, + docs, + ...transcodeState, + }; + }); +}; + +export const groupMediaItemsByTag = (items: TMediaItem[], fallbackTitle = "Upload"): TMediaSection[] => { + const grouped = new Map<string, TMediaItem[]>(); + for (const item of items) { + const key = item.primaryTag || fallbackTitle; + const group = grouped.get(key); + if (group) group.push(item); + else grouped.set(key, [item]); + } + + return Array.from(grouped.entries()).map(([title, sectionItems]) => ({ + title, + items: sectionItems, + })); +}; diff --git a/apps/web/ce/features/media-library/utils/media-library-filters.ts b/apps/web/ce/features/media-library/utils/media-library-filters.ts new file mode 100644 index 00000000000..5b210a4cdaf --- /dev/null +++ b/apps/web/ce/features/media-library/utils/media-library-filters.ts @@ -0,0 +1,328 @@ +import { FilterAdapter } from "@plane/shared-state"; +import type { + TFilterConditionNodeForDisplay, + TFilterConfig, + TFilterExpression, + TFilterProperty, + TFilterValue, + TSupportedOperators, +} from "@plane/types"; +import { COLLECTION_OPERATOR, COMPARISON_OPERATOR, EQUALITY_OPERATOR } from "@plane/types"; +import { + createFilterConfig, + createOperatorConfigEntry, + getDatePickerConfig, + getDateRangePickerConfig, + getMultiSelectConfig, + getSingleSelectConfig, +} from "@plane/utils"; + +import type { TMediaItem } from "../types/media-library.types"; + +export type TMediaLibraryFilterProperty = string; + +export type TMediaLibraryExternalFilter = { + expression?: TFilterExpression<TMediaLibraryFilterProperty> | null; +}; + +const META_PROPERTY_PREFIX = "meta."; + +export const META_FILTER_EXCLUDED_KEYS = new Set([ + "duration", + "duration_sec", + "durationSec", + "for", + "hls", + "kind", + "source", + "source_format", + "source format", +]); + +const META_FILTER_ALLOWED_KEYS = new Set([ + "category", + "level", + "season", + "opposition", + "sport", + "program", + "start time", + "start date", +]); + +const toMetaProperty = (key: string) => `${META_PROPERTY_PREFIX}${key}`; +const START_DATE_META_KEY = "start date"; +const START_TIME_META_KEY = "start time"; +const START_DATE_META_KEY_ALIASES = ["start_date", "startDate", "start date"]; +const START_TIME_META_KEY_ALIASES = ["start_time", "startTime", "start time"]; + +const toDisplayLabel = (value: string) => { + if (!value) return value; + const normalized = value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return normalized ? normalized[0].toUpperCase() + normalized.slice(1) : value; +}; + +const normalizeMetaKey = (key: string) => + key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + +const isAllowedMetaFilterKey = (key: string) => META_FILTER_ALLOWED_KEYS.has(normalizeMetaKey(key)); +const isStartDateMetaFilterKey = (key: string) => normalizeMetaKey(key) === START_DATE_META_KEY; +const isStartTimeMetaFilterKey = (key: string) => normalizeMetaKey(key) === START_TIME_META_KEY; + +const getMetaValueByKey = (meta: Record<string, unknown>, key: string) => { + if (Object.prototype.hasOwnProperty.call(meta, key)) { + return meta[key]; + } + + if (isStartDateMetaFilterKey(key)) { + for (const alias of START_DATE_META_KEY_ALIASES) { + if (Object.prototype.hasOwnProperty.call(meta, alias)) return meta[alias]; + } + } + + if (isStartTimeMetaFilterKey(key)) { + for (const alias of START_TIME_META_KEY_ALIASES) { + if (Object.prototype.hasOwnProperty.call(meta, alias)) return meta[alias]; + } + } + + return undefined; +}; + +const getMetaKeyFromProperty = (property: TFilterProperty) => + property.startsWith(META_PROPERTY_PREFIX) ? property.slice(META_PROPERTY_PREFIX.length) : ""; + +const META_OBJECT_DISPLAY_KEYS = ["name", "title", "label", "display_name", "displayName", "team_name", "teamName"]; + +const getObjectDisplayValues = (value: Record<string, unknown>): string[] => { + for (const key of META_OBJECT_DISPLAY_KEYS) { + const candidate = value[key]; + if (typeof candidate === "string" && candidate.trim()) return [candidate.trim()]; + } + const rawValue = value.value; + if (typeof rawValue === "string" && rawValue.trim()) return [rawValue.trim()]; + return []; +}; + +const normalizeMetaValues = (value: unknown): string[] => { + if (value === null || value === undefined) return []; + if (typeof value === "string") return value.trim() ? [value.trim()] : []; + if (typeof value === "number" || typeof value === "boolean") return [String(value)]; + if (Array.isArray(value)) { + return value.flatMap((entry) => normalizeMetaValues(entry)); + } + if (typeof value === "object") { + const displayValues = getObjectDisplayValues(value as Record<string, unknown>); + if (displayValues.length > 0) return displayValues; + } + return []; +}; + +export const collectMetaFilterOptions = (items: TMediaItem[]) => { + const valuesByKey = new Map<string, Set<string>>(); + + for (const item of items) { + const meta = item.meta ?? {}; + for (const [key, value] of Object.entries(meta)) { + if (META_FILTER_EXCLUDED_KEYS.has(key)) continue; + if (!isAllowedMetaFilterKey(key)) continue; + const normalizedValues = normalizeMetaValues(value); + if (normalizedValues.length === 0) continue; + const existing = valuesByKey.get(key) ?? new Set<string>(); + for (const entry of normalizedValues) { + if (entry.trim()) existing.add(entry); + } + valuesByKey.set(key, existing); + } + } + + const keys = Array.from(valuesByKey.keys()).sort((left, right) => left.localeCompare(right)); + const sortedValuesByKey = new Map<string, string[]>(); + + for (const key of keys) { + sortedValuesByKey.set( + key, + Array.from(valuesByKey.get(key) ?? []).sort((left, right) => left.localeCompare(right)) + ); + } + + return { keys, valuesByKey: sortedValuesByKey }; +}; + +type TOperatorConfigParams = { + allowedOperators: Set<TSupportedOperators>; + allowNegative: boolean; +}; + +export const buildMetaFilterConfigs = ( + metaOptions: ReturnType<typeof collectMetaFilterOptions>, + operatorConfigs: TOperatorConfigParams +): TFilterConfig<TMediaLibraryFilterProperty, TFilterValue>[] => { + const configKeys = [...metaOptions.keys]; + if (!configKeys.some((key) => isStartDateMetaFilterKey(key))) configKeys.push("start_date"); + if (!configKeys.some((key) => isStartTimeMetaFilterKey(key))) configKeys.push("start_time"); + + return configKeys.map((key) => { + const values = metaOptions.valuesByKey.get(key) ?? []; + const isTemporalFilter = isStartDateMetaFilterKey(key) || isStartTimeMetaFilterKey(key); + const isConfigEnabled = isTemporalFilter ? false : values.length > 0; + const baseParams = { + isEnabled: values.length > 0 || isTemporalFilter, + allowNegative: operatorConfigs.allowNegative, + allowedOperators: operatorConfigs.allowedOperators, + }; + + if (isTemporalFilter) { + return createFilterConfig<TMediaLibraryFilterProperty, TFilterValue>({ + id: toMetaProperty(key), + label: toDisplayLabel(key), + isEnabled: isConfigEnabled, + allowMultipleFilters: false, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(EQUALITY_OPERATOR.EXACT, baseParams, (updatedParams) => + getDatePickerConfig({ ...updatedParams }) + ), + createOperatorConfigEntry(COMPARISON_OPERATOR.RANGE, baseParams, (updatedParams) => + getDateRangePickerConfig({ ...updatedParams }) + ), + ]), + }); + } + + const optionTransforms = { + items: values, + getId: (value: string) => value, + getLabel: (value: string) => toDisplayLabel(value), + getValue: (value: string) => value, + }; + + return createFilterConfig<TMediaLibraryFilterProperty, TFilterValue>({ + id: toMetaProperty(key), + label: toDisplayLabel(key), + isEnabled: isConfigEnabled, + allowMultipleFilters: false, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(EQUALITY_OPERATOR.EXACT, baseParams, (updatedParams) => + getSingleSelectConfig(optionTransforms, { ...updatedParams }) + ), + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, baseParams, (updatedParams) => + getMultiSelectConfig(optionTransforms, { + singleValueOperator: EQUALITY_OPERATOR.EXACT, + ...updatedParams, + }) + ), + ]), + }); + }); +}; + +const parseDateComparableValue = (value: string) => { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +}; + +const parseTimeComparableValue = (value: string) => { + const parsedDate = Date.parse(value); + if (!Number.isNaN(parsedDate)) { + const date = new Date(parsedDate); + return date.getHours() * 60 + date.getMinutes(); + } + + const trimmed = value.trim(); + const match = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?\s*([AaPp][Mm])?$/); + if (!match) return null; + + let hours = Number(match[1]); + const minutes = Number(match[2]); + const meridiem = (match[3] ?? "").toLowerCase(); + + if (!Number.isFinite(hours) || !Number.isFinite(minutes) || minutes < 0 || minutes > 59) return null; + if (meridiem) { + if (hours < 1 || hours > 12) return null; + const isPM = meridiem === "pm"; + hours = (hours % 12) + (isPM ? 12 : 0); + } else if (hours < 0 || hours > 23) { + return null; + } + + return hours * 60 + minutes; +}; + +const matchesRangeCondition = ( + itemValues: string[], + conditionValues: string[], + parser: (value: string) => number | null +) => { + if (conditionValues.length < 2) return true; + + const lowerComparable = parser(conditionValues[0]); + const upperComparable = parser(conditionValues[1]); + if (lowerComparable === null || upperComparable === null) return true; + + const lowerBound = Math.min(lowerComparable, upperComparable); + const upperBound = Math.max(lowerComparable, upperComparable); + + return itemValues.some((value) => { + const comparable = parser(value); + return comparable !== null && comparable >= lowerBound && comparable <= upperBound; + }); +}; + +export const matchesMediaLibraryFilters = ( + item: TMediaItem, + conditions: TFilterConditionNodeForDisplay<TMediaLibraryFilterProperty, TFilterValue>[] +) => { + if (conditions.length === 0) return true; + + return conditions.every((condition) => { + const metaKey = getMetaKeyFromProperty(condition.property); + if (!metaKey || META_FILTER_EXCLUDED_KEYS.has(metaKey) || !isAllowedMetaFilterKey(metaKey)) return true; + + const meta = (item.meta ?? {}) as Record<string, unknown>; + const itemValues = normalizeMetaValues(getMetaValueByKey(meta, metaKey)); + if (itemValues.length === 0) return false; + + const conditionValues = (Array.isArray(condition.value) ? condition.value : [condition.value]) + .filter((value) => value !== null && value !== undefined && `${value}`.trim() !== "") + .map((value) => String(value)); + + if (conditionValues.length === 0) return true; + + if (condition.operator === EQUALITY_OPERATOR.EXACT || condition.operator === COLLECTION_OPERATOR.IN) { + return conditionValues.some((value) => itemValues.includes(value)); + } + + if (condition.operator === COMPARISON_OPERATOR.RANGE) { + if (isStartDateMetaFilterKey(metaKey)) { + return matchesRangeCondition(itemValues, conditionValues, parseDateComparableValue); + } + if (isStartTimeMetaFilterKey(metaKey)) { + return matchesRangeCondition(itemValues, conditionValues, parseTimeComparableValue); + } + return true; + } + + return true; + }); +}; + +class MediaLibraryFiltersAdapter extends FilterAdapter<TMediaLibraryFilterProperty, TMediaLibraryExternalFilter> { + toInternal(externalFilter: TMediaLibraryExternalFilter): TFilterExpression<TMediaLibraryFilterProperty> | null { + return externalFilter?.expression ?? null; + } + + toExternal(internalFilter: TFilterExpression<TMediaLibraryFilterProperty> | null): TMediaLibraryExternalFilter { + return { expression: internalFilter ?? null }; + } +} + +export const mediaLibraryFiltersAdapter = new MediaLibraryFiltersAdapter(); diff --git a/apps/web/ce/features/media-library/utils/media-library-upload-jobs.ts b/apps/web/ce/features/media-library/utils/media-library-upload-jobs.ts new file mode 100644 index 00000000000..a4a7702c95b --- /dev/null +++ b/apps/web/ce/features/media-library/utils/media-library-upload-jobs.ts @@ -0,0 +1,249 @@ +import type { TMediaArtifact } from "@/services/media-library.service"; + +export const MEDIA_LIBRARY_MAX_FILE_SIZE_5_GB = 5 * 1024 * 1024 * 1024; +export const FALLBACK_MEDIA_LIBRARY_MAX_FILE_SIZE = MEDIA_LIBRARY_MAX_FILE_SIZE_5_GB; + +const IMAGE_FORMATS = new Set([ + "jpg", + "jpeg", + "png", + "svg", + "webp", + "gif", + "bmp", + "tif", + "tiff", + "avif", + "heic", + "heif", +]); +const VIDEO_FORMATS = new Set(["mp4", "m3u8"]); +const DOC_FORMATS = new Set(["json", "csv", "pdf", "docx", "xlsx", "pptx", "txt"]); + +export type TMediaLibraryUploadStatus = "queued" | "uploading" | "processing" | "completed" | "failed" | "cancelled"; +export type TMediaLibraryUploadFailurePhase = "upload" | "processing"; + +export type TMediaLibraryUploadJob = { + id: string; + file: File; + workspaceSlug: string; + projectId: string; + status: TMediaLibraryUploadStatus; + progress: number; + uploadId: string; + requestId?: string; + artifactName?: string; + artifact?: TMediaArtifact; + packageId?: string; + transcodeJobId?: string; + uploadedBytes?: number; + totalBytes?: number; + uploadStartedAtMs?: number; + uploadCompletedAtMs?: number; + uploadSpeedBytesPerSecond?: number; + uploadEtaSeconds?: number | null; + error?: string; + failedPhase?: TMediaLibraryUploadFailurePhase; + abortController?: AbortController; + retryCount?: number; + meta: Record<string, unknown>; + workItemId?: string | null; + createdAtMs: number; + updatedAtMs: number; +}; + +export type TMediaLibraryUploadBatchInput = { + workspaceSlug: string; + projectId: string; + files: File[]; + meta: Record<string, unknown>; + workItemId?: string | null; +}; + +export const readMediaLibraryFileSizeLimit = (value: unknown) => { + const limit = typeof value === "number" ? value : typeof value === "string" ? Number(value.trim()) : NaN; + return Number.isFinite(limit) && limit > 0 ? limit : null; +}; + +export const getTitleFromFile = (fileName: string) => fileName.replace(/\.[^/.]+$/, ""); +export const getFileExtension = (fileName: string) => fileName.split(".").pop()?.toLowerCase() ?? ""; +export const buildUploadId = (file: File) => `${file.name}-${file.size}-${file.lastModified}`; + +export const buildArtifactName = (fileName: string, uploadedAt: number, index: number) => { + const base = getTitleFromFile(fileName) + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/(^-+|-+$)/g, ""); + const suffix = `${uploadedAt}-${index}`; + return base ? `${base}-${suffix}` : `artifact-${suffix}`; +}; + +export const resolveArtifactFormat = (fileName: string) => { + const extension = getFileExtension(fileName); + if (IMAGE_FORMATS.has(extension)) return extension; + if (VIDEO_FORMATS.has(extension)) return extension; + if (DOC_FORMATS.has(extension)) return extension; + return ""; +}; + +export const isDocumentUploadFormat = (format: string) => DOC_FORMATS.has(format); +export const isImageUploadFormat = (format: string) => IMAGE_FORMATS.has(format); +export const isVideoUploadFormat = (format: string) => VIDEO_FORMATS.has(format); +export const isMp4Upload = (file: File) => getFileExtension(file.name) === "mp4" || file.type === "video/mp4"; + +export const isActiveUploadStatus = (status: TMediaLibraryUploadStatus) => + status === "queued" || status === "uploading" || status === "processing"; + +export const isCompletedUploadStatus = (status: TMediaLibraryUploadStatus) => status === "completed"; + +export const getVisibleUploadProgress = (job: Pick<TMediaLibraryUploadJob, "progress">) => + Math.min(100, Math.max(0, job.progress ?? 0)); + +export const getUploadStatusLabel = (status: TMediaLibraryUploadStatus) => { + if (status === "queued") return "Queued"; + if (status === "uploading") return "Uploading"; + if (status === "processing") return "Processing"; + if (status === "completed") return "Completed"; + if (status === "cancelled") return "Cancelled"; + return "Failed"; +}; + +export const buildUploadAttemptRequestId = (uploadId: string, retryCount = 0) => `${uploadId}-try-${retryCount + 1}`; + +const pad = (value: number) => String(value).padStart(2, "0"); + +const formatTimestampForId = (timestampMs: number) => { + const date = new Date(timestampMs); + return [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + "T", + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()), + "Z", + ].join(""); +}; + +const sanitizeIdPart = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + +const buildJobUploadTraceId = (file: File, timestampMs: number) => { + const safeName = sanitizeIdPart(file.name) || "file"; + return `upload-${formatTimestampForId(timestampMs)}-${safeName}-${Math.max(0, Math.round(file.size))}-${Math.max( + 0, + Math.round(file.lastModified) + )}`; +}; + +export const formatFileSize = (value: number) => { + if (!Number.isFinite(value) || value <= 0) return "0MB"; + const sizeInMb = value / (1024 * 1024); + if (sizeInMb >= 1024) { + const sizeInGb = sizeInMb / 1024; + if (Number.isInteger(sizeInGb)) return `${sizeInGb.toFixed(0)}GB`; + return `${sizeInGb.toFixed(sizeInGb >= 10 ? 0 : 1)}GB`; + } + return `${sizeInMb.toFixed(0)}MB`; +}; + +export const escapeHtml = (value: string) => + value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +const getHttpStatus = (error: unknown): number | null => { + if (!error || typeof error !== "object") return null; + const record = error as Record<string, unknown>; + const status = record.status ?? record.statusCode; + if (typeof status === "number") return status; + if (typeof status === "string") { + const parsedStatus = Number(status); + return Number.isFinite(parsedStatus) ? parsedStatus : null; + } + return null; +}; + +const isServerFileSizeError = (error: unknown) => { + if (getHttpStatus(error) === 413) return true; + if (typeof error === "string") { + const normalizedError = error.toLowerCase(); + return normalizedError.includes("413") || normalizedError.includes("too large"); + } + if (error && typeof error === "object") { + const record = error as Record<string, unknown>; + const data = record.data; + if (data && typeof data === "object") { + const responseData = data as Record<string, unknown>; + return responseData.code === "MEDIA_LIBRARY_FILE_TOO_LARGE" || responseData.error === "REQUEST_BODY_TOO_LARGE"; + } + return record.code === "MEDIA_LIBRARY_FILE_TOO_LARGE" || record.error === "REQUEST_BODY_TOO_LARGE"; + } + return false; +}; + +export const getErrorMessage = (error: unknown, fallback: string) => { + if (error && typeof error === "object") { + const record = error as Record<string, unknown>; + const data = record.data; + if (data && typeof data === "object") { + const responseData = data as Record<string, unknown>; + if (typeof responseData.detail === "string" && responseData.detail.trim()) return responseData.detail; + if (typeof responseData.message === "string" && responseData.message.trim()) return responseData.message; + if (typeof responseData.error === "string" && responseData.error.trim()) return responseData.error; + } + const nestedError = record.error; + if (nestedError && typeof nestedError === "object") { + const nested = nestedError as Record<string, unknown>; + if (typeof nested.message === "string" && nested.message.trim()) return nested.message; + if (typeof nested.code === "string" && nested.code.trim()) return nested.code; + } + if (typeof record.message === "string" && record.message.trim()) return record.message; + if (typeof record.error === "string" && record.error.trim()) return record.error; + } + return fallback; +}; + +export const getUploadErrorMessage = (error: unknown) => { + if (isServerFileSizeError(error)) { + return "Server rejected this file as too large. Increase the upload size limit or choose a smaller file."; + } + return getErrorMessage(error, "Upload failed"); +}; + +export const buildMediaLibraryUploadJobs = ({ + workspaceSlug, + projectId, + files, + meta, + workItemId, +}: TMediaLibraryUploadBatchInput): TMediaLibraryUploadJob[] => { + const createdAtMs = Date.now(); + + return files.map((file, index) => { + const uploadId = buildJobUploadTraceId(file, createdAtMs + index); + + return { + id: `${uploadId}-${index}`, + file, + workspaceSlug, + projectId, + status: "queued", + progress: 0, + uploadId, + retryCount: 0, + meta: { ...meta }, + workItemId, + createdAtMs, + updatedAtMs: createdAtMs, + }; + }); +}; diff --git a/apps/web/ce/features/media-library/utils/upload-progress.ts b/apps/web/ce/features/media-library/utils/upload-progress.ts new file mode 100644 index 00000000000..c2472e0f389 --- /dev/null +++ b/apps/web/ce/features/media-library/utils/upload-progress.ts @@ -0,0 +1,164 @@ +const UPLOAD_PROGRESS_LOG_PERCENT_STEP = 10; +const UPLOAD_PROGRESS_LOG_INTERVAL_MS = 15_000; + +type TUploadTraceInput = { + fileName: string; + fileSize: number; + lastModified: number; + timestampMs?: number; +}; + +type TUploadProgressInput = { + loadedBytes: number; + totalBytes: number; + startedAtMs: number; + nowMs: number; +}; + +type TShouldLogUploadProgressInput = { + percent: number; + lastLoggedPercent: number | null; + lastLoggedAtMs: number | null; + nowMs: number; +}; + +export type TUploadProgressMetrics = { + percent: number; + uploadedBytes: number; + totalBytes: number; + speedBytesPerSecond: number; + etaSeconds: number | null; +}; + +export type TMediaUploadLogLevel = "info" | "warn" | "error"; + +export type TMediaUploadLifecycleEvent = { + level?: TMediaUploadLogLevel; + event: string; + uploadId: string; + requestId?: string; + fileName?: string; + fileSize?: number; + fileType?: string; + artifactName?: string; + packageId?: string; + projectId?: string; + workspaceSlug?: string; + percent?: number; + uploadedBytes?: number; + totalBytes?: number; + speedBytesPerSecond?: number; + etaSeconds?: number | null; + durationMs?: number; + transcodeJobId?: string; + error?: string; +}; + +const pad = (value: number) => String(value).padStart(2, "0"); + +const formatTimestampForId = (timestampMs: number) => { + const date = new Date(timestampMs); + return [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + "T", + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()), + "Z", + ].join(""); +}; + +const sanitizeIdPart = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + +export const buildUploadTraceId = ({ + fileName, + fileSize, + lastModified, + timestampMs = Date.now(), +}: TUploadTraceInput) => { + const safeName = sanitizeIdPart(fileName) || "file"; + return `upload-${formatTimestampForId(timestampMs)}-${safeName}-${Math.max(0, Math.round(fileSize))}-${Math.max( + 0, + Math.round(lastModified) + )}`; +}; + +export const calculateUploadProgressMetrics = ({ + loadedBytes, + totalBytes, + startedAtMs, + nowMs, +}: TUploadProgressInput): TUploadProgressMetrics => { + const safeLoaded = Math.max(0, Math.round(loadedBytes || 0)); + const safeTotal = Math.max(0, Math.round(totalBytes || 0)); + const elapsedSeconds = Math.max(0, (nowMs - startedAtMs) / 1000); + const speedBytesPerSecond = elapsedSeconds > 0 ? Math.round(safeLoaded / elapsedSeconds) : 0; + const percent = safeTotal > 0 ? Math.min(100, Math.max(0, Math.round((safeLoaded / safeTotal) * 100))) : 0; + const remainingBytes = Math.max(0, safeTotal - safeLoaded); + const etaSeconds = safeTotal > 0 && speedBytesPerSecond > 0 ? Math.ceil(remainingBytes / speedBytesPerSecond) : null; + + return { + percent, + uploadedBytes: safeLoaded, + totalBytes: safeTotal, + speedBytesPerSecond, + etaSeconds, + }; +}; + +export const formatUploadSpeed = (bytesPerSecond: number) => { + const safeValue = Math.max(0, bytesPerSecond || 0); + if (safeValue <= 0) return "Speed calculating"; + if (safeValue < 1024 * 1024) return `${Math.round(safeValue / 1024)} KB/s`; + return `${(safeValue / (1024 * 1024)).toFixed(safeValue >= 10 * 1024 * 1024 ? 0 : 1)} MB/s`; +}; + +export const formatUploadEta = (seconds: number | null | undefined) => { + if (seconds === null || seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return "ETA calculating"; + if (seconds < 60) return `ETA ${Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + if (minutes < 60) { + return remainingSeconds > 0 ? `ETA ${minutes}m ${remainingSeconds}s` : `ETA ${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `ETA ${hours}h ${remainingMinutes}m` : `ETA ${hours}h`; +}; + +export const shouldLogUploadProgress = ({ + percent, + lastLoggedPercent, + lastLoggedAtMs, + nowMs, +}: TShouldLogUploadProgressInput) => { + if (lastLoggedPercent === null || lastLoggedAtMs === null) return true; + const nextMilestone = + Math.floor(lastLoggedPercent / UPLOAD_PROGRESS_LOG_PERCENT_STEP) * UPLOAD_PROGRESS_LOG_PERCENT_STEP; + if (percent >= nextMilestone + UPLOAD_PROGRESS_LOG_PERCENT_STEP) return true; + return nowMs - lastLoggedAtMs >= UPLOAD_PROGRESS_LOG_INTERVAL_MS; +}; + +export const logMediaUploadLifecycle = ({ level = "info", ...event }: TMediaUploadLifecycleEvent) => { + const payload = { + ts: new Date().toISOString(), + source: "media-library-upload-modal", + ...event, + }; + if (level === "error") { + console.error("[media-library.upload]", payload); + return; + } + if (level === "warn") { + console.warn("[media-library.upload]", payload); + return; + } + console.info("[media-library.upload]", payload); +}; diff --git a/apps/web/ce/features/opposition/README.md b/apps/web/ce/features/opposition/README.md new file mode 100644 index 00000000000..30a572b827e --- /dev/null +++ b/apps/web/ce/features/opposition/README.md @@ -0,0 +1,9 @@ +# Opposition Feature + +Community Edition opposition-team UI lives here so the Next.js route folder only defines the URL. + +- `components/` contains page, header, list, team card, search, logo, and modal UI. +- `store/` contains React context providers used by the route layout and page. +- `services/` contains the client-side API helpers for loading, updating, and uploading opposition-team data. + +Routes import this feature through `@/plane-web/features/opposition`, which keeps the CE alias intact. diff --git a/apps/web/ce/features/opposition/components/opposition-list.tsx b/apps/web/ce/features/opposition/components/opposition-list.tsx new file mode 100644 index 00000000000..0b5cf054fe1 --- /dev/null +++ b/apps/web/ce/features/opposition/components/opposition-list.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React from "react"; +// import { Users } from "lucide-react"; +import { OppositionTeamBlock } from "./opposition-team-block"; +import { TeamLogo } from "./opposition-team-logo"; + + +interface Team { + id: string; + name: string; + address: string; + logo: string; + athletic_email: string; + athletic_phone: string; + head_coach_name: string; + asst_coach_name: string; + asst_athletic_email: string; + asst_athletic_phone: string; +} + +interface Props { + teams: Team[]; + workspaceSlug: string; + searchQuery?: string; +} + +export default function OppositionTeamsList({ teams, workspaceSlug, searchQuery = "" }: Props) { + const filteredTeams = teams.filter((team) => team.name.toLowerCase().includes(searchQuery.toLowerCase())); + console.log(filteredTeams,'filteredItems'); + return ( + <div className="w-full border-b border-custom-border-200"> + {filteredTeams.map((team) => ( + <div + key={team.id} + className="flex items-center justify-between px-4 py-4 border-b border-custom-border-200 last:border-b-0 transition" + > + <div className="flex items-center gap-4"> + {/* Logo */} + <div className="w-12 h-12 rounded-md border border-custom-border-200 overflow-hidden bg-zinc-900"> + <TeamLogo path={team.logo} name={team.name} /> + </div> + {/* LEFT */} + <div> + <h3 className="text-base font-medium">{team.name}</h3> + <span className="text-sm text-gray-500">{team.address}</span> + </div> + </div> + + {/* RIGHT */} + <div className="flex items-center gap-4"> + {/* MENU OPENED */} + <div> + <OppositionTeamBlock workspaceSlug={workspaceSlug} team={team} /> + </div> + </div> + </div> + ))} + </div> + ); +} diff --git a/apps/web/ce/features/opposition/components/opposition-search.tsx b/apps/web/ce/features/opposition/components/opposition-search.tsx new file mode 100644 index 00000000000..2d562c25f87 --- /dev/null +++ b/apps/web/ce/features/opposition/components/opposition-search.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React, { useRef, useState } from "react"; +import { Search, X } from "lucide-react"; +import { cn } from "@plane/utils"; + +interface Props { + searchQuery: string; + updateSearchQuery: (value: string) => void; +} + +const OppositionSearch: React.FC<Props> = ({ searchQuery, updateSearchQuery }) => { + const inputRef = useRef<HTMLInputElement>(null); + const [isSearchOpen, setIsSearchOpen] = useState(false); + + const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Escape") { + setIsSearchOpen(false); + updateSearchQuery(""); + } + }; + + return ( + <div className="flex items-center"> + {!isSearchOpen && ( + <button + type="button" + className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center" + onClick={() => { + setIsSearchOpen(true); + inputRef.current?.focus(); + }} + > + <Search className="h-3.5 w-3.5" /> + </button> + )} + + <div + className={cn( + "ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-all ease-linear overflow-hidden opacity-0", + { + "w-30 md:w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": + isSearchOpen, + } + )} + > + <Search className="h-3.5 w-3.5" /> + + <input + ref={inputRef} + className="w-full border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none" + placeholder="Search…" + value={searchQuery} + onChange={(e) => updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + + {isSearchOpen && ( + <button + type="button" + className="grid place-items-center" + onClick={() => { + updateSearchQuery(""); + setIsSearchOpen(false); + }} + > + <X className="h-3 w-3" /> + </button> + )} + </div> + </div> + ); +}; + +export default OppositionSearch; diff --git a/apps/web/ce/features/opposition/components/opposition-team-block.tsx b/apps/web/ce/features/opposition/components/opposition-team-block.tsx new file mode 100644 index 00000000000..8c0318ef7ba --- /dev/null +++ b/apps/web/ce/features/opposition/components/opposition-team-block.tsx @@ -0,0 +1,139 @@ +"use client"; + +import type { FC } from "react"; +import React, { useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { Pencil, Trash2 } from "lucide-react"; + +import { AlertModalCore } from "@plane/ui"; +import type { TContextMenuItem } from "@plane/ui"; + +import { WorkspaceDraftIssueQuickActions } from "@/components/issues/workspace-draft/quick-action"; +import { useOppositionTeams } from "../store/opposition-teams-context"; +import { updateEntity } from "../services/update-opposition"; +import { EditOppositionTeamModal } from "./opposition-team-form"; + +interface Team { + id: string; + name: string; + address: string; + logo: string; + athletic_email: string; + athletic_phone: string; + head_coach_name: string; + asst_coach_name: string; + asst_athletic_email: string; + asst_athletic_phone: string; +} + +type Props = { + workspaceSlug: string; + team: Team; +}; + +const API_URL = `${process.env.NEXT_PUBLIC_CP_SERVER_URL}/meta-type`; + +async function getOppositionTeamBlock() { + const res = await fetch(API_URL); + const json = await res.json(); + + const list = json?.["Gateway Response"]?.result; + if (!Array.isArray(list)) return null; + + const block = list.find( + (item: any) => Array.isArray(item) && item.some((f: any) => f?.field === "key" && f?.value === "OPPOSITIONTEAM") + ); + if (!block) return null; + + const getField = (key: string) => { + const found = block.find((x: any) => x?.field === key); + return found?.value; + }; + + return { + id: getField("id"), + name: getField("name"), + key: getField("key"), + values: getField("values") || [], + }; +} + +export const OppositionTeamBlock: FC<Props> = observer(({ workspaceSlug, team }) => { + const issueRef = useRef<HTMLDivElement | null>(null); + + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => setIsEditOpen(true), + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => setIsDeleteOpen(true), + }, + ]; + + const { refreshTeams } = useOppositionTeams(); + + const handleDelete = async () => { + try { + const block = await getOppositionTeamBlock(); + if (!block) { + // alert("Opposition Team meta-type missing"); + return; + } + + if (!team?.id) { + console.error("Team UID missing"); + return; + } + + const updatedValues = block.values.filter((t: any) => t.id !== team.id); + + const payload = { + id: block.id, + name: block.name, + key: block.key, + values: updatedValues, + }; + + await updateEntity("meta-type", payload); + refreshTeams(); + + setIsDeleteOpen(false); + } catch (err) { + console.error("Delete failed", err); + } + }; + + return ( + <div ref={issueRef} className="flex"> + {/* ACTION MENU */} + <WorkspaceDraftIssueQuickActions parentRef={issueRef} MENU_ITEMS={MENU_ITEMS} /> + + {/* EDIT MODAL */} + {isEditOpen && <EditOppositionTeamModal isOpen={isEditOpen} onClose={() => setIsEditOpen(false)} team={team} />} + + {/* DELETE CONFIRMATION MODAL */} + <AlertModalCore + isOpen={isDeleteOpen} + title="Delete Opposition Team" + content={ + <> + Are you sure you want to delete <strong className="font-medium text-custom-text-100">{team?.name}</strong> team? This action cannot be undone. + </> + } + handleClose={() => setIsDeleteOpen(false)} + handleSubmit={handleDelete} + isSubmitting={false} + variant="danger" + /> + </div> + ); +}); \ No newline at end of file diff --git a/apps/web/ce/features/opposition/components/opposition-team-form.tsx b/apps/web/ce/features/opposition/components/opposition-team-form.tsx new file mode 100644 index 00000000000..86f2967b629 --- /dev/null +++ b/apps/web/ce/features/opposition/components/opposition-team-form.tsx @@ -0,0 +1,476 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import PhoneInput from "react-phone-input-2"; +import { v4 as uuidv4 } from "uuid"; +import { Pencil, Users } from "lucide-react"; +import { Button } from "@plane/propel/button"; +import { Input, ModalCore, EModalPosition, Label } from "@plane/ui"; +import { useOppositionTeams } from "../store/opposition-teams-context"; +import { updateEntity } from "../services/update-opposition"; +import { generateFileOppositionName, getAbsoluteImageUrl, uploadImageToServer } from "../services/upload-service"; + +interface Team { + id: string; + name: string; + address: string; + logo: string; + athletic_email: string; + athletic_phone: string; + head_coach_name: string; + asst_coach_name: string; + asst_athletic_email: string; + asst_athletic_phone: string; +} + +interface Props { + isOpen: boolean; + onClose: () => void; + team?: Team; +} + +const API_URL = `${process.env.NEXT_PUBLIC_CP_SERVER_URL}/meta-type`; + +const convertToBase64 = (file: File): Promise<string> => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + }); + +async function getOppositionTeamBlock() { + const res = await fetch(API_URL); + const json = await res.json(); + + const list = json?.["Gateway Response"]?.result; + if (!Array.isArray(list)) return null; + + const block = list.find( + (item: any) => Array.isArray(item) && item.some((f: any) => f?.field === "key" && f?.value === "OPPOSITIONTEAM") + ); + if (!block) return null; + + const getField = (key: string) => { + const found = block.find((x: any) => x?.field === key); + return found?.value; + }; + + return { + id: getField("id"), + name: getField("name"), + key: getField("key"), + values: getField("values") || [], + }; +} + +export const OppositionTeamModal: React.FC<Props> = ({ isOpen, onClose }) => { + const [teamName, setTeamName] = useState(""); + const [address, setAddress] = useState(""); + const [athleticDirector, setAthleticDirector] = useState(""); + const [assistantDirector, setAssistantDirector] = useState(""); + + const [athleticEmail, setAthleticEmail] = useState(""); + const [assistantEmail, setAssistantEmail] = useState(""); + + const [athleticPhone, setAthleticPhone] = useState(""); + const [assistantPhone, setAssistantPhone] = useState(""); + + const [logo, setLogo] = useState<File | null>(null); + const [preview, setPreview] = useState<string | null>(null); + + const { refreshTeams } = useOppositionTeams(); + + useEffect(() => { + if (isOpen) { + setTeamName(""); + setAddress(""); + setAthleticDirector(""); + setAssistantDirector(""); + setAthleticEmail(""); + setAssistantEmail(""); + setAthleticPhone(""); + setAssistantPhone(""); + setLogo(null); + setPreview(null); + } + }, [isOpen]); + + const handleImageChange = (e: any) => { + const file = e.target.files?.[0]; + if (!file) return; + + setLogo(file); + setPreview(URL.createObjectURL(file)); + }; + + const handleSubmit = async () => { + const block = await getOppositionTeamBlock(); + if (!block) { + alert("Opposition Team meta-type missing"); + return; + } + + const newId = uuidv4(); + let logoPath = ""; // Default to empty if no logo + + // --- NEW UPLOAD LOGIC START --- + if (logo) { + try { + const folderName = "opposition-teams"; // Define your folder path + const fileName = generateFileOppositionName(teamName, newId, logo); + // 1. Upload the file + await uploadImageToServer(logo, folderName, fileName); + // 2. Set the path to be saved in JSON (e.g., "opposition-teams/name_id.png") + logoPath = `${folderName}/${fileName}`; + } catch (error) { + console.error("Upload failed", error); + alert("Failed to upload image"); + return; + } + } + // --- NEW UPLOAD LOGIC END --- + + const newTeam = { + id: newId, + name: teamName, + address, + athletic_email: athleticEmail, + athletic_phone: athleticPhone, + head_coach_name: athleticDirector, + asst_coach_name: assistantDirector, + asst_athletic_email: assistantEmail, + asst_athletic_phone: assistantPhone, + logo: logoPath, // Saving the path string, not Base64 + }; + + const updatedValues = [...block.values, newTeam]; + + const entity = { + id: block.id, + name: block.name, + key: block.key, + values: updatedValues, + }; + + await updateEntity("meta-type", entity); + refreshTeams(); + onClose(); + }; + + return ( + <ModalCore position={EModalPosition.TOP} isOpen={isOpen}> + <div className="px-6 py-4 border-b border-custom-border-200"> + <h2 className="text-lg font-semibold">Add Opposition Team</h2> + </div> + + <div className="px-6 py-6 grid grid-cols-2 gap-6"> + {/* IMAGE */} + <div className="col-span-2"> + <div className="flex items-center gap-4"> + <div className="relative"> + <div className="w-[50px] h-[50px] border border-custom-border-200 rounded overflow-hidden flex items-center justify-center"> + {preview ? ( + <img + src={preview} // preview contains either blob:url (new file) or http://server/blobs/path (existing) + alt="logo" + className="w-full h-full object-cover" + /> + ) : ( + <Users className="text-zinc-500 text-3xl" /> + )} + </div> + <label className="absolute -top-3 -right-2 p-1 bg-zinc-600 rounded-full cursor-pointer"> + <input type="file" accept="image/*" className="hidden" onChange={handleImageChange} /> + <Pencil className="w-4 h-4 text-zinc-300" /> + </label> + </div> + </div> + </div> + + {/* FORM FIELDS */} + <div className="col-span-2 flex flex-col gap-1"> + <Label htmlFor="teamName">Team Name</Label> + <Input value={teamName} placeholder="Enter team name" onChange={(e) => setTeamName(e.target.value)} /> + </div> + + <div className="col-span-2 flex flex-col gap-1"> + <Label htmlFor="address">Address</Label> + <Input value={address} placeholder="Enter team address" onChange={(e) => setAddress(e.target.value)} /> + </div> + + <div className="col-span-2 flex flex-col gap-1"> + <Label htmlFor="athleticDirector">Athletic Director</Label> + <Input + value={athleticDirector} + placeholder="Enter athletic director name" + onChange={(e) => setAthleticDirector(e.target.value)} + /> + </div> + + <div className="flex flex-col gap-1"> + <Label htmlFor="athleticEmail">Email</Label> + <Input value={athleticEmail} placeholder="Enter email" onChange={(e) => setAthleticEmail(e.target.value)} /> + </div> + + <div className="flex flex-col gap-1"> + <Label htmlFor="athleticPhone">Phone</Label> + <PhoneInput + country="us" + value={athleticPhone} + onChange={(val) => setAthleticPhone("+" + val)} + inputClass="!bg-transparent border-[0.5px] !border-custom-border-200 !w-full " + buttonClass="!bg-transparent border-[0.5px] !border-custom-border-200" + dropdownClass="!bg-zinc-800 border-[0.5px] !border-custom-border-200" + containerClass="w-full" + /> + </div> + + <div className="col-span-2 flex flex-col gap-1"> + <Label htmlFor="assistantDirector">Assistant Athletic Director</Label> + <Input + value={assistantDirector} + placeholder="Enter asst athlectic director name" + onChange={(e) => setAssistantDirector(e.target.value)} + /> + </div> + + <div className="flex flex-col gap-1"> + <Label htmlFor="assistantEmail">Email</Label> + <Input value={assistantEmail} placeholder="Enter email" onChange={(e) => setAssistantEmail(e.target.value)} /> + </div> + + <div className="flex flex-col gap-1"> + <Label htmlFor="assistantPhone">Phone</Label> + <PhoneInput + country="us" + value={assistantPhone} + onChange={(val) => setAssistantPhone("+" + val)} + inputClass="!bg-transparent border-[0.5px] !border-custom-border-200 !w-full " + buttonClass="!bg-transparent border-[0.5px] !border-custom-border-200" + dropdownClass="!bg-zinc-800 border-[0.5px] !border-custom-border-200" + containerClass="w-full" + /> + </div> + </div> + + <div className="px-6 py-4 border-t border-custom-border-200 flex justify-end gap-3"> + <Button variant="neutral-primary" onClick={onClose}> + Cancel + </Button> + <Button variant="primary" onClick={handleSubmit}> + Save + </Button> + </div> + </ModalCore> + ); +}; + +/* ------------------------------------------------------- + EDIT TEAM MODAL +---------------------------------------------------------*/ +export const EditOppositionTeamModal: React.FC<Props> = ({ isOpen, onClose, team }) => { + const [id, setId] = useState(""); + const [teamName, setTeamName] = useState(""); + const [address, setAddress] = useState(""); + const [athleticDirector, setAthleticDirector] = useState(""); + const [assistantDirector, setAssistantDirector] = useState(""); + + const [athleticEmail, setAthleticEmail] = useState(""); + const [assistantEmail, setAssistantEmail] = useState(""); + + const [athleticPhone, setAthleticPhone] = useState(""); + const [assistantPhone, setAssistantPhone] = useState(""); + + const [logo, setLogo] = useState<File | null>(null); + const [preview, setPreview] = useState<string | null>(null); + + const { refreshTeams } = useOppositionTeams(); + + useEffect(() => { + if (team) { + setId(team.id); + setTeamName(team.name); + setAddress(team.address); + setAthleticDirector(team.head_coach_name); + setAthleticEmail(team.athletic_email); + setAthleticPhone(team.athletic_phone); + setAssistantDirector(team.asst_coach_name); + setAssistantEmail(team.asst_athletic_email); + setAssistantPhone(team.asst_athletic_phone); + setPreview(team.logo ? getAbsoluteImageUrl(team.logo) : null); + } + }, [team]); + + const handleImageChange = (e: any) => { + const file = e.target.files?.[0]; + if (file) { + setLogo(file); + setPreview(URL.createObjectURL(file)); + } + }; + + const handleUpdate = async () => { + const block = await getOppositionTeamBlock(); + if (!block || !team?.id) return; + + let logoPath = team.logo; // Default to existing value + + // --- NEW UPLOAD LOGIC START --- + if (logo instanceof File) { + try { + const folderName = "opposition-teams"; + // Use existing ID to overwrite or maintain consistency + const fileName = generateFileOppositionName(teamName, team.id, logo); + + // 1. Upload new file + await uploadImageToServer(logo, folderName, fileName); + + // 2. Update path + logoPath = `${folderName}/${fileName}`; + } catch (error) { + console.error("Upload failed", error); + alert("Failed to upload image"); + return; + } + } + // --- NEW UPLOAD LOGIC END --- + + const updatedTeam: Team = { + id: team.id, + name: teamName, + address, + athletic_email: athleticEmail, + athletic_phone: athleticPhone, + head_coach_name: athleticDirector, + asst_coach_name: assistantDirector, + asst_athletic_email: assistantEmail, + asst_athletic_phone: assistantPhone, + logo: logoPath, + }; + + const updatedValues = block.values.map((t: Team) => + t.id === team.id ? updatedTeam : t + ); + + const entity = { + id: block.id, + name: block.name, + key: block.key, + values: updatedValues, + }; + + await updateEntity("meta-type", entity); + refreshTeams(); + onClose(); +}; + + return ( + <ModalCore position={EModalPosition.TOP} isOpen={isOpen}> + <div className="px-6 py-4 border-b border-custom-border-200"> + <h2 className="text-lg font-semibold">Update Opposition Team</h2> + </div> + + <div className="px-6 py-6 grid grid-cols-2 gap-6"> + {/* IMAGE */} + <div className="col-span-2 flex items-center gap-4"> + <div className="relative"> + <div className="w-[50px] h-[50px] border border-custom-border-200 rounded overflow-hidden"> + {preview ? ( + <img src={preview} alt="logo" className="w-full h-full object-cover" /> + ) : ( + <Users className="text-zinc-500 text-3xl" /> + )} + </div> + <label className="absolute -top-3 -right-2 p-1 bg-zinc-600 rounded-full cursor-pointer"> + <input type="file" className="hidden" accept="image/*" onChange={handleImageChange} /> + <Pencil className="w-4 h-4 text-zinc-300" /> + </label> + </div> + </div> + + {/* FORM */} + <div className="col-span-2 flex flex-col gap-1"> + <Label htmlFor="teamName">Team Name</Label> + <Input value={teamName} placeholder="Enter team name" onChange={(e) => setTeamName(e.target.value)} /> + </div> + + <div className="col-span-2 flex flex-col gap-1"> + <Label htmlFor="address">Address</Label> + <Input value={address} placeholder="Enter team address" onChange={(e) => setAddress(e.target.value)} /> + </div> + + <div className="col-span-2 flex flex-col gap-1"> + <Label htmlFor="athlecticDirector">Athletic Director</Label> + <Input + value={athleticDirector} + placeholder="Enter athletic director name" + onChange={(e) => setAthleticDirector(e.target.value)} + /> + </div> + + <div className="flex flex-col gap-1"> + <Label htmlFor="athleticEmail">Email</Label> + <Input + value={athleticEmail} + placeholder="Enter athletic director email" + onChange={(e) => setAthleticEmail(e.target.value)} + /> + </div> + + <div className="flex flex-col gap-1"> + <Label htmlFor="athleticPhone">Phone</Label> + <PhoneInput + value={athleticPhone} + country="us" + onChange={(val) => setAthleticPhone("+" + val)} + inputClass="!bg-transparent border-[0.5px] !border-custom-border-200 !w-full " + buttonClass="!bg-transparent border-[0.5px] !border-custom-border-200" + dropdownClass="!bg-custom-border-200 border-[0.5px] !border-custom-border-200" + containerClass="w-full" + /> + </div> + + <div className="col-span-2 flex flex-col gap-1"> + <Label htmlFor="assistantDirector">Assistant Athletic Director</Label> + <Input + value={assistantDirector} + placeholder="Enter asst athletic director name" + onChange={(e) => setAssistantDirector(e.target.value)} + /> + </div> + + <div className="flex flex-col gap-1"> + <Label htmlFor="assistantEmail">Email</Label> + <Input + value={assistantEmail} + placeholder="Enter asst athletic director email" + onChange={(e) => setAssistantEmail(e.target.value)} + /> + </div> + + <div className="flex flex-col gap-1"> + <Label htmlFor="assistantPhone">Phone</Label> + <PhoneInput + value={assistantPhone} + country="us" + onChange={(val) => setAthleticPhone("+" + val)} + inputClass="!bg-transparent border-[0.5px] !border-custom-border-200 !w-full " + buttonClass="!bg-transparent border-[0.5px] !border-custom-border-200" + dropdownClass="!bg-custom-border-200 border-[0.5px] !border-custom-border-200" + containerClass="w-full" + /> + </div> + </div> + + <div className="px-6 py-4 border-t border-custom-border-200 flex justify-end gap-3"> + <Button variant="neutral-primary" onClick={onClose}> + Cancel + </Button> + <Button variant="primary" onClick={handleUpdate}> + Update + </Button> + </div> + </ModalCore> + ); +}; diff --git a/apps/web/ce/features/opposition/components/opposition-team-logo.tsx b/apps/web/ce/features/opposition/components/opposition-team-logo.tsx new file mode 100644 index 00000000000..f0ed5d13cc0 --- /dev/null +++ b/apps/web/ce/features/opposition/components/opposition-team-logo.tsx @@ -0,0 +1,39 @@ +"use client"; +import React, { useState } from "react"; + +interface Props { + path: string | null; + name: string; +} + +export const TeamLogo = ({ path, name }: Props) => { + const [error, setError] = useState(false); + + + const getImageUrl = (imagePath: string) => { + if (!imagePath) return null; + if (imagePath.startsWith("http") || imagePath.startsWith("data:")) return imagePath; + return `${process.env.NEXT_PUBLIC_CP_SERVER_URL}/blobs/${imagePath}`; + }; + + const fullUrl = path ? getImageUrl(path) : null; + + + if (fullUrl && !error) { + return ( + <img + src={fullUrl} + alt={name} + className="w-full h-full object-cover" + onError={() => setError(true)} + /> + ); + } + + + return ( + <div className="w-full h-full flex items-center justify-center text-gray-500 text-xl bg-zinc-900"> + {name.charAt(0).toUpperCase()} + </div> + ); +}; \ No newline at end of file diff --git a/apps/web/ce/features/opposition/components/workspace-opposition-header.tsx b/apps/web/ce/features/opposition/components/workspace-opposition-header.tsx new file mode 100644 index 00000000000..2e1014a04d9 --- /dev/null +++ b/apps/web/ce/features/opposition/components/workspace-opposition-header.tsx @@ -0,0 +1,67 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import { UsersRoundIcon } from "lucide-react"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs, Button, Header } from "@plane/ui"; +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// import OppositionSearch from "./opposition-search"; +// import { useOppositionSearch } from "../store/opposition-search-context"; +import { OppositionTeamModal } from "./opposition-team-form"; +// import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft"; + +export const WorkspaceOppositionHeader = observer(() => { + // state + const [isOppositionTeamModalOpen, setIsOppositionTeamModalOpen] = useState(false); + // const { search, setSearch } = useOppositionSearch(); + // store hooks + const { allowPermissions } = useUserPermissions(); + 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 ( + <> + <OppositionTeamModal isOpen={isOppositionTeamModalOpen} onClose={() => setIsOppositionTeamModalOpen(false)} /> + <Header> + <Header.LeftItem> + <div className="flex items-center gap-2.5"> + <Breadcrumbs> + <Breadcrumbs.Item + component={ + <BreadcrumbLink + label={t("Opposition teams")} + icon={<UsersRoundIcon className="h-4 w-4 text-custom-text-300" />} + /> + } + /> + </Breadcrumbs> + </div> + </Header.LeftItem> + + <Header.RightItem> + {/* <OppositionSearch searchQuery={search} updateSearchQuery={setSearch} /> */} + {joinedProjectIds && joinedProjectIds.length > 0 && ( + <Button + variant="primary" + size="sm" + className="items-center gap-1" + onClick={() => setIsOppositionTeamModalOpen(true)} + disabled={!isAuthorizedUser} + > + Create Opposition Team + </Button> + )} + </Header.RightItem> + </Header> + </> + ); +}); diff --git a/apps/web/ce/features/opposition/components/workspace-opposition-page.tsx b/apps/web/ce/features/opposition/components/workspace-opposition-page.tsx new file mode 100644 index 00000000000..e82b5f69fe7 --- /dev/null +++ b/apps/web/ce/features/opposition/components/workspace-opposition-page.tsx @@ -0,0 +1,35 @@ +"use client"; +import React from "react"; +import { useParams } from "next/navigation"; +import { PageHead } from "@/components/core/page-title"; + +import { useOppositionSearch } from "../store/opposition-search-context"; +import { useOppositionTeams } from "../store/opposition-teams-context"; +// import { loadOppositionTeams } from "../services/load-opposition-teams"; +import OppositionTeamsList from "./opposition-list"; + +const WorkspaceOppositionPage = () => { + const { workspaceSlug: routeWorkspaceSlug } = useParams(); + const { search } = useOppositionSearch(); + const pageTitle = "Opposition Teams"; + const { teams, loading } = useOppositionTeams(); + + // const [teams, setTeams] = useState([]); + + + // derived values + const workspaceSlug = (routeWorkspaceSlug as string) || undefined; + + if (!workspaceSlug) return null; + + return ( + <> + <PageHead title={pageTitle} /> + <div className="relative h-full w-full overflow-hidden overflow-y-auto"> + <OppositionTeamsList teams={teams} workspaceSlug={workspaceSlug} searchQuery={search} /> + </div> + </> + ); +}; + +export default WorkspaceOppositionPage; diff --git a/apps/web/ce/features/opposition/index.ts b/apps/web/ce/features/opposition/index.ts new file mode 100644 index 00000000000..650b8d97e8a --- /dev/null +++ b/apps/web/ce/features/opposition/index.ts @@ -0,0 +1,4 @@ +export { default as WorkspaceOppositionPage } from "./components/workspace-opposition-page"; +export { WorkspaceOppositionHeader } from "./components/workspace-opposition-header"; +export { OppositionSearchProvider, useOppositionSearch } from "./store/opposition-search-context"; +export { OppositionTeamsProvider, useOppositionTeams } from "./store/opposition-teams-context"; diff --git a/apps/web/ce/features/opposition/services/adapter.ts b/apps/web/ce/features/opposition/services/adapter.ts new file mode 100644 index 00000000000..debe7b3c59d --- /dev/null +++ b/apps/web/ce/features/opposition/services/adapter.ts @@ -0,0 +1,130 @@ + +const BASE_URL = process.env.NEXT_PUBLIC_CP_SERVER_URL!; + +export interface Template { + field: string; + type: number; +} + +export class AdapterService { + templateMap: Record<string, Record<string, number>> = {}; + + //------------------------- + // READ() + //------------------------- + async read(endpoint: string) { + const res = await fetch(`${BASE_URL}/${endpoint}`); + if (!res.ok) throw new Error(`Failed to read: ${endpoint}`); + return res.json(); + } + + //------------------------- + // cppToNg() + //------------------------- + cppToNg(cppObj: { field: string; type: number; value: any }[]) { + const ngObj: any = {}; + + for (const obj of cppObj) { + if (obj.type === 6) { + // array: recurse + ngObj[obj.field] = obj.value.map((item: any) => + this.cppToNg(item) + ); + } else { + ngObj[obj.field] = obj.value; + } + } + + return ngObj; + } + + //------------------------- + // getTemplateMap() + //------------------------- + async getTemplateMap(resource: string) { + // cache + if (this.templateMap[resource]) { + return this.templateMap[resource]; + } + + // FIX #1: hyphen -> underscore (Angular behavior) + const fixed = resource.includes("-") + ? resource.replace(/-/g, "_") + : resource; + + // FIX #2: correct backend URL + const tmpl = await this.read(`${fixed}/template`); + + const templObj: Record<string, number> = {}; + + for (const elem of tmpl["Gateway Response"]) { + templObj[elem.field] = elem.type; + } + + // save cache + this.templateMap[resource] = templObj; + + return templObj; + } + + //------------------------- + // ngToCpp() + //------------------------- + async ngToCpp( + ngObj: Record<string, any>, + resource: string + ): Promise<{ field: string; type: number; value: any }[]> { + const template = await this.getTemplateMap(resource); + + const cppObj = []; + + for (const key of Object.keys(ngObj)) { + if (key === "id") continue; + + cppObj.push({ + field: key, + type: template[key], + value: ngObj[key], + }); + } + + return cppObj; + } + + //------------------------- + // demodulate() + //------------------------- + async demodulate(type: string, dataPromise: Promise<any>) { + const blob = await dataPromise; + const result = blob?.["Gateway Response"]?.result || []; + return result.map((cpp: any) => this.cppToNg(cpp)); + } + + //------------------------- + // modulate() + //------------------------- + async modulate(type: string, data: any[]) { + const all = await Promise.all(data.map((x) => this.ngToCpp(x, type))); + return all; + } + + //------------------------- + // modulateOne() + //------------------------- + async modulateOne(type: string, data: any) { + // FIX #3: hyphen → underscore for backend table name + const fixedType = type.includes("-") + ? type.replace(/-/g, "_") + : type; + + const cols = await this.ngToCpp(data, type); + + return { + table: fixedType, + columns: cols, + criteria: [{ field: "id", value: data["id"] }], + }; + } +} + +export const adapter = new AdapterService(); diff --git a/apps/web/ce/features/opposition/services/create-opposition.ts b/apps/web/ce/features/opposition/services/create-opposition.ts new file mode 100644 index 00000000000..cf151a04e8f --- /dev/null +++ b/apps/web/ce/features/opposition/services/create-opposition.ts @@ -0,0 +1,15 @@ +import { adapter } from "./adapter"; + +export async function createEntity(type: string, entity: any) { + const payload = await adapter.modulateOne(type, entity); + + const BASE_URL = process.env.NEXT_PUBLIC_CP_SERVER_URL! + const res = await fetch(`${BASE_URL}/${type}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) throw new Error("Create failed"); + return res.json(); +} diff --git a/apps/web/ce/features/opposition/services/load-opposition-teams.ts b/apps/web/ce/features/opposition/services/load-opposition-teams.ts new file mode 100644 index 00000000000..6161d1c6a43 --- /dev/null +++ b/apps/web/ce/features/opposition/services/load-opposition-teams.ts @@ -0,0 +1,66 @@ +interface OppositionTeam { + id: string; + address: string; + asst_athletic_email: string; + asst_athletic_phone: string; + asst_coach_name: string; + athletic_email: string; + athletic_phone: string; + head_coach_name: string; + logo: string; + name: string; +} + +export async function loadOppositionTeams() { + try { + const url = `${process.env.NEXT_PUBLIC_CP_SERVER_URL}/meta-type?key='OPPOSITIONTEAM'`; + + const res = await fetch(url, { cache: "no-store" }); + + if (!res.ok) { + console.error("Failed to load opposition teams", res.status, res.statusText); + return []; + } + + let data: any; + + try { + data = await res.json(); + } catch (err) { + console.error("Invalid JSON from meta-type API", err); + return []; + } + + const list = data?.["Gateway Response"]?.result; + if (!Array.isArray(list) || list.length === 0) { + console.warn("OppositionTeam meta-type not found"); + return []; + } + + const block = list[0]; + + const values = + block?.find((item: any) => item.field === "values")?.value || []; + + if (!Array.isArray(values)) return []; + + const teams = values.map((item: OppositionTeam) => ({ + id: item.id, + name: item.name, + address: item.address, + asst_coach_name: item.asst_coach_name, + head_coach_name: item.head_coach_name, + asst_athletic_email: item.asst_athletic_email, + asst_athletic_phone: item.asst_athletic_phone, + athletic_email: item.athletic_email, + athletic_phone: item.athletic_phone, + logo: item.logo, + })); + + + return teams; + } catch (error) { + console.error("Unexpected error loading opposition teams:", error); + return []; // Safe fallback to avoid provider crashing + } +} diff --git a/apps/web/ce/features/opposition/services/update-opposition.ts b/apps/web/ce/features/opposition/services/update-opposition.ts new file mode 100644 index 00000000000..73914d4fb5b --- /dev/null +++ b/apps/web/ce/features/opposition/services/update-opposition.ts @@ -0,0 +1,17 @@ +import { adapter } from "./adapter"; + + +export async function updateEntity(type: string, entity: any) { + + const BASE_URL = process.env.NEXT_PUBLIC_CP_SERVER_URL! + const payload = await adapter.modulateOne(type, entity); + + const res = await fetch(`${BASE_URL}/${type}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) throw new Error("Update failed"); + return res.json(); +} diff --git a/apps/web/ce/features/opposition/services/upload-service.ts b/apps/web/ce/features/opposition/services/upload-service.ts new file mode 100644 index 00000000000..9c9693451a5 --- /dev/null +++ b/apps/web/ce/features/opposition/services/upload-service.ts @@ -0,0 +1,32 @@ +const BASE_URL = process.env.NEXT_PUBLIC_CP_SERVER_URL; + +// 1. Helper to generate a consistent file name + export const generateFileOppositionName = (name: string, id: string, file: File) => { + const cleanName = name.replace(/\s+/g, '').toLowerCase(); + const extension = file.type.split('/')[1]; // e.g., 'png' + return `${cleanName}_${id}.${extension}`; +}; + +// 2. The Upload Logic (replaces your Angular uploadImage) +export const uploadImageToServer = async (file: File, path: string, name: string) => { + const url = `${BASE_URL}/blob?path=${path}&name=${name}&replace=1`; + const formData = new FormData(); + formData.append("file", file); + + const res = await fetch(url, { + method: "POST", + body: formData, + }); + + if (!res.ok) { + throw new Error("Image upload failed"); + } + return res.json(); // Or handle based on your specific API response +}; + +// 3. Helper to get full URL for display +export const getAbsoluteImageUrl = (partialPath: string) => { + if (!partialPath) return null; + if (partialPath.startsWith("data:") || partialPath.startsWith("http")) return partialPath; // Handle existing Base64 or full URLs + return `${BASE_URL}/blobs/${partialPath}`; +}; \ No newline at end of file diff --git a/apps/web/ce/features/opposition/store/opposition-search-context.tsx b/apps/web/ce/features/opposition/store/opposition-search-context.tsx new file mode 100644 index 00000000000..f6933456e2d --- /dev/null +++ b/apps/web/ce/features/opposition/store/opposition-search-context.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { createContext, useContext, useState } from "react"; + +interface SearchContextType { + search: string; + setSearch: (v: string) => void; +} + +const OppositionSearchContext = createContext<SearchContextType | null>(null); + +export const OppositionSearchProvider = ({ children }: { children: React.ReactNode }) => { + const [search, setSearch] = useState(""); + + return ( + <OppositionSearchContext.Provider value={{ search, setSearch }}> + {children} + </OppositionSearchContext.Provider> + ); +}; + +export const useOppositionSearch = () => { + const ctx = useContext(OppositionSearchContext); + if (!ctx) throw new Error("useOppositionSearch must be used inside provider"); + return ctx; +}; diff --git a/apps/web/ce/features/opposition/store/opposition-teams-context.tsx b/apps/web/ce/features/opposition/store/opposition-teams-context.tsx new file mode 100644 index 00000000000..bd691008443 --- /dev/null +++ b/apps/web/ce/features/opposition/store/opposition-teams-context.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React, { createContext, useContext, useEffect, useState } from "react"; +import { loadOppositionTeams } from "../services/load-opposition-teams"; + +interface Team { + id: string; + name: string; + address: string; + logo: string; + athletic_email: string; + athletic_phone: string; + head_coach_name: string; + asst_coach_name: string; + asst_athletic_email: string; + asst_athletic_phone: string; +} + +interface OppositionContextType { + teams: Team[]; + loading: boolean; + refreshTeams: () => Promise<void>; +} + +const OppositionTeamsContext = createContext<OppositionContextType | null>(null); + +export const OppositionTeamsProvider = ({ children }: { children: React.ReactNode }) => { + const [teams, setTeams] = useState<any[]>([]); + const [loading, setLoading] = useState<boolean>(true); + + const refreshTeams = async () => { + setLoading(true); + try { + const data = await loadOppositionTeams(); + setTeams(data); + } catch (err) { + console.error("Failed to refresh opposition teams:", err); + } + setLoading(false); + }; + + useEffect(() => { + refreshTeams(); + }, []); + + return ( + <OppositionTeamsContext.Provider value={{ teams, loading, refreshTeams }}> + {children} + </OppositionTeamsContext.Provider> + ); +}; + +export const useOppositionTeams = () => { + const context = useContext(OppositionTeamsContext); + + if (!context) { + throw new Error("useOppositionTeams must be used inside OppositionTeamsProvider"); + } + + return context; +}; diff --git a/apps/web/ce/features/programs/README.md b/apps/web/ce/features/programs/README.md new file mode 100644 index 00000000000..899a96ff09a --- /dev/null +++ b/apps/web/ce/features/programs/README.md @@ -0,0 +1,5 @@ +# Programs Feature + +This folder is reserved for future program-specific customizations. + +Plane's existing program behavior is still implemented through the established project/program modules in `core/` and `ce/components/projects/`. Keep that architecture unchanged unless a future refactor can isolate a self-contained program feature without changing routes or store behavior. diff --git a/apps/web/ce/features/programs/index.ts b/apps/web/ce/features/programs/index.ts new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/apps/web/ce/features/programs/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/web/ce/features/roster/README.md b/apps/web/ce/features/roster/README.md new file mode 100644 index 00000000000..4ab79d2a8b0 --- /dev/null +++ b/apps/web/ce/features/roster/README.md @@ -0,0 +1,10 @@ +# Roster Feature + +Community Edition roster UI lives here so the Next.js route folder only defines the URL. + +- `components/` contains page, header, table, card, dropdown, and modal UI. +- `store/` contains the React context provider and feature-level state transitions. +- `constants/` contains display, filter, and import mapping constants. +- `utils/` contains formatting and import helpers. + +Routes import this feature through `@/plane-web/features/roster`, which keeps the CE alias intact. diff --git a/apps/web/ce/features/roster/components/project-roster-header.tsx b/apps/web/ce/features/roster/components/project-roster-header.tsx new file mode 100644 index 00000000000..cd67349ed67 --- /dev/null +++ b/apps/web/ce/features/roster/components/project-roster-header.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { ListFilter, Search, SlidersHorizontal, Trash2, Upload } from "lucide-react"; +import type { EProjectFeatureKey } from "@plane/constants"; +import { Breadcrumbs, Button, Header, Input } from "@plane/ui"; +import { CountChip } from "@/components/common/count-chip"; +import { useProject } from "@/hooks/store/use-project"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +import { RosterDisplayDropdown, RosterFilterDropdown } from "./roster-dropdowns"; +import { useRoster } from "../store/roster-context"; + +export const ProjectRosterHeader = observer(() => { + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; + const { loader } = useProject(); + const { + players, + searchValue, + setSearchValue, + canManage, + openImportRosterModal, + selectedPlayerIds, + clearSelectedPlayers, + openBulkDeleteModal, + } = useRoster(); + + return ( + <Header> + <Header.LeftItem> + <div className="flex items-center gap-2.5"> + <Breadcrumbs isLoading={loader === "init-loader"} className="flex-grow-0"> + <CommonProjectBreadcrumbs + workspaceSlug={workspaceSlug?.toString() ?? ""} + projectId={projectId?.toString() ?? ""} + featureKey={"roster" as EProjectFeatureKey} + isLast + /> + </Breadcrumbs> + <CountChip count={players.length} /> + </div> + </Header.LeftItem> + <Header.RightItem className="flex-1"> + <div className="relative w-full max-w-[28.75rem]"> + <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-400" /> + <Input + value={searchValue} + onChange={(event) => setSearchValue(event.target.value)} + placeholder="Search roster" + className="w-full border-custom-border-200 bg-custom-background-100 py-1.5 pl-9 pr-3 text-sm text-custom-text-200" + /> + </div> + </Header.RightItem> + <Header.RightItem> + <div className="flex gap-2"> + <RosterFilterDropdown + title="Filter" + icon={<ListFilter className="size-3.5" />} + miniIcon={<ListFilter className="size-3.5" />} + /> + <RosterDisplayDropdown title="Display" miniIcon={<SlidersHorizontal className="size-3.5" />} /> + {canManage && selectedPlayerIds.length ? ( + <> + <Button variant="neutral-primary" size="sm" onClick={clearSelectedPlayers}> + Clear selection + </Button> + <Button variant="danger" size="sm" prependIcon={<Trash2 />} onClick={openBulkDeleteModal}> + Delete selected ({selectedPlayerIds.length}) + </Button> + </> + ) : null} + </div> + {canManage ? ( + <Button variant="primary" size="sm" prependIcon={<Upload />} onClick={openImportRosterModal}> + Import roster + </Button> + ) : null} + </Header.RightItem> + </Header> + ); +}); diff --git a/apps/web/ce/features/roster/components/roster-dropdowns.tsx b/apps/web/ce/features/roster/components/roster-dropdowns.tsx new file mode 100644 index 00000000000..298abccc5c4 --- /dev/null +++ b/apps/web/ce/features/roster/components/roster-dropdowns.tsx @@ -0,0 +1,246 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useState } from "react"; +import { observer } from "mobx-react"; +import { ListFilter, SlidersHorizontal } from "lucide-react"; +import { cn } from "@plane/ui"; +import { FiltersDropdown } from "@/components/issues/issue-layouts/filters/header/helpers/dropdown"; +import { FilterHeader } from "@/components/issues/issue-layouts/filters/header/helpers/filter-header"; +import { FilterOption } from "@/components/issues/issue-layouts/filters/header/helpers/filter-option"; +import { useRoster } from "../store/roster-context"; +import { + ALL_CLASS_YEAR_OPTION, + ALL_POSITION_OPTION, + ALL_STATUS_OPTION, + GROUP_BY_OPTIONS, + ORDER_BY_OPTIONS, + ROSTER_DISPLAY_PROPERTIES, +} from "../constants/roster.constants"; +import { toDisplayStatus } from "../utils/roster.utils"; + +type TRosterDropdownProps = { + title?: string; + icon?: ReactNode; + miniIcon?: ReactNode; + menuButton?: ReactNode; +}; + +export const RosterDisplayDropdown = observer((props: TRosterDropdownProps) => { + const { title = "Display", icon, miniIcon, menuButton } = props; + const { displayProperties, toggleDisplayProperty, groupBy, subGroupBy, orderBy, setGroupBy, setSubGroupBy, setOrderBy } = + useRoster(); + const [showProperties, setShowProperties] = useState(true); + const [showGroupBy, setShowGroupBy] = useState(true); + const [showSubGroupBy, setShowSubGroupBy] = useState(true); + const [showOrderBy, setShowOrderBy] = useState(true); + + return ( + <FiltersDropdown + icon={icon} + miniIcon={miniIcon ?? <SlidersHorizontal className="size-3.5" />} + menuButton={menuButton} + title={title} + placement="bottom-end" + > + <div className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5"> + <div className="py-2"> + <FilterHeader + title="Display Properties" + isPreviewEnabled={showProperties} + handleIsPreviewEnabled={() => setShowProperties((state) => !state)} + /> + {showProperties ? ( + <div className="mt-1 flex flex-wrap items-center gap-2"> + {ROSTER_DISPLAY_PROPERTIES.map((property) => ( + <button + key={property.key} + type="button" + className={cn( + "rounded border px-2 py-0.5 text-xs transition-all", + displayProperties[property.key] + ? "border-custom-primary-100 bg-custom-primary-100 text-white" + : "border-custom-border-200 text-custom-text-200 hover:bg-custom-background-80" + )} + onClick={() => toggleDisplayProperty(property.key)} + > + {property.label} + </button> + ))} + </div> + ) : null} + </div> + <div className="py-2"> + <FilterHeader + title="Group by" + isPreviewEnabled={showGroupBy} + handleIsPreviewEnabled={() => setShowGroupBy((state) => !state)} + /> + {showGroupBy ? ( + <div> + {GROUP_BY_OPTIONS.map((option) => ( + <FilterOption + key={option.key} + isChecked={groupBy === option.key} + onClick={() => setGroupBy(option.key)} + title={option.label} + multiple={false} + /> + ))} + </div> + ) : null} + </div> + <div className="py-2"> + <FilterHeader + title="Sub-group by" + isPreviewEnabled={showSubGroupBy} + handleIsPreviewEnabled={() => setShowSubGroupBy((state) => !state)} + /> + {showSubGroupBy ? ( + <div> + {GROUP_BY_OPTIONS.map((option) => ( + <FilterOption + key={option.key} + isChecked={subGroupBy === option.key} + onClick={() => setSubGroupBy(option.key)} + title={option.label} + multiple={false} + /> + ))} + </div> + ) : null} + </div> + <div className="py-2"> + <FilterHeader + title="Order by" + isPreviewEnabled={showOrderBy} + handleIsPreviewEnabled={() => setShowOrderBy((state) => !state)} + /> + {showOrderBy ? ( + <div> + {ORDER_BY_OPTIONS.map((option) => ( + <FilterOption + key={option.key} + isChecked={orderBy === option.key} + onClick={() => setOrderBy(option.key)} + title={option.label} + multiple={false} + /> + ))} + </div> + ) : null} + </div> + </div> + </FiltersDropdown> + ); +}); + +export const RosterFilterDropdown = observer((props: TRosterDropdownProps) => { + const { title = "Filter", icon, miniIcon, menuButton } = props; + const { + statusOptions, + positionOptions, + classYearOptions, + selectedPosition, + setSelectedPosition, + selectedStatus, + setSelectedStatus, + selectedClassYear, + setSelectedClassYear, + } = useRoster(); + const [showStatus, setShowStatus] = useState(true); + const [showPosition, setShowPosition] = useState(true); + const [showClassYear, setShowClassYear] = useState(true); + + return ( + <FiltersDropdown + icon={icon ?? <ListFilter className="size-3.5" />} + miniIcon={miniIcon ?? <ListFilter className="size-3.5" />} + menuButton={menuButton} + title={title} + placement="bottom-end" + isFiltersApplied={!!selectedPosition || !!selectedStatus || !!selectedClassYear} + > + <div className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5"> + <div className="py-2"> + <FilterHeader + title="Status" + isPreviewEnabled={showStatus} + handleIsPreviewEnabled={() => setShowStatus((state) => !state)} + /> + {showStatus ? ( + <div> + <FilterOption + isChecked={selectedStatus === ""} + onClick={() => setSelectedStatus("")} + title={ALL_STATUS_OPTION} + multiple={false} + /> + {statusOptions.map((option) => ( + <FilterOption + key={option} + isChecked={selectedStatus === option} + onClick={() => setSelectedStatus(option)} + title={toDisplayStatus(option)} + multiple={false} + /> + ))} + </div> + ) : null} + </div> + <div className="py-2"> + <FilterHeader + title="Position" + isPreviewEnabled={showPosition} + handleIsPreviewEnabled={() => setShowPosition((state) => !state)} + /> + {showPosition ? ( + <div> + <FilterOption + isChecked={selectedPosition === ""} + onClick={() => setSelectedPosition("")} + title={ALL_POSITION_OPTION} + multiple={false} + /> + {positionOptions.map((option) => ( + <FilterOption + key={option} + isChecked={selectedPosition === option} + onClick={() => setSelectedPosition(option)} + title={option} + multiple={false} + /> + ))} + </div> + ) : null} + </div> + <div className="py-2"> + <FilterHeader + title="Class/Year" + isPreviewEnabled={showClassYear} + handleIsPreviewEnabled={() => setShowClassYear((state) => !state)} + /> + {showClassYear ? ( + <div> + <FilterOption + isChecked={selectedClassYear === ""} + onClick={() => setSelectedClassYear("")} + title={ALL_CLASS_YEAR_OPTION} + multiple={false} + /> + {classYearOptions.map((option) => ( + <FilterOption + key={option} + isChecked={selectedClassYear === option} + onClick={() => setSelectedClassYear(option)} + title={option} + multiple={false} + /> + ))} + </div> + ) : null} + </div> + </div> + </FiltersDropdown> + ); +}); diff --git a/apps/web/ce/features/roster/components/roster-empty-state.tsx b/apps/web/ce/features/roster/components/roster-empty-state.tsx new file mode 100644 index 00000000000..ded9bae2edd --- /dev/null +++ b/apps/web/ce/features/roster/components/roster-empty-state.tsx @@ -0,0 +1,141 @@ +"use client"; + +import type { ChangeEvent, DragEvent } from "react"; +import { useRef, useState } from "react"; +import { FileSpreadsheet, ShieldCheck, UploadCloud, Users } from "lucide-react"; +import { Button } from "@plane/propel/button"; +import { useRoster } from "../store/roster-context"; + +const IMPORT_FIELDS = ["Player name", "Jersey #", "Position", "Height", "Weight", "Status"]; +const SUPPORTED_FILES = ["XLSX", "CSV"]; + +export const RosterEmptyState = () => { + const { canManage, openImportRosterModal, setPendingImportFile } = useRoster(); + const fileInputRef = useRef<HTMLInputElement | null>(null); + const [isDragging, setIsDragging] = useState(false); + + const openImportWithFile = (file: File) => { + setPendingImportFile(file); + openImportRosterModal(); + }; + + const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0]; + if (!file) return; + + openImportWithFile(file); + event.currentTarget.value = ""; + }; + + const handleFileDrop = (event: DragEvent<HTMLDivElement>) => { + event.preventDefault(); + setIsDragging(false); + + const file = event.dataTransfer.files?.[0]; + if (!file) return; + + openImportWithFile(file); + }; + + return ( + <div className="flex min-h-[420px] flex-1 items-center justify-center py-6 sm:py-8"> + <div className="flex w-full max-w-3xl flex-col items-center rounded-2xl border border-custom-border-200 bg-custom-background-100 px-6 py-8 text-center shadow-sm sm:px-8 sm:py-10"> + <div className="relative mb-6 flex h-20 w-20 items-center justify-center rounded-3xl border border-custom-border-200 bg-custom-background-90 text-custom-primary-100"> + <Users className="h-9 w-9" /> + <div className="absolute -left-5 bottom-1 flex h-10 w-10 items-center justify-center rounded-2xl border border-custom-border-200 bg-custom-background-80 text-custom-text-200 shadow-sm"> + <FileSpreadsheet className="h-4 w-4" /> + </div> + <div className="absolute -right-4 top-1 flex h-10 w-10 items-center justify-center rounded-2xl border border-custom-border-200 bg-custom-background-80 text-custom-text-200 shadow-sm"> + <UploadCloud className="h-4 w-4" /> + </div> + <div className="absolute -bottom-4 right-1 flex h-9 w-9 items-center justify-center rounded-2xl border border-custom-border-200 bg-custom-background-80 text-custom-text-200 shadow-sm"> + <ShieldCheck className="h-4 w-4" /> + </div> + </div> + + <div className="max-w-2xl"> + <h2 className="text-xl font-semibold text-custom-text-100 sm:text-2xl">No roster uploaded yet</h2> + <p className="mt-2 text-sm leading-6 text-custom-text-300 sm:text-base"> + Import your team roster to organize player details, filter members, and manage the lineup for this program. + </p> + </div> + + <div className="mt-6 flex flex-col items-center gap-2 sm:flex-row"> + {canManage ? ( + <Button variant="primary" size="sm" className="w-full sm:w-auto" prependIcon={<UploadCloud />} onClick={openImportRosterModal}> + Import roster + </Button> + ) : null} + <a + href="/templates/roster-template.xlsx" + download="roster-template.xlsx" + className="inline-flex w-full items-center justify-center rounded px-4 py-1.5 text-xs font-medium text-custom-primary-100 transition-colors hover:text-custom-primary-200 sm:w-auto" + > + Download template + </a> + </div> + + <div className="mt-6 flex max-w-2xl flex-col items-center gap-3"> + <p className="text-xs text-custom-text-300 sm:text-sm"> + Start with the roster template, then upload a spreadsheet in the importer to preview rows before saving. + </p> + <div className="flex flex-wrap justify-center gap-2"> + {SUPPORTED_FILES.map((format) => ( + <span + key={format} + className="rounded-full border border-custom-border-200 bg-custom-background-80 px-2.5 py-1 text-[11px] font-medium uppercase tracking-wide text-custom-text-300" + > + {format} + </span> + ))} + {IMPORT_FIELDS.map((field) => ( + <span + key={field} + className="rounded-full border border-custom-border-200 bg-custom-background-80 px-2.5 py-1 text-[11px] font-medium text-custom-text-300" + > + {field} + </span> + ))} + </div> + </div> + + <div + className={`mt-6 w-full max-w-xl rounded-2xl border border-dashed px-5 py-6 transition-colors sm:px-6 ${ + isDragging + ? "border-custom-primary-100 bg-custom-primary-100/10" + : "border-custom-border-200 bg-custom-background-90" + }`} + onDragOver={(event) => { + if (!canManage) return; + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={canManage ? handleFileDrop : undefined} + > + <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-custom-primary-100/10 text-custom-primary-100"> + <UploadCloud className="h-6 w-6" /> + </div> + <div className="mt-4 text-sm font-medium text-custom-text-100">Drag and drop a roster file here</div> + <div className="mt-1 text-xs leading-5 text-custom-text-300 sm:text-sm"> + Drop a `.xlsx` or `.csv` file to open the importer with a preview, or choose a file manually. + </div> + {canManage ? ( + <div className="mt-4 flex justify-center"> + <input + ref={fileInputRef} + type="file" + accept=".csv,.xlsx" + className="hidden" + onChange={handleFileChange} + /> + <Button variant="neutral-primary" size="sm" onClick={() => fileInputRef.current?.click()}> + Choose file + </Button> + </div> + ) : null} + </div> + </div> + </div> + ); +}; diff --git a/apps/web/ce/features/roster/components/roster-list.tsx b/apps/web/ce/features/roster/components/roster-list.tsx new file mode 100644 index 00000000000..e6ea4f14fda --- /dev/null +++ b/apps/web/ce/features/roster/components/roster-list.tsx @@ -0,0 +1,731 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import type { LucideIcon } from "lucide-react"; +import { + ArrowDownWideNarrow, + ArrowUpNarrowWide, + CalendarDays, + CheckIcon, + ChevronDown, + CircleDot, + Eraser, + GraduationCap, + Hash, + MoveRight, + Pencil, + Ruler, + Scale, + Shirt, + Trash2, +} from "lucide-react"; +import type { IRosterPlayer, TRosterPlayerStatus } from "@plane/types"; +import { Checkbox, CustomMenu, cn } from "@plane/ui"; +import type { IRosterGroup, IRosterGroupedResponse } from "../store/roster-context"; +import { useRoster } from "../store/roster-context"; +import { formatTimestamp, toDisplayStatus } from "../utils/roster.utils"; + +type TRosterColumnKey = + | "jersey_number" + | "position" + | "height" + | "weight" + | "class_year" + | "status" + | "created_at" + | "updated_at"; + +type TRosterSortColumnKey = "player_name" | TRosterColumnKey; +type TRosterSortDirection = "asc" | "desc"; +type TRosterSortConfig = { + column: TRosterSortColumnKey; + direction: TRosterSortDirection; +}; + +type TRosterColumn = { + key: TRosterColumnKey; + label: string; + icon: LucideIcon; + headerClassName: string; + cellClassName: string; + render: (player: IRosterPlayer) => ReactNode; +}; + +const EMPTY_VALUE = "--"; +const DATE_SORT_COLUMNS: TRosterSortColumnKey[] = ["created_at", "updated_at"]; +const NUMERIC_SORT_COLUMNS: TRosterSortColumnKey[] = ["jersey_number", "height", "weight"]; +const CLASS_YEAR_ORDER: Record<string, number> = { + freshman: 1, + sophomore: 2, + junior: 3, + senior: 4, + graduate: 5, +}; + +const sortCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +const statusStyles: Record<TRosterPlayerStatus, string> = { + active: "border-emerald-500/30 bg-emerald-500/10 text-emerald-300", + injured: "border-amber-500/30 bg-amber-500/10 text-amber-300", + inactive: "border-custom-border-300 bg-custom-background-90 text-custom-text-300", + pending: "border-sky-500/30 bg-sky-500/10 text-sky-300", +}; + +const RosterValue = ({ value, className }: { value: string | null | undefined; className?: string }) => { + const normalizedValue = value?.trim(); + const isEmptyValue = !normalizedValue || normalizedValue === EMPTY_VALUE; + + return ( + <span className={cn("truncate", className, isEmptyValue && "text-custom-text-400")}> + {normalizedValue || EMPTY_VALUE} + </span> + ); +}; + +const RosterStatusPill = ({ status }: { status: TRosterPlayerStatus }) => ( + <span + className={cn( + "inline-flex items-center rounded border px-2 py-0.5 text-xs font-medium leading-4", + statusStyles[status] + )} + > + {toDisplayStatus(status)} + </span> +); + +const parseNumberValue = (value: string | null | undefined) => { + if (!value) return null; + const parsedValue = Number.parseFloat(value.replace(/,/g, "")); + return Number.isNaN(parsedValue) ? null : parsedValue; +}; + +const parseHeightValue = (value: string | null | undefined) => { + const normalizedValue = value?.trim().toLowerCase(); + if (!normalizedValue) return null; + + if (!normalizedValue.includes("'") && !normalizedValue.includes("ft")) return parseNumberValue(normalizedValue); + + const feetMatch = normalizedValue.match(/(\d+(?:\.\d+)?)\s*(?:'|ft)/); + const inchesMatch = normalizedValue.match(/(?:'|ft)\s*(\d+(?:\.\d+)?)/); + const feet = feetMatch ? Number.parseFloat(feetMatch[1]) : 0; + const inches = inchesMatch ? Number.parseFloat(inchesMatch[1]) : 0; + + return feet || inches ? feet * 12 + inches : null; +}; + +const getSortValue = (player: IRosterPlayer, column: TRosterSortColumnKey): string | number | null => { + switch (column) { + case "player_name": + return player.player_name?.trim() || null; + case "jersey_number": + return player.jersey_number?.trim() || null; + case "height": + return parseHeightValue(player.height); + case "weight": + return parseNumberValue(player.weight); + case "class_year": { + const normalizedClassYear = player.class_year?.trim().toLowerCase(); + return normalizedClassYear ? (CLASS_YEAR_ORDER[normalizedClassYear] ?? player.class_year) : null; + } + case "created_at": + case "updated_at": { + const timestamp = Date.parse(player[column]); + return Number.isNaN(timestamp) ? null : timestamp; + } + default: + return player[column]?.trim() || null; + } +}; + +const sortRosterPlayers = (players: IRosterPlayer[], sortConfig: TRosterSortConfig | null) => { + if (!sortConfig) return players; + + return players + .map((player, index) => ({ player, index })) + .sort((currentPlayer, nextPlayer) => { + const currentValue = getSortValue(currentPlayer.player, sortConfig.column); + const nextValue = getSortValue(nextPlayer.player, sortConfig.column); + const currentIsMissing = currentValue === null || currentValue === ""; + const nextIsMissing = nextValue === null || nextValue === ""; + + if (currentIsMissing && nextIsMissing) return currentPlayer.index - nextPlayer.index; + if (currentIsMissing) return 1; + if (nextIsMissing) return -1; + + const sortResult = + typeof currentValue === "number" && typeof nextValue === "number" + ? currentValue - nextValue + : sortCollator.compare(String(currentValue), String(nextValue)); + + if (sortResult === 0) return currentPlayer.index - nextPlayer.index; + return sortConfig.direction === "asc" ? sortResult : -sortResult; + }) + .map(({ player }) => player); +}; + +const isDateSortColumn = (columnKey: TRosterSortColumnKey) => DATE_SORT_COLUMNS.includes(columnKey); +const isNumericSortColumn = (columnKey: TRosterSortColumnKey) => NUMERIC_SORT_COLUMNS.includes(columnKey); + +const getSortIcon = (columnKey: TRosterSortColumnKey, direction: TRosterSortDirection) => { + if (isDateSortColumn(columnKey)) return direction === "desc" ? ArrowDownWideNarrow : ArrowUpNarrowWide; + return direction === "asc" ? ArrowDownWideNarrow : ArrowUpNarrowWide; +}; + +const ROSTER_COLUMNS: TRosterColumn[] = [ + { + key: "jersey_number", + label: "Jersey #", + icon: Hash, + headerClassName: "min-w-32", + cellClassName: "min-w-32 text-custom-text-200", + render: (player) => <RosterValue value={player.jersey_number} className="text-custom-text-200" />, + }, + { + key: "position", + label: "Position", + icon: Shirt, + headerClassName: "min-w-36", + cellClassName: "min-w-36 text-custom-text-300", + render: (player) => <RosterValue value={player.position} />, + }, + { + key: "height", + label: "Height", + icon: Ruler, + headerClassName: "min-w-36", + cellClassName: "min-w-36 text-custom-text-300", + render: (player) => <RosterValue value={player.height} />, + }, + { + key: "weight", + label: "Weight", + icon: Scale, + headerClassName: "min-w-36", + cellClassName: "min-w-36 text-custom-text-300", + render: (player) => <RosterValue value={player.weight} />, + }, + { + key: "class_year", + label: "Class/Year", + icon: GraduationCap, + headerClassName: "min-w-44", + cellClassName: "min-w-44 text-custom-text-300", + render: (player) => <RosterValue value={player.class_year} />, + }, + { + key: "status", + label: "Status", + icon: CircleDot, + headerClassName: "min-w-40", + cellClassName: "min-w-40", + render: (player) => <RosterStatusPill status={player.status} />, + }, + { + key: "created_at", + label: "Created on", + icon: CalendarDays, + headerClassName: "min-w-40", + cellClassName: "min-w-40 text-custom-text-300", + render: (player) => <RosterValue value={formatTimestamp(player.created_at)} />, + }, + { + key: "updated_at", + label: "Updated on", + icon: CalendarDays, + headerClassName: "min-w-40", + cellClassName: "min-w-40 text-custom-text-300", + render: (player) => <RosterValue value={formatTimestamp(player.updated_at)} />, + }, +]; + +const RosterSortOption = ({ + columnKey, + direction, + isActive, + onSelect, +}: { + columnKey: TRosterSortColumnKey; + direction: TRosterSortDirection; + isActive: boolean; + onSelect: () => void; +}) => { + const isDateColumn = isDateSortColumn(columnKey); + const isNumericColumn = isNumericSortColumn(columnKey); + const SortIcon = getSortIcon(columnKey, direction); + const startLabel = isDateColumn + ? direction === "desc" + ? "New" + : "Old" + : isNumericColumn + ? direction === "asc" + ? "1" + : "9" + : direction === "asc" + ? "A" + : "Z"; + const endLabel = isDateColumn + ? direction === "desc" + ? "Old" + : "New" + : isNumericColumn + ? direction === "asc" + ? "9" + : "1" + : direction === "asc" + ? "Z" + : "A"; + + return ( + <CustomMenu.MenuItem onClick={onSelect}> + <div + className={cn( + "flex items-center justify-between gap-3 px-1", + isActive ? "text-custom-text-100" : "text-custom-text-200 hover:text-custom-text-100" + )} + > + <div className="flex items-center gap-2"> + <SortIcon className="h-3 w-3 stroke-[1.5]" /> + <span>{startLabel}</span> + <MoveRight className="h-3 w-3" /> + <span>{endLabel}</span> + </div> + {isActive ? <CheckIcon className="h-3 w-3" /> : null} + </div> + </CustomMenu.MenuItem> + ); +}; + +const RosterClearSortOption = ({ onSelect }: { onSelect: () => void }) => ( + <CustomMenu.MenuItem className="mt-0.5" onClick={onSelect}> + <div className="flex items-center gap-2 px-1 text-custom-text-200 hover:text-custom-text-100"> + <Eraser className="h-3 w-3" /> + <span>Clear sorting</span> + </div> + </CustomMenu.MenuItem> +); + +const RosterSortableHeaderCell = ({ + columnKey, + label, + icon: Icon, + className, + sortConfig, + onSort, + onClearSort, + isFirstColumn = false, + selectionControl, +}: { + columnKey: TRosterSortColumnKey; + label: string; + icon?: LucideIcon; + className?: string; + sortConfig: TRosterSortConfig | null; + onSort: (column: TRosterSortColumnKey, direction: TRosterSortDirection) => void; + onClearSort: () => void; + isFirstColumn?: boolean; + selectionControl?: ReactNode; +}) => ( + <th + className={cn( + "h-11 bg-custom-background-90 py-1 text-sm font-medium text-custom-text-200", + isFirstColumn + ? "sticky left-0 z-[15] min-w-80 border-r-[0.5px] border-custom-border-100" + : "border border-b-0 border-t-0 border-custom-border-100", + className + )} + tabIndex={-1} + > + <CustomMenu + customButtonClassName="clickable !w-full" + customButtonTabIndex={-1} + className="!w-full" + customButton={ + <div + className={cn( + "flex h-full w-full cursor-pointer items-center justify-between gap-1.5 px-4 py-2 hover:text-custom-text-100", + isFirstColumn && "px-6", + sortConfig?.column === columnKey && "text-custom-text-100" + )} + > + <div className="flex min-w-0 items-center gap-3"> + {selectionControl ? ( + <div className="flex flex-shrink-0 items-center" onClick={(event) => event.stopPropagation()}> + {selectionControl} + </div> + ) : null} + {Icon ? <Icon className="h-4 w-4 flex-shrink-0 text-custom-text-400" /> : null} + <span className="truncate">{label}</span> + </div> + <div className="ml-3 flex flex-shrink-0 items-center gap-1 text-custom-text-400"> + {sortConfig?.column === columnKey + ? (() => { + const SortIcon = getSortIcon(columnKey, sortConfig.direction); + return <SortIcon className="h-3 w-3" />; + })() + : null} + <ChevronDown className="h-3 w-3" aria-hidden="true" /> + </div> + </div> + } + placement="bottom-start" + closeOnSelect + > + {(isDateSortColumn(columnKey) ? (["desc", "asc"] as const) : (["asc", "desc"] as const)).map((direction) => ( + <RosterSortOption + key={direction} + columnKey={columnKey} + direction={direction} + isActive={sortConfig?.column === columnKey && sortConfig.direction === direction} + onSelect={() => onSort(columnKey, direction)} + /> + ))} + {sortConfig?.column === columnKey ? <RosterClearSortOption onSelect={onClearSort} /> : null} + </CustomMenu> + </th> +); + +const RosterTableHeaderCell = ({ label, className }: { label: string; className?: string }) => ( + <th + className={cn( + "h-11 min-w-28 border border-b-0 border-t-0 border-custom-border-100 bg-custom-background-90 py-1 text-sm font-medium text-custom-text-200", + className + )} + tabIndex={-1} + > + <div className="flex h-full w-full items-center justify-end gap-1.5 px-4 py-2"> + <div className="flex min-w-0 items-center gap-1.5"> + <span className="truncate">{label}</span> + </div> + </div> + </th> +); + +const RosterTableCell = ({ + children, + className, + isFirstColumn = false, + isActionsColumn = false, +}: { + children: ReactNode; + className?: string; + isFirstColumn?: boolean; + isActionsColumn?: boolean; +}) => ( + <td + className={cn( + "h-11 border-b-[0.5px] border-r-[1px] border-custom-border-100 bg-custom-background-100 px-4 text-sm group-hover:bg-custom-background-90/60", + isFirstColumn && "sticky left-0 z-10 min-w-80 max-w-[32rem] border-r-[0.5px] border-custom-border-200 px-0", + isActionsColumn && "min-w-28", + className + )} + tabIndex={0} + > + <div className={cn("flex h-full min-w-0 items-center", isActionsColumn && "justify-end")}>{children}</div> + </td> +); + +const RosterPlayerCell = ({ player, selectionControl }: { player: IRosterPlayer; selectionControl?: ReactNode }) => ( + <RosterTableCell isFirstColumn> + <div className="flex h-11 min-w-0 items-center gap-3 px-6"> + {selectionControl ? <div className="flex flex-shrink-0 items-center">{selectionControl}</div> : null} + <span className="truncate text-[0.825rem] font-medium text-custom-text-100"> + {player.player_name || "Unnamed player"} + </span> + </div> + </RosterTableCell> +); + +const RosterActionsMenu = observer(({ player }: { player: IRosterPlayer }) => { + const { canManage, openEditPlayerModal, openDeletePlayerModal } = useRoster(); + + return ( + <div className="flex justify-end" onClick={(event) => event.stopPropagation()}> + <CustomMenu + ellipsis + placement="bottom-end" + closeOnSelect + buttonClassName="grid size-7 place-items-center rounded text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-100" + > + <CustomMenu.MenuItem + className={cn("flex items-center gap-2", !canManage && "text-custom-text-400")} + disabled={!canManage} + onClick={() => openEditPlayerModal(player)} + > + <Pencil className="h-3.5 w-3.5" /> + Edit + </CustomMenu.MenuItem> + <CustomMenu.MenuItem + className={cn("flex items-center gap-2", canManage ? "text-red-400" : "text-custom-text-400")} + disabled={!canManage} + onClick={() => openDeletePlayerModal(player)} + > + <Trash2 className="h-3.5 w-3.5" /> + Delete + </CustomMenu.MenuItem> + </CustomMenu> + </div> + ); +}); + +const RosterLoadingRows = ({ columns }: { columns: TRosterColumn[] }) => ( + <> + {Array.from({ length: 6 }).map((_, rowIndex) => ( + <tr key={rowIndex} className="group bg-custom-background-100"> + <RosterTableCell isFirstColumn> + <div className="flex h-11 items-center gap-3 px-6"> + <span className="h-3 w-14 animate-pulse rounded bg-custom-background-80" /> + <span className="h-3 w-40 animate-pulse rounded bg-custom-background-80" /> + </div> + </RosterTableCell> + {columns.map((column) => ( + <RosterTableCell key={column.key} className={column.cellClassName}> + <span className="h-3 w-20 animate-pulse rounded bg-custom-background-80" /> + </RosterTableCell> + ))} + <RosterTableCell isActionsColumn> + <span className="h-5 w-5 animate-pulse rounded bg-custom-background-80" /> + </RosterTableCell> + </tr> + ))} + </> +); + +const RosterEmptyRow = ({ columnCount, message }: { columnCount: number; message: string }) => ( + <tr className="bg-custom-background-100"> + <td + colSpan={columnCount} + className="h-11 border-b-[0.5px] border-custom-border-100 px-6 text-sm text-custom-text-400" + > + {message} + </td> + </tr> +); + +const getGroupLabel = (group: IRosterGroup) => group.label || group.key || "Unassigned"; + +const RosterGroupRow = ({ + label, + count, + columnCount, + isSubGroup = false, +}: { + label: string; + count: number; + columnCount: number; + isSubGroup?: boolean; +}) => ( + <tr className="bg-custom-background-90"> + <td + colSpan={columnCount} + className={cn( + "h-10 border-b-[0.5px] border-custom-border-100 px-6 text-sm font-medium text-custom-text-200", + isSubGroup && "pl-10 text-custom-text-300" + )} + > + <div className="flex items-center justify-between gap-3"> + <span>{label}</span> + <span className="text-xs text-custom-text-400">{count}</span> + </div> + </td> + </tr> +); + +export const RosterTable = observer( + ({ + players, + groupedRoster, + isLoading = false, + }: { + players: IRosterPlayer[]; + groupedRoster?: IRosterGroupedResponse | null; + isLoading?: boolean; + }) => { + const { + canManage, + displayProperties, + searchValue, + selectedClassYear, + selectedPosition, + selectedStatus, + selectedPlayerIds, + togglePlayerSelection, + toggleAllPlayerSelections, + } = useRoster(); + const containerRef = useRef<HTMLDivElement | null>(null); + const [sortConfig, setSortConfig] = useState<TRosterSortConfig | null>(null); + + const visibleColumns = useMemo( + () => ROSTER_COLUMNS.filter((column) => displayProperties[column.key]), + [displayProperties] + ); + const sortedPlayers = useMemo(() => sortRosterPlayers(players, sortConfig), [players, sortConfig]); + const allVisiblePlayerIds = useMemo(() => sortedPlayers.map((player) => player.id), [sortedPlayers]); + const allVisiblePlayersSelected = + allVisiblePlayerIds.length > 0 && allVisiblePlayerIds.every((playerId) => selectedPlayerIds.includes(playerId)); + const someVisiblePlayersSelected = + allVisiblePlayerIds.some((playerId) => selectedPlayerIds.includes(playerId)) && !allVisiblePlayersSelected; + const handleSort = useCallback((column: TRosterSortColumnKey, direction: TRosterSortDirection) => { + setSortConfig({ column, direction }); + }, []); + const handleClearSort = useCallback(() => setSortConfig(null), []); + + const handleScroll = useCallback(() => { + const scrollContainer = containerRef.current; + if (!scrollContainer) return; + + const firstColumns = scrollContainer.querySelectorAll("table tr td:first-child, table tr th:first-child"); + const shadow = scrollContainer.scrollLeft > 0 ? "8px 22px 22px 10px rgba(0, 0, 0, 0.05)" : "none"; + const headerShadow = scrollContainer.scrollLeft > 0 ? "8px -22px 22px 10px rgba(0, 0, 0, 0.05)" : "none"; + + firstColumns.forEach((column, index) => { + (column as HTMLElement).style.boxShadow = index === 0 ? headerShadow : shadow; + }); + }, []); + + useEffect(() => { + const currentContainer = containerRef.current; + if (!currentContainer) return; + + currentContainer.addEventListener("scroll", handleScroll); + handleScroll(); + + return () => currentContainer.removeEventListener("scroll", handleScroll); + }, [handleScroll]); + + const columnCount = visibleColumns.length + 2; + const hasActiveFilters = + searchValue.trim().length > 0 || + Boolean(selectedPosition) || + Boolean(selectedStatus) || + Boolean(selectedClassYear); + const emptyMessage = hasActiveFilters ? "No players match your search or filters." : "No roster players found."; + + const renderPlayerRow = (player: IRosterPlayer, nested = false) => ( + <tr + key={player.id} + className={cn( + "group bg-custom-background-100 text-sm text-custom-text-300 transition-[background-color]", + nested && "bg-custom-background-100/80" + )} + > + <RosterPlayerCell + player={player} + selectionControl={ + canManage ? ( + <Checkbox + checked={selectedPlayerIds.includes(player.id)} + onClick={(event) => { + event.stopPropagation(); + togglePlayerSelection(player.id); + }} + readOnly + aria-label={`Select ${player.player_name || "player"}`} + /> + ) : undefined + } + /> + {visibleColumns.map((column) => ( + <RosterTableCell key={column.key} className={column.cellClassName}> + {column.render(player)} + </RosterTableCell> + ))} + <RosterTableCell isActionsColumn> + <RosterActionsMenu player={player} /> + </RosterTableCell> + </tr> + ); + + const renderGroupedRows = (groups: IRosterGroup[]) => + groups.flatMap((group) => { + const groupRows: ReactNode[] = [ + <RosterGroupRow + key={`group-${group.key ?? "none"}`} + label={getGroupLabel(group)} + count={group.count} + columnCount={columnCount} + />, + ]; + + if (group.sub_groups?.length) { + groupRows.push( + ...group.sub_groups.flatMap((subGroup: IRosterGroup) => [ + <RosterGroupRow + key={`sub-group-${group.key ?? "none"}-${subGroup.key ?? "none"}`} + label={getGroupLabel(subGroup)} + count={subGroup.count} + columnCount={columnCount} + isSubGroup + />, + ...subGroup.players.map((player: IRosterPlayer) => renderPlayerRow(player, true)), + ]) + ); + } else { + groupRows.push(...group.players.map((player: IRosterPlayer) => renderPlayerRow(player))); + } + + return groupRows; + }); + + return ( + <div className="relative flex h-full w-full flex-col overflow-x-hidden whitespace-nowrap rounded-lg bg-custom-background-200 text-custom-text-200"> + <div ref={containerRef} className="vertical-scrollbar horizontal-scrollbar scrollbar-lg h-full w-full"> + <table className="w-full min-w-max overflow-y-auto bg-custom-background-100"> + <thead className="sticky left-0 top-0 z-[12] border-b-[0.5px] border-custom-border-100"> + <tr> + <RosterSortableHeaderCell + columnKey="player_name" + label="Player" + sortConfig={sortConfig} + onSort={handleSort} + onClearSort={handleClearSort} + isFirstColumn + selectionControl={ + canManage ? ( + <Checkbox + checked={allVisiblePlayersSelected} + indeterminate={someVisiblePlayersSelected} + onClick={(event) => { + event.stopPropagation(); + toggleAllPlayerSelections(); + }} + disabled={!allVisiblePlayerIds.length || isLoading} + readOnly + aria-label="Select all visible players" + /> + ) : undefined + } + /> + {visibleColumns.map((column) => ( + <RosterSortableHeaderCell + key={column.key} + columnKey={column.key} + label={column.label} + icon={column.icon} + className={column.headerClassName} + sortConfig={sortConfig} + onSort={handleSort} + onClearSort={handleClearSort} + /> + ))} + <RosterTableHeaderCell label="Actions" /> + </tr> + </thead> + <tbody> + {isLoading ? ( + <RosterLoadingRows columns={visibleColumns} /> + ) : groupedRoster?.results.length ? ( + renderGroupedRows(groupedRoster.results) + ) : sortedPlayers.length > 0 ? ( + sortedPlayers.map((player) => renderPlayerRow(player)) + ) : ( + <RosterEmptyRow columnCount={columnCount} message={emptyMessage} /> + )} + </tbody> + </table> + </div> + </div> + ); + } +); diff --git a/apps/web/ce/features/roster/components/roster-modals.tsx b/apps/web/ce/features/roster/components/roster-modals.tsx new file mode 100644 index 00000000000..28eb50a69b7 --- /dev/null +++ b/apps/web/ce/features/roster/components/roster-modals.tsx @@ -0,0 +1,427 @@ +"use client"; + +import type { ChangeEvent, DragEvent, ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { ChevronDown, FileSpreadsheet, Upload, X } from "lucide-react"; +import { Button } from "@plane/propel/button"; +import type { IRosterPlayerPayload, TRosterPlayerStatus } from "@plane/types"; +import { AlertModalCore, cn, CustomSelect, EModalPosition, EModalWidth, Input, ModalCore, TextArea } from "@plane/ui"; +import { STATUS_SELECT_OPTIONS } from "../constants/roster.constants"; +import type { TRosterFormState } from "../store/roster-context"; +import { getRosterFormState, useRoster } from "../store/roster-context"; +import { mapImportedRows, toDisplayStatus } from "../utils/roster.utils"; + +const FieldLabel = ({ children }: { children: ReactNode }) => ( + <label className="text-xs font-medium uppercase tracking-wide text-custom-text-400">{children}</label> +); + +export const AddPlayerModal = observer(({ isOpen, onClose }: { isOpen?: boolean; onClose?: () => void }) => { + const { editingPlayer, isAddPlayerModalOpen, isSubmitting, closePlayerModal, submitPlayer } = useRoster(); + const [formState, setFormState] = useState<TRosterFormState>(getRosterFormState(editingPlayer)); + const modalOpen = isOpen ?? isAddPlayerModalOpen; + const handleClose = onClose ?? closePlayerModal; + + useEffect(() => { + setFormState(getRosterFormState(editingPlayer)); + }, [editingPlayer, modalOpen]); + + const updateField = <K extends keyof TRosterFormState>(key: K, value: TRosterFormState[K]) => + setFormState((currentState) => ({ ...currentState, [key]: value })); + + return ( + <ModalCore isOpen={modalOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXXL}> + <div className="border-b border-custom-border-200 px-5 py-4"> + <div className="flex items-start justify-between gap-4"> + <div> + <h3 className="text-lg font-semibold text-custom-text-100"> + {editingPlayer ? "Edit player" : "Add player"} + </h3> + <p className="mt-1 text-sm text-custom-text-300"> + Create and manage player roster details for this program. + </p> + </div> + <button + type="button" + onClick={handleClose} + className="rounded-md p-1.5 text-custom-text-400 transition-colors hover:bg-custom-background-90 hover:text-custom-text-200" + aria-label="Close player modal" + > + <X className="h-4 w-4" /> + </button> + </div> + </div> + <form + onSubmit={(event) => { + event.preventDefault(); + submitPlayer(formState); + }} + > + <div className="grid gap-4 p-5 md:grid-cols-2"> + <div className="space-y-2 md:col-span-2"> + <FieldLabel>Player name</FieldLabel> + <Input + value={formState.player_name} + onChange={(event) => updateField("player_name", event.target.value)} + placeholder="Enter player name" + className="w-full border-custom-border-200 bg-custom-background-100" + autoFocus + /> + </div> + <div className="space-y-2"> + <FieldLabel>Jersey number</FieldLabel> + <Input + value={formState.jersey_number} + onChange={(event) => updateField("jersey_number", event.target.value)} + placeholder="17" + className="w-full border-custom-border-200 bg-custom-background-100" + /> + </div> + <div className="space-y-2"> + <FieldLabel>Position</FieldLabel> + <Input + value={formState.position} + onChange={(event) => updateField("position", event.target.value)} + placeholder="QB" + className="w-full border-custom-border-200 bg-custom-background-100" + /> + </div> + <div className="space-y-2"> + <FieldLabel>Height</FieldLabel> + <Input + value={formState.height} + onChange={(event) => updateField("height", event.target.value)} + placeholder={"6'2\""} + className="w-full border-custom-border-200 bg-custom-background-100" + /> + </div> + <div className="space-y-2"> + <FieldLabel>Weight</FieldLabel> + <Input + value={formState.weight} + onChange={(event) => updateField("weight", event.target.value)} + placeholder="205 lb" + className="w-full border-custom-border-200 bg-custom-background-100" + /> + </div> + <div className="space-y-2"> + <FieldLabel>Class/Year</FieldLabel> + <Input + value={formState.class_year} + onChange={(event) => updateField("class_year", event.target.value)} + placeholder="Senior" + className="w-full border-custom-border-200 bg-custom-background-100" + /> + </div> + <div className="space-y-2"> + <FieldLabel>Status</FieldLabel> + <CustomSelect + value={formState.status} + onChange={(selected: TRosterPlayerStatus) => updateField("status", selected)} + label={<span className="text-sm text-custom-text-200">{toDisplayStatus(formState.status)}</span>} + buttonClassName="w-full justify-between rounded-md border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm text-custom-text-200" + > + {STATUS_SELECT_OPTIONS.map((option) => ( + <CustomSelect.Option key={option.value} value={option.value}> + {option.label} + </CustomSelect.Option> + ))} + </CustomSelect> + </div> + <div className="space-y-2 md:col-span-2"> + <FieldLabel>Notes</FieldLabel> + <TextArea + value={formState.notes} + onChange={(event) => updateField("notes", event.target.value)} + placeholder="Add optional notes about the player" + className="min-h-28 w-full resize-none border-custom-border-200 bg-custom-background-100" + /> + </div> + </div> + <div className="flex items-center justify-end gap-2 border-t border-custom-border-200 px-5 py-4"> + <Button variant="neutral-primary" size="sm" onClick={handleClose} disabled={isSubmitting}> + Cancel + </Button> + <Button variant="primary" size="sm" type="submit" loading={isSubmitting}> + {editingPlayer ? "Save changes" : "Save player"} + </Button> + </div> + </form> + </ModalCore> + ); +}); + +export const ImportRosterModal = observer(({ isOpen, onClose }: { isOpen?: boolean; onClose?: () => void }) => { + const { + isImportRosterModalOpen, + isSubmitting, + closeImportRosterModal, + importPlayers, + pendingImportFile, + setPendingImportFile, + } = useRoster(); + const modalOpen = isOpen ?? isImportRosterModalOpen; + const handleClose = onClose ?? closeImportRosterModal; + const fileInputRef = useRef<HTMLInputElement | null>(null); + const [selectedFileName, setSelectedFileName] = useState(""); + const [parsedRows, setParsedRows] = useState<IRosterPlayerPayload[]>([]); + const [parseError, setParseError] = useState<string | null>(null); + const [isParsing, setIsParsing] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + if (!modalOpen) { + setSelectedFileName(""); + setParsedRows([]); + setParseError(null); + setIsParsing(false); + setIsDragging(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }, [modalOpen]); + + const parseRosterFile = useCallback(async (file: File) => { + setSelectedFileName(file.name); + setParseError(null); + setIsParsing(true); + + try { + const normalizedFileName = file.name.trim().toLowerCase(); + if (!normalizedFileName.endsWith(".xlsx") && !normalizedFileName.endsWith(".csv")) { + throw new Error("Only .xlsx and .csv files are supported."); + } + + const xlsxModule = await import("xlsx"); + const XLSX = "default" in xlsxModule ? xlsxModule.default : xlsxModule; + const arrayBuffer = await file.arrayBuffer(); + const workbook = XLSX.read(arrayBuffer, { type: "array" }); + const firstSheetName = workbook.SheetNames[0]; + const firstSheet = firstSheetName ? workbook.Sheets[firstSheetName] : undefined; + + if (!firstSheet) throw new Error("The selected file does not contain any readable sheets."); + + const rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(firstSheet, { + defval: "", + raw: false, + }); + const normalizedRows = mapImportedRows(rawRows); + + if (!normalizedRows.length) throw new Error("No roster rows were found in the selected file."); + if (!normalizedRows.some((row) => row.player_name)) { + throw new Error("The file must include a player name column with at least one value."); + } + + setParsedRows(normalizedRows); + } catch (error) { + setParsedRows([]); + setParseError(error instanceof Error ? error.message : "The selected file could not be parsed."); + } finally { + setIsParsing(false); + } + }, []); + + useEffect(() => { + if (!modalOpen || !pendingImportFile) return; + + parseRosterFile(pendingImportFile); + setPendingImportFile(null); + }, [modalOpen, parseRosterFile, pendingImportFile, setPendingImportFile]); + + const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => { + const input = event.currentTarget; + const file = event.target.files?.[0]; + if (!file) return; + + await parseRosterFile(file); + input.value = ""; + }; + + const handleFileDrop = async (event: DragEvent<HTMLDivElement>) => { + event.preventDefault(); + setIsDragging(false); + + const file = event.dataTransfer.files?.[0]; + if (!file) return; + + await parseRosterFile(file); + }; + + return ( + <ModalCore isOpen={modalOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXXXL}> + <div className="border-b border-custom-border-200 px-5 py-4"> + <div className="flex items-start justify-between gap-4"> + <div> + <h3 className="text-lg font-semibold text-custom-text-100">Import roster</h3> + <p className="mt-1 text-sm text-custom-text-300"> + Upload a roster file with player name, jersey number, position, height, weight, and status. + </p> + </div> + <button + type="button" + onClick={handleClose} + className="rounded-md p-1.5 text-custom-text-400 transition-colors hover:bg-custom-background-90 hover:text-custom-text-200" + aria-label="Close import roster modal" + > + <X className="h-4 w-4" /> + </button> + </div> + </div> + <div className="space-y-5 p-5"> + <div + className={cn( + "rounded-xl border border-dashed p-6 transition-colors", + isDragging + ? "border-custom-primary-100 bg-custom-primary-100/10" + : "border-custom-border-300 bg-custom-background-90" + )} + onDragOver={(event) => { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleFileDrop} + > + <div className="flex flex-col items-center justify-center text-center"> + <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full border border-custom-border-200 bg-custom-background-100 text-custom-text-300"> + <FileSpreadsheet className="h-5 w-5" /> + </div> + <div className="text-sm font-medium text-custom-text-100">Drop `.xlsx` or `.csv` file here</div> + <div className="mt-1 text-sm text-custom-text-300"> + Browse a local file to preview and import roster rows. + </div> + <a + href="/templates/roster-template.xlsx" + download="roster-template.xlsx" + className="mt-2 text-sm font-medium text-custom-primary-100 transition-colors hover:text-custom-primary-200 hover:underline" + > + Download roster template + </a> + <input ref={fileInputRef} type="file" accept=".csv,.xlsx" className="hidden" onChange={handleFileChange} /> + <Button + variant="neutral-primary" + size="sm" + prependIcon={<Upload />} + className="mt-4" + onClick={() => fileInputRef.current?.click()} + disabled={isParsing || isSubmitting} + > + Choose file + </Button> + {selectedFileName ? <p className="mt-3 text-xs text-custom-text-400">{selectedFileName}</p> : null} + {isParsing ? <p className="mt-3 text-xs text-custom-text-400">Parsing roster file...</p> : null} + {parseError ? <p className="mt-3 text-xs text-red-400">{parseError}</p> : null} + </div> + </div> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <h4 className="text-sm font-semibold text-custom-text-100">Preview</h4> + {parsedRows.length ? ( + <div className="flex items-center gap-1 text-xs text-custom-text-400"> + {parsedRows.length} row{parsedRows.length === 1 ? "" : "s"} ready + <ChevronDown className="h-3.5 w-3.5" /> + </div> + ) : null} + </div> + <div className="rounded-lg border border-custom-border-200 bg-custom-background-100"> + <div className="max-h-[36vh] overflow-auto"> + <table className="min-w-full whitespace-nowrap"> + <thead className="sticky top-0 z-[1] border-b border-custom-border-200 bg-custom-background-90"> + <tr className="text-left text-xs font-medium uppercase tracking-wide text-custom-text-400"> + <th className="px-4 py-3">Player</th> + <th className="px-4 py-3">Jersey #</th> + <th className="px-4 py-3">Position</th> + <th className="px-4 py-3">Height</th> + <th className="px-4 py-3">Weight</th> + <th className="px-4 py-3">Status</th> + </tr> + </thead> + <tbody> + {parsedRows.length ? ( + parsedRows.map((row, index) => ( + <tr + key={`${row.player_name}-${row.jersey_number ?? index}`} + className="border-b border-custom-border-200 text-sm text-custom-text-200 last:border-b-0" + > + <td className="px-4 py-3">{row.player_name || "—"}</td> + <td className="px-4 py-3">{row.jersey_number ? `#${row.jersey_number}` : "—"}</td> + <td className="px-4 py-3">{row.position || "—"}</td> + <td className="px-4 py-3">{row.height || "—"}</td> + <td className="px-4 py-3">{row.weight || "—"}</td> + <td className="px-4 py-3">{toDisplayStatus(row.status || "active")}</td> + </tr> + )) + ) : ( + <tr className="text-sm text-custom-text-300"> + <td className="px-4 py-6 text-center" colSpan={6}> + Choose a roster file to preview imported rows. + </td> + </tr> + )} + </tbody> + </table> + </div> + </div> + </div> + </div> + <div className="flex items-center justify-end gap-2 border-t border-custom-border-200 px-5 py-4"> + <Button variant="neutral-primary" size="sm" onClick={handleClose}> + Close + </Button> + <Button + variant="primary" + size="sm" + onClick={() => importPlayers(parsedRows)} + disabled={!parsedRows.length || isParsing} + loading={isSubmitting} + > + Import roster + </Button> + </div> + </ModalCore> + ); +}); + +export const DeletePlayerModal = observer(() => { + const { deletingPlayer, isSubmitting, closeDeletePlayerModal, deletePlayer } = useRoster(); + + return ( + <AlertModalCore + isOpen={!!deletingPlayer} + handleClose={closeDeletePlayerModal} + handleSubmit={deletePlayer} + isSubmitting={isSubmitting} + title="Delete player" + content={`Are you sure you want to delete ${deletingPlayer?.player_name ?? "this player"} from the roster?`} + primaryButtonText={{ loading: "Deleting", default: "Delete player" }} + /> + ); +}); + +export const BulkDeletePlayersModal = observer(() => { + const { isBulkDeleteModalOpen, selectedPlayers, isSubmitting, closeBulkDeleteModal, deleteSelectedPlayers } = + useRoster(); + const selectedPlayerNames = selectedPlayers + .slice(0, 3) + .map((player) => player.player_name || "Unnamed player") + .join(", "); + const remainingPlayersCount = Math.max(selectedPlayers.length - 3, 0); + const content = + selectedPlayers.length > 1 + ? `Are you sure you want to delete ${selectedPlayers.length} selected players from the roster${ + selectedPlayerNames ? `, including ${selectedPlayerNames}` : "" + }${remainingPlayersCount ? ` and ${remainingPlayersCount} more` : ""}?` + : `Are you sure you want to delete ${selectedPlayerNames || "this player"} from the roster?`; + const primaryButtonLabel = selectedPlayers.length === 1 ? "Delete selected player" : "Delete selected players"; + + return ( + <AlertModalCore + isOpen={isBulkDeleteModalOpen} + handleClose={closeBulkDeleteModal} + handleSubmit={deleteSelectedPlayers} + isSubmitting={isSubmitting} + title="Delete selected players" + content={content} + primaryButtonText={{ loading: "Deleting", default: primaryButtonLabel }} + /> + ); +}); diff --git a/apps/web/ce/features/roster/components/roster-page.tsx b/apps/web/ce/features/roster/components/roster-page.tsx new file mode 100644 index 00000000000..b872fe78d1e --- /dev/null +++ b/apps/web/ce/features/roster/components/roster-page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { observer } from "mobx-react"; +import { PageHead } from "@/components/core/page-title"; +import { useRoster } from "../store/roster-context"; +import { RosterEmptyState } from "./roster-empty-state"; +import { RosterTable } from "./roster-list"; +import { AddPlayerModal, BulkDeletePlayersModal, DeletePlayerModal, ImportRosterModal } from "./roster-modals"; + +const ProgramRosterPage = observer(() => { + const { allPlayers, players, groupedRoster, isLoading } = useRoster(); + const showEmptyState = !isLoading && allPlayers.length === 0; + + return ( + <> + <PageHead title="Roster" /> + <div className="flex h-full w-full flex-col"> + {showEmptyState ? ( + <RosterEmptyState /> + ) : ( + <RosterTable players={players} groupedRoster={groupedRoster} isLoading={isLoading} /> + )} + <AddPlayerModal /> + <ImportRosterModal /> + <DeletePlayerModal /> + <BulkDeletePlayersModal /> + </div> + </> + ); +}); + +export default ProgramRosterPage; diff --git a/apps/web/ce/features/roster/constants/roster.constants.ts b/apps/web/ce/features/roster/constants/roster.constants.ts new file mode 100644 index 00000000000..53e32756bc2 --- /dev/null +++ b/apps/web/ce/features/roster/constants/roster.constants.ts @@ -0,0 +1,54 @@ +import type { IRosterPlayerPayload, TRosterPlayerStatus } from "@plane/types"; +import type { TRosterDisplayPropertyKey, TRosterGroupByOption, TRosterOrderByOption } from "../store/roster-context"; + +const toDisplayLabel = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); + +export const ALL_POSITION_OPTION = "All positions"; +export const ALL_STATUS_OPTION = "All statuses"; +export const ALL_CLASS_YEAR_OPTION = "All classes"; + +export const ROSTER_DISPLAY_PROPERTIES: { key: TRosterDisplayPropertyKey; label: string }[] = [ + { key: "jersey_number", label: "Jersey #" }, + { key: "position", label: "Position" }, + { key: "height", label: "Height" }, + { key: "weight", label: "Weight" }, + { key: "class_year", label: "Class/Year" }, + { key: "status", label: "Status" }, + { key: "created_at", label: "Created on" }, + { key: "updated_at", label: "Updated on" }, +]; + +export const GROUP_BY_OPTIONS: { key: TRosterGroupByOption; label: string }[] = [ + { key: "none", label: "None" }, + { key: "position", label: "Position" }, + { key: "status", label: "Status" }, + { key: "class_year", label: "Class/Year" }, +]; + +export const ORDER_BY_OPTIONS: { key: TRosterOrderByOption; label: string }[] = [ + { key: "manual", label: "Manual" }, + { key: "player_name", label: "Player name" }, + { key: "jersey_number", label: "Jersey #" }, + { key: "position", label: "Position" }, + { key: "status", label: "Status" }, + { key: "created_at", label: "Last created" }, + { key: "updated_at", label: "Last updated" }, +]; + +export const STATUS_VALUES: TRosterPlayerStatus[] = ["active", "injured", "inactive", "pending"]; +export const STATUS_OPTIONS = STATUS_VALUES.map(toDisplayLabel); +export const STATUS_SELECT_OPTIONS = STATUS_VALUES.map((value) => ({ + value, + label: toDisplayLabel(value), +})); + +export const ROSTER_HEADER_MAP: Record<keyof IRosterPlayerPayload, string[]> = { + player_name: ["player name", "name", "player"], + jersey_number: ["jersey #", "jersey", "jersey number", "number", "#"], + position: ["position", "pos"], + height: ["height", "ht"], + weight: ["weight", "wt"], + class_year: ["class/year", "class year", "class", "year"], + status: ["status"], + notes: ["notes", "note"], +}; diff --git a/apps/web/ce/features/roster/index.ts b/apps/web/ce/features/roster/index.ts new file mode 100644 index 00000000000..7a07e5b1e95 --- /dev/null +++ b/apps/web/ce/features/roster/index.ts @@ -0,0 +1,10 @@ +export { ProjectRosterHeader } from "./components/project-roster-header"; +export { default as RosterPage } from "./components/roster-page"; +export { RosterProvider, useRoster } from "./store/roster-context"; +export type { + TRosterDisplayPropertyKey, + TRosterFormState, + TRosterGroupByOption, + TRosterOrderByOption, + TRosterViewOption, +} from "./store/roster-context"; diff --git a/apps/web/ce/features/roster/store/roster-context.tsx b/apps/web/ce/features/roster/store/roster-context.tsx new file mode 100644 index 00000000000..67573e48c95 --- /dev/null +++ b/apps/web/ce/features/roster/store/roster-context.tsx @@ -0,0 +1,741 @@ +"use client"; + +import type { ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useParams } from "next/navigation"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IRosterFilters, IRosterPlayer, IRosterPlayerPayload, TRosterPlayerStatus } from "@plane/types"; +import { useUserPermissions } from "@/hooks/store/user"; +import { RosterService } from "@/services/roster.service"; +import { getUniqueRosterValues } from "../utils/roster.utils"; + +export type TRosterFormState = { + player_name: string; + jersey_number: string; + position: string; + height: string; + weight: string; + class_year: string; + status: TRosterPlayerStatus; + notes: string; +}; + +export type TRosterDisplayPropertyKey = + | "player" + | "jersey_number" + | "position" + | "height" + | "weight" + | "class_year" + | "status" + | "notes" + | "created_at" + | "updated_at"; + +export type TRosterGroupByOption = "none" | "position" | "status" | "class_year"; +export type TRosterOrderByOption = + | "manual" + | "player_name" + | "jersey_number" + | "position" + | "status" + | "created_at" + | "updated_at"; +export type TRosterViewOption = "list" | "grid"; +export type TRosterGroupValue = Exclude<TRosterGroupByOption, "none">; +export type IRosterGroup = { + key: string | null; + label: string; + count: number; + players: IRosterPlayer[]; + sub_groups?: IRosterGroup[]; +}; + +export type IRosterGroupedResponse = { + grouped_by: TRosterGroupValue; + sub_grouped_by: TRosterGroupValue | null; + order_by: TRosterOrderByOption; + results: IRosterGroup[]; +}; + +type TRosterContext = { + workspaceSlug: string; + projectId: string; + players: IRosterPlayer[]; + groupedRoster: IRosterGroupedResponse | null; + allPlayers: IRosterPlayer[]; + statusOptions: string[]; + positionOptions: string[]; + classYearOptions: string[]; + isLoading: boolean; + isSubmitting: boolean; + canManage: boolean; + searchValue: string; + selectedPosition: string; + selectedStatus: string; + selectedClassYear: string; + activeView: TRosterViewOption; + displayProperties: Record<TRosterDisplayPropertyKey, boolean>; + groupBy: TRosterGroupByOption; + subGroupBy: TRosterGroupByOption; + orderBy: TRosterOrderByOption; + isAddPlayerModalOpen: boolean; + isImportRosterModalOpen: boolean; + pendingImportFile: File | null; + editingPlayer: IRosterPlayer | null; + deletingPlayer: IRosterPlayer | null; + selectedPlayerIds: string[]; + selectedPlayers: IRosterPlayer[]; + isBulkDeleteModalOpen: boolean; + setSearchValue: (value: string) => void; + setSelectedPosition: (value: string) => void; + setSelectedStatus: (value: string) => void; + setSelectedClassYear: (value: string) => void; + setActiveView: (value: TRosterViewOption) => void; + toggleDisplayProperty: (key: TRosterDisplayPropertyKey) => void; + setGroupBy: (value: TRosterGroupByOption) => void; + setSubGroupBy: (value: TRosterGroupByOption) => void; + setOrderBy: (value: TRosterOrderByOption) => void; + openCreatePlayerModal: () => void; + openEditPlayerModal: (player: IRosterPlayer) => void; + closePlayerModal: () => void; + openImportRosterModal: () => void; + closeImportRosterModal: () => void; + setPendingImportFile: (file: File | null) => void; + togglePlayerSelection: (playerId: string) => void; + toggleAllPlayerSelections: () => void; + clearSelectedPlayers: () => void; + openDeletePlayerModal: (player: IRosterPlayer) => void; + closeDeletePlayerModal: () => void; + openBulkDeleteModal: () => void; + closeBulkDeleteModal: () => void; + submitPlayer: (payload: TRosterFormState) => Promise<void>; + deletePlayer: () => Promise<void>; + deleteSelectedPlayers: () => Promise<void>; + importPlayers: (players: IRosterPlayerPayload[]) => Promise<void>; + refetchRoster: () => Promise<void>; +}; + +const RosterContext = createContext<TRosterContext | undefined>(undefined); + +const rosterService = new RosterService(); + +const DEFAULT_FORM_STATE: TRosterFormState = { + player_name: "", + jersey_number: "", + position: "", + height: "", + weight: "", + class_year: "", + status: "active", + notes: "", +}; + +const DEFAULT_DISPLAY_PROPERTIES: Record<TRosterDisplayPropertyKey, boolean> = { + player: true, + jersey_number: true, + position: true, + height: true, + weight: true, + class_year: true, + status: true, + notes: false, + created_at: false, + updated_at: false, +}; +const STATUS_ORDER: TRosterPlayerStatus[] = ["active", "injured", "inactive", "pending"]; +const CLASS_YEAR_ORDER: Record<string, number> = { + freshman: 1, + sophomore: 2, + junior: 3, + senior: 4, + graduate: 5, +}; + +const sortCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +const normalizeValue = (value: string | null | undefined) => value?.trim() || ""; + +const getPlayerSortValue = (player: IRosterPlayer, orderBy: TRosterOrderByOption) => { + switch (orderBy) { + case "player_name": + return normalizeValue(player.player_name); + case "jersey_number": + return normalizeValue(player.jersey_number); + case "position": + return normalizeValue(player.position); + case "status": + return STATUS_ORDER.indexOf(player.status); + case "created_at": + case "updated_at": { + const timestamp = Date.parse(player[orderBy]); + return Number.isNaN(timestamp) ? null : timestamp; + } + default: + return null; + } +}; + +const sortPlayers = (players: IRosterPlayer[], orderBy: TRosterOrderByOption) => { + if (orderBy === "manual") return players; + + return players + .map((player, index) => ({ player, index })) + .sort((currentPlayer, nextPlayer) => { + const currentValue = getPlayerSortValue(currentPlayer.player, orderBy); + const nextValue = getPlayerSortValue(nextPlayer.player, orderBy); + const currentMissing = currentValue === null || currentValue === ""; + const nextMissing = nextValue === null || nextValue === ""; + + if (currentMissing && nextMissing) return currentPlayer.index - nextPlayer.index; + if (currentMissing) return 1; + if (nextMissing) return -1; + + let sortResult = 0; + if (typeof currentValue === "number" && typeof nextValue === "number") { + sortResult = currentValue - nextValue; + } else { + sortResult = sortCollator.compare(String(currentValue), String(nextValue)); + } + + if (sortResult === 0) { + return sortCollator.compare( + normalizeValue(currentPlayer.player.player_name), + normalizeValue(nextPlayer.player.player_name) + ); + } + + if (orderBy === "created_at" || orderBy === "updated_at") return -sortResult; + return sortResult; + }) + .map(({ player }) => player); +}; + +const matchesSearch = (player: IRosterPlayer, searchTerm: string) => { + if (!searchTerm) return true; + + const haystack = [ + player.player_name, + player.jersey_number, + player.position, + player.class_year, + player.status, + player.height, + player.weight, + player.notes, + ] + .map((value) => normalizeValue(value).toLowerCase()) + .join(" "); + + return haystack.includes(searchTerm.toLowerCase()); +}; + +const getGroupLabel = (field: TRosterGroupValue, value: string | null) => { + if (!value) return "Unassigned"; + if (field === "status") return value.replace(/_/g, " ").replace(/\b\w/g, (match) => match.toUpperCase()); + return value; +}; + +type TRosterGroupSortKey = [number, number, string]; + +const getGroupSortKey = (field: TRosterGroupValue, value: string | null): TRosterGroupSortKey => { + if (!value) return [1, Number.MAX_SAFE_INTEGER, ""]; + if (field === "status") { + const statusIndex = STATUS_ORDER.indexOf(value as TRosterPlayerStatus); + return [0, statusIndex >= 0 ? statusIndex : Number.MAX_SAFE_INTEGER, ""]; + } + if (field === "class_year") { + return [0, CLASS_YEAR_ORDER[value.toLowerCase()] ?? Number.MAX_SAFE_INTEGER, value.toLowerCase()]; + } + return [0, 0, value.toLowerCase()]; +}; + +const compareGroupSortKeys = (currentKey: TRosterGroupSortKey, nextKey: TRosterGroupSortKey) => { + if (currentKey[0] !== nextKey[0]) return currentKey[0] - nextKey[0]; + if (currentKey[1] !== nextKey[1]) return currentKey[1] - nextKey[1]; + return sortCollator.compare(currentKey[2], nextKey[2]); +}; + +const buildGroupedRoster = ( + players: IRosterPlayer[], + groupBy: TRosterGroupByOption, + subGroupBy: TRosterGroupByOption, + orderBy: TRosterOrderByOption +): IRosterGroupedResponse | null => { + if (groupBy === "none") return null; + + const groupedResults = new Map<string | null, IRosterGroup & { sub_groups_map: Map<string | null, IRosterGroup> }>(); + + players.forEach((player) => { + const groupKey = (player[groupBy] as string | null) ?? null; + const group = groupedResults.get(groupKey) ?? { + key: groupKey, + label: getGroupLabel(groupBy, groupKey), + count: 0, + players: [], + sub_groups_map: new Map<string | null, IRosterGroup>(), + }; + + group.count += 1; + + if (subGroupBy !== "none") { + const subGroupKey = (player[subGroupBy] as string | null) ?? null; + const subGroup = group.sub_groups_map.get(subGroupKey) ?? { + key: subGroupKey, + label: getGroupLabel(subGroupBy, subGroupKey), + count: 0, + players: [], + }; + + subGroup.count += 1; + subGroup.players.push(player); + group.sub_groups_map.set(subGroupKey, subGroup); + } else { + group.players.push(player); + } + + groupedResults.set(groupKey, group); + }); + + const results = Array.from(groupedResults.values()) + .sort((currentGroup, nextGroup) => + compareGroupSortKeys(getGroupSortKey(groupBy, currentGroup.key), getGroupSortKey(groupBy, nextGroup.key)) + ) + .map((group) => { + const payload: IRosterGroup = { + key: group.key, + label: group.label, + count: group.count, + players: group.players, + }; + + if (subGroupBy !== "none") { + payload.sub_groups = Array.from(group.sub_groups_map.values()).sort((currentGroup, nextGroup) => + compareGroupSortKeys( + getGroupSortKey(subGroupBy, currentGroup.key), + getGroupSortKey(subGroupBy, nextGroup.key) + ) + ); + } + + return payload; + }); + + return { + grouped_by: groupBy, + sub_grouped_by: subGroupBy === "none" ? null : subGroupBy, + order_by: orderBy, + results, + }; +}; + +const normalizePayload = (payload: TRosterFormState): IRosterPlayerPayload => ({ + player_name: payload.player_name.trim(), + jersey_number: payload.jersey_number.trim() || null, + position: payload.position.trim() || null, + height: payload.height.trim() || null, + weight: payload.weight.trim() || null, + class_year: payload.class_year.trim() || null, + status: payload.status, + notes: payload.notes.trim() || null, +}); + +const getErrorMessage = (error: unknown, fallback: string) => { + if (!error || typeof error !== "object") return fallback; + if ("error" in error && typeof error.error === "string") return error.error; + if ("players" in error && Array.isArray(error.players) && typeof error.players[0] === "string") + return error.players[0]; + const firstFieldError = Object.values(error)[0]; + if (typeof firstFieldError === "string") return firstFieldError; + if (Array.isArray(firstFieldError) && typeof firstFieldError[0] === "string") return firstFieldError[0]; + return fallback; +}; + +export const RosterProvider = ({ children }: { children: ReactNode }) => { + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; + const { allowPermissions } = useUserPermissions(); + const [allPlayers, setAllPlayers] = useState<IRosterPlayer[]>([]); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const [selectedPosition, setSelectedPosition] = useState(""); + const [selectedStatus, setSelectedStatus] = useState(""); + const [selectedClassYear, setSelectedClassYear] = useState(""); + const [activeView, setActiveView] = useState<TRosterViewOption>("list"); + const [displayProperties, setDisplayProperties] = useState(DEFAULT_DISPLAY_PROPERTIES); + const [groupBy, setGroupBy] = useState<TRosterGroupByOption>("none"); + const [subGroupBy, setSubGroupBy] = useState<TRosterGroupByOption>("none"); + const [orderBy, setOrderBy] = useState<TRosterOrderByOption>("created_at"); + const [isAddPlayerModalOpen, setIsAddPlayerModalOpen] = useState(false); + const [isImportRosterModalOpen, setIsImportRosterModalOpen] = useState(false); + const [pendingImportFile, setPendingImportFile] = useState<File | null>(null); + const [editingPlayer, setEditingPlayer] = useState<IRosterPlayer | null>(null); + const [deletingPlayer, setDeletingPlayer] = useState<IRosterPlayer | null>(null); + const [selectedPlayerIds, setSelectedPlayerIds] = useState<string[]>([]); + const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); + const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + const canManage = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug, + projectId + ); + + useEffect(() => { + if (debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current); + debounceTimeoutRef.current = setTimeout(() => setDebouncedSearch(searchValue.trim()), 300); + return () => { + if (debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current); + }; + }, [searchValue]); + + const activeFilters = useMemo<IRosterFilters>( + () => ({ + search: debouncedSearch || undefined, + position: selectedPosition || undefined, + status: (selectedStatus as TRosterPlayerStatus) || undefined, + class_year: selectedClassYear || undefined, + }), + [debouncedSearch, selectedClassYear, selectedPosition, selectedStatus] + ); + + const statusOptions = useMemo(() => getUniqueRosterValues(allPlayers, "status"), [allPlayers]); + const positionOptions = useMemo(() => getUniqueRosterValues(allPlayers, "position"), [allPlayers]); + const classYearOptions = useMemo(() => getUniqueRosterValues(allPlayers, "class_year"), [allPlayers]); + const players = useMemo(() => { + const filteredPlayers = allPlayers.filter( + (player) => + matchesSearch(player, debouncedSearch) && + (!activeFilters.position || player.position === activeFilters.position) && + (!activeFilters.status || player.status === activeFilters.status) && + (!activeFilters.class_year || player.class_year === activeFilters.class_year) + ); + + return sortPlayers(filteredPlayers, orderBy); + }, [activeFilters.class_year, activeFilters.position, activeFilters.status, allPlayers, debouncedSearch, orderBy]); + const groupedRoster = useMemo( + () => buildGroupedRoster(players, groupBy, subGroupBy, orderBy), + [groupBy, orderBy, players, subGroupBy] + ); + const selectedPlayers = useMemo( + () => allPlayers.filter((player) => selectedPlayerIds.includes(player.id)), + [allPlayers, selectedPlayerIds] + ); + + useEffect(() => { + setSelectedPlayerIds((currentSelectedPlayerIds) => + currentSelectedPlayerIds.filter((playerId) => allPlayers.some((player) => player.id === playerId)) + ); + }, [allPlayers]); + + const refetchRoster = useCallback(async () => { + setIsLoading(true); + try { + const roster = await rosterService.getRoster(workspaceSlug, projectId); + setAllPlayers(roster); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: getErrorMessage(error, "Roster could not be loaded. Please try again."), + }); + } finally { + setIsLoading(false); + } + }, [projectId, workspaceSlug]); + + useEffect(() => { + refetchRoster(); + }, [refetchRoster]); + + const openCreatePlayerModal = () => { + setEditingPlayer(null); + setIsAddPlayerModalOpen(true); + }; + + const openEditPlayerModal = (player: IRosterPlayer) => { + setEditingPlayer(player); + setIsAddPlayerModalOpen(true); + }; + + const closePlayerModal = () => { + setEditingPlayer(null); + setIsAddPlayerModalOpen(false); + }; + + const openImportRosterModal = () => setIsImportRosterModalOpen(true); + const closeImportRosterModal = () => { + setPendingImportFile(null); + setIsImportRosterModalOpen(false); + }; + const togglePlayerSelection = (playerId: string) => + setSelectedPlayerIds((currentSelectedPlayerIds) => + currentSelectedPlayerIds.includes(playerId) + ? currentSelectedPlayerIds.filter((id) => id !== playerId) + : [...currentSelectedPlayerIds, playerId] + ); + const toggleAllPlayerSelections = () => + setSelectedPlayerIds((currentSelectedPlayerIds) => { + const visiblePlayerIds = players.map((player) => player.id); + const hasUnselectedVisiblePlayers = visiblePlayerIds.some( + (playerId) => !currentSelectedPlayerIds.includes(playerId) + ); + + if (hasUnselectedVisiblePlayers) { + return Array.from(new Set([...currentSelectedPlayerIds, ...visiblePlayerIds])); + } + + return currentSelectedPlayerIds.filter((playerId) => !visiblePlayerIds.includes(playerId)); + }); + const clearSelectedPlayers = () => setSelectedPlayerIds([]); + const openDeletePlayerModal = (player: IRosterPlayer) => setDeletingPlayer(player); + const closeDeletePlayerModal = () => setDeletingPlayer(null); + const openBulkDeleteModal = () => setIsBulkDeleteModalOpen(true); + const closeBulkDeleteModal = () => setIsBulkDeleteModalOpen(false); + const toggleDisplayProperty = (key: TRosterDisplayPropertyKey) => + setDisplayProperties((current) => ({ ...current, [key]: !current[key] })); + const updateGroupBy = (value: TRosterGroupByOption) => { + setGroupBy(value); + if (value === "none") { + setSubGroupBy("none"); + return; + } + if (subGroupBy === value) setSubGroupBy("none"); + }; + const updateSubGroupBy = (value: TRosterGroupByOption) => { + if (groupBy === "none") { + setGroupBy(value === "none" ? "none" : value); + setSubGroupBy("none"); + return; + } + setSubGroupBy(value === groupBy ? "none" : value); + }; + + const submitPlayer = async (payload: TRosterFormState) => { + setIsSubmitting(true); + try { + if (editingPlayer) { + const updatedPlayer = await rosterService.updateRosterPlayer( + workspaceSlug, + projectId, + editingPlayer.id, + normalizePayload(payload) + ); + setAllPlayers((currentPlayers) => + currentPlayers.map((player) => (player.id === updatedPlayer.id ? updatedPlayer : player)) + ); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Player updated successfully.", + }); + } else { + const createdPlayer = await rosterService.createRosterPlayer( + workspaceSlug, + projectId, + normalizePayload(payload) + ); + setAllPlayers((currentPlayers) => [...currentPlayers, createdPlayer]); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Player added successfully.", + }); + } + + closePlayerModal(); + await refetchRoster(); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: getErrorMessage(error, "Player could not be saved. Please try again."), + }); + } finally { + setIsSubmitting(false); + } + }; + + const deletePlayer = async () => { + if (!deletingPlayer) return; + + setIsSubmitting(true); + try { + await rosterService.deleteRosterPlayer(workspaceSlug, projectId, deletingPlayer.id); + setAllPlayers((currentPlayers) => currentPlayers.filter((player) => player.id !== deletingPlayer.id)); + setSelectedPlayerIds((currentSelectedPlayerIds) => + currentSelectedPlayerIds.filter((playerId) => playerId !== deletingPlayer.id) + ); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Player deleted successfully.", + }); + closeDeletePlayerModal(); + await refetchRoster(); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: getErrorMessage(error, "Player could not be deleted. Please try again."), + }); + } finally { + setIsSubmitting(false); + } + }; + + const deleteSelectedPlayers = async () => { + if (!selectedPlayerIds.length) return; + + setIsSubmitting(true); + try { + const playerIdsToDelete = [...selectedPlayerIds]; + const deleteResults = await Promise.allSettled( + playerIdsToDelete.map((playerId) => rosterService.deleteRosterPlayer(workspaceSlug, projectId, playerId)) + ); + const deletedPlayerIds = playerIdsToDelete.filter((_, index) => deleteResults[index].status === "fulfilled"); + const failedPlayerIds = playerIdsToDelete.filter((_, index) => deleteResults[index].status === "rejected"); + + if (deletedPlayerIds.length) { + setAllPlayers((currentPlayers) => currentPlayers.filter((player) => !deletedPlayerIds.includes(player.id))); + } + + setSelectedPlayerIds(failedPlayerIds); + closeBulkDeleteModal(); + + if (!failedPlayerIds.length) { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: `${deletedPlayerIds.length} player${deletedPlayerIds.length === 1 ? "" : "s"} deleted successfully.`, + }); + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Partial delete", + message: `${deletedPlayerIds.length} player${deletedPlayerIds.length === 1 ? "" : "s"} deleted. ${failedPlayerIds.length} failed to delete.`, + }); + } + + await refetchRoster(); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: getErrorMessage(error, "Selected players could not be deleted. Please try again."), + }); + } finally { + setIsSubmitting(false); + } + }; + + const importPlayers = async (playersToImport: IRosterPlayerPayload[]) => { + setIsSubmitting(true); + try { + const response = await rosterService.importRoster(workspaceSlug, projectId, { players: playersToImport }); + setAllPlayers((currentPlayers) => [...currentPlayers, ...response.data]); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: response.message || "Roster imported successfully.", + }); + closeImportRosterModal(); + await refetchRoster(); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: getErrorMessage(error, "Roster could not be imported. Please try again."), + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <RosterContext.Provider + value={{ + workspaceSlug, + projectId, + players, + groupedRoster, + allPlayers, + statusOptions, + positionOptions, + classYearOptions, + isLoading, + isSubmitting, + canManage, + searchValue, + selectedPosition, + selectedStatus, + selectedClassYear, + activeView, + displayProperties, + groupBy, + subGroupBy, + orderBy, + isAddPlayerModalOpen, + isImportRosterModalOpen, + pendingImportFile, + editingPlayer, + deletingPlayer, + selectedPlayerIds, + selectedPlayers, + isBulkDeleteModalOpen, + setSearchValue, + setSelectedPosition, + setSelectedStatus, + setSelectedClassYear, + setActiveView, + toggleDisplayProperty, + setGroupBy: updateGroupBy, + setSubGroupBy: updateSubGroupBy, + setOrderBy, + openCreatePlayerModal, + openEditPlayerModal, + closePlayerModal, + openImportRosterModal, + closeImportRosterModal, + setPendingImportFile, + togglePlayerSelection, + toggleAllPlayerSelections, + clearSelectedPlayers, + openDeletePlayerModal, + closeDeletePlayerModal, + openBulkDeleteModal, + closeBulkDeleteModal, + submitPlayer, + deletePlayer, + deleteSelectedPlayers, + importPlayers, + refetchRoster, + }} + > + {children} + </RosterContext.Provider> + ); +}; + +export const useRoster = () => { + const context = useContext(RosterContext); + if (!context) throw new Error("useRoster must be used within RosterProvider"); + return context; +}; + +export const getRosterFormState = (player?: IRosterPlayer | null): TRosterFormState => + player + ? { + player_name: player.player_name ?? "", + jersey_number: player.jersey_number ?? "", + position: player.position ?? "", + height: player.height ?? "", + weight: player.weight ?? "", + class_year: player.class_year ?? "", + status: player.status ?? "active", + notes: player.notes ?? "", + } + : DEFAULT_FORM_STATE; diff --git a/apps/web/ce/features/roster/utils/roster.utils.ts b/apps/web/ce/features/roster/utils/roster.utils.ts new file mode 100644 index 00000000000..59ce545afc5 --- /dev/null +++ b/apps/web/ce/features/roster/utils/roster.utils.ts @@ -0,0 +1,93 @@ +import type { IRosterPlayer, IRosterPlayerPayload, TRosterPlayerStatus } from "@plane/types"; +import { ROSTER_HEADER_MAP, STATUS_VALUES } from "../constants/roster.constants"; + +export const toDisplayStatus = (status: string) => status.charAt(0).toUpperCase() + status.slice(1); + +export const formatTimestamp = (value: string | null | undefined) => + value && !Number.isNaN(new Date(value).getTime()) + ? new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(value)) + : "--"; + +const normalizeHeader = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); + +const getMappedValue = (row: Record<string, unknown>, aliases: string[]) => { + const normalizedEntries = Object.entries(row).map(([key, value]) => [normalizeHeader(key), value] as const); + for (const alias of aliases) { + const match = normalizedEntries.find(([normalizedKey]) => normalizedKey === normalizeHeader(alias)); + if (match) return match[1]; + } + return undefined; +}; + +const toOptionalString = (value: unknown) => { + if (value === null || value === undefined) return null; + const normalized = String(value).trim(); + return normalized ? normalized : null; +}; + +const normalizeImportedStatus = (value: unknown): TRosterPlayerStatus => { + const normalized = String(value ?? "") + .trim() + .toLowerCase(); + if (!normalized) return "active"; + if (STATUS_VALUES.includes(normalized as TRosterPlayerStatus)) return normalized as TRosterPlayerStatus; + throw new Error(`Invalid status value "${String(value)}". Use active, injured, inactive, or pending.`); +}; + +export const mapImportedRows = (rows: Record<string, unknown>[]): IRosterPlayerPayload[] => + rows.reduce<IRosterPlayerPayload[]>((accumulator, row, index) => { + const mappedRow: IRosterPlayerPayload = { + player_name: String(getMappedValue(row, ROSTER_HEADER_MAP.player_name) ?? "").trim(), + jersey_number: toOptionalString(getMappedValue(row, ROSTER_HEADER_MAP.jersey_number)), + position: toOptionalString(getMappedValue(row, ROSTER_HEADER_MAP.position)), + height: toOptionalString(getMappedValue(row, ROSTER_HEADER_MAP.height)), + weight: toOptionalString(getMappedValue(row, ROSTER_HEADER_MAP.weight)), + class_year: toOptionalString(getMappedValue(row, ROSTER_HEADER_MAP.class_year)), + status: (() => { + try { + return normalizeImportedStatus(getMappedValue(row, ROSTER_HEADER_MAP.status)); + } catch (error) { + throw new Error(`Row ${index + 2}: ${error instanceof Error ? error.message : "Invalid status value."}`); + } + })(), + notes: toOptionalString(getMappedValue(row, ROSTER_HEADER_MAP.notes)), + }; + + const hasRosterValue = [ + mappedRow.player_name, + mappedRow.jersey_number, + mappedRow.position, + mappedRow.height, + mappedRow.weight, + mappedRow.class_year, + mappedRow.notes, + ].some((value) => value !== null && value !== ""); + + if (hasRosterValue) accumulator.push(mappedRow); + return accumulator; + }, []); + +export const getPreviewRows = (rows: IRosterPlayerPayload[]) => rows.slice(0, 5); + +export const getUniqueRosterValues = <K extends "position" | "status" | "class_year">( + players: IRosterPlayer[], + key: K +) => { + const uniqueValues = new Set<string>(); + + players.forEach((player) => { + const value = player[key]; + if (typeof value !== "string") return; + + const normalizedValue = value.trim(); + if (!normalizedValue) return; + + uniqueValues.add(normalizedValue); + }); + + return Array.from(uniqueValues); +}; diff --git a/apps/web/ce/helpers/command-palette.ts b/apps/web/ce/helpers/command-palette.ts index d29660a168a..85b474a7a30 100644 --- a/apps/web/ce/helpers/command-palette.ts +++ b/apps/web/ce/helpers/command-palette.ts @@ -18,7 +18,7 @@ export const getGlobalShortcutsList: () => TCommandPaletteActionList = () => { return { c: { title: "Create a new work item", - description: "Create a new work item in the current project", + description: "Create a new work item in the current program", action: () => { toggleCreateIssueModal(true); captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON }); @@ -32,8 +32,8 @@ export const getWorkspaceShortcutsList: () => TCommandPaletteActionList = () => return { p: { - title: "Create a new project", - description: "Create a new project in the current workspace", + title: "Create a new program", + description: "Create a new program in the current workspace", action: () => { toggleCreateProjectModal(true); captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_CREATE_BUTTON }); @@ -54,7 +54,7 @@ export const getProjectShortcutsList: () => TCommandPaletteActionList = () => { return { d: { title: "Create a new page", - description: "Create a new page in the current project", + description: "Create a new page in the current program", action: () => { toggleCreatePageModal({ isOpen: true }); captureClick({ elementName: PROJECT_PAGE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_CREATE_BUTTON }); @@ -62,7 +62,7 @@ export const getProjectShortcutsList: () => TCommandPaletteActionList = () => { }, m: { title: "Create a new module", - description: "Create a new module in the current project", + description: "Create a new module in the current program", action: () => { toggleCreateModuleModal(true); captureClick({ elementName: MODULE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM }); @@ -70,7 +70,7 @@ export const getProjectShortcutsList: () => TCommandPaletteActionList = () => { }, q: { title: "Create a new cycle", - description: "Create a new cycle in the current project", + description: "Create a new cycle in the current program", action: () => { toggleCreateCycleModal(true); captureClick({ elementName: CYCLE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM }); @@ -78,7 +78,7 @@ export const getProjectShortcutsList: () => TCommandPaletteActionList = () => { }, v: { title: "Create a new view", - description: "Create a new view in the current project", + description: "Create a new view in the current program", action: () => { toggleCreateViewModal(true); captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM }); @@ -86,12 +86,12 @@ export const getProjectShortcutsList: () => TCommandPaletteActionList = () => { }, backspace: { title: "Bulk delete work items", - description: "Bulk delete work items in the current project", + description: "Bulk delete work items in the current program", action: () => toggleBulkDeleteIssueModal(true), }, delete: { title: "Bulk delete work items", - description: "Bulk delete work items in the current project", + description: "Bulk delete work items in the current program", action: () => toggleBulkDeleteIssueModal(true), }, }; @@ -105,7 +105,7 @@ export const getNavigationShortcutsList = (): TCommandPaletteShortcut[] => [ ]; export const getCommonShortcutsList = (platform: string): TCommandPaletteShortcut[] => [ - { keys: "P", description: "Create project" }, + { keys: "P", description: "Create program" }, { keys: "C", description: "Create work item" }, { keys: "Q", description: "Create cycle" }, { keys: "M", description: "Create module" }, diff --git a/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts b/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts index 0c65a4de826..e592467269f 100644 --- a/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts +++ b/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import type { TSupportedOperators } from "@plane/types"; import { CORE_OPERATORS } from "@plane/types"; @@ -10,7 +11,11 @@ export type TUseFiltersOperatorConfigsProps = { workspaceSlug: string; }; -export const useFiltersOperatorConfigs = (_props: TUseFiltersOperatorConfigsProps): TFiltersOperatorConfigs => ({ - allowedOperators: new Set(Object.values(CORE_OPERATORS)), - allowNegative: false, -}); +export const useFiltersOperatorConfigs = (_props: TUseFiltersOperatorConfigsProps): TFiltersOperatorConfigs => + useMemo( + () => ({ + allowedOperators: new Set(Object.values(CORE_OPERATORS)), + allowNegative: false, + }), + [] + ); diff --git a/apps/web/core/components/analytics/select/project.tsx b/apps/web/core/components/analytics/select/project.tsx index aee3090268e..14390487d04 100644 --- a/apps/web/core/components/analytics/select/project.tsx +++ b/apps/web/core/components/analytics/select/project.tsx @@ -47,13 +47,13 @@ export const ProjectSelect: React.FC<Props> = observer((props) => { <div className="flex items-center gap-2 p-1 "> <ProjectIcon className="h-4 w-4" /> {value && value.length > 3 - ? `3+ projects` + ? `3+ programs` : value && value.length > 0 ? projectIds ?.filter((p) => value.includes(p)) .map((p) => getProjectById(p)?.name) .join(", ") - : "All projects"} + : "All programs"} </div> } multiple diff --git a/apps/web/core/components/annotation/components/playlist-annotation-overlay.tsx b/apps/web/core/components/annotation/components/playlist-annotation-overlay.tsx new file mode 100644 index 00000000000..be30311b632 --- /dev/null +++ b/apps/web/core/components/annotation/components/playlist-annotation-overlay.tsx @@ -0,0 +1,728 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react"; +import type { TCustomPlaylistAnnotation, TCustomPlaylistAnnotationPoint } from "../types/annotation.types"; +import type { + AnnotationResizeHandle, + AnnotationTransformMode, + AnnotationTransformState, + OverlayBounds, + PlaylistAnnotationOverlayProps, +} from "../types/playlist-annotation-overlay.types"; +import { + CANVAS_SIZE, + MAX_POINT_COUNT, + MIN_POINT_DISTANCE, + clamp, + createPlaylistAnnotationId, + getAnnotationBounds, + getAnnotationRotation, + getLinearAnnotationEndpoints, + getPointAngle, + getPointBounds, + getPointDistance, + isAnnotationResizable, + isAnnotationValid, + isLinearAnnotation, + isPointInAnnotation, + isPointOnAnnotationEdge, + moveAnnotation, + normalizeAnnotationBox, + normalizePlaylistAnnotations, + normalizeRotation, + resizeAnnotation, +} from "../utils/playlist-annotation-model"; +import { + areOverlayBoundsEqual, + drawCanvasAnnotation, + getFittedVideoBounds, +} from "../utils/playlist-annotation-rendering"; +import { PlaylistAnnotationSelectionControls } from "./playlist-annotation-selection-controls"; +import { PlaylistAnnotationTextDraftInput } from "./playlist-annotation-text-draft-input"; + +export { + DEFAULT_PLAYLIST_ANNOTATION_DURATION_SECONDS, + arePlaylistAnnotationsEqual, + createPlaylistAnnotationId, + getActivePlaylistAnnotations, + isPlaylistAnnotationVisibleAtTime, + normalizePlaylistAnnotations, +} from "../utils/playlist-annotation-model"; + +const getAspectLockedImageAnnotation = ( + annotation: TCustomPlaylistAnnotation, + origin: TCustomPlaylistAnnotationPoint, + point: TCustomPlaylistAnnotationPoint +) => { + const baseWidth = Math.abs(annotation.width ?? 0); + const baseHeight = Math.abs(annotation.height ?? 0); + const aspectRatio = baseWidth > 0 && baseHeight > 0 ? baseWidth / baseHeight : 1; + const rawWidth = point.x - origin.x; + const rawHeight = point.y - origin.y; + const widthDistance = Math.abs(rawWidth); + const heightDistance = Math.abs(rawHeight); + + if (widthDistance === 0 && heightDistance === 0) return annotation; + + const widthDirection = rawWidth < 0 ? -1 : 1; + const heightDirection = rawHeight < 0 ? -1 : 1; + const isWidthDominant = widthDistance / aspectRatio >= heightDistance; + const width = isWidthDominant ? rawWidth : heightDistance * aspectRatio * widthDirection; + const height = isWidthDominant ? (widthDistance / aspectRatio) * heightDirection : rawHeight; + + return normalizeAnnotationBox({ + ...annotation, + height, + width, + x: origin.x, + y: origin.y, + }); +}; + +export const PlaylistAnnotationOverlay = ({ + annotations, + className, + color, + durationSeconds, + enableAnnotationTransforms = false, + enabled, + fitToVideoBounds = false, + imageContent = null, + imageHeight, + imageOpacity, + imagePlacementKey, + imageTitle, + imageWidth, + inputEnabled = enabled, + onCreateAnnotation, + onUpdateAnnotation, + textFontFamily, + textFontSize, + textFontWeight, + startTime, + strokeStyle, + strokeWidth, + tool, +}: PlaylistAnnotationOverlayProps) => { + const canvasRef = useRef<HTMLCanvasElement | null>(null); + const imageCacheRef = useRef<Map<string, HTMLImageElement>>(new Map()); + const overlayRootRef = useRef<HTMLDivElement | null>(null); + const pointerIdRef = useRef<number | null>(null); + const draftAnnotationRef = useRef<TCustomPlaylistAnnotation | null>(null); + const draftOriginRef = useRef<TCustomPlaylistAnnotationPoint | null>(null); + const annotationTransformStateRef = useRef<AnnotationTransformState | null>(null); + const lastImagePlacementKeyRef = useRef<number | undefined>(undefined); + const textDraftInputRef = useRef<HTMLInputElement | null>(null); + const shouldSkipTextDraftCommitRef = useRef(false); + const [draftAnnotation, setDraftAnnotation] = useState<TCustomPlaylistAnnotation | null>(null); + const [overlayBounds, setOverlayBounds] = useState<OverlayBounds | null>(null); + const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null); + const [textDraft, setTextDraft] = useState<{ point: TCustomPlaylistAnnotationPoint; value: string } | null>(null); + const [canvasRevision, setCanvasRevision] = useState(0); + const [imageRevision, setImageRevision] = useState(0); + + const renderedAnnotations = useMemo( + () => [...annotations, ...(draftAnnotation ? [draftAnnotation] : [])], + [annotations, draftAnnotation] + ); + const canTransformAnnotations = inputEnabled && enableAnnotationTransforms && Boolean(onUpdateAnnotation); + const selectedAnnotation = useMemo( + () => annotations.find((annotation) => annotation.id === selectedAnnotationId) ?? null, + [annotations, selectedAnnotationId] + ); + const selectedAnnotationIsLinear = selectedAnnotation ? isLinearAnnotation(selectedAnnotation) : false; + const selectedAnnotationBounds = selectedAnnotation ? getAnnotationBounds(selectedAnnotation) : null; + const selectedAnnotationRotation = selectedAnnotation ? getAnnotationRotation(selectedAnnotation) : 0; + const selectedAnnotationCanResize = selectedAnnotation ? isAnnotationResizable(selectedAnnotation) : false; + const selectedLinearAnnotationEndpoints = + selectedAnnotation && selectedAnnotationIsLinear ? getLinearAnnotationEndpoints(selectedAnnotation) : null; + const selectedLinearAnnotationMidpoint = selectedLinearAnnotationEndpoints + ? { + x: (selectedLinearAnnotationEndpoints.start.x + selectedLinearAnnotationEndpoints.end.x) / 2, + y: (selectedLinearAnnotationEndpoints.start.y + selectedLinearAnnotationEndpoints.end.y) / 2, + } + : null; + + useEffect(() => { + if (enabled) return; + draftAnnotationRef.current = null; + draftOriginRef.current = null; + annotationTransformStateRef.current = null; + setDraftAnnotation(null); + setSelectedAnnotationId(null); + setTextDraft(null); + pointerIdRef.current = null; + }, [enabled]); + + useEffect(() => { + if (!selectedAnnotationId || annotations.some((annotation) => annotation.id === selectedAnnotationId)) return; + + setSelectedAnnotationId(null); + }, [annotations, selectedAnnotationId]); + + const updateOverlayBounds = useCallback(() => { + if (!fitToVideoBounds) { + setOverlayBounds(null); + return; + } + + const root = overlayRootRef.current; + const nextBounds = root ? getFittedVideoBounds(root) : null; + setOverlayBounds((currentBounds) => + areOverlayBoundsEqual(currentBounds, nextBounds) ? currentBounds : nextBounds + ); + }, [fitToVideoBounds]); + + useEffect(() => { + updateOverlayBounds(); + if (!fitToVideoBounds || typeof window === "undefined") return; + + const root = overlayRootRef.current; + const host = root?.parentElement ?? null; + const video = host?.querySelector<HTMLVideoElement>("video") ?? null; + const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(updateOverlayBounds) : null; + const animationFrameId = window.requestAnimationFrame(updateOverlayBounds); + + if (host && resizeObserver) resizeObserver.observe(host); + if (video && resizeObserver) resizeObserver.observe(video); + + video?.addEventListener("loadedmetadata", updateOverlayBounds); + video?.addEventListener("loadeddata", updateOverlayBounds); + video?.addEventListener("resize", updateOverlayBounds); + window.addEventListener("resize", updateOverlayBounds); + document.addEventListener("fullscreenchange", updateOverlayBounds); + + return () => { + resizeObserver?.disconnect(); + window.cancelAnimationFrame(animationFrameId); + video?.removeEventListener("loadedmetadata", updateOverlayBounds); + video?.removeEventListener("loadeddata", updateOverlayBounds); + video?.removeEventListener("resize", updateOverlayBounds); + window.removeEventListener("resize", updateOverlayBounds); + document.removeEventListener("fullscreenchange", updateOverlayBounds); + }; + }, [fitToVideoBounds, updateOverlayBounds]); + + useEffect(() => { + if (!textDraft) return; + + window.requestAnimationFrame(() => { + textDraftInputRef.current?.focus(); + }); + }, [textDraft]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const updateCanvasSize = () => { + const rect = canvas.getBoundingClientRect(); + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; + const nextWidth = Math.max(1, Math.round(rect.width * dpr)); + const nextHeight = Math.max(1, Math.round(rect.height * dpr)); + + if (canvas.width === nextWidth && canvas.height === nextHeight) return; + + canvas.width = nextWidth; + canvas.height = nextHeight; + setCanvasRevision((currentValue) => currentValue + 1); + }; + + updateCanvasSize(); + + if (typeof ResizeObserver !== "undefined") { + const resizeObserver = new ResizeObserver(updateCanvasSize); + resizeObserver.observe(canvas); + return () => resizeObserver.disconnect(); + } + + window.addEventListener("resize", updateCanvasSize); + + return () => { + window.removeEventListener("resize", updateCanvasSize); + }; + }, []); + + const handleImageLoad = useCallback(() => { + setImageRevision((currentValue) => currentValue + 1); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas?.getContext("2d"); + if (!canvas || !context) return; + + const rect = canvas.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; + const nextWidth = Math.max(1, Math.round(rect.width * dpr)); + const nextHeight = Math.max(1, Math.round(rect.height * dpr)); + + if (canvas.width !== nextWidth || canvas.height !== nextHeight) { + canvas.width = nextWidth; + canvas.height = nextHeight; + } + + context.setTransform(dpr, 0, 0, dpr, 0, 0); + context.clearRect(0, 0, rect.width, rect.height); + + const annotationsToDraw = selectedAnnotationId + ? [ + ...renderedAnnotations.filter((annotation) => annotation.id !== selectedAnnotationId), + ...renderedAnnotations.filter((annotation) => annotation.id === selectedAnnotationId), + ] + : renderedAnnotations; + + annotationsToDraw.forEach((annotation) => { + drawCanvasAnnotation({ + annotation, + context, + imageCache: imageCacheRef.current, + isDraft: annotation.id === draftAnnotation?.id, + onImageLoad: handleImageLoad, + size: { + height: rect.height, + width: rect.width, + }, + }); + }); + }, [canvasRevision, draftAnnotation?.id, handleImageLoad, imageRevision, renderedAnnotations, selectedAnnotationId]); + + const getEventPoint = useCallback((event: ReactPointerEvent<HTMLElement>) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) return null; + + return { + x: clamp(((event.clientX - rect.left) / rect.width) * CANVAS_SIZE, 0, CANVAS_SIZE), + y: clamp(((event.clientY - rect.top) / rect.height) * CANVAS_SIZE, 0, CANVAS_SIZE), + }; + }, []); + + const buildAnnotation = useCallback( + (point: TCustomPlaylistAnnotationPoint, content?: string): TCustomPlaylistAnnotation => ({ + content: tool === "image" ? (imageContent ?? undefined) : content, + createdAt: new Date().toISOString(), + endTime: startTime + durationSeconds, + height: tool === "image" ? imageHeight : 0, + id: createPlaylistAnnotationId(), + points: tool === "pen" ? [point] : undefined, + startTime, + style: { + color, + stroke: color, + strokeStyle, + strokeWidth, + ...(tool === "image" ? { opacity: imageOpacity } : {}), + ...(tool === "text" ? { fontFamily: textFontFamily, fontSize: textFontSize, fontWeight: textFontWeight } : {}), + }, + title: tool === "image" ? imageTitle || "Image" : undefined, + type: tool, + width: tool === "image" ? imageWidth : 0, + x: point.x, + y: point.y, + }), + [ + color, + durationSeconds, + imageContent, + imageHeight, + imageOpacity, + imageTitle, + imageWidth, + startTime, + strokeStyle, + strokeWidth, + textFontFamily, + textFontSize, + textFontWeight, + tool, + ] + ); + + useEffect(() => { + if (!enabled || tool !== "image" || !imageContent || !imagePlacementKey) return; + if (lastImagePlacementKeyRef.current === imagePlacementKey) return; + + lastImagePlacementKeyRef.current = imagePlacementKey; + const point = { + x: clamp((CANVAS_SIZE - imageWidth) / 2, 0, CANVAS_SIZE), + y: clamp((CANVAS_SIZE - imageHeight) / 2, 0, CANVAS_SIZE), + }; + const normalizedAnnotation = normalizePlaylistAnnotations([buildAnnotation(point)])[0]; + if (!normalizedAnnotation) return; + + draftAnnotationRef.current = null; + draftOriginRef.current = null; + pointerIdRef.current = null; + setDraftAnnotation(null); + setSelectedAnnotationId(normalizedAnnotation.id); + onCreateAnnotation(normalizedAnnotation); + }, [buildAnnotation, enabled, imageContent, imageHeight, imagePlacementKey, imageWidth, onCreateAnnotation, tool]); + + const commitTextDraft = useCallback(() => { + if (shouldSkipTextDraftCommitRef.current) { + shouldSkipTextDraftCommitRef.current = false; + setTextDraft(null); + return; + } + + if (!textDraft) return; + + const content = textDraft.value.trim(); + shouldSkipTextDraftCommitRef.current = false; + setTextDraft(null); + if (!content) return; + + const normalizedAnnotation = normalizePlaylistAnnotations([buildAnnotation(textDraft.point, content)])[0]; + if (normalizedAnnotation) onCreateAnnotation(normalizedAnnotation); + }, [buildAnnotation, onCreateAnnotation, textDraft]); + + const handleTextDraftKeyDown = useCallback( + (event: ReactKeyboardEvent<HTMLInputElement>) => { + if (event.key === "Enter") { + event.preventDefault(); + commitTextDraft(); + } + + if (event.key === "Escape") { + event.preventDefault(); + shouldSkipTextDraftCommitRef.current = true; + setTextDraft(null); + } + }, + [commitTextDraft] + ); + + const updateDraftAnnotation = useCallback( + (annotation: TCustomPlaylistAnnotation, point: TCustomPlaylistAnnotationPoint): TCustomPlaylistAnnotation => { + if (annotation.type === "pen") { + const points = annotation.points ?? []; + const lastPoint = points[points.length - 1]; + if (lastPoint && getPointDistance(lastPoint, point) < MIN_POINT_DISTANCE) return annotation; + + const nextPoints = [...points, point].slice(-MAX_POINT_COUNT); + return { + ...annotation, + ...getPointBounds(nextPoints), + points: nextPoints, + }; + } + + const origin = draftOriginRef.current ?? { x: annotation.x, y: annotation.y }; + const width = point.x - origin.x; + const height = point.y - origin.y; + if (annotation.type === "image") { + return getAspectLockedImageAnnotation(annotation, origin, point); + } + + if (annotation.type === "rectangle" || annotation.type === "ellipse") { + return normalizeAnnotationBox({ + ...annotation, + height, + width, + x: origin.x, + y: origin.y, + }); + } + + return { + ...annotation, + height, + width, + x: origin.x, + y: origin.y, + }; + }, + [] + ); + + const startAnnotationTransform = useCallback( + ( + event: ReactPointerEvent<HTMLElement>, + annotation: TCustomPlaylistAnnotation, + mode: AnnotationTransformMode, + resizeHandle?: AnnotationResizeHandle + ) => { + if (!canTransformAnnotations) return false; + if (mode === "resize" && (!resizeHandle || !isAnnotationResizable(annotation))) return false; + + const point = getEventPoint(event); + const bounds = getAnnotationBounds(annotation); + const center = bounds + ? { + x: bounds.x + bounds.width / 2, + y: bounds.y + bounds.height / 2, + } + : null; + if (!point || !bounds || !center) return false; + + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + annotationTransformStateRef.current = { + annotationId: annotation.id, + center, + mode, + originalAnnotation: annotation, + originalBounds: bounds, + originalRotation: getAnnotationRotation(annotation), + pointerId: event.pointerId, + resizeHandle, + startAngle: getPointAngle(point, center), + startPoint: point, + }; + pointerIdRef.current = null; + draftAnnotationRef.current = null; + draftOriginRef.current = null; + setDraftAnnotation(null); + setSelectedAnnotationId(annotation.id); + + return true; + }, + [canTransformAnnotations, getEventPoint] + ); + + const handleAnnotationTransformPointerMove = useCallback( + (event: ReactPointerEvent<HTMLElement>) => { + const transformState = annotationTransformStateRef.current; + if (!transformState || transformState.pointerId !== event.pointerId || !onUpdateAnnotation) return false; + + const point = getEventPoint(event); + if (!point) return true; + + event.preventDefault(); + event.stopPropagation(); + + const nextAnnotation = + transformState.mode === "move" + ? moveAnnotation( + transformState.originalAnnotation, + point.x - transformState.startPoint.x, + point.y - transformState.startPoint.y + ) + : transformState.mode === "resize" && transformState.resizeHandle + ? resizeAnnotation( + transformState.originalAnnotation, + transformState.originalBounds, + transformState.center, + transformState.originalRotation, + transformState.resizeHandle, + point + ) + : { + ...transformState.originalAnnotation, + rotation: normalizeRotation( + transformState.originalRotation + + ((getPointAngle(point, transformState.center) - transformState.startAngle) * 180) / Math.PI + ), + }; + + onUpdateAnnotation(nextAnnotation); + + return true; + }, + [getEventPoint, onUpdateAnnotation] + ); + + const finishAnnotationTransform = useCallback((event: ReactPointerEvent<HTMLElement>) => { + const transformState = annotationTransformStateRef.current; + if (!transformState || transformState.pointerId !== event.pointerId) return false; + + event.preventDefault(); + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + annotationTransformStateRef.current = null; + + return true; + }, []); + + const cancelAnnotationTransform = useCallback((event: ReactPointerEvent<HTMLElement>) => { + const transformState = annotationTransformStateRef.current; + if (!transformState || transformState.pointerId !== event.pointerId) return false; + + event.preventDefault(); + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + annotationTransformStateRef.current = null; + + return true; + }, []); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent<HTMLCanvasElement>) => { + if (!inputEnabled || event.button !== 0) return; + + const point = getEventPoint(event); + if (!point) return; + + if (tool === "image" && !imageContent) return; + + if (canTransformAnnotations) { + const annotationToTransform = [...annotations] + .reverse() + .find((annotation) => + annotation.type === "image" && tool === "image" + ? isPointInAnnotation(point, annotation) + : isPointOnAnnotationEdge(point, annotation) + ); + if (annotationToTransform && startAnnotationTransform(event, annotationToTransform, "move")) return; + setSelectedAnnotationId(null); + } + + event.preventDefault(); + event.stopPropagation(); + + if (tool === "text") { + shouldSkipTextDraftCommitRef.current = false; + setTextDraft({ point, value: "" }); + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + pointerIdRef.current = event.pointerId; + + const nextDraftAnnotation = buildAnnotation(point); + draftAnnotationRef.current = nextDraftAnnotation; + draftOriginRef.current = point; + setDraftAnnotation(nextDraftAnnotation); + }, + [ + annotations, + buildAnnotation, + canTransformAnnotations, + getEventPoint, + imageContent, + inputEnabled, + startAnnotationTransform, + tool, + ] + ); + + const handlePointerMove = useCallback( + (event: ReactPointerEvent<HTMLCanvasElement>) => { + if (handleAnnotationTransformPointerMove(event)) return; + if (!inputEnabled || pointerIdRef.current !== event.pointerId) return; + + const point = getEventPoint(event); + if (!point) return; + + event.preventDefault(); + event.stopPropagation(); + const currentDraftAnnotation = draftAnnotationRef.current; + if (!currentDraftAnnotation) return; + + const nextDraftAnnotation = updateDraftAnnotation(currentDraftAnnotation, point); + if (nextDraftAnnotation === currentDraftAnnotation) return; + + draftAnnotationRef.current = nextDraftAnnotation; + setDraftAnnotation(nextDraftAnnotation); + }, + [getEventPoint, handleAnnotationTransformPointerMove, inputEnabled, updateDraftAnnotation] + ); + + const handlePointerUp = useCallback( + (event: ReactPointerEvent<HTMLCanvasElement>) => { + if (finishAnnotationTransform(event)) return; + if (pointerIdRef.current !== event.pointerId) return; + + event.preventDefault(); + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + pointerIdRef.current = null; + const currentDraftAnnotation = draftAnnotationRef.current; + draftAnnotationRef.current = null; + draftOriginRef.current = null; + setDraftAnnotation(null); + + if (currentDraftAnnotation && isAnnotationValid(currentDraftAnnotation)) { + const normalizedAnnotation = normalizePlaylistAnnotations([currentDraftAnnotation])[0]; + if (normalizedAnnotation) onCreateAnnotation(normalizedAnnotation); + } + }, + [finishAnnotationTransform, onCreateAnnotation] + ); + + const handlePointerCancel = useCallback( + (event: ReactPointerEvent<HTMLCanvasElement>) => { + if (cancelAnnotationTransform(event)) return; + if (pointerIdRef.current !== event.pointerId) return; + + pointerIdRef.current = null; + draftAnnotationRef.current = null; + draftOriginRef.current = null; + setDraftAnnotation(null); + }, + [cancelAnnotationTransform] + ); + const overlayStyle = overlayBounds + ? { + height: `${overlayBounds.height}px`, + left: `${overlayBounds.left}px`, + top: `${overlayBounds.top}px`, + width: `${overlayBounds.width}px`, + } + : { inset: 0 }; + + return ( + <div + ref={overlayRootRef} + className={[ + "absolute touch-none select-none bg-transparent", + inputEnabled ? "pointer-events-auto" : "pointer-events-none", + className, + ] + .filter(Boolean) + .join(" ")} + style={overlayStyle} + > + <canvas + ref={canvasRef} + aria-label="Video annotations" + className={[ + "absolute inset-0 h-full w-full touch-none select-none bg-transparent", + inputEnabled ? (tool === "text" ? "cursor-text" : "cursor-crosshair") : "", + ] + .filter(Boolean) + .join(" ")} + onPointerCancel={handlePointerCancel} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + /> + <PlaylistAnnotationSelectionControls + canTransformAnnotations={canTransformAnnotations} + onCancelAnnotationTransform={cancelAnnotationTransform} + onFinishAnnotationTransform={finishAnnotationTransform} + onStartAnnotationTransform={startAnnotationTransform} + onTransformPointerMove={handleAnnotationTransformPointerMove} + selectedAnnotation={selectedAnnotation} + selectedAnnotationBounds={selectedAnnotationBounds} + selectedAnnotationCanResize={selectedAnnotationCanResize} + selectedAnnotationRotation={selectedAnnotationRotation} + selectedLinearAnnotationEndpoints={selectedLinearAnnotationEndpoints} + selectedLinearAnnotationMidpoint={selectedLinearAnnotationMidpoint} + /> + <PlaylistAnnotationTextDraftInput + color={color} + enabled={enabled} + inputRef={textDraftInputRef} + onBlur={commitTextDraft} + onKeyDown={handleTextDraftKeyDown} + onPointerDown={(event) => event.stopPropagation()} + onTextDraftChange={setTextDraft} + textDraft={textDraft} + textFontFamily={textFontFamily} + textFontSize={textFontSize} + textFontWeight={textFontWeight} + /> + </div> + ); +}; diff --git a/apps/web/core/components/annotation/components/playlist-annotation-selection-controls.tsx b/apps/web/core/components/annotation/components/playlist-annotation-selection-controls.tsx new file mode 100644 index 00000000000..64778656cea --- /dev/null +++ b/apps/web/core/components/annotation/components/playlist-annotation-selection-controls.tsx @@ -0,0 +1,188 @@ +"use client"; + +import type { PointerEvent as ReactPointerEvent } from "react"; +import { RotateCw } from "lucide-react"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { TCustomPlaylistAnnotation, TCustomPlaylistAnnotationPoint } from "../types/annotation.types"; +import type { + AnnotationBounds, + AnnotationResizeHandle, + AnnotationTransformMode, +} from "../types/playlist-annotation-overlay.types"; +import { CANVAS_SIZE } from "../utils/playlist-annotation-model"; +import { ANNOTATION_RESIZE_HANDLES } from "../utils/playlist-annotation-transform"; + +type PlaylistAnnotationSelectionControlsProps = { + canTransformAnnotations: boolean; + onCancelAnnotationTransform: (event: ReactPointerEvent<HTMLElement>) => boolean; + onFinishAnnotationTransform: (event: ReactPointerEvent<HTMLElement>) => boolean; + onStartAnnotationTransform: ( + event: ReactPointerEvent<HTMLElement>, + annotation: TCustomPlaylistAnnotation, + mode: AnnotationTransformMode, + resizeHandle?: AnnotationResizeHandle + ) => boolean; + onTransformPointerMove: (event: ReactPointerEvent<HTMLElement>) => boolean; + selectedAnnotation: TCustomPlaylistAnnotation | null; + selectedAnnotationBounds: AnnotationBounds | null; + selectedAnnotationCanResize: boolean; + selectedAnnotationRotation: number; + selectedLinearAnnotationEndpoints: { + end: TCustomPlaylistAnnotationPoint; + start: TCustomPlaylistAnnotationPoint; + } | null; + selectedLinearAnnotationMidpoint: TCustomPlaylistAnnotationPoint | null; +}; + +export const PlaylistAnnotationSelectionControls = ({ + canTransformAnnotations, + onCancelAnnotationTransform, + onFinishAnnotationTransform, + onStartAnnotationTransform, + onTransformPointerMove, + selectedAnnotation, + selectedAnnotationBounds, + selectedAnnotationCanResize, + selectedAnnotationRotation, + selectedLinearAnnotationEndpoints, + selectedLinearAnnotationMidpoint, +}: PlaylistAnnotationSelectionControlsProps) => { + if ( + canTransformAnnotations && + selectedAnnotation && + selectedLinearAnnotationEndpoints && + selectedLinearAnnotationMidpoint + ) { + return ( + <div className="pointer-events-none absolute inset-0 z-10"> + <svg + aria-hidden="true" + className="absolute inset-0 h-full w-full overflow-visible" + preserveAspectRatio="none" + viewBox={`0 0 ${CANVAS_SIZE} ${CANVAS_SIZE}`} + > + <line + x1={selectedLinearAnnotationEndpoints.start.x} + y1={selectedLinearAnnotationEndpoints.start.y} + x2={selectedLinearAnnotationEndpoints.end.x} + y2={selectedLinearAnnotationEndpoints.end.y} + stroke="#facc15" + strokeDasharray="8 6" + strokeLinecap="round" + strokeWidth={2} + vectorEffect="non-scaling-stroke" + /> + </svg> + {( + [ + { handle: "start", label: "start", point: selectedLinearAnnotationEndpoints.start }, + { handle: "end", label: "end", point: selectedLinearAnnotationEndpoints.end }, + ] as const + ).map(({ handle, label, point }) => ( + <span + key={handle} + className="pointer-events-none absolute flex h-5 w-5 items-center justify-center" + style={{ + left: `${point.x / 10}%`, + top: `${point.y / 10}%`, + transform: "translate(-50%, -50%)", + }} + > + <button + type="button" + onPointerCancel={onCancelAnnotationTransform} + onPointerDown={(event) => onStartAnnotationTransform(event, selectedAnnotation, "resize", handle)} + onPointerMove={onTransformPointerMove} + onPointerUp={onFinishAnnotationTransform} + className="pointer-events-auto flex h-5 w-5 cursor-move items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-[#facc15]/50" + aria-label={`Resize annotation ${label}`} + > + <span className="block h-3 w-3 rounded-full border-2 border-[#facc15] bg-transparent shadow-[0_2px_8px_rgba(0,0,0,0.35)]" /> + </button> + </span> + ))} + <span + className="pointer-events-none absolute flex h-6 w-6 items-center justify-center" + style={{ + left: `${selectedLinearAnnotationMidpoint.x / 10}%`, + top: `${selectedLinearAnnotationMidpoint.y / 10}%`, + transform: "translate(-50%, calc(-50% - 2rem))", + }} + > + <Tooltip tooltipContent="Rotate annotation" position="top" sideOffset={8}> + <button + type="button" + onPointerCancel={onCancelAnnotationTransform} + onPointerDown={(event) => onStartAnnotationTransform(event, selectedAnnotation, "rotate")} + onPointerMove={onTransformPointerMove} + onPointerUp={onFinishAnnotationTransform} + className="pointer-events-auto flex h-6 w-6 cursor-grab items-center justify-center rounded-full border border-[#facc15] bg-custom-background-100 text-[13px] font-semibold leading-none text-[#facc15] shadow-[0_8px_20px_rgba(0,0,0,0.32)] outline-none transition-colors hover:bg-custom-background-90 focus-visible:ring-2 focus-visible:ring-[#facc15]/50 active:cursor-grabbing" + aria-label="Rotate annotation" + > + <RotateCw className="h-3.5 w-3.5" /> + </button> + </Tooltip> + </span> + </div> + ); + } + + if (!canTransformAnnotations || !selectedAnnotation || !selectedAnnotationBounds) return null; + + return ( + <div + className="pointer-events-none absolute z-10 rounded-[4px] border border-dashed border-[#facc15] shadow-[0_0_0_1px_rgba(0,0,0,0.36),0_0_18px_rgba(250,204,21,0.28)]" + style={{ + height: `max(24px, ${selectedAnnotationBounds.height / 10}%)`, + left: `${selectedAnnotationBounds.x / 10}%`, + top: `${selectedAnnotationBounds.y / 10}%`, + transform: `rotate(${selectedAnnotationRotation}deg)`, + transformOrigin: "center", + width: `max(28px, ${selectedAnnotationBounds.width / 10}%)`, + }} + > + {selectedAnnotationCanResize + ? ANNOTATION_RESIZE_HANDLES.map(({ className: handleClassName, cursorClassName, handle, label }) => ( + <span + key={handle} + className={`pointer-events-none absolute flex h-4 w-4 items-center justify-center ${handleClassName}`} + > + <button + type="button" + onPointerCancel={onCancelAnnotationTransform} + onPointerDown={(event) => onStartAnnotationTransform(event, selectedAnnotation, "resize", handle)} + onPointerMove={onTransformPointerMove} + onPointerUp={onFinishAnnotationTransform} + className={`pointer-events-auto flex h-4 w-4 items-center justify-center rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-[#facc15]/50 ${cursorClassName}`} + style={{ + transform: `rotate(${-selectedAnnotationRotation}deg)`, + }} + aria-label={`Resize annotation ${label}`} + > + <span className="block h-2.5 w-2.5 rounded-[2px] border border-[#facc15] bg-custom-background-100 shadow-[0_2px_8px_rgba(0,0,0,0.35)]" /> + </button> + </span> + )) + : null} + <span className="absolute left-1/2 top-0 h-6 w-px -translate-x-1/2 -translate-y-full bg-[#facc15]" /> + <span className="pointer-events-none absolute left-1/2 top-0 flex h-6 w-6 -translate-x-1/2 -translate-y-[calc(100%+1.5rem)] items-center justify-center"> + <Tooltip tooltipContent="Rotate annotation" position="top" sideOffset={8}> + <button + type="button" + onPointerCancel={onCancelAnnotationTransform} + onPointerDown={(event) => onStartAnnotationTransform(event, selectedAnnotation, "rotate")} + onPointerMove={onTransformPointerMove} + onPointerUp={onFinishAnnotationTransform} + className="pointer-events-auto flex h-6 w-6 cursor-grab items-center justify-center rounded-full border border-[#facc15] bg-custom-background-100 text-[13px] font-semibold leading-none text-[#facc15] shadow-[0_8px_20px_rgba(0,0,0,0.32)] outline-none transition-colors hover:bg-custom-background-90 focus-visible:ring-2 focus-visible:ring-[#facc15]/50 active:cursor-grabbing" + style={{ + transform: `rotate(${-selectedAnnotationRotation}deg)`, + }} + aria-label="Rotate annotation" + > + <RotateCw className="h-3.5 w-3.5" /> + </button> + </Tooltip> + </span> + </div> + ); +}; diff --git a/apps/web/core/components/annotation/components/playlist-annotation-text-draft-input.tsx b/apps/web/core/components/annotation/components/playlist-annotation-text-draft-input.tsx new file mode 100644 index 00000000000..0e72d5e679f --- /dev/null +++ b/apps/web/core/components/annotation/components/playlist-annotation-text-draft-input.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { + Dispatch, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, + Ref, + SetStateAction, +} from "react"; +import type { TCustomPlaylistAnnotationPoint } from "../types/annotation.types"; +import { clamp } from "../utils/playlist-annotation-model"; + +type PlaylistAnnotationTextDraft = { + point: TCustomPlaylistAnnotationPoint; + value: string; +}; + +type PlaylistAnnotationTextDraftInputProps = { + color: string; + enabled: boolean; + inputRef: Ref<HTMLInputElement>; + onBlur: () => void; + onKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void; + onTextDraftChange: Dispatch<SetStateAction<PlaylistAnnotationTextDraft | null>>; + onPointerDown: (event: ReactPointerEvent<HTMLInputElement>) => void; + textDraft: PlaylistAnnotationTextDraft | null; + textFontFamily: string; + textFontSize: number; + textFontWeight: number; +}; + +export const PlaylistAnnotationTextDraftInput = ({ + color, + enabled, + inputRef, + onBlur, + onKeyDown, + onPointerDown, + onTextDraftChange, + textDraft, + textFontFamily, + textFontSize, + textFontWeight, +}: PlaylistAnnotationTextDraftInputProps) => { + if (!enabled || !textDraft) return null; + + return ( + <input + ref={inputRef} + type="text" + value={textDraft.value} + onBlur={onBlur} + onChange={(event) => + onTextDraftChange((currentValue) => currentValue && { ...currentValue, value: event.target.value }) + } + onKeyDown={onKeyDown} + onPointerDown={onPointerDown} + className="absolute z-20 h-8 min-w-36 max-w-60 rounded-[4px] border border-custom-border-200 bg-custom-background-100 px-2 text-[14px] font-semibold shadow-lg outline-none ring-2 ring-custom-primary-100/35 placeholder:text-custom-text-400" + placeholder="Text" + style={{ + color, + fontFamily: textFontFamily, + fontSize: `${clamp(textFontSize, 12, 32)}px`, + fontWeight: textFontWeight, + left: `${textDraft.point.x / 10}%`, + top: `${textDraft.point.y / 10}%`, + transform: "translateY(-50%)", + }} + /> + ); +}; diff --git a/apps/web/core/components/annotation/components/video-annotation-color-picker-button.tsx b/apps/web/core/components/annotation/components/video-annotation-color-picker-button.tsx new file mode 100644 index 00000000000..d259bc1a5cd --- /dev/null +++ b/apps/web/core/components/annotation/components/video-annotation-color-picker-button.tsx @@ -0,0 +1,28 @@ +"use client"; + +type VideoAnnotationColorPickerButtonProps = { + annotationColor: string; + onColorChange: (colorValue: string) => void; +}; + +export const VideoAnnotationColorPickerButton = ({ + annotationColor, + onColorChange, +}: VideoAnnotationColorPickerButtonProps) => ( + <label + className="relative inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-[5px] border border-custom-border-200 bg-custom-background-90 transition-colors hover:bg-custom-background-80 focus-within:ring-2 focus-within:ring-custom-primary-100/40" + title={`Pick annotation color (${annotationColor.toUpperCase()})`} + > + <span + className="h-4 w-4 rounded-full border border-custom-border-200 shadow-sm" + style={{ backgroundColor: annotationColor }} + /> + <input + type="color" + value={annotationColor} + onChange={(event) => onColorChange(event.currentTarget.value)} + className="absolute inset-0 h-full w-full cursor-pointer opacity-0" + aria-label="Pick annotation color" + /> + </label> +); diff --git a/apps/web/core/components/annotation/components/video-annotation-editor.tsx b/apps/web/core/components/annotation/components/video-annotation-editor.tsx new file mode 100644 index 00000000000..6d9ecf221d4 --- /dev/null +++ b/apps/web/core/components/annotation/components/video-annotation-editor.tsx @@ -0,0 +1,499 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { useVideoAnnotationClock } from "../hooks/use-video-annotation-clock"; +import { useVideoAnnotationColorControls } from "../hooks/use-video-annotation-color-controls"; +import { useVideoAnnotationImageControls } from "../hooks/use-video-annotation-image-controls"; +import { useVideoAnnotationTimeline } from "../hooks/use-video-annotation-timeline"; +import type { + TCustomPlaylistAnnotation, + TCustomPlaylistAnnotationStrokeStyle, + TCustomPlaylistAnnotationTool, +} from "../types/annotation.types"; +import type { VideoAnnotationEditorProps } from "../types/video-annotation-editor.types"; +import { + applyAnnotationCreationStartTimeOffset, + getAnnotationStartTimeWithCreationOffset, +} from "../utils/playlist-annotation-creation-time"; +import { VIDEO_ANNOTATION_TOOLS } from "../utils/video-annotation-editor-config"; +import { resolveAnnotationTimelineLayers } from "../utils/video-annotation-timeline"; +import { + PlaylistAnnotationOverlay, + arePlaylistAnnotationsEqual, + getActivePlaylistAnnotations, + normalizePlaylistAnnotations, +} from "./playlist-annotation-overlay"; +import { VideoAnnotationColorPickerButton } from "./video-annotation-color-picker-button"; +import { VideoAnnotationInlineToolbar } from "./video-annotation-inline-toolbar"; +import { VideoAnnotationPropertiesPanel } from "./video-annotation-properties-panel"; +import { VideoAnnotationTimelinePanel } from "./video-annotation-timeline-panel"; +import { VideoAnnotationToolbar } from "./video-annotation-toolbar"; + +export const VideoAnnotationEditor = ({ + annotationKey, + annotations: savedAnnotationValue, + autoEnableAnnotationModeKey, + canEdit, + className, + currentTime, + durationSeconds = null, + enableAnnotationTransforms = false, + enableTextTool = false, + fitToVideoBounds = false, + isPlaying = false, + modeResetKey, + onModeChange, + onRegisterSaveHandler, + onRequestPause, + onSave, + onSeek, + playbackRate = 1, + propertyHostElement = null, + toolbarHostElement = null, + showTimeline = false, + timelineHostElement = null, +}: VideoAnnotationEditorProps) => { + const savedAnnotations = useMemo( + () => resolveAnnotationTimelineLayers(normalizePlaylistAnnotations(savedAnnotationValue)), + [savedAnnotationValue] + ); + const [annotations, setAnnotations] = useState<TCustomPlaylistAnnotation[]>(savedAnnotations); + const [baselineAnnotations, setBaselineAnnotations] = useState<TCustomPlaylistAnnotation[]>(savedAnnotations); + const [isAnnotationMode, setIsAnnotationMode] = useState(canEdit); + const [annotationTool, setAnnotationTool] = useState<TCustomPlaylistAnnotationTool>("pen"); + const [annotationStrokeWidth, setAnnotationStrokeWidth] = useState(5); + const [annotationStrokeStyle, setAnnotationStrokeStyle] = useState<TCustomPlaylistAnnotationStrokeStyle>("solid"); + const [annotationDurationSeconds, setAnnotationDurationSeconds] = useState(2); + const [annotationTextFontSize, setAnnotationTextFontSize] = useState(28); + const [annotationTextFontWeight, setAnnotationTextFontWeight] = useState(700); + const [annotationTextFontFamily, setAnnotationTextFontFamily] = useState("sans-serif"); + const [isSavingAnnotations, setIsSavingAnnotations] = useState(false); + const hasAnnotationChanges = !arePlaylistAnnotationsEqual(annotations, baselineAnnotations); + const availableAnnotationTools = useMemo( + () => VIDEO_ANNOTATION_TOOLS.filter((toolOption) => enableTextTool || toolOption.type !== "text"), + [enableTextTool] + ); + const sortedAnnotations = useMemo( + () => + [...annotations].sort((first, second) => first.startTime - second.startTime || first.endTime - second.endTime), + [annotations] + ); + const { + annotationColor, + annotationColorHsv, + annotationColorInputValue, + annotationColorRgb, + handleAnnotationColorChange, + handleAnnotationColorChannelChange, + handleAnnotationColorHueChange, + handleAnnotationColorInputBlur, + handleAnnotationColorInputChange, + handleAnnotationColorPickerPointerDown, + handleAnnotationColorPickerPointerMove, + isAnnotationColorPickerOpen, + setIsAnnotationColorPickerOpen, + } = useVideoAnnotationColorControls(); + const { + annotationImageContent, + annotationImageHeight, + annotationImageInputRef, + annotationImageName, + annotationImageOpacity, + annotationImagePlacementKey, + annotationImageWidth, + handleAnnotationImageChange, + handleAnnotationImageOpacityChange, + handleAnnotationImageSizeChange, + handleChooseAnnotationImage, + } = useVideoAnnotationImageControls({ + onModeChange, + onRequestPause, + setAnnotationTool, + setIsAnnotationMode, + }); + const { effectiveCurrentTime } = useVideoAnnotationClock({ + currentTime, + isPlaying, + playbackRate, + showTimeline, + sortedAnnotations, + }); + const activeAnnotations = useMemo( + () => getActivePlaylistAnnotations(sortedAnnotations, effectiveCurrentTime), + [effectiveCurrentTime, sortedAnnotations] + ); + const activeAnnotationIds = useMemo( + () => new Set(activeAnnotations.map((annotation) => annotation.id)), + [activeAnnotations] + ); + const hasActiveAnnotations = activeAnnotations.length > 0; + const annotationInputEnabled = canEdit && isAnnotationMode && !isPlaying; + const { + annotationTimelineMoments, + beginEditingTimelineMoment, + canZoomTimelineIn, + canZoomTimelineOut, + commitTimelineMomentTitle, + editingTimelineMoment, + handleAnnotationTimelineResizePointerEnd, + handleAnnotationTimelineResizePointerDown, + handleAnnotationTimelineResizePointerMove, + handleTimelineBodyScroll, + handleTimelineHeaderScroll, + handleTimelineKeyDown, + handleTimelinePointerDown, + handleTimelineSeek, + jumpToNearestAnnotation, + jumpToRelativeTimelineTime, + minimumVisibleAnnotationDurationSeconds, + openTimelineMomentIds, + setEditingTimelineMoment, + stepTimelineZoom, + timelineContentWidthPx, + timelineDurationSeconds, + timelineHeaderScrollableElementRef, + timelineProgressPercent, + timelineResizeId, + timelineScrollableElementRef, + timelineTicks, + timelineZoomPercent, + toggleTimelineMoment, + } = useVideoAnnotationTimeline({ + durationSeconds, + effectiveCurrentTime, + isSavingAnnotations, + onSeek, + setAnnotations, + sortedAnnotations, + }); + + useEffect(() => { + const shouldOpenAnnotationMode = canEdit; + setAnnotations(savedAnnotations); + setBaselineAnnotations(savedAnnotations); + setIsAnnotationMode(shouldOpenAnnotationMode); + setIsSavingAnnotations(false); + onModeChange?.(shouldOpenAnnotationMode); + }, [annotationKey, canEdit, onModeChange, savedAnnotations]); + + useEffect(() => { + if (enableTextTool || annotationTool !== "text") return; + + setAnnotationTool("pen"); + }, [annotationTool, enableTextTool]); + + useEffect( + () => () => { + onModeChange?.(false); + }, + [onModeChange] + ); + + useEffect(() => { + const shouldOpenAnnotationMode = canEdit; + setIsAnnotationMode(shouldOpenAnnotationMode); + onModeChange?.(shouldOpenAnnotationMode); + }, [canEdit, modeResetKey, onModeChange]); + + useEffect(() => { + if (autoEnableAnnotationModeKey === undefined || !canEdit) return; + + setIsAnnotationMode(true); + onModeChange?.(true); + }, [autoEnableAnnotationModeKey, canEdit, onModeChange]); + + const handleSelectAnnotationTool = useCallback( + (tool: TCustomPlaylistAnnotationTool) => { + onRequestPause?.(); + setAnnotationTool(tool); + if (tool === "image" && !annotationImageContent) { + annotationImageInputRef.current?.click(); + } + if (isAnnotationMode) return; + + setIsAnnotationMode(true); + onModeChange?.(true); + }, + [annotationImageContent, annotationImageInputRef, isAnnotationMode, onModeChange, onRequestPause] + ); + + const handleUndoVisibleAnnotation = useCallback(() => { + setAnnotations((currentAnnotations) => { + const annotationToRemove = activeAnnotations[activeAnnotations.length - 1]; + if (!annotationToRemove) return currentAnnotations; + + return currentAnnotations.filter((annotation) => annotation.id !== annotationToRemove.id); + }); + }, [activeAnnotations]); + + const handleClearVisibleAnnotations = useCallback(() => { + const activeAnnotationIds = new Set(activeAnnotations.map((annotation) => annotation.id)); + setAnnotations((currentAnnotations) => + currentAnnotations.filter((annotation) => !activeAnnotationIds.has(annotation.id)) + ); + }, [activeAnnotations]); + + const handleCreateAnnotation = useCallback( + (annotation: TCustomPlaylistAnnotation) => { + const offsetAnnotation = applyAnnotationCreationStartTimeOffset(annotation); + + setAnnotations((currentAnnotations) => + resolveAnnotationTimelineLayers( + normalizePlaylistAnnotations([...currentAnnotations, offsetAnnotation]), + offsetAnnotation.id, + minimumVisibleAnnotationDurationSeconds + ) + ); + }, + [minimumVisibleAnnotationDurationSeconds] + ); + + const handleUpdateAnnotation = useCallback( + (updatedAnnotation: TCustomPlaylistAnnotation) => { + setAnnotations((currentAnnotations) => + resolveAnnotationTimelineLayers( + normalizePlaylistAnnotations( + currentAnnotations.map((annotation) => + annotation.id === updatedAnnotation.id ? updatedAnnotation : annotation + ) + ), + updatedAnnotation.id, + minimumVisibleAnnotationDurationSeconds + ) + ); + }, + [minimumVisibleAnnotationDurationSeconds] + ); + + const handleSaveAnnotations = useCallback(async () => { + if (isSavingAnnotations) return false; + if (!hasAnnotationChanges) return true; + + setIsSavingAnnotations(true); + try { + const annotationsToSave = resolveAnnotationTimelineLayers( + normalizePlaylistAnnotations(annotations), + undefined, + minimumVisibleAnnotationDurationSeconds + ); + const updatedAnnotations = resolveAnnotationTimelineLayers( + normalizePlaylistAnnotations((await onSave(annotationsToSave)) ?? annotationsToSave), + undefined, + minimumVisibleAnnotationDurationSeconds + ); + setAnnotations(updatedAnnotations); + setBaselineAnnotations(updatedAnnotations); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Annotations saved", + message: "The video annotations were updated.", + }); + return true; + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Save annotations failed", + message: "Unable to save video annotations. Please try again.", + }); + return false; + } finally { + setIsSavingAnnotations(false); + } + }, [annotations, hasAnnotationChanges, isSavingAnnotations, minimumVisibleAnnotationDurationSeconds, onSave]); + + useEffect(() => { + if (!onRegisterSaveHandler) return; + + onRegisterSaveHandler(canEdit ? handleSaveAnnotations : null); + + return () => { + onRegisterSaveHandler(null); + }; + }, [canEdit, handleSaveAnnotations, onRegisterSaveHandler]); + + const timelineContent = + showTimeline && timelineHostElement ? ( + <VideoAnnotationTimelinePanel + activeAnnotationIds={activeAnnotationIds} + annotationTimelineMoments={annotationTimelineMoments} + canZoomTimelineIn={canZoomTimelineIn} + canZoomTimelineOut={canZoomTimelineOut} + editingTimelineMoment={editingTimelineMoment} + effectiveCurrentTime={effectiveCurrentTime} + isPlaying={isPlaying} + onBeginEditingTimelineMoment={beginEditingTimelineMoment} + onCommitTimelineMomentTitle={commitTimelineMomentTitle} + onEditingTimelineMomentChange={setEditingTimelineMoment} + onJumpToNearestAnnotation={jumpToNearestAnnotation} + onJumpToRelativeTimelineTime={jumpToRelativeTimelineTime} + onSeek={onSeek} + onStepTimelineZoom={stepTimelineZoom} + onTimelineBodyScroll={handleTimelineBodyScroll} + onTimelineHeaderScroll={handleTimelineHeaderScroll} + onTimelineKeyDown={handleTimelineKeyDown} + onTimelinePointerDown={handleTimelinePointerDown} + onTimelineResizePointerEnd={handleAnnotationTimelineResizePointerEnd} + onTimelineResizePointerDown={handleAnnotationTimelineResizePointerDown} + onTimelineResizePointerMove={handleAnnotationTimelineResizePointerMove} + onTimelineSeek={handleTimelineSeek} + onToggleTimelineMoment={toggleTimelineMoment} + openTimelineMomentIds={openTimelineMomentIds} + playbackRate={playbackRate} + sortedAnnotations={sortedAnnotations} + timelineContentWidthPx={timelineContentWidthPx} + timelineDurationSeconds={timelineDurationSeconds} + timelineHeaderScrollableElementRef={timelineHeaderScrollableElementRef} + timelineProgressPercent={timelineProgressPercent} + timelineResizeId={timelineResizeId} + timelineScrollableElementRef={timelineScrollableElementRef} + timelineTicks={timelineTicks} + timelineZoomPercent={timelineZoomPercent} + /> + ) : null; + + const annotationColorPicker = ( + <VideoAnnotationColorPickerButton annotationColor={annotationColor} onColorChange={handleAnnotationColorChange} /> + ); + + const shouldRenderSeparateAnnotationProperties = showTimeline && Boolean(propertyHostElement); + const selectedAnnotationToolOption = + availableAnnotationTools.find((toolOption) => toolOption.type === annotationTool) ?? availableAnnotationTools[0]; + const annotationPreviewStartTime = getAnnotationStartTimeWithCreationOffset(effectiveCurrentTime); + const annotationPreviewEndTime = annotationPreviewStartTime + annotationDurationSeconds; + const annotationPropertyPanelContent = canEdit ? ( + <VideoAnnotationPropertiesPanel + annotationColor={annotationColor} + annotationColorHsv={annotationColorHsv} + annotationColorInputValue={annotationColorInputValue} + annotationColorRgb={annotationColorRgb} + annotationDurationSeconds={annotationDurationSeconds} + annotationImageContent={annotationImageContent} + annotationImageHeight={annotationImageHeight} + annotationImageName={annotationImageName} + annotationImageOpacity={annotationImageOpacity} + annotationImageWidth={annotationImageWidth} + annotationStrokeStyle={annotationStrokeStyle} + annotationStrokeWidth={annotationStrokeWidth} + annotationTextFontFamily={annotationTextFontFamily} + annotationTextFontSize={annotationTextFontSize} + annotationTextFontWeight={annotationTextFontWeight} + annotationTool={annotationTool} + isAnnotationColorPickerOpen={isAnnotationColorPickerOpen} + isAnnotationMode={isAnnotationMode} + onAnnotationColorChange={handleAnnotationColorChange} + onAnnotationColorChannelChange={handleAnnotationColorChannelChange} + onAnnotationColorHueChange={handleAnnotationColorHueChange} + onAnnotationColorInputBlur={handleAnnotationColorInputBlur} + onAnnotationColorInputChange={handleAnnotationColorInputChange} + onAnnotationColorPickerPointerDown={handleAnnotationColorPickerPointerDown} + onAnnotationColorPickerPointerMove={handleAnnotationColorPickerPointerMove} + onAnnotationImageOpacityChange={handleAnnotationImageOpacityChange} + onAnnotationImageSizeChange={handleAnnotationImageSizeChange} + onChooseAnnotationImage={handleChooseAnnotationImage} + onDurationChange={setAnnotationDurationSeconds} + onStrokeStyleChange={setAnnotationStrokeStyle} + onStrokeWidthChange={setAnnotationStrokeWidth} + onTextFontFamilyChange={setAnnotationTextFontFamily} + onTextFontSizeChange={setAnnotationTextFontSize} + onTextFontWeightChange={setAnnotationTextFontWeight} + selectedAnnotationToolOption={selectedAnnotationToolOption} + setIsAnnotationColorPickerOpen={setIsAnnotationColorPickerOpen} + /> + ) : null; + + const annotationToolbarContent = canEdit ? ( + <VideoAnnotationToolbar + annotationColorPicker={annotationColorPicker} + annotationDurationSeconds={annotationDurationSeconds} + annotationStrokeStyle={annotationStrokeStyle} + annotationStrokeWidth={annotationStrokeWidth} + annotationTool={annotationTool} + availableAnnotationTools={availableAnnotationTools} + hasActiveAnnotations={hasActiveAnnotations} + hasAnnotationChanges={hasAnnotationChanges} + isAnnotationMode={isAnnotationMode} + isSavingAnnotations={isSavingAnnotations} + onClearVisibleAnnotations={handleClearVisibleAnnotations} + onDurationChange={setAnnotationDurationSeconds} + onSaveAnnotations={handleSaveAnnotations} + onSelectAnnotationTool={handleSelectAnnotationTool} + onStrokeStyleChange={setAnnotationStrokeStyle} + onStrokeWidthChange={setAnnotationStrokeWidth} + onUndoVisibleAnnotation={handleUndoVisibleAnnotation} + shouldRenderSeparateAnnotationProperties={shouldRenderSeparateAnnotationProperties} + /> + ) : null; + + return ( + <> + <input + ref={annotationImageInputRef} + type="file" + accept="image/*" + className="hidden" + onChange={(event) => { + handleAnnotationImageChange(event.currentTarget.files); + event.currentTarget.value = ""; + }} + /> + <PlaylistAnnotationOverlay + annotations={activeAnnotations} + className={["z-10", className].filter(Boolean).join(" ")} + color={annotationColor} + durationSeconds={annotationDurationSeconds} + enableAnnotationTransforms={enableAnnotationTransforms} + enabled={canEdit && isAnnotationMode} + fitToVideoBounds={fitToVideoBounds} + imageContent={annotationImageContent} + imageHeight={annotationImageHeight} + imageOpacity={annotationImageOpacity} + imagePlacementKey={annotationImagePlacementKey} + imageTitle={annotationImageName} + imageWidth={annotationImageWidth} + inputEnabled={annotationInputEnabled} + onCreateAnnotation={handleCreateAnnotation} + onUpdateAnnotation={handleUpdateAnnotation} + startTime={effectiveCurrentTime} + strokeStyle={annotationStrokeStyle} + strokeWidth={annotationStrokeWidth} + textFontFamily={annotationTextFontFamily} + textFontSize={annotationTextFontSize} + textFontWeight={annotationTextFontWeight} + tool={annotationTool} + /> + + {canEdit && !toolbarHostElement && !showTimeline ? ( + <VideoAnnotationInlineToolbar + annotationColorPicker={annotationColorPicker} + annotationDurationSeconds={annotationDurationSeconds} + annotationPreviewEndTime={annotationPreviewEndTime} + annotationPreviewStartTime={annotationPreviewStartTime} + annotationStrokeStyle={annotationStrokeStyle} + annotationStrokeWidth={annotationStrokeWidth} + annotationTool={annotationTool} + availableAnnotationTools={availableAnnotationTools} + hasActiveAnnotations={hasActiveAnnotations} + hasAnnotationChanges={hasAnnotationChanges} + isAnnotationMode={isAnnotationMode} + isSavingAnnotations={isSavingAnnotations} + onClearVisibleAnnotations={handleClearVisibleAnnotations} + onDurationChange={setAnnotationDurationSeconds} + onSaveAnnotations={handleSaveAnnotations} + onSelectAnnotationTool={handleSelectAnnotationTool} + onStrokeStyleChange={setAnnotationStrokeStyle} + onStrokeWidthChange={setAnnotationStrokeWidth} + onUndoVisibleAnnotation={handleUndoVisibleAnnotation} + /> + ) : null} + {annotationToolbarContent && toolbarHostElement + ? createPortal(annotationToolbarContent, toolbarHostElement) + : null} + {annotationPropertyPanelContent && propertyHostElement + ? createPortal(annotationPropertyPanelContent, propertyHostElement) + : null} + {timelineContent && timelineHostElement ? createPortal(timelineContent, timelineHostElement) : null} + </> + ); +}; diff --git a/apps/web/core/components/annotation/components/video-annotation-inline-toolbar.tsx b/apps/web/core/components/annotation/components/video-annotation-inline-toolbar.tsx new file mode 100644 index 00000000000..6e40fac3b0f --- /dev/null +++ b/apps/web/core/components/annotation/components/video-annotation-inline-toolbar.tsx @@ -0,0 +1,203 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Save, Trash2, Undo2 } from "lucide-react"; +import type { TCustomPlaylistAnnotationStrokeStyle, TCustomPlaylistAnnotationTool } from "../types/annotation.types"; +import type { VIDEO_ANNOTATION_TOOLS } from "../utils/video-annotation-editor-config"; +import { + VIDEO_ANNOTATION_DURATIONS, + VIDEO_ANNOTATION_STROKE_STYLES, + VIDEO_ANNOTATION_STROKE_WIDTHS, + VIDEO_ANNOTATION_TOOL_BUTTON_CLASS, +} from "../utils/video-annotation-editor-config"; +import { formatAnnotationTime } from "../utils/video-annotation-timeline"; + +type VideoAnnotationToolOption = (typeof VIDEO_ANNOTATION_TOOLS)[number]; + +type VideoAnnotationInlineToolbarProps = { + annotationColorPicker: ReactNode; + annotationDurationSeconds: number; + annotationPreviewEndTime: number; + annotationPreviewStartTime: number; + annotationStrokeStyle: TCustomPlaylistAnnotationStrokeStyle; + annotationStrokeWidth: number; + annotationTool: TCustomPlaylistAnnotationTool; + availableAnnotationTools: VideoAnnotationToolOption[]; + hasActiveAnnotations: boolean; + hasAnnotationChanges: boolean; + isAnnotationMode: boolean; + isSavingAnnotations: boolean; + onClearVisibleAnnotations: () => void; + onDurationChange: (durationSeconds: number) => void; + onSaveAnnotations: () => void; + onSelectAnnotationTool: (tool: TCustomPlaylistAnnotationTool) => void; + onStrokeStyleChange: (strokeStyle: TCustomPlaylistAnnotationStrokeStyle) => void; + onStrokeWidthChange: (strokeWidth: number) => void; + onUndoVisibleAnnotation: () => void; +}; + +export const VideoAnnotationInlineToolbar = ({ + annotationColorPicker, + annotationDurationSeconds, + annotationPreviewEndTime, + annotationPreviewStartTime, + annotationStrokeStyle, + annotationStrokeWidth, + annotationTool, + availableAnnotationTools, + hasActiveAnnotations, + hasAnnotationChanges, + isAnnotationMode, + isSavingAnnotations, + onClearVisibleAnnotations, + onDurationChange, + onSaveAnnotations, + onSelectAnnotationTool, + onStrokeStyleChange, + onStrokeWidthChange, + onUndoVisibleAnnotation, +}: VideoAnnotationInlineToolbarProps) => { + const annotationButtonClass = VIDEO_ANNOTATION_TOOL_BUTTON_CLASS; + + return ( + <div className="absolute left-2 top-2 z-20 flex max-w-[calc(100%-1rem)] flex-wrap items-center gap-1 rounded-[6px] border border-custom-border-200 bg-custom-background-100/95 p-1 shadow-lg backdrop-blur"> + {isAnnotationMode ? ( + <> + <span className="inline-flex h-8 shrink-0 items-center rounded-[5px] border border-custom-border-200 bg-custom-background-90 px-2 text-[11px] font-medium text-custom-text-200"> + {formatAnnotationTime(annotationPreviewStartTime)}-{formatAnnotationTime(annotationPreviewEndTime)} + </span> + <span className="mx-0.5 h-6 w-px bg-custom-border-200" /> + {availableAnnotationTools.map((toolOption) => { + const ToolIcon = toolOption.icon; + const isSelected = annotationTool === toolOption.type; + + return ( + <button + key={toolOption.type} + type="button" + onClick={() => onSelectAnnotationTool(toolOption.type)} + className={[ + annotationButtonClass, + isSelected ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" : "", + ].join(" ")} + aria-label={toolOption.label} + aria-pressed={isSelected} + title={toolOption.label} + > + <ToolIcon className="h-4 w-4" /> + </button> + ); + })} + + <span className="mx-0.5 h-6 w-px bg-custom-border-200" /> + {annotationColorPicker} + + <span className="mx-0.5 h-6 w-px bg-custom-border-200" /> + {VIDEO_ANNOTATION_DURATIONS.map((durationSeconds) => { + const isSelected = annotationDurationSeconds === durationSeconds; + + return ( + <button + key={durationSeconds} + type="button" + onClick={() => onDurationChange(durationSeconds)} + className={[ + "inline-flex h-8 shrink-0 items-center justify-center rounded-[5px] border border-custom-border-200 bg-custom-background-90 px-2 text-[11px] font-medium text-custom-text-200 transition-colors hover:bg-custom-background-80 hover:text-custom-text-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40", + isSelected ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" : "", + ].join(" ")} + aria-label={`Show annotation for ${durationSeconds} seconds`} + aria-pressed={isSelected} + title={`${durationSeconds}s duration`} + > + {durationSeconds}s + </button> + ); + })} + + <span className="mx-0.5 h-6 w-px bg-custom-border-200" /> + {VIDEO_ANNOTATION_STROKE_WIDTHS.map((strokeWidth) => { + const isSelected = annotationStrokeWidth === strokeWidth; + + return ( + <button + key={strokeWidth} + type="button" + onClick={() => onStrokeWidthChange(strokeWidth)} + className={[ + annotationButtonClass, + isSelected ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" : "", + ].join(" ")} + aria-label={`${strokeWidth}px annotation stroke`} + aria-pressed={isSelected} + title={`${strokeWidth}px`} + > + <span className="w-4 rounded-full bg-current" style={{ height: Math.max(2, strokeWidth / 1.5) }} /> + </button> + ); + })} + + {VIDEO_ANNOTATION_STROKE_STYLES.map((strokeStyleOption) => { + const isSelected = annotationStrokeStyle === strokeStyleOption.value; + + return ( + <button + key={strokeStyleOption.value} + type="button" + onClick={() => onStrokeStyleChange(strokeStyleOption.value)} + className={[ + annotationButtonClass, + isSelected ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" : "", + ].join(" ")} + aria-label={`${strokeStyleOption.label} annotation stroke`} + aria-pressed={isSelected} + title={`${strokeStyleOption.label} stroke`} + > + <span + className={[ + "w-4 border-t-2 border-current", + strokeStyleOption.value === "dotted" ? "border-dotted" : "border-solid", + ].join(" ")} + /> + </button> + ); + })} + + <span className="mx-0.5 h-6 w-px bg-custom-border-200" /> + <button + type="button" + onClick={onUndoVisibleAnnotation} + className={annotationButtonClass} + disabled={!hasActiveAnnotations || isSavingAnnotations} + aria-label="Undo last annotation at this timestamp" + title="Undo timestamp" + > + <Undo2 className="h-4 w-4" /> + </button> + <button + type="button" + onClick={onClearVisibleAnnotations} + className={annotationButtonClass} + disabled={!hasActiveAnnotations || isSavingAnnotations} + aria-label="Clear annotations at this timestamp" + title="Clear timestamp" + > + <Trash2 className="h-4 w-4" /> + </button> + <button + type="button" + onClick={() => void onSaveAnnotations()} + className={[ + annotationButtonClass, + hasAnnotationChanges ? "border-green-500/45 bg-green-500/10 text-green-600" : "", + ].join(" ")} + disabled={!hasAnnotationChanges || isSavingAnnotations} + aria-label="Save annotations" + title="Save" + > + <Save className="h-4 w-4" /> + </button> + </> + ) : null} + </div> + ); +}; diff --git a/apps/web/core/components/annotation/components/video-annotation-properties-panel.tsx b/apps/web/core/components/annotation/components/video-annotation-properties-panel.tsx new file mode 100644 index 00000000000..277fd88e9e1 --- /dev/null +++ b/apps/web/core/components/annotation/components/video-annotation-properties-panel.tsx @@ -0,0 +1,507 @@ +"use client"; + +import type { PointerEvent as ReactPointerEvent } from "react"; +import { Image as ImageIcon, Pencil } from "lucide-react"; +import type { TCustomPlaylistAnnotationStrokeStyle, TCustomPlaylistAnnotationTool } from "../types/annotation.types"; +import type { VIDEO_ANNOTATION_TOOLS } from "../utils/video-annotation-editor-config"; +import { + VIDEO_ANNOTATION_COLOR_PRESETS, + VIDEO_ANNOTATION_DURATIONS, + VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS, + VIDEO_ANNOTATION_STROKE_STYLES, + VIDEO_ANNOTATION_STROKE_WIDTHS, + VIDEO_ANNOTATION_TEXT_FONT_FAMILIES, + VIDEO_ANNOTATION_TEXT_FONT_SIZES, + VIDEO_ANNOTATION_TEXT_FONT_WEIGHTS, + VIDEO_ANNOTATION_TOOL_BUTTON_CLASS, +} from "../utils/video-annotation-editor-config"; + +type VideoAnnotationToolOption = (typeof VIDEO_ANNOTATION_TOOLS)[number]; + +type VideoAnnotationPropertiesPanelProps = { + annotationColor: string; + annotationColorHsv: { + hue: number; + saturation: number; + value: number; + }; + annotationColorInputValue: string; + annotationColorRgb: { + blue: number; + green: number; + red: number; + }; + annotationDurationSeconds: number; + annotationImageContent: string | null; + annotationImageHeight: number; + annotationImageName: string; + annotationImageOpacity: number; + annotationImageWidth: number; + annotationStrokeStyle: TCustomPlaylistAnnotationStrokeStyle; + annotationStrokeWidth: number; + annotationTextFontFamily: string; + annotationTextFontSize: number; + annotationTextFontWeight: number; + annotationTool: TCustomPlaylistAnnotationTool; + isAnnotationColorPickerOpen: boolean; + isAnnotationMode: boolean; + onAnnotationColorChange: (colorValue: string) => void; + onAnnotationColorChannelChange: (channel: "blue" | "green" | "red", colorValue: string) => void; + onAnnotationColorHueChange: (hueValue: string) => void; + onAnnotationColorInputBlur: () => void; + onAnnotationColorInputChange: (colorValue: string) => void; + onAnnotationColorPickerPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void; + onAnnotationColorPickerPointerMove: (event: ReactPointerEvent<HTMLButtonElement>) => void; + onAnnotationImageOpacityChange: (value: string) => void; + onAnnotationImageSizeChange: (dimension: "height" | "width", value: string) => void; + onChooseAnnotationImage: () => void; + onDurationChange: (durationSeconds: number) => void; + onStrokeStyleChange: (strokeStyle: TCustomPlaylistAnnotationStrokeStyle) => void; + onStrokeWidthChange: (strokeWidth: number) => void; + onTextFontFamilyChange: (fontFamily: string) => void; + onTextFontSizeChange: (fontSize: number) => void; + onTextFontWeightChange: (fontWeight: number) => void; + selectedAnnotationToolOption: VideoAnnotationToolOption | undefined; + setIsAnnotationColorPickerOpen: (updater: (currentValue: boolean) => boolean) => void; +}; + +const annotationPanelOptionClass = + "inline-flex h-8 min-w-0 items-center justify-center rounded-[5px] border border-custom-border-200 bg-custom-background-90 px-2 text-[10px] font-semibold text-custom-text-200 transition-colors hover:bg-custom-background-80 hover:text-custom-text-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40"; + +export const VideoAnnotationPropertiesPanel = ({ + annotationColor, + annotationColorHsv, + annotationColorInputValue, + annotationColorRgb, + annotationDurationSeconds, + annotationImageContent, + annotationImageHeight, + annotationImageName, + annotationImageOpacity, + annotationImageWidth, + annotationStrokeStyle, + annotationStrokeWidth, + annotationTextFontFamily, + annotationTextFontSize, + annotationTextFontWeight, + annotationTool, + isAnnotationColorPickerOpen, + isAnnotationMode, + onAnnotationColorChange, + onAnnotationColorChannelChange, + onAnnotationColorHueChange, + onAnnotationColorInputBlur, + onAnnotationColorInputChange, + onAnnotationColorPickerPointerDown, + onAnnotationColorPickerPointerMove, + onAnnotationImageOpacityChange, + onAnnotationImageSizeChange, + onChooseAnnotationImage, + onDurationChange, + onStrokeStyleChange, + onStrokeWidthChange, + onTextFontFamilyChange, + onTextFontSizeChange, + onTextFontWeightChange, + selectedAnnotationToolOption, + setIsAnnotationColorPickerOpen, +}: VideoAnnotationPropertiesPanelProps) => { + const SelectedAnnotationToolIcon = selectedAnnotationToolOption?.icon ?? Pencil; + + return ( + <div className="flex h-full w-full min-w-0 flex-col gap-3 overflow-y-auto rounded-[7px] border border-custom-border-200 bg-custom-background-100 p-2 shadow-sm"> + <div className="min-w-0"> + <div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-custom-text-400">Properties</div> + <div className="flex min-w-0 items-center gap-1.5 text-[12px] font-semibold text-custom-text-100"> + <SelectedAnnotationToolIcon className="h-3.5 w-3.5 shrink-0" /> + <span className="min-w-0 truncate">{selectedAnnotationToolOption?.label ?? "Annotation"}</span> + </div> + </div> + + {isAnnotationMode ? ( + <> + {annotationTool !== "image" ? ( + <div className="space-y-1.5"> + <div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-custom-text-400">Color</div> + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <button + type="button" + onClick={() => setIsAnnotationColorPickerOpen((currentValue) => !currentValue)} + className={[ + "flex h-9 w-12 shrink-0 cursor-pointer items-center justify-center rounded-[5px] border border-custom-border-200 bg-custom-background-90 transition-colors hover:bg-custom-background-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40", + isAnnotationColorPickerOpen ? "border-custom-primary-100 bg-custom-primary-100/10" : "", + ].join(" ")} + aria-expanded={isAnnotationColorPickerOpen} + aria-label={`Open annotation color picker. Current color ${annotationColor.toUpperCase()}`} + title={`Pick annotation color (${annotationColor.toUpperCase()})`} + > + <span + className="h-5 w-7 rounded-[4px] border border-custom-border-200 shadow-sm" + style={{ backgroundColor: annotationColor }} + /> + </button> + <input + type="text" + value={annotationColorInputValue} + onBlur={onAnnotationColorInputBlur} + onChange={(event) => onAnnotationColorInputChange(event.currentTarget.value)} + className="h-9 min-w-0 flex-1 rounded-[5px] border border-custom-border-200 bg-custom-background-90 px-2 font-mono text-[11px] font-semibold uppercase text-custom-text-100 outline-none transition-colors placeholder:text-custom-text-400 focus:border-custom-primary-100 focus:ring-2 focus:ring-custom-primary-100/30" + aria-label="Annotation color hex value" + placeholder="#F97316" + spellCheck={false} + /> + </div> + {isAnnotationColorPickerOpen ? ( + <div className="space-y-2 rounded-[6px] border border-custom-border-200 bg-custom-background-90 p-2 shadow-sm"> + <button + type="button" + onPointerDown={onAnnotationColorPickerPointerDown} + onPointerMove={onAnnotationColorPickerPointerMove} + className="relative h-28 w-full touch-none overflow-hidden rounded-[5px] border border-custom-border-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40" + style={{ + background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, rgba(255,255,255,0)), hsl(${annotationColorHsv.hue}, 100%, 50%)`, + }} + aria-label="Pick annotation color shade" + title="Drag to pick color" + > + <span + className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.65)]" + style={{ + left: `${annotationColorHsv.saturation * 100}%`, + top: `${(1 - annotationColorHsv.value) * 100}%`, + }} + /> + </button> + <label className="block space-y-1"> + <span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-custom-text-400"> + Hue + </span> + <input + type="range" + min={0} + max={360} + value={Math.round(annotationColorHsv.hue)} + onChange={(event) => onAnnotationColorHueChange(event.currentTarget.value)} + className="h-2 w-full cursor-pointer appearance-none rounded-full" + style={{ + background: + "linear-gradient(to right, #ef4444, #eab308, #22c55e, #38bdf8, #6366f1, #a855f7, #ef4444)", + }} + aria-label="Annotation color hue" + /> + </label> + <div className="space-y-1"> + {[ + { channel: "red" as const, label: "R", value: annotationColorRgb.red }, + { channel: "green" as const, label: "G", value: annotationColorRgb.green }, + { channel: "blue" as const, label: "B", value: annotationColorRgb.blue }, + ].map((colorChannel) => ( + <label key={colorChannel.channel} className="flex items-center gap-2"> + <span className="w-4 text-[10px] font-semibold text-custom-text-300"> + {colorChannel.label} + </span> + <input + type="range" + min={0} + max={255} + value={colorChannel.value} + onChange={(event) => + onAnnotationColorChannelChange(colorChannel.channel, event.currentTarget.value) + } + className="h-1.5 min-w-0 flex-1 accent-custom-primary-100" + aria-label={`${colorChannel.label} color channel`} + /> + <span className="w-6 text-right font-mono text-[10px] font-semibold text-custom-text-300"> + {colorChannel.value} + </span> + </label> + ))} + </div> + <div className="grid grid-cols-3 gap-1"> + {VIDEO_ANNOTATION_COLOR_PRESETS.map((colorPreset) => { + const isSelected = annotationColor.toLowerCase() === colorPreset; + + return ( + <button + key={colorPreset} + type="button" + onClick={() => onAnnotationColorChange(colorPreset)} + className={[ + "grid h-7 place-items-center rounded-[5px] border border-custom-border-200 bg-custom-background-100 transition-colors hover:bg-custom-background-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40", + isSelected ? "border-custom-primary-100 ring-2 ring-custom-primary-100/30" : "", + ].join(" ")} + aria-label={`Use ${colorPreset.toUpperCase()} annotation color`} + aria-pressed={isSelected} + title={colorPreset.toUpperCase()} + > + <span + className="h-3.5 w-3.5 rounded-full border border-custom-border-200 shadow-sm" + style={{ backgroundColor: colorPreset }} + /> + </button> + ); + })} + </div> + </div> + ) : null} + </div> + </div> + ) : null} + + <div className="space-y-1.5"> + <div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-custom-text-400">Duration</div> + <div className="grid grid-cols-1 gap-1"> + {VIDEO_ANNOTATION_DURATIONS.map((durationSeconds) => { + const isSelected = annotationDurationSeconds === durationSeconds; + + return ( + <button + key={durationSeconds} + type="button" + onClick={() => onDurationChange(durationSeconds)} + className={[ + annotationPanelOptionClass, + isSelected ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" : "", + ].join(" ")} + aria-label={`Show annotation for ${durationSeconds} seconds`} + aria-pressed={isSelected} + title={`${durationSeconds}s duration`} + > + {durationSeconds}s + </button> + ); + })} + </div> + </div> + + {annotationTool === "text" ? ( + <div className="space-y-2"> + <div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-custom-text-400">Text</div> + <div className="space-y-1.5"> + <div className="text-[11px] font-medium text-custom-text-300">Font</div> + <div className="grid grid-cols-1 gap-1"> + {VIDEO_ANNOTATION_TEXT_FONT_FAMILIES.map((fontFamilyOption) => { + const isSelected = annotationTextFontFamily === fontFamilyOption.value; + + return ( + <button + key={fontFamilyOption.value} + type="button" + onClick={() => onTextFontFamilyChange(fontFamilyOption.value)} + className={[ + annotationPanelOptionClass, + isSelected + ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" + : "", + ].join(" ")} + aria-label={`${fontFamilyOption.label} font`} + aria-pressed={isSelected} + > + {fontFamilyOption.label} + </button> + ); + })} + </div> + </div> + <div className="space-y-1.5"> + <div className="text-[11px] font-medium text-custom-text-300">Size</div> + <div className="grid grid-cols-1 gap-1"> + {VIDEO_ANNOTATION_TEXT_FONT_SIZES.map((fontSize) => { + const isSelected = annotationTextFontSize === fontSize; + + return ( + <button + key={fontSize} + type="button" + onClick={() => onTextFontSizeChange(fontSize)} + className={[ + annotationPanelOptionClass, + isSelected + ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" + : "", + ].join(" ")} + aria-label={`${fontSize}px text size`} + aria-pressed={isSelected} + > + {fontSize} + </button> + ); + })} + </div> + </div> + <div className="space-y-1.5"> + <div className="text-[11px] font-medium text-custom-text-300">Weight</div> + <div className="grid grid-cols-1 gap-1"> + {VIDEO_ANNOTATION_TEXT_FONT_WEIGHTS.map((fontWeightOption) => { + const isSelected = annotationTextFontWeight === fontWeightOption.value; + + return ( + <button + key={fontWeightOption.value} + type="button" + onClick={() => onTextFontWeightChange(fontWeightOption.value)} + className={[ + annotationPanelOptionClass, + isSelected + ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" + : "", + ].join(" ")} + aria-label={`${fontWeightOption.label} text weight`} + aria-pressed={isSelected} + > + {fontWeightOption.label} + </button> + ); + })} + </div> + </div> + </div> + ) : annotationTool === "image" ? ( + <div className="space-y-2"> + <div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-custom-text-400">Image</div> + {annotationImageContent ? ( + <div className="flex h-20 items-center justify-center overflow-hidden rounded-[5px] border border-custom-border-200 bg-custom-background-90"> + <img + src={annotationImageContent} + alt="" + className="max-h-full max-w-full object-contain" + style={{ opacity: annotationImageOpacity }} + /> + </div> + ) : null} + <button + type="button" + onClick={onChooseAnnotationImage} + className={[ + annotationPanelOptionClass, + "w-full justify-start gap-2 px-2 text-left", + annotationImageContent + ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" + : "", + ].join(" ")} + aria-label={annotationImageContent ? "Change annotation image" : "Choose annotation image"} + title={annotationImageContent ? "Change image" : "Choose image"} + > + <ImageIcon className="h-3.5 w-3.5 shrink-0" /> + <span className="min-w-0 truncate">{annotationImageName || "Choose image"}</span> + </button> + <div className="rounded-[5px] border border-custom-border-200 bg-custom-background-90 px-2 py-1.5 text-[10px] leading-4 text-custom-text-300"> + {annotationImageContent + ? "Drag on the video to place and size the image." + : "Choose an image before placing it on the video."} + </div> + <div className="space-y-1.5"> + <div className="flex items-center justify-between gap-2"> + <span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-custom-text-400"> + Opacity + </span> + <span className="font-mono text-[10px] font-semibold text-custom-text-300"> + {Math.round(annotationImageOpacity * 100)}% + </span> + </div> + <input + type="range" + min={20} + max={100} + value={Math.round(annotationImageOpacity * 100)} + onChange={(event) => onAnnotationImageOpacityChange(event.currentTarget.value)} + className="h-1.5 w-full accent-custom-primary-100" + aria-label="Image annotation opacity" + /> + </div> + <div className="space-y-1.5"> + <div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-custom-text-400"> + Default Size + </div> + <div className="grid grid-cols-2 gap-1.5"> + <label className="space-y-1"> + <span className="text-[10px] font-medium text-custom-text-300">Width</span> + <input + type="number" + min={VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS.min} + max={VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS.max} + value={annotationImageWidth} + onChange={(event) => onAnnotationImageSizeChange("width", event.currentTarget.value)} + className="h-8 w-full rounded-[5px] border border-custom-border-200 bg-custom-background-90 px-2 text-[11px] font-semibold text-custom-text-100 outline-none transition-colors focus:border-custom-primary-100 focus:ring-2 focus:ring-custom-primary-100/30" + aria-label="Image annotation default width" + /> + </label> + <label className="space-y-1"> + <span className="text-[10px] font-medium text-custom-text-300">Height</span> + <input + type="number" + min={VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS.min} + max={VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS.max} + value={annotationImageHeight} + onChange={(event) => onAnnotationImageSizeChange("height", event.currentTarget.value)} + className="h-8 w-full rounded-[5px] border border-custom-border-200 bg-custom-background-90 px-2 text-[11px] font-semibold text-custom-text-100 outline-none transition-colors focus:border-custom-primary-100 focus:ring-2 focus:ring-custom-primary-100/30" + aria-label="Image annotation default height" + /> + </label> + </div> + </div> + </div> + ) : ( + <div className="space-y-1.5"> + <div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-custom-text-400">Stroke</div> + <div className="grid grid-cols-1 gap-1"> + {VIDEO_ANNOTATION_STROKE_WIDTHS.map((strokeWidth) => { + const isSelected = annotationStrokeWidth === strokeWidth; + + return ( + <button + key={strokeWidth} + type="button" + onClick={() => onStrokeWidthChange(strokeWidth)} + className={[ + VIDEO_ANNOTATION_TOOL_BUTTON_CLASS, + "w-full", + isSelected ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" : "", + ].join(" ")} + aria-label={`${strokeWidth}px annotation stroke`} + aria-pressed={isSelected} + title={`${strokeWidth}px`} + > + <span + className="w-4 rounded-full bg-current" + style={{ height: Math.max(2, strokeWidth / 1.5) }} + /> + </button> + ); + })} + </div> + <div className="grid grid-cols-1 gap-1"> + {VIDEO_ANNOTATION_STROKE_STYLES.map((strokeStyleOption) => { + const isSelected = annotationStrokeStyle === strokeStyleOption.value; + + return ( + <button + key={strokeStyleOption.value} + type="button" + onClick={() => onStrokeStyleChange(strokeStyleOption.value)} + className={[ + annotationPanelOptionClass, + isSelected ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" : "", + ].join(" ")} + aria-label={`${strokeStyleOption.label} annotation stroke`} + aria-pressed={isSelected} + title={`${strokeStyleOption.label} stroke`} + > + <span + className={[ + "w-8 border-t-2 border-current", + strokeStyleOption.value === "dotted" ? "border-dotted" : "border-solid", + ].join(" ")} + /> + </button> + ); + })} + </div> + </div> + )} + </> + ) : null} + </div> + ); +}; diff --git a/apps/web/core/components/annotation/components/video-annotation-timeline-panel.tsx b/apps/web/core/components/annotation/components/video-annotation-timeline-panel.tsx new file mode 100644 index 00000000000..0aa82574afc --- /dev/null +++ b/apps/web/core/components/annotation/components/video-annotation-timeline-panel.tsx @@ -0,0 +1,539 @@ +"use client"; + +import type { + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, + Ref, + UIEvent as ReactUIEvent, +} from "react"; +import { ChevronRight, FastForward, Minus, Plus, Rewind, SkipBack, SkipForward } from "lucide-react"; +import type { TCustomPlaylistAnnotation } from "../types/annotation.types"; +import { getAnnotationColor, getTimelineColorWithAlpha } from "../utils/video-annotation-colors"; +import { + VIDEO_ANNOTATION_TIMELINE_CLIP_MIN_WIDTH_PX, + VIDEO_ANNOTATION_TIMELINE_MOMENT_COLUMN_WIDTH_PX, + VIDEO_ANNOTATION_TOOL_BUTTON_CLASS, +} from "../utils/video-annotation-editor-config"; +import type { AnnotationTimelineMoment } from "../utils/video-annotation-timeline"; +import { + clampTimelineValue, + formatAnnotationTime, + getAnnotationTimelineIcon, + getAnnotationTimelineLabel, + getAnnotationTimelineToolLabel, + getTimelinePercent, +} from "../utils/video-annotation-timeline"; +import { VideoAnnotationTimelinePlayhead } from "./video-annotation-timeline-playhead"; + +type VideoAnnotationTimelinePanelProps = { + activeAnnotationIds: Set<string>; + annotationTimelineMoments: AnnotationTimelineMoment[]; + canZoomTimelineIn: boolean; + canZoomTimelineOut: boolean; + editingTimelineMoment: { id: string; value: string } | null; + effectiveCurrentTime: number; + isPlaying: boolean; + onBeginEditingTimelineMoment: (moment: AnnotationTimelineMoment) => void; + onCommitTimelineMomentTitle: (moment: AnnotationTimelineMoment, value: string) => void; + onEditingTimelineMomentChange: (value: { id: string; value: string }) => void; + onTimelineBodyScroll: (event: ReactUIEvent<HTMLDivElement>) => void; + onTimelineHeaderScroll: (event: ReactUIEvent<HTMLDivElement>) => void; + onTimelineKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void; + onTimelinePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void; + onTimelineResizePointerEnd: (event: ReactPointerEvent<HTMLButtonElement>) => void; + onTimelineResizePointerDown: ( + event: ReactPointerEvent<HTMLButtonElement>, + annotation: TCustomPlaylistAnnotation + ) => void; + onTimelineResizePointerMove: (event: ReactPointerEvent<HTMLButtonElement>) => void; + onTimelineSeek: (seconds: number) => void; + onJumpToNearestAnnotation: (direction: "next" | "previous") => void; + onJumpToRelativeTimelineTime: (deltaSeconds: number) => void; + onSeek?: (seconds: number) => void; + onStepTimelineZoom: (direction: "in" | "out") => void; + onToggleTimelineMoment: (momentId: string) => void; + openTimelineMomentIds: Set<string>; + playbackRate: number; + sortedAnnotations: TCustomPlaylistAnnotation[]; + timelineContentWidthPx: number; + timelineDurationSeconds: number; + timelineHeaderScrollableElementRef: Ref<HTMLDivElement>; + timelineProgressPercent: number; + timelineResizeId: string | null; + timelineScrollableElementRef: Ref<HTMLDivElement>; + timelineTicks: number[]; + timelineZoomPercent: number; +}; + +export const VideoAnnotationTimelinePanel = ({ + activeAnnotationIds, + annotationTimelineMoments, + canZoomTimelineIn, + canZoomTimelineOut, + editingTimelineMoment, + effectiveCurrentTime, + isPlaying, + onBeginEditingTimelineMoment, + onCommitTimelineMomentTitle, + onEditingTimelineMomentChange, + onJumpToNearestAnnotation, + onJumpToRelativeTimelineTime, + onSeek, + onStepTimelineZoom, + onTimelineBodyScroll, + onTimelineHeaderScroll, + onTimelineKeyDown, + onTimelinePointerDown, + onTimelineResizePointerEnd, + onTimelineResizePointerDown, + onTimelineResizePointerMove, + onTimelineSeek, + onToggleTimelineMoment, + openTimelineMomentIds, + playbackRate, + sortedAnnotations, + timelineContentWidthPx, + timelineDurationSeconds, + timelineHeaderScrollableElementRef, + timelineProgressPercent, + timelineResizeId, + timelineScrollableElementRef, + timelineTicks, + timelineZoomPercent, +}: VideoAnnotationTimelinePanelProps) => ( + <div className="overflow-hidden rounded-[6px] border border-custom-border-200 bg-custom-background-100 shadow-sm"> + <div className="flex min-h-[52px] flex-wrap items-center gap-2 border-b border-custom-border-200 bg-custom-background-100 px-3 py-2"> + <div className="flex items-center gap-1.5"> + <button + type="button" + onClick={() => onTimelineSeek(0)} + disabled={!onSeek} + className={VIDEO_ANNOTATION_TOOL_BUTTON_CLASS} + aria-label="Jump to start" + title="Jump to start" + > + <SkipBack className="h-4 w-4" /> + </button> + <button + type="button" + onClick={() => onJumpToNearestAnnotation("previous")} + disabled={!onSeek || sortedAnnotations.length === 0} + className={VIDEO_ANNOTATION_TOOL_BUTTON_CLASS} + aria-label="Previous annotation" + title="Previous annotation" + > + <Rewind className="h-4 w-4" /> + </button> + <button + type="button" + onClick={() => onJumpToRelativeTimelineTime(-1)} + disabled={!onSeek} + className={VIDEO_ANNOTATION_TOOL_BUTTON_CLASS} + aria-label="Step backward one second" + title="Step backward one second" + > + <Minus className="h-4 w-4" /> + </button> + <button + type="button" + onClick={() => onJumpToRelativeTimelineTime(1)} + disabled={!onSeek} + className={VIDEO_ANNOTATION_TOOL_BUTTON_CLASS} + aria-label="Step forward one second" + title="Step forward one second" + > + <Plus className="h-4 w-4" /> + </button> + <button + type="button" + onClick={() => onJumpToNearestAnnotation("next")} + disabled={!onSeek || sortedAnnotations.length === 0} + className={VIDEO_ANNOTATION_TOOL_BUTTON_CLASS} + aria-label="Next annotation" + title="Next annotation" + > + <FastForward className="h-4 w-4" /> + </button> + <button + type="button" + onClick={() => onTimelineSeek(timelineDurationSeconds)} + disabled={!onSeek} + className={VIDEO_ANNOTATION_TOOL_BUTTON_CLASS} + aria-label="Jump to end" + title="Jump to end" + > + <SkipForward className="h-4 w-4" /> + </button> + </div> + + <div className="flex min-w-0 items-baseline gap-2 font-mono tabular-nums"> + <span className="text-[18px] font-semibold leading-none text-custom-text-100"> + {formatAnnotationTime(effectiveCurrentTime)} + </span> + <span className="text-[12px] text-custom-text-400">/</span> + <span className="text-[14px] font-semibold leading-none text-custom-text-200"> + {formatAnnotationTime(timelineDurationSeconds)} + </span> + </div> + + <div className="ml-auto flex min-w-0 items-center justify-end"> + <span className="hidden text-[12px] text-custom-text-300 md:inline"> + {sortedAnnotations.length} annotation{sortedAnnotations.length === 1 ? "" : "s"} + </span> + </div> + </div> + + <div + className="grid bg-custom-background-100" + style={{ + gridTemplateColumns: `${VIDEO_ANNOTATION_TIMELINE_MOMENT_COLUMN_WIDTH_PX}px minmax(0, 1fr)`, + }} + > + <div className="flex h-[30px] items-center border-b border-r border-custom-border-200 bg-custom-background-90 px-4 text-[11px] font-semibold uppercase tracking-[0.05em] text-custom-text-400"> + Moments + </div> + <div + ref={timelineHeaderScrollableElementRef} + className={[ + "min-w-0 cursor-pointer overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--sg-matrix-active-border)]", + onSeek ? "" : "cursor-default", + ].join(" ")} + onPointerDown={onTimelinePointerDown} + onScroll={onTimelineHeaderScroll} + > + <div + className="relative h-[30px] border-b border-custom-border-200 bg-custom-background-100" + style={{ width: `max(100%, ${timelineContentWidthPx}px)` }} + > + {timelineTicks.map((seconds) => { + const tickPercent = getTimelinePercent(seconds, timelineDurationSeconds); + + return ( + <div + key={`annotation-header-tick-${seconds}`} + className="pointer-events-none absolute top-0 h-[30px] -translate-x-px" + style={{ left: `${tickPercent}%` }} + > + <span className="block h-2.5 w-px bg-custom-border-300" /> + <span className="absolute left-1 top-1/2 -translate-y-1/2 whitespace-nowrap font-mono text-[11px] leading-none text-custom-text-400"> + {formatAnnotationTime(seconds)} + </span> + </div> + ); + })} + </div> + </div> + </div> + + <div + className="vertical-scrollbar scrollbar-md grid max-h-[308px] overflow-y-auto overflow-x-hidden bg-custom-background-100" + style={{ + gridTemplateColumns: `${VIDEO_ANNOTATION_TIMELINE_MOMENT_COLUMN_WIDTH_PX}px minmax(0, 1fr)`, + }} + > + <div className="shrink-0 border-r border-custom-border-200 bg-custom-background-90"> + {annotationTimelineMoments.map((moment) => { + const isMomentOpen = openTimelineMomentIds.has(moment.id); + const isEditingMomentTitle = editingTimelineMoment?.id === moment.id; + + return ( + <div key={`moment-label-${moment.id}`}> + <div + className={[ + "flex h-11 w-full items-center gap-2 border-b border-custom-border-200 px-3 text-left transition-colors hover:bg-custom-background-80", + isMomentOpen ? "bg-custom-background-80" : "bg-custom-background-90", + ].join(" ")} + title={`${formatAnnotationTime(moment.startTime)} - ${moment.title}`} + > + <button + type="button" + onClick={() => onToggleTimelineMoment(moment.id)} + className="grid h-5 w-5 shrink-0 place-items-center rounded-[4px] text-custom-text-400 transition-colors hover:bg-custom-background-80 hover:text-custom-text-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40" + aria-expanded={isMomentOpen} + aria-label={`${isMomentOpen ? "Collapse" : "Expand"} ${moment.title}`} + > + <ChevronRight + className={["h-3.5 w-3.5 transition-transform", isMomentOpen ? "rotate-90" : ""].join(" ")} + /> + </button> + <button + type="button" + onClick={() => onTimelineSeek(moment.startTime)} + className="shrink-0 rounded-[6px] border border-custom-border-200 bg-custom-background-100 px-1.5 py-0.5 font-mono text-[11px] font-semibold tabular-nums text-custom-text-100 transition-colors hover:border-custom-text-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40" + title={`Seek to ${formatAnnotationTime(moment.startTime)}`} + > + {formatAnnotationTime(moment.startTime)} + </button> + <input + type="text" + value={isEditingMomentTitle ? editingTimelineMoment.value : moment.title} + onChange={(event) => + onEditingTimelineMomentChange({ id: moment.id, value: event.currentTarget.value }) + } + onFocus={() => onBeginEditingTimelineMoment(moment)} + onBlur={(event) => onCommitTimelineMomentTitle(moment, event.currentTarget.value)} + onClick={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + } + }} + className="min-w-0 flex-1 rounded-[4px] border border-transparent bg-transparent px-1 py-0.5 text-[13px] font-medium text-custom-text-100 outline-none transition-colors focus:border-custom-border-300 focus:bg-custom-background-100" + aria-label={`Edit title for ${formatAnnotationTime(moment.startTime)} moment`} + /> + <span className="shrink-0 rounded-full border border-custom-border-200 bg-custom-background-100 px-2 text-[11px] leading-[17px] text-custom-text-300"> + {moment.annotations.length} + </span> + </div> + {isMomentOpen && + moment.annotations.map(({ annotation, index }) => { + const color = getAnnotationColor(annotation); + const annotationLabel = getAnnotationTimelineLabel(annotation, index); + + return ( + <button + key={`moment-item-label-${annotation.id}`} + type="button" + onClick={() => onTimelineSeek(annotation.startTime)} + className="flex h-[34px] w-full items-center gap-2 border-b border-custom-border-200 bg-custom-background-100 px-3 pl-10 text-left transition-colors hover:bg-custom-background-80" + title={annotationLabel} + > + <span className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: color }} /> + <span className="min-w-0 truncate text-[12px] font-medium text-custom-text-200"> + {getAnnotationTimelineToolLabel(annotation.type)} - {annotationLabel} + </span> + </button> + ); + })} + </div> + ); + })} + </div> + + <div + ref={timelineScrollableElementRef} + aria-label="Seek annotation timeline" + aria-valuemax={Math.round(timelineDurationSeconds)} + aria-valuemin={0} + aria-valuenow={Math.round(clampTimelineValue(effectiveCurrentTime, 0, timelineDurationSeconds))} + className={[ + "horizontal-scrollbar scrollbar-md min-w-0 cursor-pointer overflow-x-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--sg-matrix-active-border)]", + onSeek ? "" : "cursor-default", + ].join(" ")} + onKeyDown={onTimelineKeyDown} + onPointerDown={onTimelinePointerDown} + onScroll={onTimelineBodyScroll} + role="slider" + tabIndex={onSeek ? 0 : -1} + > + <div + className="relative min-h-full bg-custom-background-100" + style={{ width: `max(100%, ${timelineContentWidthPx}px)` }} + > + {timelineTicks.map((seconds) => ( + <span + key={`annotation-grid-${seconds}`} + className="pointer-events-none absolute bottom-0 top-0 w-px -translate-x-px bg-custom-border-200/40" + style={{ left: `${getTimelinePercent(seconds, timelineDurationSeconds)}%` }} + /> + ))} + + <VideoAnnotationTimelinePlayhead + currentTime={effectiveCurrentTime} + durationSeconds={timelineDurationSeconds} + isPlaying={isPlaying} + playbackRate={playbackRate} + progressPercent={timelineProgressPercent} + /> + + {annotationTimelineMoments.map((moment) => { + const isMomentOpen = openTimelineMomentIds.has(moment.id); + + return ( + <div key={`moment-track-${moment.id}`}> + <div className="relative h-11 border-b border-custom-border-200 bg-custom-background-100"> + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + onToggleTimelineMoment(moment.id); + onTimelineSeek(moment.startTime); + }} + onPointerDown={(event) => event.stopPropagation()} + className={[ + "absolute top-1/2 inline-flex h-[26px] max-w-[280px] -translate-y-1/2 items-center gap-2 rounded-[6px] border px-2 text-[11px] font-medium shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40", + isMomentOpen + ? "border-custom-text-400 bg-custom-background-80 text-custom-text-100" + : "border-custom-border-200 bg-custom-background-90 text-custom-text-200 hover:border-custom-text-400 hover:text-custom-text-100", + ].join(" ")} + style={{ left: `${getTimelinePercent(moment.startTime, timelineDurationSeconds)}%` }} + aria-expanded={isMomentOpen} + aria-label={`${isMomentOpen ? "Collapse" : "Expand"} ${moment.title}`} + title={`${formatAnnotationTime(moment.startTime)} - ${moment.title}`} + > + <ChevronRight + className={["h-3.5 w-3.5 shrink-0 transition-transform", isMomentOpen ? "rotate-90" : ""].join( + " " + )} + /> + <span className="flex shrink-0 items-center"> + {moment.annotations.slice(0, 4).map(({ annotation }, summaryIndex) => { + const SummaryIcon = getAnnotationTimelineIcon(annotation); + const color = getAnnotationColor(annotation); + + return ( + <span + key={`moment-summary-${annotation.id}`} + className="grid h-[18px] w-[18px] place-items-center rounded-[5px] border border-custom-background-100" + style={{ + backgroundColor: getTimelineColorWithAlpha(color, 0.06), + marginLeft: summaryIndex === 0 ? 0 : -5, + }} + > + <SummaryIcon + className="h-2.5 w-2.5" + style={{ color: getTimelineColorWithAlpha(color, 0.5) }} + /> + </span> + ); + })} + </span> + <span className="min-w-0 truncate">{moment.title}</span> + </button> + </div> + {isMomentOpen && + moment.annotations.map(({ annotation, index }) => { + const leftPercent = getTimelinePercent(annotation.startTime, timelineDurationSeconds); + const rightPercent = getTimelinePercent(annotation.endTime, timelineDurationSeconds); + const widthPercent = Math.max(0.8, rightPercent - leftPercent); + const isActive = activeAnnotationIds.has(annotation.id); + const color = getAnnotationColor(annotation); + const AnnotationIcon = getAnnotationTimelineIcon(annotation); + const annotationLabel = getAnnotationTimelineLabel(annotation, index); + const annotationDurationSeconds = Math.max(0, annotation.endTime - annotation.startTime); + const isResizing = timelineResizeId === annotation.id; + + return ( + <div + key={`moment-item-track-${annotation.id}`} + className="relative h-[34px] border-b border-custom-border-200 bg-custom-background-90" + > + <div + className={["absolute top-1/2 z-10 min-w-14 -translate-y-1/2", isResizing ? "z-30" : ""].join( + " " + )} + style={{ + left: `${leftPercent}%`, + width: `max(${VIDEO_ANNOTATION_TIMELINE_CLIP_MIN_WIDTH_PX}px, ${widthPercent}%)`, + }} + > + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + onTimelineSeek(annotation.startTime); + }} + onPointerDown={(event) => event.stopPropagation()} + className={[ + "relative inline-flex h-6 w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-[5px] border px-3 pl-3 pr-5 text-left text-[11px] font-semibold text-custom-text-100 shadow-sm transition-[filter,box-shadow] hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40", + isResizing ? "shadow-[0_0_0_1px_rgba(37,99,235,0.24)]" : "", + ].join(" ")} + style={{ + backgroundColor: getTimelineColorWithAlpha(color, 0.06), + borderColor: getTimelineColorWithAlpha(color, isActive || isResizing ? 0.28 : 0.18), + }} + aria-current={isActive ? "true" : undefined} + title={`${getAnnotationTimelineToolLabel(annotation.type)} - ${annotationLabel}. Start ${formatAnnotationTime(annotation.startTime)}. Duration ${formatAnnotationTime(annotationDurationSeconds)}.`} + > + <span + aria-hidden="true" + className="absolute inset-y-0 left-0 w-[3px]" + style={{ backgroundColor: getTimelineColorWithAlpha(color, 0.38) }} + /> + <AnnotationIcon + className="h-3 w-3 shrink-0" + style={{ color: getTimelineColorWithAlpha(color, 0.5) }} + /> + <span className="min-w-0 truncate">{annotationLabel}</span> + </button> + <button + type="button" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onPointerDown={(event) => onTimelineResizePointerDown(event, annotation)} + onPointerMove={onTimelineResizePointerMove} + onPointerCancel={onTimelineResizePointerEnd} + onPointerUp={onTimelineResizePointerEnd} + className={[ + "absolute inset-y-0 right-0 z-20 flex w-4 cursor-ew-resize touch-none select-none items-center justify-center rounded-r-[5px] border-y border-r border-l transition-[background-color,border-color,box-shadow] hover:brightness-125 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40", + isResizing ? "shadow-[0_0_0_1px_rgba(37,99,235,0.26)]" : "", + ].join(" ")} + style={{ + backgroundColor: getTimelineColorWithAlpha(color, isResizing ? 0.24 : 0.14), + borderColor: getTimelineColorWithAlpha(color, isResizing ? 0.46 : 0.28), + color: getTimelineColorWithAlpha(color, isResizing ? 0.95 : 0.7), + }} + aria-label={`Resize ${annotationLabel} duration`} + title="Pull to change duration" + > + <span className="flex flex-col items-center gap-px" aria-hidden="true"> + <span className="h-0.5 w-0.5 rounded-full bg-current" /> + <span className="h-0.5 w-0.5 rounded-full bg-current" /> + <span className="h-0.5 w-0.5 rounded-full bg-current" /> + </span> + </button> + </div> + </div> + ); + })} + </div> + ); + })} + + {annotationTimelineMoments.length === 0 && ( + <div className="relative h-11 border-b border-custom-border-200 bg-custom-background-100"> + <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[11px] text-custom-text-400"> + No annotations yet + </span> + </div> + )} + </div> + </div> + </div> + <div className="flex h-11 items-center gap-3 border-t border-custom-border-200 bg-custom-background-100 px-3"> + <span className="shrink-0 text-[12px] font-medium text-custom-text-200">Scale Size</span> + <div className="flex h-[28px] items-center overflow-hidden rounded-[8px] border border-custom-border-200 bg-custom-background-90"> + <button + type="button" + onClick={() => onStepTimelineZoom("out")} + disabled={!canZoomTimelineOut} + className="grid h-full w-9 place-items-center text-custom-text-200 transition-colors hover:bg-custom-background-80 hover:text-custom-text-100 disabled:cursor-not-allowed disabled:opacity-45" + aria-label="Zoom timeline out" + title="Zoom timeline out" + > + <Minus className="h-3.5 w-3.5" /> + </button> + <span className="h-full w-px bg-custom-border-200" /> + <span className="inline-flex h-full min-w-14 items-center justify-center px-2 text-[12px] font-semibold text-custom-text-100 tabular-nums"> + {timelineZoomPercent}% + </span> + <span className="h-full w-px bg-custom-border-200" /> + <button + type="button" + onClick={() => onStepTimelineZoom("in")} + disabled={!canZoomTimelineIn} + className="grid h-full w-9 place-items-center text-custom-text-200 transition-colors hover:bg-custom-background-80 hover:text-custom-text-100 disabled:cursor-not-allowed disabled:opacity-45" + aria-label="Zoom timeline in" + title="Zoom timeline in" + > + <Plus className="h-3.5 w-3.5" /> + </button> + </div> + </div> + </div> +); diff --git a/apps/web/core/components/annotation/components/video-annotation-timeline-playhead.tsx b/apps/web/core/components/annotation/components/video-annotation-timeline-playhead.tsx new file mode 100644 index 00000000000..1f458992c47 --- /dev/null +++ b/apps/web/core/components/annotation/components/video-annotation-timeline-playhead.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { clampTimelineValue, getTimelinePercent } from "../utils/video-annotation-timeline"; + +type VideoAnnotationTimelinePlayheadProps = { + currentTime: number; + durationSeconds: number; + isPlaying: boolean; + playbackRate: number; + progressPercent: number; +}; + +const getClockNow = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); + +export const VideoAnnotationTimelinePlayhead = ({ + currentTime, + durationSeconds, + isPlaying, + playbackRate, + progressPercent, +}: VideoAnnotationTimelinePlayheadProps) => { + const [smoothProgressPercent, setSmoothProgressPercent] = useState(progressPercent); + const clockOriginRef = useRef({ + mediaTime: currentTime, + wallTime: getClockNow(), + }); + const safePlaybackRate = Number.isFinite(playbackRate) && playbackRate > 0 ? playbackRate : 1; + + useEffect(() => { + clockOriginRef.current = { + mediaTime: clampTimelineValue(currentTime, 0, durationSeconds), + wallTime: getClockNow(), + }; + + if (!isPlaying) { + setSmoothProgressPercent(progressPercent); + } + }, [currentTime, durationSeconds, isPlaying, progressPercent]); + + useEffect(() => { + if (isPlaying || typeof window === "undefined") return; + + setSmoothProgressPercent(progressPercent); + }, [isPlaying, progressPercent]); + + useEffect(() => { + if (!isPlaying || typeof window === "undefined") return; + + let animationFrameId = 0; + const updatePlayhead = () => { + const elapsedSeconds = Math.max(0, (getClockNow() - clockOriginRef.current.wallTime) / 1000); + const nextTime = clampTimelineValue( + clockOriginRef.current.mediaTime + elapsedSeconds * safePlaybackRate, + 0, + durationSeconds + ); + + setSmoothProgressPercent(getTimelinePercent(nextTime, durationSeconds)); + + if (nextTime < durationSeconds) { + animationFrameId = window.requestAnimationFrame(updatePlayhead); + } + }; + + animationFrameId = window.requestAnimationFrame(updatePlayhead); + + return () => { + window.cancelAnimationFrame(animationFrameId); + }; + }, [currentTime, durationSeconds, isPlaying, safePlaybackRate]); + + return ( + <span + className="pointer-events-none absolute bottom-0 top-0 z-20 w-0 -translate-x-1/2 border-l-2 border-[#ef4444] drop-shadow-[0_0_8px_rgba(239,68,68,0.4)]" + style={{ left: `${smoothProgressPercent}%`, willChange: "left" }} + > + <span className="absolute -top-px left-1/2 h-2 w-2.5 -translate-x-1/2 rounded-[2px] bg-[#ef4444]" /> + </span> + ); +}; diff --git a/apps/web/core/components/annotation/components/video-annotation-toolbar.tsx b/apps/web/core/components/annotation/components/video-annotation-toolbar.tsx new file mode 100644 index 00000000000..55e05eba38f --- /dev/null +++ b/apps/web/core/components/annotation/components/video-annotation-toolbar.tsx @@ -0,0 +1,200 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Save, Trash2, Undo2 } from "lucide-react"; +import type { TCustomPlaylistAnnotationStrokeStyle, TCustomPlaylistAnnotationTool } from "../types/annotation.types"; +import type { VIDEO_ANNOTATION_TOOLS } from "../utils/video-annotation-editor-config"; +import { + VIDEO_ANNOTATION_DURATIONS, + VIDEO_ANNOTATION_STROKE_STYLES, + VIDEO_ANNOTATION_STROKE_WIDTHS, + VIDEO_ANNOTATION_TOOL_BUTTON_CLASS, +} from "../utils/video-annotation-editor-config"; + +type VideoAnnotationToolOption = (typeof VIDEO_ANNOTATION_TOOLS)[number]; + +type VideoAnnotationToolbarProps = { + annotationColorPicker: ReactNode; + annotationDurationSeconds: number; + annotationStrokeStyle: TCustomPlaylistAnnotationStrokeStyle; + annotationStrokeWidth: number; + annotationTool: TCustomPlaylistAnnotationTool; + availableAnnotationTools: VideoAnnotationToolOption[]; + hasActiveAnnotations: boolean; + hasAnnotationChanges: boolean; + isAnnotationMode: boolean; + isSavingAnnotations: boolean; + onClearVisibleAnnotations: () => void; + onDurationChange: (durationSeconds: number) => void; + onSaveAnnotations: () => void; + onSelectAnnotationTool: (tool: TCustomPlaylistAnnotationTool) => void; + onStrokeStyleChange: (strokeStyle: TCustomPlaylistAnnotationStrokeStyle) => void; + onStrokeWidthChange: (strokeWidth: number) => void; + onUndoVisibleAnnotation: () => void; + shouldRenderSeparateAnnotationProperties: boolean; +}; + +export const VideoAnnotationToolbar = ({ + annotationColorPicker, + annotationDurationSeconds, + annotationStrokeStyle, + annotationStrokeWidth, + annotationTool, + availableAnnotationTools, + hasActiveAnnotations, + hasAnnotationChanges, + isAnnotationMode, + isSavingAnnotations, + onClearVisibleAnnotations, + onDurationChange, + onSaveAnnotations, + onSelectAnnotationTool, + onStrokeStyleChange, + onStrokeWidthChange, + onUndoVisibleAnnotation, + shouldRenderSeparateAnnotationProperties, +}: VideoAnnotationToolbarProps) => { + const annotationButtonClass = VIDEO_ANNOTATION_TOOL_BUTTON_CLASS; + + return ( + <div className="flex flex-col items-center gap-1 rounded-[7px] border border-custom-border-200 bg-custom-background-100 p-1 shadow-sm"> + {isAnnotationMode ? ( + <> + {availableAnnotationTools.map((toolOption) => { + const ToolIcon = toolOption.icon; + const isSelected = annotationTool === toolOption.type; + + return ( + <button + key={toolOption.type} + type="button" + onClick={() => onSelectAnnotationTool(toolOption.type)} + className={[ + annotationButtonClass, + isSelected ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" : "", + ].join(" ")} + aria-label={toolOption.label} + aria-pressed={isSelected} + title={toolOption.label} + > + <ToolIcon className="h-4 w-4" /> + </button> + ); + })} + + {!shouldRenderSeparateAnnotationProperties ? ( + <> + <span className="my-0.5 h-px w-6 bg-custom-border-200" /> + {annotationColorPicker} + + <span className="my-0.5 h-px w-6 bg-custom-border-200" /> + {VIDEO_ANNOTATION_DURATIONS.map((durationSeconds) => { + const isSelected = annotationDurationSeconds === durationSeconds; + + return ( + <button + key={durationSeconds} + type="button" + onClick={() => onDurationChange(durationSeconds)} + className={[ + "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[5px] border border-custom-border-200 bg-custom-background-90 text-[10px] font-semibold text-custom-text-200 transition-colors hover:bg-custom-background-80 hover:text-custom-text-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40", + isSelected ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" : "", + ].join(" ")} + aria-label={`Show annotation for ${durationSeconds} seconds`} + aria-pressed={isSelected} + title={`${durationSeconds}s duration`} + > + {durationSeconds}s + </button> + ); + })} + + <span className="my-0.5 h-px w-6 bg-custom-border-200" /> + {VIDEO_ANNOTATION_STROKE_WIDTHS.map((strokeWidth) => { + const isSelected = annotationStrokeWidth === strokeWidth; + + return ( + <button + key={strokeWidth} + type="button" + onClick={() => onStrokeWidthChange(strokeWidth)} + className={[ + annotationButtonClass, + isSelected ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" : "", + ].join(" ")} + aria-label={`${strokeWidth}px annotation stroke`} + aria-pressed={isSelected} + title={`${strokeWidth}px`} + > + <span className="w-4 rounded-full bg-current" style={{ height: Math.max(2, strokeWidth / 1.5) }} /> + </button> + ); + })} + + {VIDEO_ANNOTATION_STROKE_STYLES.map((strokeStyleOption) => { + const isSelected = annotationStrokeStyle === strokeStyleOption.value; + + return ( + <button + key={strokeStyleOption.value} + type="button" + onClick={() => onStrokeStyleChange(strokeStyleOption.value)} + className={[ + annotationButtonClass, + isSelected ? "border-custom-primary-100 bg-custom-primary-100/15 text-custom-primary-100" : "", + ].join(" ")} + aria-label={`${strokeStyleOption.label} annotation stroke`} + aria-pressed={isSelected} + title={`${strokeStyleOption.label} stroke`} + > + <span + className={[ + "w-4 border-t-2 border-current", + strokeStyleOption.value === "dotted" ? "border-dotted" : "border-solid", + ].join(" ")} + /> + </button> + ); + })} + </> + ) : null} + + <span className="my-0.5 h-px w-6 bg-custom-border-200" /> + <button + type="button" + onClick={onUndoVisibleAnnotation} + className={annotationButtonClass} + disabled={!hasActiveAnnotations || isSavingAnnotations} + aria-label="Undo last annotation at this timestamp" + title="Undo timestamp" + > + <Undo2 className="h-4 w-4" /> + </button> + <button + type="button" + onClick={onClearVisibleAnnotations} + className={annotationButtonClass} + disabled={!hasActiveAnnotations || isSavingAnnotations} + aria-label="Clear annotations at this timestamp" + title="Clear timestamp" + > + <Trash2 className="h-4 w-4" /> + </button> + <button + type="button" + onClick={() => void onSaveAnnotations()} + className={[ + annotationButtonClass, + hasAnnotationChanges ? "border-green-500/45 bg-green-500/10 text-green-600" : "", + ].join(" ")} + disabled={!hasAnnotationChanges || isSavingAnnotations} + aria-label="Save annotations" + title="Save" + > + <Save className="h-4 w-4" /> + </button> + </> + ) : null} + </div> + ); +}; diff --git a/apps/web/core/components/annotation/hooks/use-video-annotation-clock.ts b/apps/web/core/components/annotation/hooks/use-video-annotation-clock.ts new file mode 100644 index 00000000000..6cc965149f8 --- /dev/null +++ b/apps/web/core/components/annotation/hooks/use-video-annotation-clock.ts @@ -0,0 +1,81 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { TCustomPlaylistAnnotation } from "../types/annotation.types"; + +type UseVideoAnnotationClockParams = { + currentTime: number; + isPlaying: boolean; + playbackRate: number; + showTimeline: boolean; + sortedAnnotations: TCustomPlaylistAnnotation[]; +}; + +export const useVideoAnnotationClock = ({ + currentTime, + isPlaying, + playbackRate, + showTimeline, + sortedAnnotations, +}: UseVideoAnnotationClockParams) => { + const [annotationClockTick, setAnnotationClockTick] = useState(0); + const clockOriginRef = useRef({ + mediaTime: currentTime, + wallTime: typeof performance !== "undefined" ? performance.now() : Date.now(), + }); + const safePlaybackRate = Number.isFinite(playbackRate) && playbackRate > 0 ? playbackRate : 1; + const effectiveCurrentTime = useMemo(() => { + void annotationClockTick; + + if (!isPlaying) return currentTime; + + const now = typeof performance !== "undefined" ? performance.now() : Date.now(); + const elapsedSeconds = Math.max(0, (now - clockOriginRef.current.wallTime) / 1000); + return Math.max(0, clockOriginRef.current.mediaTime + elapsedSeconds * safePlaybackRate); + }, [annotationClockTick, currentTime, isPlaying, safePlaybackRate]); + + useEffect(() => { + clockOriginRef.current = { + mediaTime: currentTime, + wallTime: typeof performance !== "undefined" ? performance.now() : Date.now(), + }; + setAnnotationClockTick((currentValue) => currentValue + 1); + }, [currentTime, isPlaying, safePlaybackRate]); + + useEffect(() => { + if (!isPlaying || sortedAnnotations.length === 0) return; + + const nextBoundary = sortedAnnotations.reduce<number | null>((currentBoundary, annotation) => { + const candidateBoundaries = [annotation.startTime, annotation.endTime].filter( + (boundary) => boundary > effectiveCurrentTime + 0.005 + ); + const annotationBoundary = candidateBoundaries.length > 0 ? Math.min(...candidateBoundaries) : null; + if (annotationBoundary === null) return currentBoundary; + return currentBoundary === null ? annotationBoundary : Math.min(currentBoundary, annotationBoundary); + }, null); + if (nextBoundary === null) return; + + const delayMs = Math.max(16, ((nextBoundary - effectiveCurrentTime) / safePlaybackRate) * 1000); + const timeoutId = window.setTimeout(() => { + setAnnotationClockTick((currentValue) => currentValue + 1); + }, delayMs); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [effectiveCurrentTime, isPlaying, safePlaybackRate, sortedAnnotations]); + + useEffect(() => { + if (!showTimeline || !isPlaying) return; + + const intervalId = window.setInterval(() => { + setAnnotationClockTick((currentValue) => currentValue + 1); + }, 250); + + return () => { + window.clearInterval(intervalId); + }; + }, [isPlaying, showTimeline]); + + return { + effectiveCurrentTime, + }; +}; diff --git a/apps/web/core/components/annotation/hooks/use-video-annotation-color-controls.ts b/apps/web/core/components/annotation/hooks/use-video-annotation-color-controls.ts new file mode 100644 index 00000000000..86fada22c39 --- /dev/null +++ b/apps/web/core/components/annotation/hooks/use-video-annotation-color-controls.ts @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; +import { + getHexColorFromHsv, + getHexColorFromRgb, + getHsvFromRgb, + getRgbFromHexColor, + normalizeAnnotationHexColor, +} from "../utils/video-annotation-colors"; +import { DEFAULT_VIDEO_ANNOTATION_COLOR } from "../utils/video-annotation-editor-config"; +import { clampTimelineValue } from "../utils/video-annotation-timeline"; + +export const useVideoAnnotationColorControls = () => { + const [annotationColor, setAnnotationColor] = useState(DEFAULT_VIDEO_ANNOTATION_COLOR); + const [annotationColorInputValue, setAnnotationColorInputValue] = useState(DEFAULT_VIDEO_ANNOTATION_COLOR); + const [isAnnotationColorPickerOpen, setIsAnnotationColorPickerOpen] = useState(false); + const annotationColorRgb = getRgbFromHexColor(annotationColor); + const annotationColorHsv = getHsvFromRgb(annotationColorRgb.red, annotationColorRgb.green, annotationColorRgb.blue); + + useEffect(() => { + setAnnotationColorInputValue(annotationColor.toUpperCase()); + }, [annotationColor]); + + const handleAnnotationColorChange = (colorValue: string) => { + const normalizedColor = normalizeAnnotationHexColor(colorValue); + if (!normalizedColor) return; + + setAnnotationColor(normalizedColor); + setAnnotationColorInputValue(normalizedColor.toUpperCase()); + }; + + const handleAnnotationColorInputChange = (colorValue: string) => { + setAnnotationColorInputValue(colorValue.toUpperCase()); + + const normalizedColor = normalizeAnnotationHexColor(colorValue); + if (normalizedColor) setAnnotationColor(normalizedColor); + }; + + const handleAnnotationColorInputBlur = () => { + setAnnotationColorInputValue(annotationColor.toUpperCase()); + }; + + const handleAnnotationColorChannelChange = (channel: "blue" | "green" | "red", colorValue: string) => { + const channelValue = clampTimelineValue(Number(colorValue), 0, 255); + const nextColor = { + ...annotationColorRgb, + [channel]: channelValue, + }; + + handleAnnotationColorChange(getHexColorFromRgb(nextColor.red, nextColor.green, nextColor.blue)); + }; + + const handleAnnotationColorHueChange = (hueValue: string) => { + const nextHue = clampTimelineValue(Number(hueValue), 0, 360); + handleAnnotationColorChange(getHexColorFromHsv(nextHue, annotationColorHsv.saturation, annotationColorHsv.value)); + }; + + const updateAnnotationColorFromPickerPoint = (event: ReactPointerEvent<HTMLButtonElement>) => { + const pickerRect = event.currentTarget.getBoundingClientRect(); + const saturation = clampTimelineValue((event.clientX - pickerRect.left) / pickerRect.width, 0, 1); + const value = 1 - clampTimelineValue((event.clientY - pickerRect.top) / pickerRect.height, 0, 1); + + handleAnnotationColorChange(getHexColorFromHsv(annotationColorHsv.hue, saturation, value)); + }; + + const handleAnnotationColorPickerPointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => { + event.currentTarget.setPointerCapture(event.pointerId); + updateAnnotationColorFromPickerPoint(event); + }; + + const handleAnnotationColorPickerPointerMove = (event: ReactPointerEvent<HTMLButtonElement>) => { + if (event.buttons !== 1) return; + updateAnnotationColorFromPickerPoint(event); + }; + + return { + annotationColor, + annotationColorHsv, + annotationColorInputValue, + annotationColorRgb, + handleAnnotationColorChange, + handleAnnotationColorChannelChange, + handleAnnotationColorHueChange, + handleAnnotationColorInputBlur, + handleAnnotationColorInputChange, + handleAnnotationColorPickerPointerDown, + handleAnnotationColorPickerPointerMove, + isAnnotationColorPickerOpen, + setIsAnnotationColorPickerOpen, + }; +}; diff --git a/apps/web/core/components/annotation/hooks/use-video-annotation-image-controls.ts b/apps/web/core/components/annotation/hooks/use-video-annotation-image-controls.ts new file mode 100644 index 00000000000..a2bde1f8b37 --- /dev/null +++ b/apps/web/core/components/annotation/hooks/use-video-annotation-image-controls.ts @@ -0,0 +1,170 @@ +import { useCallback, useRef, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TCustomPlaylistAnnotationTool } from "../types/annotation.types"; +import { + MAX_VIDEO_ANNOTATION_IMAGE_BYTES, + VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS, +} from "../utils/video-annotation-editor-config"; +import { clampTimelineValue } from "../utils/video-annotation-timeline"; + +type UseVideoAnnotationImageControlsParams = { + onModeChange?: (enabled: boolean) => void; + onRequestPause?: () => void; + setAnnotationTool: Dispatch<SetStateAction<TCustomPlaylistAnnotationTool>>; + setIsAnnotationMode: Dispatch<SetStateAction<boolean>>; +}; + +const DEFAULT_IMAGE_ANNOTATION_WIDTH = 180; +const DEFAULT_IMAGE_ANNOTATION_HEIGHT = 120; + +const getImageAnnotationDefaultSize = (naturalWidth: number, naturalHeight: number) => { + if (!Number.isFinite(naturalWidth) || !Number.isFinite(naturalHeight) || naturalWidth <= 0 || naturalHeight <= 0) { + return { + height: DEFAULT_IMAGE_ANNOTATION_HEIGHT, + width: DEFAULT_IMAGE_ANNOTATION_WIDTH, + }; + } + + const aspectRatio = naturalWidth / naturalHeight; + let width = DEFAULT_IMAGE_ANNOTATION_WIDTH; + let height = Math.round(width / aspectRatio); + + if (height > DEFAULT_IMAGE_ANNOTATION_HEIGHT) { + height = DEFAULT_IMAGE_ANNOTATION_HEIGHT; + width = Math.round(height * aspectRatio); + } + + const growScale = Math.max( + VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS.min / Math.max(width, 1), + VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS.min / Math.max(height, 1), + 1 + ); + width *= growScale; + height *= growScale; + + const shrinkScale = Math.min( + VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS.max / Math.max(width, 1), + VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS.max / Math.max(height, 1), + 1 + ); + + return { + height: Math.round(height * shrinkScale), + width: Math.round(width * shrinkScale), + }; +}; + +export const useVideoAnnotationImageControls = ({ + onModeChange, + onRequestPause, + setAnnotationTool, + setIsAnnotationMode, +}: UseVideoAnnotationImageControlsParams) => { + const [annotationImageContent, setAnnotationImageContent] = useState<string | null>(null); + const [annotationImageHeight, setAnnotationImageHeight] = useState(DEFAULT_IMAGE_ANNOTATION_HEIGHT); + const [annotationImageName, setAnnotationImageName] = useState(""); + const [annotationImageOpacity, setAnnotationImageOpacity] = useState(1); + const [annotationImagePlacementKey, setAnnotationImagePlacementKey] = useState(0); + const [annotationImageWidth, setAnnotationImageWidth] = useState(DEFAULT_IMAGE_ANNOTATION_WIDTH); + const annotationImageInputRef = useRef<HTMLInputElement | null>(null); + + const handleChooseAnnotationImage = useCallback(() => { + onRequestPause?.(); + annotationImageInputRef.current?.click(); + }, [onRequestPause]); + + const handleAnnotationImageChange = useCallback( + (fileList: FileList | null) => { + const selectedFile = fileList?.[0]; + if (!selectedFile) return; + + if (!selectedFile.type.startsWith("image/")) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Image annotation failed", + message: "Choose a valid image file.", + }); + return; + } + + if (selectedFile.size > MAX_VIDEO_ANNOTATION_IMAGE_BYTES) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Image annotation failed", + message: "Use an image smaller than 2 MB.", + }); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result !== "string") return; + + const imageContent = reader.result; + const image = new Image(); + image.onload = () => { + const nextSize = getImageAnnotationDefaultSize(image.naturalWidth, image.naturalHeight); + + onRequestPause?.(); + setAnnotationImageContent(imageContent); + setAnnotationImageHeight(nextSize.height); + setAnnotationImageName(selectedFile.name); + setAnnotationImagePlacementKey((currentValue) => currentValue + 1); + setAnnotationImageWidth(nextSize.width); + setAnnotationTool("image"); + setIsAnnotationMode(true); + onModeChange?.(true); + }; + image.onerror = () => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Image annotation failed", + message: "Unable to load this image file.", + }); + }; + image.src = imageContent; + }; + reader.onerror = () => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Image annotation failed", + message: "Unable to read this image file.", + }); + }; + reader.readAsDataURL(selectedFile); + }, + [onModeChange, onRequestPause, setAnnotationTool, setIsAnnotationMode] + ); + + const handleAnnotationImageSizeChange = useCallback((dimension: "height" | "width", value: string) => { + const nextValue = Math.round( + clampTimelineValue(Number(value), VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS.min, VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS.max) + ); + + if (dimension === "height") { + setAnnotationImageHeight(nextValue); + return; + } + + setAnnotationImageWidth(nextValue); + }, []); + + const handleAnnotationImageOpacityChange = useCallback((value: string) => { + setAnnotationImageOpacity(clampTimelineValue(Number(value), 20, 100) / 100); + }, []); + + return { + annotationImageContent, + annotationImageHeight, + annotationImageInputRef, + annotationImageName, + annotationImageOpacity, + annotationImagePlacementKey, + annotationImageWidth, + handleAnnotationImageChange, + handleAnnotationImageOpacityChange, + handleAnnotationImageSizeChange, + handleChooseAnnotationImage, + }; +}; diff --git a/apps/web/core/components/annotation/hooks/use-video-annotation-timeline.ts b/apps/web/core/components/annotation/hooks/use-video-annotation-timeline.ts new file mode 100644 index 00000000000..a00aaf29bd8 --- /dev/null +++ b/apps/web/core/components/annotation/hooks/use-video-annotation-timeline.ts @@ -0,0 +1,381 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + Dispatch, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, + SetStateAction, + UIEvent as ReactUIEvent, +} from "react"; +import type { TCustomPlaylistAnnotation } from "../types/annotation.types"; +import { + VIDEO_ANNOTATION_TIMELINE_CLIP_GAP_PX, + VIDEO_ANNOTATION_TIMELINE_CLIP_MIN_WIDTH_PX, + VIDEO_ANNOTATION_TIMELINE_DEFAULT_ZOOM_PERCENT, + VIDEO_ANNOTATION_TIMELINE_MIN_DURATION_SECONDS, + VIDEO_ANNOTATION_TIMELINE_ZOOM_STEPS, +} from "../utils/video-annotation-editor-config"; +import type { AnnotationTimelineMoment, AnnotationTimelineResizeState } from "../utils/video-annotation-timeline"; +import { + buildAnnotationTimelineMoments, + buildAnnotationTimelineTicks, + clampTimelineValue, + getTimelineContentWidthPx, + getTimelineDuration, + getTimelinePercent, +} from "../utils/video-annotation-timeline"; + +type UseVideoAnnotationTimelineParams = { + durationSeconds?: number | null; + effectiveCurrentTime: number; + isSavingAnnotations: boolean; + onSeek?: (seconds: number) => void; + setAnnotations: Dispatch<SetStateAction<TCustomPlaylistAnnotation[]>>; + sortedAnnotations: TCustomPlaylistAnnotation[]; +}; + +export const useVideoAnnotationTimeline = ({ + durationSeconds, + effectiveCurrentTime, + isSavingAnnotations, + onSeek, + setAnnotations, + sortedAnnotations, +}: UseVideoAnnotationTimelineParams) => { + const [timelineZoomPercent, setTimelineZoomPercent] = useState(VIDEO_ANNOTATION_TIMELINE_DEFAULT_ZOOM_PERCENT); + const [openTimelineMomentIds, setOpenTimelineMomentIds] = useState<Set<string>>(() => new Set()); + const [editingTimelineMoment, setEditingTimelineMoment] = useState<{ id: string; value: string } | null>(null); + const [timelineResizeId, setTimelineResizeId] = useState<string | null>(null); + const timelineHeaderScrollableElementRef = useRef<HTMLDivElement | null>(null); + const timelineScrollableElementRef = useRef<HTMLDivElement | null>(null); + const timelineResizeStateRef = useRef<AnnotationTimelineResizeState | null>(null); + const annotationTimelineMoments = useMemo( + () => buildAnnotationTimelineMoments(sortedAnnotations), + [sortedAnnotations] + ); + const timelineDurationSeconds = useMemo( + () => getTimelineDuration(durationSeconds, sortedAnnotations, effectiveCurrentTime), + [durationSeconds, effectiveCurrentTime, sortedAnnotations] + ); + const timelineProgressPercent = getTimelinePercent(effectiveCurrentTime, timelineDurationSeconds); + const timelineTicks = useMemo( + () => buildAnnotationTimelineTicks(timelineDurationSeconds, timelineZoomPercent), + [timelineDurationSeconds, timelineZoomPercent] + ); + const timelineContentWidthPx = getTimelineContentWidthPx(timelineDurationSeconds, timelineZoomPercent); + const minimumVisibleAnnotationDurationSeconds = + ((VIDEO_ANNOTATION_TIMELINE_CLIP_MIN_WIDTH_PX + VIDEO_ANNOTATION_TIMELINE_CLIP_GAP_PX) / + Math.max(1, timelineContentWidthPx)) * + timelineDurationSeconds; + const minimumResizableAnnotationDurationSeconds = + (VIDEO_ANNOTATION_TIMELINE_CLIP_MIN_WIDTH_PX / Math.max(1, timelineContentWidthPx)) * timelineDurationSeconds; + const timelineZoomIndex = VIDEO_ANNOTATION_TIMELINE_ZOOM_STEPS.indexOf(timelineZoomPercent); + const activeTimelineZoomIndex = + timelineZoomIndex >= 0 + ? timelineZoomIndex + : VIDEO_ANNOTATION_TIMELINE_ZOOM_STEPS.indexOf(VIDEO_ANNOTATION_TIMELINE_DEFAULT_ZOOM_PERCENT); + const canZoomTimelineOut = activeTimelineZoomIndex > 0; + const canZoomTimelineIn = + activeTimelineZoomIndex >= 0 && activeTimelineZoomIndex < VIDEO_ANNOTATION_TIMELINE_ZOOM_STEPS.length - 1; + + useEffect(() => { + setOpenTimelineMomentIds((currentMomentIds) => { + const availableMomentIds = new Set(annotationTimelineMoments.map((moment) => moment.id)); + const nextMomentIds = new Set([...currentMomentIds].filter((momentId) => availableMomentIds.has(momentId))); + + if (nextMomentIds.size === 0 && annotationTimelineMoments[0]) { + nextMomentIds.add(annotationTimelineMoments[0].id); + } + + if ( + nextMomentIds.size === currentMomentIds.size && + [...nextMomentIds].every((momentId) => currentMomentIds.has(momentId)) + ) { + return currentMomentIds; + } + + return nextMomentIds; + }); + }, [annotationTimelineMoments]); + + const handleTimelineSeek = useCallback( + (seconds: number) => { + if (!onSeek) return; + + onSeek(clampTimelineValue(seconds, 0, timelineDurationSeconds)); + }, + [onSeek, timelineDurationSeconds] + ); + + const handleTimelinePointerDown = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + if (!onSeek) return; + + const rect = event.currentTarget.getBoundingClientRect(); + if (rect.width <= 0) return; + + const scrollableWidth = Math.max(timelineContentWidthPx, event.currentTarget.scrollWidth, rect.width); + const pointerOffset = event.clientX - rect.left + event.currentTarget.scrollLeft; + const ratio = clampTimelineValue(pointerOffset / scrollableWidth, 0, 1); + handleTimelineSeek(ratio * timelineDurationSeconds); + }, + [handleTimelineSeek, onSeek, timelineContentWidthPx, timelineDurationSeconds] + ); + + const handleTimelineKeyDown = useCallback( + (event: ReactKeyboardEvent<HTMLDivElement>) => { + if (!onSeek) return; + + const smallStep = Math.max(1, timelineDurationSeconds / 100); + const largeStep = Math.max(5, timelineDurationSeconds / 20); + let nextTime: number | null = null; + + if (event.key === "ArrowLeft") nextTime = effectiveCurrentTime - smallStep; + if (event.key === "ArrowRight") nextTime = effectiveCurrentTime + smallStep; + if (event.key === "PageUp") nextTime = effectiveCurrentTime + largeStep; + if (event.key === "PageDown") nextTime = effectiveCurrentTime - largeStep; + if (event.key === "Home") nextTime = 0; + if (event.key === "End") nextTime = timelineDurationSeconds; + + if (nextTime === null) return; + + event.preventDefault(); + handleTimelineSeek(nextTime); + }, + [effectiveCurrentTime, handleTimelineSeek, onSeek, timelineDurationSeconds] + ); + + const handleTimelineHeaderScroll = useCallback((event: ReactUIEvent<HTMLDivElement>) => { + const timelineBodyElement = timelineScrollableElementRef.current; + if (!timelineBodyElement || timelineBodyElement.scrollLeft === event.currentTarget.scrollLeft) return; + + timelineBodyElement.scrollLeft = event.currentTarget.scrollLeft; + }, []); + + const handleTimelineBodyScroll = useCallback((event: ReactUIEvent<HTMLDivElement>) => { + const timelineHeaderElement = timelineHeaderScrollableElementRef.current; + if (!timelineHeaderElement || timelineHeaderElement.scrollLeft === event.currentTarget.scrollLeft) return; + + timelineHeaderElement.scrollLeft = event.currentTarget.scrollLeft; + }, []); + + const updateAnnotationTimelineResizeByClientX = useCallback( + (pointerId: number, clientX: number, preventDefault: () => void) => { + const resizeState = timelineResizeStateRef.current; + if (!resizeState || resizeState.pointerId !== pointerId) return false; + + preventDefault(); + if (!resizeState.hasMoved && Math.abs(clientX - resizeState.startClientX) <= 2) { + return true; + } + + if (!resizeState.hasMoved) { + resizeState.hasMoved = true; + } + + const timelineResizeWidthPx = Math.max( + 1, + timelineScrollableElementRef.current?.scrollWidth ?? timelineContentWidthPx + ); + const deltaSeconds = ((clientX - resizeState.startClientX) / timelineResizeWidthPx) * timelineDurationSeconds; + const nextEndTime = Number( + clampTimelineValue( + resizeState.originalEndTime + deltaSeconds, + resizeState.startTime + VIDEO_ANNOTATION_TIMELINE_MIN_DURATION_SECONDS, + timelineDurationSeconds + ).toFixed(3) + ); + + setAnnotations((currentAnnotations) => + currentAnnotations.map((annotation) => + annotation.id === resizeState.annotationId ? { ...annotation, endTime: nextEndTime } : annotation + ) + ); + + return true; + }, + [setAnnotations, timelineContentWidthPx, timelineDurationSeconds] + ); + + const updateAnnotationTimelineResize = useCallback( + (event: PointerEvent) => { + updateAnnotationTimelineResizeByClientX(event.pointerId, event.clientX, () => event.preventDefault()); + }, + [updateAnnotationTimelineResizeByClientX] + ); + + const finishAnnotationTimelineResize = useCallback((pointerId: number, preventDefault: () => void) => { + const resizeState = timelineResizeStateRef.current; + if (!resizeState || resizeState.pointerId !== pointerId) return false; + + if (resizeState.hasMoved) { + preventDefault(); + } + + timelineResizeStateRef.current = null; + setTimelineResizeId(null); + return true; + }, []); + + useEffect(() => { + const handleWindowPointerEnd = (event: PointerEvent) => { + finishAnnotationTimelineResize(event.pointerId, () => event.preventDefault()); + }; + + window.addEventListener("pointermove", updateAnnotationTimelineResize); + window.addEventListener("pointerup", handleWindowPointerEnd); + window.addEventListener("pointercancel", handleWindowPointerEnd); + + return () => { + window.removeEventListener("pointermove", updateAnnotationTimelineResize); + window.removeEventListener("pointerup", handleWindowPointerEnd); + window.removeEventListener("pointercancel", handleWindowPointerEnd); + }; + }, [finishAnnotationTimelineResize, updateAnnotationTimelineResize]); + + const handleAnnotationTimelineResizePointerDown = useCallback( + (event: ReactPointerEvent<HTMLButtonElement>, annotation: TCustomPlaylistAnnotation) => { + if (event.button !== 0 || isSavingAnnotations) return; + + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + const resizeStartEndTime = clampTimelineValue( + Math.max(annotation.endTime, annotation.startTime + minimumResizableAnnotationDurationSeconds), + annotation.startTime + VIDEO_ANNOTATION_TIMELINE_MIN_DURATION_SECONDS, + timelineDurationSeconds + ); + timelineResizeStateRef.current = { + annotationId: annotation.id, + hasMoved: false, + originalEndTime: resizeStartEndTime, + pointerId: event.pointerId, + startClientX: event.clientX, + startTime: annotation.startTime, + }; + setTimelineResizeId(annotation.id); + }, + [isSavingAnnotations, minimumResizableAnnotationDurationSeconds, timelineDurationSeconds] + ); + + const handleAnnotationTimelineResizePointerMove = useCallback( + (event: ReactPointerEvent<HTMLButtonElement>) => { + if (updateAnnotationTimelineResizeByClientX(event.pointerId, event.clientX, () => event.preventDefault())) { + event.stopPropagation(); + } + }, + [updateAnnotationTimelineResizeByClientX] + ); + + const handleAnnotationTimelineResizePointerEnd = useCallback( + (event: ReactPointerEvent<HTMLButtonElement>) => { + if (finishAnnotationTimelineResize(event.pointerId, () => event.preventDefault())) { + event.stopPropagation(); + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }, + [finishAnnotationTimelineResize] + ); + + const stepTimelineZoom = (direction: "in" | "out") => { + const nextIndex = clampTimelineValue( + activeTimelineZoomIndex + (direction === "in" ? 1 : -1), + 0, + VIDEO_ANNOTATION_TIMELINE_ZOOM_STEPS.length - 1 + ); + + setTimelineZoomPercent(VIDEO_ANNOTATION_TIMELINE_ZOOM_STEPS[nextIndex]); + }; + + const jumpToRelativeTimelineTime = (deltaSeconds: number) => { + handleTimelineSeek(effectiveCurrentTime + deltaSeconds); + }; + + const jumpToNearestAnnotation = (direction: "previous" | "next") => { + if (sortedAnnotations.length === 0) return; + + const edgeOffsetSeconds = direction === "previous" ? -0.05 : 0.05; + const candidate = + direction === "previous" + ? [...sortedAnnotations] + .reverse() + .find((annotation) => annotation.startTime < effectiveCurrentTime + edgeOffsetSeconds) + : sortedAnnotations.find((annotation) => annotation.startTime > effectiveCurrentTime + edgeOffsetSeconds); + + handleTimelineSeek( + (candidate ?? sortedAnnotations[direction === "previous" ? sortedAnnotations.length - 1 : 0]).startTime + ); + }; + + const toggleTimelineMoment = (momentId: string) => { + setOpenTimelineMomentIds((currentMomentIds) => { + const nextMomentIds = new Set(currentMomentIds); + + if (nextMomentIds.has(momentId)) { + nextMomentIds.delete(momentId); + } else { + nextMomentIds.add(momentId); + } + + return nextMomentIds; + }); + }; + + const beginEditingTimelineMoment = (moment: AnnotationTimelineMoment) => { + setEditingTimelineMoment({ id: moment.id, value: moment.title }); + }; + + const commitTimelineMomentTitle = (moment: AnnotationTimelineMoment, value: string) => { + const nextTitle = value.trim(); + setEditingTimelineMoment(null); + if (!nextTitle || nextTitle === moment.title) return; + + const momentAnnotationIds = new Set(moment.annotations.map(({ annotation }) => annotation.id)); + setAnnotations((currentAnnotations) => + currentAnnotations.map((annotation) => { + if (!momentAnnotationIds.has(annotation.id)) return annotation; + + const { timelineTitle: _timelineTitle, ...annotationWithoutLegacyTitle } = + annotation as TCustomPlaylistAnnotation & { + timelineTitle?: string; + }; + + return { ...annotationWithoutLegacyTitle, title: nextTitle }; + }) + ); + }; + + return { + annotationTimelineMoments, + beginEditingTimelineMoment, + canZoomTimelineIn, + canZoomTimelineOut, + commitTimelineMomentTitle, + editingTimelineMoment, + handleAnnotationTimelineResizePointerEnd, + handleAnnotationTimelineResizePointerMove, + handleAnnotationTimelineResizePointerDown, + handleTimelineBodyScroll, + handleTimelineHeaderScroll, + handleTimelineKeyDown, + handleTimelinePointerDown, + handleTimelineSeek, + jumpToNearestAnnotation, + jumpToRelativeTimelineTime, + minimumVisibleAnnotationDurationSeconds, + openTimelineMomentIds, + setEditingTimelineMoment, + stepTimelineZoom, + timelineContentWidthPx, + timelineDurationSeconds, + timelineHeaderScrollableElementRef, + timelineProgressPercent, + timelineResizeId, + timelineScrollableElementRef, + timelineTicks, + timelineZoomPercent, + toggleTimelineMoment, + }; +}; diff --git a/apps/web/core/components/annotation/index.ts b/apps/web/core/components/annotation/index.ts new file mode 100644 index 00000000000..94236daafcb --- /dev/null +++ b/apps/web/core/components/annotation/index.ts @@ -0,0 +1,20 @@ +export { + PlaylistAnnotationOverlay, + arePlaylistAnnotationsEqual, + getActivePlaylistAnnotations, + normalizePlaylistAnnotations, +} from "./components/playlist-annotation-overlay"; +export { VideoAnnotationEditor } from "./components/video-annotation-editor"; +export type { + TCustomPlaylistAnnotation, + TCustomPlaylistAnnotationPoint, + TCustomPlaylistAnnotationStrokeStyle, + TCustomPlaylistAnnotationStyle, + TCustomPlaylistAnnotationTool, +} from "./types/annotation.types"; +export { + buildSgEventAnnotationDisplayMeta, + buildSgEventAnnotationVideoItem, + buildSgEventAnnotationViewKey, + getSgEventMediaReferenceAnnotations, +} from "./utils/event-video-annotation"; diff --git a/apps/web/core/components/annotation/types/annotation.types.ts b/apps/web/core/components/annotation/types/annotation.types.ts new file mode 100644 index 00000000000..e492c3e8c11 --- /dev/null +++ b/apps/web/core/components/annotation/types/annotation.types.ts @@ -0,0 +1,7 @@ +export type { + TCustomPlaylistAnnotation, + TCustomPlaylistAnnotationPoint, + TCustomPlaylistAnnotationStrokeStyle, + TCustomPlaylistAnnotationStyle, + TCustomPlaylistAnnotationTool, +} from "@/services/media-library.service"; diff --git a/apps/web/core/components/annotation/types/playlist-annotation-overlay.types.ts b/apps/web/core/components/annotation/types/playlist-annotation-overlay.types.ts new file mode 100644 index 00000000000..d5104649ce5 --- /dev/null +++ b/apps/web/core/components/annotation/types/playlist-annotation-overlay.types.ts @@ -0,0 +1,79 @@ +import type { + TCustomPlaylistAnnotation, + TCustomPlaylistAnnotationPoint, + TCustomPlaylistAnnotationStrokeStyle, + TCustomPlaylistAnnotationTool, +} from "./annotation.types"; + +export type PlaylistAnnotationOverlayProps = { + annotations: TCustomPlaylistAnnotation[]; + className?: string; + color: string; + durationSeconds: number; + enableAnnotationTransforms?: boolean; + enabled: boolean; + fitToVideoBounds?: boolean; + imageContent?: string | null; + imageHeight: number; + imageOpacity: number; + imagePlacementKey?: number; + imageTitle?: string; + imageWidth: number; + inputEnabled?: boolean; + onCreateAnnotation: (annotation: TCustomPlaylistAnnotation) => void; + onUpdateAnnotation?: (annotation: TCustomPlaylistAnnotation) => void; + textFontFamily: string; + textFontSize: number; + textFontWeight: number; + startTime: number; + strokeStyle: TCustomPlaylistAnnotationStrokeStyle; + strokeWidth: number; + tool: TCustomPlaylistAnnotationTool; +}; + +export type CanvasSize = { + height: number; + width: number; +}; + +export type AnnotationBounds = { + height: number; + width: number; + x: number; + y: number; +}; + +export type AnnotationBoxResizeHandle = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw"; + +export type AnnotationLinearResizeHandle = "start" | "end"; + +export type AnnotationResizeHandle = AnnotationBoxResizeHandle | AnnotationLinearResizeHandle; + +export type AnnotationTransformMode = "move" | "resize" | "rotate"; + +export type AnnotationTransformState = { + annotationId: string; + center: TCustomPlaylistAnnotationPoint; + mode: AnnotationTransformMode; + originalAnnotation: TCustomPlaylistAnnotation; + originalBounds: AnnotationBounds; + originalRotation: number; + pointerId: number; + resizeHandle?: AnnotationResizeHandle; + startAngle: number; + startPoint: TCustomPlaylistAnnotationPoint; +}; + +export type OverlayBounds = { + height: number; + left: number; + top: number; + width: number; +}; + +export type AnnotationResizeHandleOption = { + className: string; + cursorClassName: string; + handle: AnnotationBoxResizeHandle; + label: string; +}; diff --git a/apps/web/core/components/annotation/types/video-annotation-editor.types.ts b/apps/web/core/components/annotation/types/video-annotation-editor.types.ts new file mode 100644 index 00000000000..c064f92ddd1 --- /dev/null +++ b/apps/web/core/components/annotation/types/video-annotation-editor.types.ts @@ -0,0 +1,27 @@ +import type { TCustomPlaylistAnnotation } from "./annotation.types"; + +export type VideoAnnotationEditorProps = { + annotationKey: string; + annotations: TCustomPlaylistAnnotation[] | unknown; + autoEnableAnnotationModeKey?: number | string; + canEdit: boolean; + className?: string; + currentTime: number; + durationSeconds?: number | null; + enableAnnotationTransforms?: boolean; + enableTextTool?: boolean; + fitToVideoBounds?: boolean; + isPlaying?: boolean; + modeResetKey?: number | string; + onModeChange?: (enabled: boolean) => void; + onRegisterSaveHandler?: (saveAnnotations: (() => Promise<boolean>) | null) => void; + onRequestPause?: () => void; + onSave: (annotations: TCustomPlaylistAnnotation[]) => Promise<TCustomPlaylistAnnotation[] | void>; + onSeek?: (seconds: number) => void; + playbackRate?: number; + propertyHostElement?: HTMLElement | null; + toolbarHostElement?: HTMLElement | null; + showTimeline?: boolean; + thumbnailUrl?: string | null; + timelineHostElement?: HTMLElement | null; +}; diff --git a/apps/web/core/components/annotation/utils/__tests__/playlist-annotation-creation-time.test.ts b/apps/web/core/components/annotation/utils/__tests__/playlist-annotation-creation-time.test.ts new file mode 100644 index 00000000000..bedcedcf8e0 --- /dev/null +++ b/apps/web/core/components/annotation/utils/__tests__/playlist-annotation-creation-time.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { TCustomPlaylistAnnotation } from "../../types/annotation.types"; + +// Node's type-stripping test runner requires explicit TypeScript extensions. +// @ts-expect-error See comment above. +import * as playlistAnnotationCreationTime from "../playlist-annotation-creation-time.ts"; + +const { applyAnnotationCreationStartTimeOffset, getAnnotationStartTimeWithCreationOffset } = + playlistAnnotationCreationTime; + +const createAnnotation = (startTime: number, endTime: number): TCustomPlaylistAnnotation => ({ + createdAt: "2026-08-17T00:00:00.000Z", + endTime, + height: 100, + id: `annotation-${startTime}-${endTime}`, + startTime, + style: { + stroke: "#f97316", + strokeStyle: "solid", + strokeWidth: 5, + }, + type: "rectangle", + width: 100, + x: 100, + y: 100, +}); + +test("annotation creation start time is offset one second before the playhead", () => { + assert.equal(getAnnotationStartTimeWithCreationOffset(18), 17); + assert.equal(getAnnotationStartTimeWithCreationOffset(85.5), 84.5); +}); + +test("annotation creation start time never becomes negative", () => { + assert.equal(getAnnotationStartTimeWithCreationOffset(0.5), 0); + assert.equal(getAnnotationStartTimeWithCreationOffset(0), 0); +}); + +test("annotation creation offset preserves the selected duration", () => { + const offsetWholeSecondAnnotation = applyAnnotationCreationStartTimeOffset(createAnnotation(18, 20)); + const offsetSubSecondAnnotation = applyAnnotationCreationStartTimeOffset(createAnnotation(0.5, 2.5)); + + assert.equal(offsetWholeSecondAnnotation.startTime, 17); + assert.equal(offsetWholeSecondAnnotation.endTime, 19); + assert.equal(offsetWholeSecondAnnotation.id, "annotation-18-20"); + assert.equal(offsetSubSecondAnnotation.startTime, 0); + assert.equal(offsetSubSecondAnnotation.endTime, 2); + assert.equal(offsetSubSecondAnnotation.id, "annotation-0.5-2.5"); +}); diff --git a/apps/web/core/components/annotation/utils/event-video-annotation.ts b/apps/web/core/components/annotation/utils/event-video-annotation.ts new file mode 100644 index 00000000000..c696091ee77 --- /dev/null +++ b/apps/web/core/components/annotation/utils/event-video-annotation.ts @@ -0,0 +1,323 @@ +"use client"; + +import { asRecord, buildArchivedStreamUrl, toText } from "@/components/issues/issue-detail/sg-event-detail-page/utils"; +import type { TCustomPlaylistAnnotation } from "@/services/media-library.service"; +import type { TMediaItem } from "ce/features/media-library/types/media-library.types"; + +type TSgEventAnnotationVideoOptions = { + deviceId?: string | number | null; + eventPayload?: Record<string, unknown> | null; + streamId?: string | number | null; + streamName?: string | null; + title?: string | null; + viewKey?: string | null; + videoSrc?: string | null; +}; + +const EVENT_VIDEO_SOURCE_KEYS = [ + "hlsUrl", + "hls_url", + "playlistUrl", + "playlist_url", + "previewUrl", + "preview_url", + "videoUrl", + "video_url", + "sourceUrl", + "source_url", + "mediaUrl", + "media_url", + "url", +]; + +const EVENT_STREAM_NAME_KEYS = [ + "primaryStreamName", + "primary_stream_name", + "streamName", + "stream_name", + "originalStreamName", + "original_stream_name", +]; + +const EVENT_DEVICE_COLLECTION_KEYS = ["devices", "mediaReferences", "media_references"]; +const GENERIC_VIDEO_SOURCE_KEYS = new Set(["url"]); +const VIDEO_SOURCE_PATTERN = /\.(m3u8|mp4|m4v|mov|webm|avi|mkv|mpeg|mpg)(?:[?#]|$)|mpegurl|\/llhls\.m3u8(?:[?#]|$)/i; +const MEDIA_REFERENCE_COLLECTION_KEYS = ["mediaReferences", "media_references", "devices"]; + +const getFirstTextValue = (record: Record<string, unknown>, keys: string[]) => { + for (const key of keys) { + const value = toText(record[key]).trim(); + if (value) return value; + } + + return ""; +}; + +const getFirstVideoSourceValue = (record: Record<string, unknown>) => { + for (const key of EVENT_VIDEO_SOURCE_KEYS) { + const value = toText(record[key]).trim(); + if (!value) continue; + if (!GENERIC_VIDEO_SOURCE_KEYS.has(key) || VIDEO_SOURCE_PATTERN.test(value)) return value; + } + + return ""; +}; + +const getNestedEventRecords = (item: TMediaItem) => { + const meta = asRecord(item.meta); + const event = asRecord(meta.event); + const rawEvent = asRecord(meta.rawEvent ?? meta.raw_event); + + return [meta, event, rawEvent].filter((record) => Object.keys(record).length > 0); +}; + +const getEventDeviceRecords = (records: Record<string, unknown>[]) => + records.flatMap((record) => + EVENT_DEVICE_COLLECTION_KEYS.flatMap((key) => { + const value = record[key]; + if (!Array.isArray(value)) return []; + + return value.map((entry) => asRecord(entry)).filter((entry) => Object.keys(entry).length > 0); + }) + ); + +const resolveEventAnnotationVideoSrc = (item: TMediaItem, options: TSgEventAnnotationVideoOptions = {}) => { + const optionVideoSrc = toText(options.videoSrc).trim(); + if (optionVideoSrc) return optionVideoSrc; + + const optionStreamUrl = buildArchivedStreamUrl(toText(options.streamName)); + if (optionStreamUrl) return optionStreamUrl; + + const eventRecords = getNestedEventRecords(item); + const sourceRecords = [...eventRecords, ...getEventDeviceRecords(eventRecords)]; + + for (const record of sourceRecords) { + const directSource = getFirstVideoSourceValue(record); + if (directSource) return directSource; + } + + for (const record of sourceRecords) { + const streamName = getFirstTextValue(record, EVENT_STREAM_NAME_KEYS); + const streamUrl = buildArchivedStreamUrl(streamName); + if (streamUrl) return streamUrl; + } + + return ""; +}; + +const resolveEventAnnotationVideoFormat = (videoSrc: string) => { + const normalizedSrc = videoSrc.toLowerCase(); + if (normalizedSrc.includes(".m3u8") || normalizedSrc.includes("mpegurl") || normalizedSrc.includes("/llhls.m3u8")) { + return "m3u8"; + } + + const pathWithoutQuery = normalizedSrc.split("?")[0].split("#")[0]; + const extension = pathWithoutQuery.split("/").pop()?.split(".").pop() ?? ""; + return extension || "m3u8"; +}; + +const toAnnotationList = (value: unknown): TCustomPlaylistAnnotation[] | null => + Array.isArray(value) ? (value as TCustomPlaylistAnnotation[]) : null; + +export const buildSgEventAnnotationViewKey = (options: TSgEventAnnotationVideoOptions = {}) => { + const explicitViewKey = toText(options.viewKey).trim(); + if (explicitViewKey) return explicitViewKey; + + const streamName = toText(options.streamName).trim(); + if (streamName) return `stream:${streamName}`; + + const streamId = toText(options.streamId).trim(); + if (streamId) return `stream-id:${streamId}`; + + const deviceId = toText(options.deviceId).trim(); + if (deviceId) return `device:${deviceId}`; + + const videoSrc = toText(options.videoSrc).trim(); + if (videoSrc) return `video:${videoSrc}`; + + return ""; +}; + +const getMediaReferenceCollections = (source: Record<string, unknown>) => + MEDIA_REFERENCE_COLLECTION_KEYS.flatMap((key) => { + const value = source[key]; + return Array.isArray(value) ? value.map((entry) => asRecord(entry)) : []; + }).filter((entry) => Object.keys(entry).length > 0); + +const getMediaReferenceSources = (meta: Record<string, unknown>, options: TSgEventAnnotationVideoOptions = {}) => { + const eventPayload = asRecord(options.eventPayload); + const event = asRecord(meta.event); + const rawEvent = asRecord(meta.rawEvent ?? meta.raw_event); + + return [eventPayload, meta, event, rawEvent].filter((record) => Object.keys(record).length > 0); +}; + +const getMediaReferenceScore = (reference: Record<string, unknown>, options: TSgEventAnnotationVideoOptions = {}) => { + let score = 0; + const streamId = toText(options.streamId).trim(); + const streamName = toText(options.streamName).trim(); + const deviceId = toText(options.deviceId).trim(); + const videoSrc = toText(options.videoSrc).trim().replace(/\/+$/, ""); + const viewKey = buildSgEventAnnotationViewKey(options); + + const referenceStreamId = toText(reference.streamId ?? reference.stream_id).trim(); + const referenceStreamName = toText(reference.streamName ?? reference.stream_name).trim(); + const referenceDeviceId = toText(reference.deviceId ?? reference.device_id ?? reference.activeDeviceId).trim(); + const referenceVideoSrc = toText( + reference.hlsUrl ?? + reference.hls_url ?? + reference.previewUrl ?? + reference.preview_url ?? + reference.videoUrl ?? + reference.video_url ?? + reference.sourceUrl ?? + reference.source_url + ) + .trim() + .replace(/\/+$/, ""); + const referenceViewKeys = [ + buildSgEventAnnotationViewKey({ + deviceId: referenceDeviceId, + streamId: referenceStreamId, + streamName: referenceStreamName, + videoSrc: referenceVideoSrc, + }), + toText(reference.annotationViewKey).trim(), + ].filter(Boolean); + + if (viewKey && referenceViewKeys.includes(viewKey)) score += 16; + if (streamId && referenceStreamId === streamId) score += 8; + if (streamName && referenceStreamName === streamName) score += 6; + if (deviceId && referenceDeviceId === deviceId) score += 4; + if (videoSrc && referenceVideoSrc === videoSrc) score += 2; + + return score; +}; + +export const findSgEventMediaReference = ( + meta: Record<string, unknown>, + options: TSgEventAnnotationVideoOptions = {} +): Record<string, unknown> | null => { + const references = getMediaReferenceSources(meta, options).flatMap(getMediaReferenceCollections); + let bestMatch: Record<string, unknown> | null = null; + let bestScore = 0; + + references.forEach((reference) => { + const score = getMediaReferenceScore(reference, options); + if (score > bestScore) { + bestMatch = reference; + bestScore = score; + } + }); + + return bestMatch; +}; + +export const getSgEventMediaReferenceAnnotations = ( + meta: Record<string, unknown>, + options: TSgEventAnnotationVideoOptions = {} +) => { + const reference = findSgEventMediaReference(meta, options); + const referenceAnnotations = reference ? toAnnotationList(reference["annotations"]) : null; + if (referenceAnnotations) return referenceAnnotations; + + return buildSgEventAnnotationViewKey(options) ? [] : (toAnnotationList(meta.annotations) ?? []); +}; + +export const buildSgEventAnnotationDisplayMeta = ( + meta: Record<string, unknown>, + options: TSgEventAnnotationVideoOptions = {} +) => { + const viewKey = buildSgEventAnnotationViewKey(options); + if (!viewKey) return meta; + + const annotationVideoSource = toText(options.videoSrc).trim(); + const isHlsAnnotationVideo = annotationVideoSource + ? resolveEventAnnotationVideoFormat(annotationVideoSource) === "m3u8" + : false; + + return { + ...meta, + annotations: getSgEventMediaReferenceAnnotations(meta, options), + annotationViewDeviceId: toText(options.deviceId).trim() || meta.annotationViewDeviceId, + annotationViewKey: viewKey, + annotationViewLabel: toText(options.title).trim() || meta.annotationViewLabel, + annotationViewStreamId: toText(options.streamId).trim() || meta.annotationViewStreamId, + annotationViewStreamName: toText(options.streamName).trim() || meta.annotationViewStreamName, + annotationVideoSource: annotationVideoSource || meta.annotationVideoSource, + hls: isHlsAnnotationVideo ? true : meta.hls, + hls_direct: isHlsAnnotationVideo ? true : meta.hls_direct, + }; +}; + +export const buildSgEventAnnotationVideoItem = ( + item: TMediaItem | null | undefined, + options: TSgEventAnnotationVideoOptions = {} +): TMediaItem | null => { + if (!item?.packageId || !item.id) return null; + if (item.mediaType === "video") { + const optionVideoSrc = toText(options.videoSrc).trim() || buildArchivedStreamUrl(toText(options.streamName)); + if (optionVideoSrc) { + const format = resolveEventAnnotationVideoFormat(optionVideoSrc); + const isHls = format === "m3u8"; + const meta = asRecord(item.meta); + const title = toText(options.title).trim() || item.title; + + return { + ...item, + action: isHls ? "play_streaming" : "play", + downloadSrc: optionVideoSrc, + fileSrc: optionVideoSrc, + format, + link: optionVideoSrc, + linkedFormat: format, + linkedMediaType: "video", + mediaType: "video", + meta: { + ...buildSgEventAnnotationDisplayMeta(meta, { + ...options, + videoSrc: optionVideoSrc, + }), + hls: isHls ? true : meta.hls, + hls_direct: isHls ? true : meta.hls_direct, + annotationVideoSource: optionVideoSrc, + }, + title, + videoSrc: optionVideoSrc, + }; + } + + return { + ...item, + meta: buildSgEventAnnotationDisplayMeta(item.meta ?? {}, options), + }; + } + + const videoSrc = resolveEventAnnotationVideoSrc(item, options); + if (!videoSrc) return null; + + const format = resolveEventAnnotationVideoFormat(videoSrc); + const isHls = format === "m3u8"; + const meta = asRecord(item.meta); + const title = toText(options.title).trim() || item.title; + + return { + ...item, + action: isHls ? "play_streaming" : "play", + downloadSrc: videoSrc, + fileSrc: videoSrc, + format, + link: videoSrc, + linkedFormat: format, + linkedMediaType: "video", + mediaType: "video", + meta: { + ...buildSgEventAnnotationDisplayMeta(meta, options), + hls: isHls ? true : meta.hls, + hls_direct: isHls ? true : meta.hls_direct, + annotationVideoSource: videoSrc, + }, + title, + videoSrc, + }; +}; diff --git a/apps/web/core/components/annotation/utils/playlist-annotation-creation-time.ts b/apps/web/core/components/annotation/utils/playlist-annotation-creation-time.ts new file mode 100644 index 00000000000..364fe694e21 --- /dev/null +++ b/apps/web/core/components/annotation/utils/playlist-annotation-creation-time.ts @@ -0,0 +1,27 @@ +import type { TCustomPlaylistAnnotation } from "../types/annotation.types"; + +export const VIDEO_ANNOTATION_START_TIME_OFFSET_SECONDS = 1; + +const normalizeCreationTime = (value: unknown) => { + const numberValue = Number(value); + return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : 0; +}; + +export const getAnnotationStartTimeWithCreationOffset = (playheadTime: number) => + Math.max(0, normalizeCreationTime(playheadTime) - VIDEO_ANNOTATION_START_TIME_OFFSET_SECONDS); + +export const applyAnnotationCreationStartTimeOffset = ( + annotation: TCustomPlaylistAnnotation +): TCustomPlaylistAnnotation => { + const annotationDurationSeconds = Math.max( + 0, + normalizeCreationTime(annotation.endTime) - normalizeCreationTime(annotation.startTime) + ); + const startTime = getAnnotationStartTimeWithCreationOffset(annotation.startTime); + + return { + ...annotation, + endTime: startTime + annotationDurationSeconds, + startTime, + }; +}; diff --git a/apps/web/core/components/annotation/utils/playlist-annotation-model.ts b/apps/web/core/components/annotation/utils/playlist-annotation-model.ts new file mode 100644 index 00000000000..c0c0d6e91df --- /dev/null +++ b/apps/web/core/components/annotation/utils/playlist-annotation-model.ts @@ -0,0 +1,752 @@ +import type { + TCustomPlaylistAnnotation, + TCustomPlaylistAnnotationPoint, + TCustomPlaylistAnnotationStyle, + TCustomPlaylistAnnotationStrokeStyle, + TCustomPlaylistAnnotationTool, +} from "../types/annotation.types"; +import type { + AnnotationBounds, + AnnotationBoxResizeHandle, + AnnotationResizeHandle, +} from "../types/playlist-annotation-overlay.types"; +import { OPPOSITE_RESIZE_HANDLE, isBoxResizeHandle } from "./playlist-annotation-transform"; + +const CANVAS_SIZE = 1000; +const MIN_POINT_DISTANCE = 3; +const MIN_RESIZE_DIMENSION = 8; +const MIN_SHAPE_DISTANCE = 6; +const MAX_POINT_COUNT = 700; +const MIN_ARROW_HEAD_LENGTH = 14; +const MAX_ARROW_HEAD_LENGTH = 34; +const MIN_TEXT_FONT_SIZE = 12; +const MAX_TEXT_FONT_SIZE = 140; +export const DEFAULT_PLAYLIST_ANNOTATION_DURATION_SECONDS = 4; +const VALID_ANNOTATION_TYPES = new Set<TCustomPlaylistAnnotationTool>([ + "text", + "rectangle", + "ellipse", + "line", + "arrow", + "image", + "pen", +]); + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const getPointDistance = (firstPoint: TCustomPlaylistAnnotationPoint, secondPoint: TCustomPlaylistAnnotationPoint) => + Math.hypot(firstPoint.x - secondPoint.x, firstPoint.y - secondPoint.y); + +const normalizeNumber = (value: unknown) => { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : null; +}; + +const normalizeRotation = (value: unknown) => { + const numberValue = normalizeNumber(value); + if (numberValue === null) return undefined; + + return ((numberValue % 360) + 360) % 360; +}; + +const normalizeCoordinate = (value: unknown) => { + const numberValue = normalizeNumber(value); + return numberValue === null ? 0 : clamp(numberValue, 0, CANVAS_SIZE); +}; + +const normalizeDimension = (value: unknown) => { + const numberValue = normalizeNumber(value); + return numberValue === null ? 0 : clamp(numberValue, -CANVAS_SIZE, CANVAS_SIZE); +}; + +const normalizeTime = (value: unknown) => { + const numberValue = normalizeNumber(value); + return numberValue === null || numberValue < 0 ? 0 : numberValue; +}; + +const normalizeTrackIndex = (value: unknown) => { + const numberValue = normalizeNumber(value); + if (numberValue === null || numberValue < 0) return undefined; + + return clamp(Math.floor(numberValue), 0, 99); +}; + +const normalizeStyle = (value: unknown): TCustomPlaylistAnnotationStyle => { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + + return value as TCustomPlaylistAnnotationStyle; +}; + +const normalizeStroke = (style: TCustomPlaylistAnnotationStyle, legacyColor?: unknown) => { + const stroke = typeof style.stroke === "string" ? style.stroke : undefined; + const color = typeof style.color === "string" ? style.color : undefined; + const fallback = typeof legacyColor === "string" ? legacyColor : undefined; + return stroke ?? color ?? fallback ?? "#f97316"; +}; + +const normalizeStrokeWidth = (style: TCustomPlaylistAnnotationStyle, legacyStrokeWidth?: unknown) => { + const styleStrokeWidth = normalizeNumber(style.strokeWidth); + const legacyValue = normalizeNumber(legacyStrokeWidth); + const value = styleStrokeWidth ?? legacyValue ?? 4; + return clamp(value, 2, 12); +}; + +const normalizeStrokeStyle = (style: TCustomPlaylistAnnotationStyle): TCustomPlaylistAnnotationStrokeStyle => + style.strokeStyle === "dotted" ? "dotted" : "solid"; + +const getStrokeLineDash = (strokeStyle: TCustomPlaylistAnnotationStrokeStyle, strokeWidth: number) => + strokeStyle === "dotted" ? [Math.max(1, strokeWidth * 0.1), Math.max(4, strokeWidth * 2.2)] : []; + +const normalizePoint = (value: unknown): TCustomPlaylistAnnotationPoint | null => { + if (!value || typeof value !== "object") return null; + + const record = value as Record<string, unknown>; + const x = normalizeNumber(record.x); + const y = normalizeNumber(record.y); + if (x === null || y === null) return null; + + return { + x: clamp(x, 0, CANVAS_SIZE), + y: clamp(y, 0, CANVAS_SIZE), + }; +}; + +const getPointBounds = (points: TCustomPlaylistAnnotationPoint[]) => { + if (points.length === 0) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const x = Math.min(...xs); + const y = Math.min(...ys); + + return { + x, + y, + width: Math.max(...xs) - x, + height: Math.max(...ys) - y, + }; +}; + +const normalizeAnnotationBox = (annotation: TCustomPlaylistAnnotation): TCustomPlaylistAnnotation => { + if (annotation.type !== "rectangle" && annotation.type !== "ellipse" && annotation.type !== "image") { + return annotation; + } + + const width = annotation.width ?? 0; + const height = annotation.height ?? 0; + + return { + ...annotation, + height: Math.abs(height), + width: Math.abs(width), + x: width < 0 ? annotation.x + width : annotation.x, + y: height < 0 ? annotation.y + height : annotation.y, + }; +}; + +const hasValidTimeRange = (annotation: TCustomPlaylistAnnotation) => annotation.endTime > annotation.startTime; + +const isAnnotationValid = (annotation: TCustomPlaylistAnnotation) => { + if (!hasValidTimeRange(annotation)) return false; + + if (annotation.type === "pen") { + const points = annotation.points ?? []; + return points.length > 1 && getPointDistance(points[0], points[points.length - 1]) >= MIN_POINT_DISTANCE; + } + + if (annotation.type === "text") return Boolean(annotation.content?.trim()); + if (annotation.type === "image") return Boolean(annotation.content?.trim()) && (annotation.width ?? 0) > 0; + + const width = annotation.width ?? 0; + const height = annotation.height ?? 0; + return Math.hypot(width, height) >= MIN_SHAPE_DISTANCE; +}; + +export const createPlaylistAnnotationId = () => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `annotation-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +export const normalizePlaylistAnnotations = (value: unknown): TCustomPlaylistAnnotation[] => { + if (!Array.isArray(value)) return []; + + return value + .map((annotation): TCustomPlaylistAnnotation | null => { + if (!annotation || typeof annotation !== "object") return null; + + const record = annotation as Record<string, unknown>; + const type = record.type; + if (typeof type !== "string" || !VALID_ANNOTATION_TYPES.has(type as TCustomPlaylistAnnotationTool)) return null; + + const style = normalizeStyle(record.style); + const legacyStartTime = normalizeTime(record.timestampSeconds); + const legacyDurationSeconds = + normalizeTime(record.durationSeconds) || DEFAULT_PLAYLIST_ANNOTATION_DURATION_SECONDS; + const startTime = "startTime" in record ? normalizeTime(record.startTime) : legacyStartTime; + const endTime = + "endTime" in record + ? normalizeTime(record.endTime) + : Math.max(startTime, legacyStartTime + legacyDurationSeconds); + const points = Array.isArray(record.points) + ? record.points.map(normalizePoint).filter((point): point is TCustomPlaylistAnnotationPoint => Boolean(point)) + : []; + const pointBounds = points.length > 0 ? getPointBounds(points) : null; + const legacyStart = normalizePoint(record.start); + const legacyEnd = normalizePoint(record.end); + const x = "x" in record ? normalizeCoordinate(record.x) : (pointBounds?.x ?? legacyStart?.x ?? 0); + const y = "y" in record ? normalizeCoordinate(record.y) : (pointBounds?.y ?? legacyStart?.y ?? 0); + const width = + "width" in record + ? normalizeDimension(record.width) + : (pointBounds?.width ?? (legacyStart && legacyEnd ? legacyEnd.x - legacyStart.x : 0)); + const height = + "height" in record + ? normalizeDimension(record.height) + : (pointBounds?.height ?? (legacyStart && legacyEnd ? legacyEnd.y - legacyStart.y : 0)); + + const normalizedAnnotation = normalizeAnnotationBox({ + content: typeof record.content === "string" ? record.content : undefined, + createdAt: typeof record.createdAt === "string" ? record.createdAt : undefined, + endTime, + height, + id: typeof record.id === "string" && record.id.trim() ? record.id : createPlaylistAnnotationId(), + points: points.slice(0, MAX_POINT_COUNT), + rotation: normalizeRotation(record.rotation), + startTime, + style: { + ...style, + stroke: normalizeStroke(style, record.color), + strokeStyle: normalizeStrokeStyle(style), + strokeWidth: normalizeStrokeWidth(style, record.strokeWidth), + }, + title: + typeof record.title === "string" && record.title.trim() + ? record.title.trim() + : typeof record.timelineTitle === "string" && record.timelineTitle.trim() + ? record.timelineTitle.trim() + : undefined, + trackIndex: normalizeTrackIndex(record.trackIndex), + type: type as TCustomPlaylistAnnotationTool, + width, + x, + y, + }); + + return normalizedAnnotation; + }) + .filter((annotation): annotation is TCustomPlaylistAnnotation => Boolean(annotation)) + .filter(isAnnotationValid) + .sort((first, second) => first.startTime - second.startTime || first.endTime - second.endTime); +}; + +export const arePlaylistAnnotationsEqual = ( + firstAnnotations: TCustomPlaylistAnnotation[], + secondAnnotations: TCustomPlaylistAnnotation[] +) => JSON.stringify(firstAnnotations) === JSON.stringify(secondAnnotations); + +export const isPlaylistAnnotationVisibleAtTime = (annotation: TCustomPlaylistAnnotation, currentTime: number) => + currentTime >= annotation.startTime && currentTime <= annotation.endTime; + +export const getActivePlaylistAnnotations = (sortedAnnotations: TCustomPlaylistAnnotation[], currentTime: number) => { + const activeAnnotations: TCustomPlaylistAnnotation[] = []; + + for (const annotation of sortedAnnotations) { + if (annotation.startTime > currentTime) break; + if (isPlaylistAnnotationVisibleAtTime(annotation, currentTime)) activeAnnotations.push(annotation); + } + + return activeAnnotations; +}; + +const getAnnotationStyle = (annotation: TCustomPlaylistAnnotation) => { + const style = annotation.style ?? {}; + return { + fill: typeof style.color === "string" ? style.color : "#ffffff", + fontFamily: typeof style.fontFamily === "string" ? style.fontFamily : "sans-serif", + fontSize: typeof style.fontSize === "number" ? style.fontSize : 28, + fontWeight: style.fontWeight, + opacity: typeof style.opacity === "number" ? clamp(style.opacity, 0, 1) : undefined, + stroke: normalizeStroke(style), + strokeStyle: normalizeStrokeStyle(style), + strokeWidth: normalizeStrokeWidth(style), + }; +}; + +const getAnnotationRotation = (annotation: TCustomPlaylistAnnotation) => normalizeRotation(annotation.rotation) ?? 0; + +const getTextAnnotationSize = (annotation: TCustomPlaylistAnnotation, fontSize: number) => { + const content = annotation.content?.trim() || "Text"; + + return { + height: Math.max(18, fontSize * 1.25), + width: Math.max(48, content.length * fontSize * 0.62), + }; +}; + +const getAnnotationBounds = (annotation: TCustomPlaylistAnnotation): AnnotationBounds | null => { + if (annotation.type === "pen") { + const points = annotation.points ?? []; + return points.length > 0 ? getPointBounds(points) : null; + } + + if (annotation.type === "text") { + const resolvedStyle = getAnnotationStyle(annotation); + const textSize = getTextAnnotationSize(annotation, resolvedStyle.fontSize); + + return { + height: textSize.height, + width: textSize.width, + x: annotation.x, + y: annotation.y - textSize.height, + }; + } + + const width = annotation.width ?? 0; + const height = annotation.height ?? 0; + + return { + height: Math.abs(height), + width: Math.abs(width), + x: Math.min(annotation.x, annotation.x + width), + y: Math.min(annotation.y, annotation.y + height), + }; +}; + +const getAnnotationCenter = (annotation: TCustomPlaylistAnnotation) => { + const bounds = getAnnotationBounds(annotation); + if (!bounds) return null; + + return { + x: bounds.x + bounds.width / 2, + y: bounds.y + bounds.height / 2, + }; +}; + +const getPointAngle = (point: TCustomPlaylistAnnotationPoint, center: TCustomPlaylistAnnotationPoint) => + Math.atan2(point.y - center.y, point.x - center.x); + +const rotatePointAroundCenter = ( + point: TCustomPlaylistAnnotationPoint, + center: TCustomPlaylistAnnotationPoint, + rotationDegrees: number +) => { + const rotationRadians = (rotationDegrees * Math.PI) / 180; + const cos = Math.cos(rotationRadians); + const sin = Math.sin(rotationRadians); + const offsetX = point.x - center.x; + const offsetY = point.y - center.y; + + return { + x: center.x + offsetX * cos - offsetY * sin, + y: center.y + offsetX * sin + offsetY * cos, + }; +}; + +const rotateVector = (vector: TCustomPlaylistAnnotationPoint, rotationDegrees: number) => { + const rotationRadians = (rotationDegrees * Math.PI) / 180; + const cos = Math.cos(rotationRadians); + const sin = Math.sin(rotationRadians); + + return { + x: vector.x * cos - vector.y * sin, + y: vector.x * sin + vector.y * cos, + }; +}; + +const isLinearAnnotation = (annotation: TCustomPlaylistAnnotation) => + annotation.type === "line" || annotation.type === "arrow"; + +const isAnnotationResizable = (annotation: TCustomPlaylistAnnotation) => + annotation.type !== "text" || Boolean(annotation.content?.trim()); + +const getLinearAnnotationEndpoints = (annotation: TCustomPlaylistAnnotation) => { + const start = { x: annotation.x, y: annotation.y }; + const end = { + x: annotation.x + (annotation.width ?? 0), + y: annotation.y + (annotation.height ?? 0), + }; + const rotation = getAnnotationRotation(annotation); + const center = rotation ? getAnnotationCenter(annotation) : null; + + if (!center) return { end, start }; + + return { + end: rotatePointAroundCenter(end, center, rotation), + start: rotatePointAroundCenter(start, center, rotation), + }; +}; + +const getPointToSegmentDistance = ( + point: TCustomPlaylistAnnotationPoint, + start: TCustomPlaylistAnnotationPoint, + end: TCustomPlaylistAnnotationPoint +) => { + const segmentLengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2; + if (segmentLengthSquared <= 0) return getPointDistance(point, start); + + const position = clamp( + ((point.x - start.x) * (end.x - start.x) + (point.y - start.y) * (end.y - start.y)) / segmentLengthSquared, + 0, + 1 + ); + const closestPoint = { + x: start.x + position * (end.x - start.x), + y: start.y + position * (end.y - start.y), + }; + + return getPointDistance(point, closestPoint); +}; + +const isPointNearPolyline = (point: TCustomPlaylistAnnotationPoint, points: TCustomPlaylistAnnotationPoint[]) => { + if (points.length === 0) return false; + if (points.length === 1) return getPointDistance(point, points[0]) <= 18; + + return points.some((currentPoint, index) => { + const nextPoint = points[index + 1]; + if (!nextPoint) return false; + + return getPointToSegmentDistance(point, currentPoint, nextPoint) <= 18; + }); +}; + +const getResizeHandlePoint = (bounds: AnnotationBounds, handle: AnnotationBoxResizeHandle) => { + const right = bounds.x + bounds.width; + const bottom = bounds.y + bounds.height; + + return { + x: handle.includes("w") ? bounds.x : handle.includes("e") ? right : bounds.x + bounds.width / 2, + y: handle.includes("n") ? bounds.y : handle.includes("s") ? bottom : bounds.y + bounds.height / 2, + }; +}; + +const getResizedBounds = ({ + bounds, + center, + handle, + point, + rotation, +}: { + bounds: AnnotationBounds; + center: TCustomPlaylistAnnotationPoint; + handle: AnnotationBoxResizeHandle; + point: TCustomPlaylistAnnotationPoint; + rotation: number; +}) => { + const fixedLocalPoint = getResizeHandlePoint(bounds, OPPOSITE_RESIZE_HANDLE[handle]); + const fixedWorldPoint = rotatePointAroundCenter(fixedLocalPoint, center, rotation); + const localDelta = rotateVector( + { + x: point.x - fixedWorldPoint.x, + y: point.y - fixedWorldPoint.y, + }, + -rotation + ); + const draggedLocalPoint = { + x: fixedLocalPoint.x + localDelta.x, + y: fixedLocalPoint.y + localDelta.y, + }; + let left = bounds.x; + let right = bounds.x + bounds.width; + let top = bounds.y; + let bottom = bounds.y + bounds.height; + + if (handle.includes("w")) left = clamp(draggedLocalPoint.x, 0, Math.max(0, right - MIN_RESIZE_DIMENSION)); + if (handle.includes("e")) + right = clamp(draggedLocalPoint.x, Math.min(CANVAS_SIZE, left + MIN_RESIZE_DIMENSION), CANVAS_SIZE); + if (handle.includes("n")) top = clamp(draggedLocalPoint.y, 0, Math.max(0, bottom - MIN_RESIZE_DIMENSION)); + if (handle.includes("s")) + bottom = clamp(draggedLocalPoint.y, Math.min(CANVAS_SIZE, top + MIN_RESIZE_DIMENSION), CANVAS_SIZE); + + return { + height: Math.max(0, bottom - top), + width: Math.max(0, right - left), + x: left, + y: top, + }; +}; + +const scalePointBetweenBounds = ( + point: TCustomPlaylistAnnotationPoint, + originalBounds: AnnotationBounds, + nextBounds: AnnotationBounds +) => { + const relativeX = originalBounds.width > 0 ? (point.x - originalBounds.x) / originalBounds.width : 0.5; + const relativeY = originalBounds.height > 0 ? (point.y - originalBounds.y) / originalBounds.height : 0.5; + + return { + x: clamp(nextBounds.x + nextBounds.width * relativeX, 0, CANVAS_SIZE), + y: clamp(nextBounds.y + nextBounds.height * relativeY, 0, CANVAS_SIZE), + }; +}; + +const getResizedTextAnnotation = ( + annotation: TCustomPlaylistAnnotation, + originalBounds: AnnotationBounds, + nextBounds: AnnotationBounds, + handle: AnnotationBoxResizeHandle +): TCustomPlaylistAnnotation => { + const resolvedStyle = getAnnotationStyle(annotation); + const widthScale = originalBounds.width > 0 ? nextBounds.width / originalBounds.width : 1; + const heightScale = originalBounds.height > 0 ? nextBounds.height / originalBounds.height : 1; + const scale = + handle.length === 2 ? (widthScale + heightScale) / 2 : handle === "e" || handle === "w" ? widthScale : heightScale; + const fontSize = clamp(resolvedStyle.fontSize * scale, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE); + const textSize = getTextAnnotationSize(annotation, fontSize); + const fixedPoint = getResizeHandlePoint(originalBounds, OPPOSITE_RESIZE_HANDLE[handle]); + const x = handle.includes("w") + ? fixedPoint.x - textSize.width + : handle.includes("e") + ? fixedPoint.x + : fixedPoint.x - textSize.width / 2; + const y = handle.includes("n") + ? fixedPoint.y - textSize.height + : handle.includes("s") + ? fixedPoint.y + : fixedPoint.y - textSize.height / 2; + const clampedX = clamp(x, 0, Math.max(0, CANVAS_SIZE - textSize.width)); + const clampedY = clamp(y, 0, Math.max(0, CANVAS_SIZE - textSize.height)); + + return { + ...annotation, + height: textSize.height, + style: { + ...annotation.style, + fontSize, + }, + width: textSize.width, + x: clampedX, + y: clampedY + textSize.height, + }; +}; + +const getAspectLockedResizeBounds = ( + originalBounds: AnnotationBounds, + nextBounds: AnnotationBounds, + handle: AnnotationBoxResizeHandle +): AnnotationBounds => { + const aspectRatio = + originalBounds.width > 0 && originalBounds.height > 0 ? originalBounds.width / originalBounds.height : 1; + const fixedPoint = getResizeHandlePoint(originalBounds, OPPOSITE_RESIZE_HANDLE[handle]); + let width = Math.max(MIN_RESIZE_DIMENSION, nextBounds.width); + let height = Math.max(MIN_RESIZE_DIMENSION, nextBounds.height); + + if (handle === "e" || handle === "w") { + height = width / aspectRatio; + } else if (handle === "n" || handle === "s") { + width = height * aspectRatio; + } else if (width / aspectRatio >= height) { + height = width / aspectRatio; + } else { + width = height * aspectRatio; + } + + const maxWidth = handle.includes("w") + ? fixedPoint.x + : handle.includes("e") + ? CANVAS_SIZE - fixedPoint.x + : Math.min(fixedPoint.x, CANVAS_SIZE - fixedPoint.x) * 2; + const maxHeight = handle.includes("n") + ? fixedPoint.y + : handle.includes("s") + ? CANVAS_SIZE - fixedPoint.y + : Math.min(fixedPoint.y, CANVAS_SIZE - fixedPoint.y) * 2; + const fitScale = Math.min(maxWidth / Math.max(width, 1), maxHeight / Math.max(height, 1), 1); + width = Math.max(MIN_RESIZE_DIMENSION, width * fitScale); + height = Math.max(MIN_RESIZE_DIMENSION, height * fitScale); + + return { + height, + width, + x: handle.includes("w") ? fixedPoint.x - width : handle.includes("e") ? fixedPoint.x : fixedPoint.x - width / 2, + y: handle.includes("n") ? fixedPoint.y - height : handle.includes("s") ? fixedPoint.y : fixedPoint.y - height / 2, + }; +}; + +const resizeAnnotation = ( + annotation: TCustomPlaylistAnnotation, + originalBounds: AnnotationBounds, + center: TCustomPlaylistAnnotationPoint, + rotation: number, + handle: AnnotationResizeHandle, + point: TCustomPlaylistAnnotationPoint +): TCustomPlaylistAnnotation => { + if (isLinearAnnotation(annotation)) { + const endpoints = getLinearAnnotationEndpoints(annotation); + const nextStart = handle === "start" ? point : endpoints.start; + const nextEnd = handle === "end" ? point : endpoints.end; + if (getPointDistance(nextStart, nextEnd) < MIN_RESIZE_DIMENSION) return annotation; + + return { + ...annotation, + height: nextEnd.y - nextStart.y, + rotation: undefined, + width: nextEnd.x - nextStart.x, + x: nextStart.x, + y: nextStart.y, + }; + } + + if (!isBoxResizeHandle(handle)) return annotation; + + const nextBounds = getResizedBounds({ bounds: originalBounds, center, handle, point, rotation }); + + if (annotation.type === "text") { + return getResizedTextAnnotation(annotation, originalBounds, nextBounds, handle); + } + + if (annotation.type === "pen") { + const nextPoints = (annotation.points ?? []).map((annotationPoint) => + scalePointBetweenBounds(annotationPoint, originalBounds, nextBounds) + ); + const nextPointBounds = getPointBounds(nextPoints); + + return { + ...annotation, + ...nextPointBounds, + points: nextPoints, + }; + } + + if (annotation.type === "image") { + const nextImageBounds = getAspectLockedResizeBounds(originalBounds, nextBounds, handle); + + return normalizeAnnotationBox({ + ...annotation, + height: nextImageBounds.height, + width: nextImageBounds.width, + x: nextImageBounds.x, + y: nextImageBounds.y, + }); + } + + return normalizeAnnotationBox({ + ...annotation, + height: nextBounds.height, + width: nextBounds.width, + x: nextBounds.x, + y: nextBounds.y, + }); +}; + +const isPointInAnnotation = (point: TCustomPlaylistAnnotationPoint, annotation: TCustomPlaylistAnnotation) => { + if (isLinearAnnotation(annotation)) { + const endpoints = getLinearAnnotationEndpoints(annotation); + return getPointToSegmentDistance(point, endpoints.start, endpoints.end) <= 18; + } + + const bounds = getAnnotationBounds(annotation); + const center = getAnnotationCenter(annotation); + if (!bounds || !center) return false; + + const unrotatedPoint = rotatePointAroundCenter(point, center, -getAnnotationRotation(annotation)); + const hitPadding = annotation.type === "line" || annotation.type === "arrow" || annotation.type === "pen" ? 18 : 10; + + return ( + unrotatedPoint.x >= bounds.x - hitPadding && + unrotatedPoint.x <= bounds.x + bounds.width + hitPadding && + unrotatedPoint.y >= bounds.y - hitPadding && + unrotatedPoint.y <= bounds.y + bounds.height + hitPadding + ); +}; + +const isPointOnAnnotationEdge = (point: TCustomPlaylistAnnotationPoint, annotation: TCustomPlaylistAnnotation) => { + if (isLinearAnnotation(annotation)) { + const endpoints = getLinearAnnotationEndpoints(annotation); + return getPointToSegmentDistance(point, endpoints.start, endpoints.end) <= 18; + } + + if (annotation.type === "pen") { + return isPointNearPolyline(point, annotation.points ?? []); + } + + const bounds = getAnnotationBounds(annotation); + const center = getAnnotationCenter(annotation); + if (!bounds || !center) return false; + + const hitPadding = annotation.type === "text" ? 12 : 18; + const unrotatedPoint = rotatePointAroundCenter(point, center, -getAnnotationRotation(annotation)); + const isInsidePaddedBounds = + unrotatedPoint.x >= bounds.x - hitPadding && + unrotatedPoint.x <= bounds.x + bounds.width + hitPadding && + unrotatedPoint.y >= bounds.y - hitPadding && + unrotatedPoint.y <= bounds.y + bounds.height + hitPadding; + if (!isInsidePaddedBounds) return false; + + if (annotation.type === "ellipse") { + const radiusX = bounds.width / 2; + const radiusY = bounds.height / 2; + if (radiusX <= 0 || radiusY <= 0) return false; + + const localX = unrotatedPoint.x - (bounds.x + radiusX); + const localY = unrotatedPoint.y - (bounds.y + radiusY); + const ellipseAngle = Math.atan2(localY / radiusY, localX / radiusX); + const edgePoint = { + x: radiusX * Math.cos(ellipseAngle), + y: radiusY * Math.sin(ellipseAngle), + }; + + return getPointDistance({ x: localX, y: localY }, edgePoint) <= hitPadding; + } + + const right = bounds.x + bounds.width; + const bottom = bounds.y + bounds.height; + const distanceToBoxEdge = Math.min( + Math.abs(unrotatedPoint.x - bounds.x), + Math.abs(unrotatedPoint.x - right), + Math.abs(unrotatedPoint.y - bounds.y), + Math.abs(unrotatedPoint.y - bottom) + ); + + return distanceToBoxEdge <= hitPadding; +}; + +const moveAnnotation = ( + annotation: TCustomPlaylistAnnotation, + deltaX: number, + deltaY: number +): TCustomPlaylistAnnotation => { + const bounds = getAnnotationBounds(annotation); + if (!bounds) return annotation; + + const clampedDeltaX = clamp(deltaX, -bounds.x, CANVAS_SIZE - (bounds.x + bounds.width)); + const clampedDeltaY = clamp(deltaY, -bounds.y, CANVAS_SIZE - (bounds.y + bounds.height)); + + return { + ...annotation, + points: annotation.points?.map((point) => ({ + x: clamp(point.x + clampedDeltaX, 0, CANVAS_SIZE), + y: clamp(point.y + clampedDeltaY, 0, CANVAS_SIZE), + })), + x: clamp(annotation.x + clampedDeltaX, 0, CANVAS_SIZE), + y: clamp(annotation.y + clampedDeltaY, 0, CANVAS_SIZE), + }; +}; + +export { + CANVAS_SIZE, + MAX_ARROW_HEAD_LENGTH, + MAX_POINT_COUNT, + MIN_ARROW_HEAD_LENGTH, + MIN_POINT_DISTANCE, + clamp, + getAnnotationBounds, + getAnnotationCenter, + getAnnotationRotation, + getAnnotationStyle, + getLinearAnnotationEndpoints, + getPointAngle, + getPointBounds, + getPointDistance, + getStrokeLineDash, + isAnnotationResizable, + isAnnotationValid, + isLinearAnnotation, + isPointInAnnotation, + isPointOnAnnotationEdge, + moveAnnotation, + normalizeAnnotationBox, + normalizeRotation, + resizeAnnotation, +}; diff --git a/apps/web/core/components/annotation/utils/playlist-annotation-rendering.ts b/apps/web/core/components/annotation/utils/playlist-annotation-rendering.ts new file mode 100644 index 00000000000..55344bd43e5 --- /dev/null +++ b/apps/web/core/components/annotation/utils/playlist-annotation-rendering.ts @@ -0,0 +1,297 @@ +import type { TCustomPlaylistAnnotation, TCustomPlaylistAnnotationPoint } from "../types/annotation.types"; +import type { CanvasSize, OverlayBounds } from "../types/playlist-annotation-overlay.types"; +import { + CANVAS_SIZE, + MAX_ARROW_HEAD_LENGTH, + MIN_ARROW_HEAD_LENGTH, + clamp, + getAnnotationCenter, + getAnnotationRotation, + getAnnotationStyle, + getStrokeLineDash, +} from "./playlist-annotation-model"; + +const toCanvasX = (value: number, size: CanvasSize) => (value / CANVAS_SIZE) * size.width; + +const toCanvasY = (value: number, size: CanvasSize) => (value / CANVAS_SIZE) * size.height; + +const toCanvasPoint = (point: TCustomPlaylistAnnotationPoint, size: CanvasSize) => ({ + x: toCanvasX(point.x, size), + y: toCanvasY(point.y, size), +}); + +const toCanvasWidth = (value: number, size: CanvasSize) => (value / CANVAS_SIZE) * size.width; + +const toCanvasHeight = (value: number, size: CanvasSize) => (value / CANVAS_SIZE) * size.height; + +export const getFittedVideoBounds = (root: HTMLElement): OverlayBounds | null => { + const host = root.parentElement; + const video = host?.querySelector<HTMLVideoElement>("video"); + if (!host || !video) return null; + + const hostRect = host.getBoundingClientRect(); + const videoRect = video.getBoundingClientRect(); + if (hostRect.width <= 0 || hostRect.height <= 0 || videoRect.width <= 0 || videoRect.height <= 0) return null; + + let left = videoRect.left - hostRect.left; + let top = videoRect.top - hostRect.top; + let width = videoRect.width; + let height = videoRect.height; + const videoAspectRatio = video.videoWidth > 0 && video.videoHeight > 0 ? video.videoWidth / video.videoHeight : 0; + const objectFit = typeof window !== "undefined" ? window.getComputedStyle(video).objectFit : ""; + + if (videoAspectRatio > 0 && objectFit !== "fill") { + const elementAspectRatio = videoRect.width / videoRect.height; + + if (objectFit === "cover") { + if (elementAspectRatio > videoAspectRatio) { + height = videoRect.width / videoAspectRatio; + top = videoRect.top - hostRect.top - (height - videoRect.height) / 2; + } else { + width = videoRect.height * videoAspectRatio; + left = videoRect.left - hostRect.left - (width - videoRect.width) / 2; + } + } else if (elementAspectRatio > videoAspectRatio) { + width = videoRect.height * videoAspectRatio; + left = videoRect.left - hostRect.left + (videoRect.width - width) / 2; + } else { + height = videoRect.width / videoAspectRatio; + top = videoRect.top - hostRect.top + (videoRect.height - height) / 2; + } + } + + return { + height: Math.max(1, height), + left, + top, + width: Math.max(1, width), + }; +}; + +export const areOverlayBoundsEqual = (firstBounds: OverlayBounds | null, secondBounds: OverlayBounds | null) => { + if (firstBounds === secondBounds) return true; + if (!firstBounds || !secondBounds) return false; + + return ( + Math.abs(firstBounds.left - secondBounds.left) < 0.5 && + Math.abs(firstBounds.top - secondBounds.top) < 0.5 && + Math.abs(firstBounds.width - secondBounds.width) < 0.5 && + Math.abs(firstBounds.height - secondBounds.height) < 0.5 + ); +}; + +const drawRoundedRect = ( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number +) => { + const resolvedRadius = Math.min(radius, Math.abs(width) / 2, Math.abs(height) / 2); + + context.beginPath(); + context.moveTo(x + resolvedRadius, y); + context.lineTo(x + width - resolvedRadius, y); + context.quadraticCurveTo(x + width, y, x + width, y + resolvedRadius); + context.lineTo(x + width, y + height - resolvedRadius); + context.quadraticCurveTo(x + width, y + height, x + width - resolvedRadius, y + height); + context.lineTo(x + resolvedRadius, y + height); + context.quadraticCurveTo(x, y + height, x, y + height - resolvedRadius); + context.lineTo(x, y + resolvedRadius); + context.quadraticCurveTo(x, y, x + resolvedRadius, y); + context.closePath(); +}; + +const drawArrow = ( + context: CanvasRenderingContext2D, + startX: number, + startY: number, + endX: number, + endY: number, + strokeWidth: number +) => { + const length = Math.hypot(endX - startX, endY - startY); + if (length <= 0) return; + + const directionX = (endX - startX) / length; + const directionY = (endY - startY) / length; + const headLength = Math.min( + clamp(strokeWidth * 4.6, MIN_ARROW_HEAD_LENGTH, MAX_ARROW_HEAD_LENGTH), + Math.max(MIN_ARROW_HEAD_LENGTH * 0.75, length * 0.45) + ); + const headWidth = clamp(strokeWidth * 3.6, strokeWidth + 8, headLength * 0.9); + const baseX = endX - directionX * headLength; + const baseY = endY - directionY * headLength; + const normalX = -directionY; + const normalY = directionX; + + context.beginPath(); + context.moveTo(startX, startY); + context.lineTo(baseX, baseY); + context.stroke(); + + context.beginPath(); + context.moveTo(endX, endY); + context.lineTo(baseX + normalX * (headWidth / 2), baseY + normalY * (headWidth / 2)); + context.lineTo(baseX - normalX * (headWidth / 2), baseY - normalY * (headWidth / 2)); + context.closePath(); + context.fill(); +}; + +const drawImageAnnotation = ({ + annotation, + context, + imageCache, + onImageLoad, + size, +}: { + annotation: TCustomPlaylistAnnotation; + context: CanvasRenderingContext2D; + imageCache: Map<string, HTMLImageElement>; + onImageLoad: () => void; + size: CanvasSize; +}) => { + if (!annotation.content?.trim()) return; + + let image = imageCache.get(annotation.content); + if (!image) { + image = new Image(); + image.onload = onImageLoad; + image.onerror = onImageLoad; + image.src = annotation.content; + imageCache.set(annotation.content, image); + } + + if (!image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0) return; + + context.drawImage( + image, + toCanvasX(annotation.x, size), + toCanvasY(annotation.y, size), + toCanvasWidth(annotation.width || 120, size), + toCanvasHeight(annotation.height || 120, size) + ); +}; + +export const drawCanvasAnnotation = ({ + annotation, + context, + imageCache, + isDraft = false, + onImageLoad, + size, +}: { + annotation: TCustomPlaylistAnnotation; + context: CanvasRenderingContext2D; + imageCache: Map<string, HTMLImageElement>; + isDraft?: boolean; + onImageLoad: () => void; + size: CanvasSize; +}) => { + const resolvedStyle = getAnnotationStyle(annotation); + const rotation = getAnnotationRotation(annotation); + const center = rotation ? getAnnotationCenter(annotation) : null; + + context.save(); + if (center) { + context.translate(toCanvasX(center.x, size), toCanvasY(center.y, size)); + context.rotate((rotation * Math.PI) / 180); + context.translate(-toCanvasX(center.x, size), -toCanvasY(center.y, size)); + } + + context.globalAlpha = (resolvedStyle.opacity ?? 1) * (isDraft ? 0.7 : 1); + context.strokeStyle = resolvedStyle.stroke; + context.fillStyle = resolvedStyle.stroke; + context.lineCap = "round"; + context.lineJoin = "round"; + context.lineWidth = resolvedStyle.strokeWidth; + context.setLineDash(getStrokeLineDash(resolvedStyle.strokeStyle, resolvedStyle.strokeWidth)); + + if (annotation.type === "pen") { + const points = annotation.points ?? []; + if (points.length < 2) { + context.restore(); + return; + } + + const firstPoint = toCanvasPoint(points[0], size); + context.beginPath(); + context.moveTo(firstPoint.x, firstPoint.y); + points.slice(1).forEach((point) => { + const nextPoint = toCanvasPoint(point, size); + context.lineTo(nextPoint.x, nextPoint.y); + }); + context.stroke(); + context.restore(); + return; + } + + if (annotation.type === "text") { + const fontSize = Math.max(8, toCanvasHeight(resolvedStyle.fontSize, size)); + const fontWeight = resolvedStyle.fontWeight ? `${resolvedStyle.fontWeight} ` : ""; + context.fillStyle = resolvedStyle.fill; + context.font = `${fontWeight}${fontSize}px ${resolvedStyle.fontFamily}`; + context.textBaseline = "alphabetic"; + context.fillText(annotation.content ?? "", toCanvasX(annotation.x, size), toCanvasY(annotation.y, size)); + context.restore(); + return; + } + + if (annotation.type === "image") { + drawImageAnnotation({ annotation, context, imageCache, onImageLoad, size }); + context.restore(); + return; + } + + if (annotation.type === "rectangle") { + drawRoundedRect( + context, + toCanvasX(annotation.x, size), + toCanvasY(annotation.y, size), + toCanvasWidth(annotation.width ?? 0, size), + toCanvasHeight(annotation.height ?? 0, size), + 6 + ); + context.stroke(); + context.restore(); + return; + } + + if (annotation.type === "ellipse") { + const width = toCanvasWidth(annotation.width ?? 0, size); + const height = toCanvasHeight(annotation.height ?? 0, size); + + context.beginPath(); + context.ellipse( + toCanvasX(annotation.x, size) + width / 2, + toCanvasY(annotation.y, size) + height / 2, + Math.abs(width) / 2, + Math.abs(height) / 2, + 0, + 0, + Math.PI * 2 + ); + context.stroke(); + context.restore(); + return; + } + + const startX = toCanvasX(annotation.x, size); + const startY = toCanvasY(annotation.y, size); + const endX = toCanvasX(annotation.x + (annotation.width ?? 0), size); + const endY = toCanvasY(annotation.y + (annotation.height ?? 0), size); + + if (annotation.type === "arrow") { + drawArrow(context, startX, startY, endX, endY, resolvedStyle.strokeWidth); + context.restore(); + return; + } + + context.beginPath(); + context.moveTo(startX, startY); + context.lineTo(endX, endY); + context.stroke(); + + context.restore(); +}; diff --git a/apps/web/core/components/annotation/utils/playlist-annotation-transform.ts b/apps/web/core/components/annotation/utils/playlist-annotation-transform.ts new file mode 100644 index 00000000000..0b7b0819c6a --- /dev/null +++ b/apps/web/core/components/annotation/utils/playlist-annotation-transform.ts @@ -0,0 +1,70 @@ +import type { + AnnotationBoxResizeHandle, + AnnotationResizeHandle, + AnnotationResizeHandleOption, +} from "../types/playlist-annotation-overlay.types"; + +export const ANNOTATION_RESIZE_HANDLES: AnnotationResizeHandleOption[] = [ + { + className: "left-0 top-0 -translate-x-1/2 -translate-y-1/2", + cursorClassName: "cursor-nwse-resize", + handle: "nw", + label: "top left", + }, + { + className: "left-1/2 top-0 -translate-x-1/2 -translate-y-1/2", + cursorClassName: "cursor-ns-resize", + handle: "n", + label: "top", + }, + { + className: "right-0 top-0 -translate-y-1/2 translate-x-1/2", + cursorClassName: "cursor-nesw-resize", + handle: "ne", + label: "top right", + }, + { + className: "right-0 top-1/2 -translate-y-1/2 translate-x-1/2", + cursorClassName: "cursor-ew-resize", + handle: "e", + label: "right", + }, + { + className: "bottom-0 right-0 translate-x-1/2 translate-y-1/2", + cursorClassName: "cursor-nwse-resize", + handle: "se", + label: "bottom right", + }, + { + className: "bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2", + cursorClassName: "cursor-ns-resize", + handle: "s", + label: "bottom", + }, + { + className: "bottom-0 left-0 -translate-x-1/2 translate-y-1/2", + cursorClassName: "cursor-nesw-resize", + handle: "sw", + label: "bottom left", + }, + { + className: "left-0 top-1/2 -translate-x-1/2 -translate-y-1/2", + cursorClassName: "cursor-ew-resize", + handle: "w", + label: "left", + }, +]; + +export const OPPOSITE_RESIZE_HANDLE: Record<AnnotationBoxResizeHandle, AnnotationBoxResizeHandle> = { + e: "w", + n: "s", + ne: "sw", + nw: "se", + s: "n", + se: "nw", + sw: "ne", + w: "e", +}; + +export const isBoxResizeHandle = (handle: AnnotationResizeHandle): handle is AnnotationBoxResizeHandle => + handle !== "start" && handle !== "end"; diff --git a/apps/web/core/components/annotation/utils/video-annotation-colors.ts b/apps/web/core/components/annotation/utils/video-annotation-colors.ts new file mode 100644 index 00000000000..484517a07f8 --- /dev/null +++ b/apps/web/core/components/annotation/utils/video-annotation-colors.ts @@ -0,0 +1,141 @@ +import type { TCustomPlaylistAnnotation } from "../types/annotation.types"; +import { DEFAULT_VIDEO_ANNOTATION_COLOR } from "./video-annotation-editor-config"; +import { clampTimelineValue } from "./video-annotation-timeline"; + +const getAnnotationColor = (annotation: TCustomPlaylistAnnotation) => { + const style = annotation.style ?? {}; + return typeof style.stroke === "string" ? style.stroke : typeof style.color === "string" ? style.color : "#f97316"; +}; + +const normalizeAnnotationHexColor = (value: string) => { + const trimmedValue = value.trim(); + const prefixedValue = trimmedValue.startsWith("#") ? trimmedValue : `#${trimmedValue}`; + + return /^#[0-9a-fA-F]{6}$/.test(prefixedValue) ? prefixedValue.toLowerCase() : null; +}; + +const getRgbFromHexColor = (colorValue: string) => { + const normalizedColor = normalizeAnnotationHexColor(colorValue) ?? DEFAULT_VIDEO_ANNOTATION_COLOR; + const colorNumber = Number.parseInt(normalizedColor.slice(1), 16); + + return { + blue: colorNumber & 255, + green: (colorNumber >> 8) & 255, + red: (colorNumber >> 16) & 255, + }; +}; + +const getHexColorFromRgb = (red: number, green: number, blue: number) => { + const toHexChannel = (channelValue: number) => + Math.round(clampTimelineValue(channelValue, 0, 255)) + .toString(16) + .padStart(2, "0"); + + return `#${toHexChannel(red)}${toHexChannel(green)}${toHexChannel(blue)}`; +}; + +const getHsvFromRgb = (red: number, green: number, blue: number) => { + const normalizedRed = clampTimelineValue(red, 0, 255) / 255; + const normalizedGreen = clampTimelineValue(green, 0, 255) / 255; + const normalizedBlue = clampTimelineValue(blue, 0, 255) / 255; + const maxChannel = Math.max(normalizedRed, normalizedGreen, normalizedBlue); + const minChannel = Math.min(normalizedRed, normalizedGreen, normalizedBlue); + const delta = maxChannel - minChannel; + let hue = 0; + + if (delta > 0) { + if (maxChannel === normalizedRed) { + hue = 60 * (((normalizedGreen - normalizedBlue) / delta) % 6); + } else if (maxChannel === normalizedGreen) { + hue = 60 * ((normalizedBlue - normalizedRed) / delta + 2); + } else { + hue = 60 * ((normalizedRed - normalizedGreen) / delta + 4); + } + } + + return { + hue: hue < 0 ? hue + 360 : hue, + saturation: maxChannel === 0 ? 0 : delta / maxChannel, + value: maxChannel, + }; +}; + +const getRgbFromHsv = (hue: number, saturation: number, value: number) => { + const normalizedHue = ((hue % 360) + 360) % 360; + const normalizedSaturation = clampTimelineValue(saturation, 0, 1); + const normalizedValue = clampTimelineValue(value, 0, 1); + const chroma = normalizedValue * normalizedSaturation; + const huePrime = normalizedHue / 60; + const x = chroma * (1 - Math.abs((huePrime % 2) - 1)); + const match = normalizedValue - chroma; + let red = 0; + let green = 0; + let blue = 0; + + if (huePrime >= 0 && huePrime < 1) { + red = chroma; + green = x; + } else if (huePrime < 2) { + red = x; + green = chroma; + } else if (huePrime < 3) { + green = chroma; + blue = x; + } else if (huePrime < 4) { + green = x; + blue = chroma; + } else if (huePrime < 5) { + red = x; + blue = chroma; + } else { + red = chroma; + blue = x; + } + + return { + blue: Math.round((blue + match) * 255), + green: Math.round((green + match) * 255), + red: Math.round((red + match) * 255), + }; +}; + +const getHexColorFromHsv = (hue: number, saturation: number, value: number) => { + const rgbColor = getRgbFromHsv(hue, saturation, value); + return getHexColorFromRgb(rgbColor.red, rgbColor.green, rgbColor.blue); +}; + +const getTimelineColorWithAlpha = (color: string, alpha: number) => { + const normalizedColor = color.trim(); + const hexMatch = normalizedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + const hexValue = + hexMatch[1].length === 3 + ? hexMatch[1] + .split("") + .map((character) => `${character}${character}`) + .join("") + : hexMatch[1]; + const red = parseInt(hexValue.slice(0, 2), 16); + const green = parseInt(hexValue.slice(2, 4), 16); + const blue = parseInt(hexValue.slice(4, 6), 16); + + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + const rgbMatch = normalizedColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i); + if (rgbMatch) { + return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${alpha})`; + } + + return normalizedColor; +}; + +export { + getAnnotationColor, + getHexColorFromHsv, + getHexColorFromRgb, + getHsvFromRgb, + getRgbFromHexColor, + getTimelineColorWithAlpha, + normalizeAnnotationHexColor, +}; diff --git a/apps/web/core/components/annotation/utils/video-annotation-editor-config.ts b/apps/web/core/components/annotation/utils/video-annotation-editor-config.ts new file mode 100644 index 00000000000..c6d2b852c2b --- /dev/null +++ b/apps/web/core/components/annotation/utils/video-annotation-editor-config.ts @@ -0,0 +1,76 @@ +import { ArrowUpRight, Circle, Image as ImageIcon, Minus, Pencil, Square, Type } from "lucide-react"; +import type { TCustomPlaylistAnnotationStrokeStyle, TCustomPlaylistAnnotationTool } from "../types/annotation.types"; +import { VIDEO_ANNOTATION_START_TIME_OFFSET_SECONDS } from "./playlist-annotation-creation-time"; + +const DEFAULT_VIDEO_ANNOTATION_COLOR = "#f97316"; +const VIDEO_ANNOTATION_COLOR_PRESETS = [ + "#f97316", + "#ef4444", + "#eab308", + "#22c55e", + "#38bdf8", + "#6366f1", + "#a855f7", + "#ffffff", + "#111827", +] as const; +const MAX_VIDEO_ANNOTATION_IMAGE_BYTES = 2 * 1024 * 1024; +const VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS = { + max: 600, + min: 40, +}; +const VIDEO_ANNOTATION_DURATIONS = [1, 2, 4, 8]; +const VIDEO_ANNOTATION_STROKE_WIDTHS = [3, 5, 8]; +const VIDEO_ANNOTATION_STROKE_STYLES: { label: string; value: TCustomPlaylistAnnotationStrokeStyle }[] = [ + { label: "Solid", value: "solid" }, + { label: "Dotted", value: "dotted" }, +]; +const VIDEO_ANNOTATION_TEXT_FONT_SIZES = [20, 28, 36, 48]; +const VIDEO_ANNOTATION_TEXT_FONT_WEIGHTS = [ + { label: "Regular", value: 400 }, + { label: "Bold", value: 700 }, +] as const; +const VIDEO_ANNOTATION_TEXT_FONT_FAMILIES = [ + { label: "Sans", value: "sans-serif" }, + { label: "Serif", value: "serif" }, + { label: "Mono", value: "monospace" }, +] as const; +const VIDEO_ANNOTATION_TIMELINE_ZOOM_STEPS = [50, 75, 100, 150, 200, 300]; +const VIDEO_ANNOTATION_TIMELINE_DEFAULT_ZOOM_PERCENT = 100; +const VIDEO_ANNOTATION_TIMELINE_CLIP_MIN_WIDTH_PX = 56; +const VIDEO_ANNOTATION_TIMELINE_CLIP_GAP_PX = 8; +const VIDEO_ANNOTATION_TIMELINE_MOMENT_COLUMN_WIDTH_PX = 236; +const VIDEO_ANNOTATION_TIMELINE_MIN_DURATION_SECONDS = 0.1; +const VIDEO_ANNOTATION_TOOL_BUTTON_CLASS = + "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[5px] border border-custom-border-200 bg-custom-background-100 text-custom-text-200 transition-colors hover:bg-custom-background-80 hover:text-custom-text-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100/40 disabled:cursor-not-allowed disabled:opacity-45"; +const VIDEO_ANNOTATION_TOOLS = [ + { icon: Pencil, label: "Freehand draw", type: "pen" }, + { icon: Type, label: "Text", type: "text" }, + { icon: Square, label: "Rectangle", type: "rectangle" }, + { icon: Circle, label: "Ellipse", type: "ellipse" }, + { icon: Minus, label: "Line", type: "line" }, + { icon: ArrowUpRight, label: "Arrow", type: "arrow" }, + { icon: ImageIcon, label: "Image", type: "image" }, +] satisfies Array<{ icon: typeof Pencil; label: string; type: TCustomPlaylistAnnotationTool }>; + +export { + DEFAULT_VIDEO_ANNOTATION_COLOR, + MAX_VIDEO_ANNOTATION_IMAGE_BYTES, + VIDEO_ANNOTATION_COLOR_PRESETS, + VIDEO_ANNOTATION_DURATIONS, + VIDEO_ANNOTATION_IMAGE_SIZE_LIMITS, + VIDEO_ANNOTATION_START_TIME_OFFSET_SECONDS, + VIDEO_ANNOTATION_STROKE_STYLES, + VIDEO_ANNOTATION_STROKE_WIDTHS, + VIDEO_ANNOTATION_TEXT_FONT_FAMILIES, + VIDEO_ANNOTATION_TEXT_FONT_SIZES, + VIDEO_ANNOTATION_TEXT_FONT_WEIGHTS, + VIDEO_ANNOTATION_TIMELINE_CLIP_GAP_PX, + VIDEO_ANNOTATION_TIMELINE_CLIP_MIN_WIDTH_PX, + VIDEO_ANNOTATION_TIMELINE_DEFAULT_ZOOM_PERCENT, + VIDEO_ANNOTATION_TIMELINE_MIN_DURATION_SECONDS, + VIDEO_ANNOTATION_TIMELINE_MOMENT_COLUMN_WIDTH_PX, + VIDEO_ANNOTATION_TIMELINE_ZOOM_STEPS, + VIDEO_ANNOTATION_TOOL_BUTTON_CLASS, + VIDEO_ANNOTATION_TOOLS, +}; diff --git a/apps/web/core/components/annotation/utils/video-annotation-timeline.ts b/apps/web/core/components/annotation/utils/video-annotation-timeline.ts new file mode 100644 index 00000000000..7dd6bfd24fe --- /dev/null +++ b/apps/web/core/components/annotation/utils/video-annotation-timeline.ts @@ -0,0 +1,274 @@ +import { ArrowUpRight, Circle, Image as ImageIcon, Minus, Pencil, Square, Type } from "lucide-react"; +import type { TCustomPlaylistAnnotation, TCustomPlaylistAnnotationTool } from "../types/annotation.types"; + +const formatAnnotationTime = (seconds: number) => { + if (!Number.isFinite(seconds) || seconds < 0) return "--:--"; + + const totalSeconds = Math.floor(seconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const remainingSeconds = totalSeconds % 60; + + if (hours > 0) { + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; + } + + return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; +}; + +const clampTimelineValue = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const getTimelineDuration = ( + durationSeconds: number | null | undefined, + annotations: TCustomPlaylistAnnotation[], + currentTime: number +) => { + const normalizedDuration = Number(durationSeconds); + const annotationEndSeconds = annotations.reduce( + (maxSeconds, annotation) => Math.max(maxSeconds, annotation.startTime, annotation.endTime), + 0 + ); + const fallbackDuration = Math.max(annotationEndSeconds, currentTime, 1); + + return Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : fallbackDuration; +}; + +const getTimelinePercent = (seconds: number, durationSeconds: number) => { + if (!Number.isFinite(seconds) || !Number.isFinite(durationSeconds) || durationSeconds <= 0) return 0; + + return clampTimelineValue((seconds / durationSeconds) * 100, 0, 100); +}; + +const getAnnotationTimelineToolLabel = (type: TCustomPlaylistAnnotationTool) => { + if (type === "arrow") return "Arrow"; + if (type === "ellipse") return "Oval"; + if (type === "image") return "Image"; + if (type === "line") return "Line"; + if (type === "pen") return "Draw"; + if (type === "rectangle") return "Rect"; + if (type === "text") return "Text"; + + return "Annotation"; +}; + +const getAnnotationTimelineLabel = (annotation: TCustomPlaylistAnnotation, index: number) => { + if (annotation.type === "image") return annotation.title?.trim() || `Image ${index + 1}`; + if (annotation.content?.trim()) return annotation.content.trim(); + + if (annotation.type === "pen") return `Draw ${index + 1}`; + if (annotation.type === "rectangle") return `Box ${index + 1}`; + if (annotation.type === "ellipse") return `Circle ${index + 1}`; + if (annotation.type === "arrow") return `Arrow ${index + 1}`; + if (annotation.type === "line") return `Line ${index + 1}`; + + return `Annotation ${index + 1}`; +}; + +const getAnnotationTimelineIcon = (annotation: TCustomPlaylistAnnotation) => { + if (annotation.type === "arrow") return ArrowUpRight; + if (annotation.type === "ellipse") return Circle; + if (annotation.type === "image") return ImageIcon; + if (annotation.type === "line") return Minus; + if (annotation.type === "pen") return Pencil; + if (annotation.type === "rectangle") return Square; + if (annotation.type === "text") return Type; + + return Pencil; +}; + +const getAnnotationTimelineMomentTitle = (annotation: TCustomPlaylistAnnotation) => + annotation.title?.trim() || (annotation.type === "image" ? "Image moment" : annotation.content?.trim()); + +type AnnotationTimelineMomentItem = { + annotation: TCustomPlaylistAnnotation; + index: number; +}; + +export type AnnotationTimelineMoment = { + annotations: AnnotationTimelineMomentItem[]; + id: string; + startTime: number; + title: string; +}; + +export type AnnotationTimelineResizeState = { + annotationId: string; + hasMoved: boolean; + originalEndTime: number; + pointerId: number; + startClientX: number; + startTime: number; +}; + +const getAnnotationMomentKey = (seconds: number) => (Math.round(seconds * 10) / 10).toFixed(1); + +const buildAnnotationTimelineMoments = (annotations: TCustomPlaylistAnnotation[]): AnnotationTimelineMoment[] => { + const momentMap = new Map<string, AnnotationTimelineMoment>(); + + annotations.forEach((annotation, index) => { + const momentKey = getAnnotationMomentKey(annotation.startTime); + const momentStartTime = Number(momentKey); + const existingMoment = momentMap.get(momentKey); + + if (existingMoment) { + existingMoment.annotations.push({ annotation, index }); + return; + } + + momentMap.set(momentKey, { + annotations: [{ annotation, index }], + id: `moment-${momentKey}`, + startTime: momentStartTime, + title: + getAnnotationTimelineMomentTitle(annotation) || `${getAnnotationTimelineToolLabel(annotation.type)} moment`, + }); + }); + + return [...momentMap.values()] + .map((moment) => ({ + ...moment, + annotations: [...moment.annotations].sort((firstItem, secondItem) => { + const firstTrackIndex = getAnnotationTrackIndex(firstItem.annotation); + const secondTrackIndex = getAnnotationTrackIndex(secondItem.annotation); + + if (firstTrackIndex !== null && secondTrackIndex !== null && firstTrackIndex !== secondTrackIndex) { + return firstTrackIndex - secondTrackIndex; + } + + if (firstTrackIndex !== null && secondTrackIndex === null) return -1; + if (firstTrackIndex === null && secondTrackIndex !== null) return 1; + + return firstItem.index - secondItem.index; + }), + })) + .sort((firstMoment, secondMoment) => firstMoment.startTime - secondMoment.startTime); +}; + +const getAnnotationTrackIndex = (annotation: TCustomPlaylistAnnotation) => { + const trackIndex = Number(annotation.trackIndex); + + return Number.isInteger(trackIndex) && trackIndex >= 0 ? trackIndex : null; +}; + +const getAnnotationCollisionEndTime = (annotation: TCustomPlaylistAnnotation, minimumVisibleDurationSeconds: number) => + Math.max(annotation.endTime, annotation.startTime + minimumVisibleDurationSeconds); + +const doAnnotationTimeRangesOverlap = ( + firstAnnotation: TCustomPlaylistAnnotation, + secondAnnotation: TCustomPlaylistAnnotation, + minimumVisibleDurationSeconds = 0 +) => + firstAnnotation.startTime < getAnnotationCollisionEndTime(secondAnnotation, minimumVisibleDurationSeconds) && + getAnnotationCollisionEndTime(firstAnnotation, minimumVisibleDurationSeconds) > secondAnnotation.startTime; + +const canPlaceAnnotationInTimelineLane = ( + laneAnnotations: TCustomPlaylistAnnotation[] | undefined, + annotation: TCustomPlaylistAnnotation, + minimumVisibleDurationSeconds = 0 +) => + (laneAnnotations ?? []).every( + (laneAnnotation) => + laneAnnotation.id === annotation.id || + !doAnnotationTimeRangesOverlap(laneAnnotation, annotation, minimumVisibleDurationSeconds) + ); + +const resolveAnnotationTimelineLayers = ( + annotations: TCustomPlaylistAnnotation[], + priorityAnnotationId?: string, + minimumVisibleDurationSeconds = 0 +): TCustomPlaylistAnnotation[] => { + const originalAnnotationIndexes = new Map(annotations.map((annotation, index) => [annotation.id, index])); + const lanes: TCustomPlaylistAnnotation[][] = []; + const prioritizedAnnotation = priorityAnnotationId + ? annotations.find((annotation) => annotation.id === priorityAnnotationId) + : undefined; + const remainingAnnotations = annotations + .filter((annotation) => annotation.id !== priorityAnnotationId) + .sort((firstAnnotation, secondAnnotation) => { + const firstTrackIndex = getAnnotationTrackIndex(firstAnnotation) ?? 0; + const secondTrackIndex = getAnnotationTrackIndex(secondAnnotation) ?? 0; + + return ( + firstTrackIndex - secondTrackIndex || + firstAnnotation.startTime - secondAnnotation.startTime || + firstAnnotation.endTime - secondAnnotation.endTime + ); + }); + const placementQueue = prioritizedAnnotation + ? [prioritizedAnnotation, ...remainingAnnotations] + : remainingAnnotations; + + const resolvedAnnotations = placementQueue.map((annotation) => { + let targetTrackIndex = getAnnotationTrackIndex(annotation) ?? 0; + + while (!canPlaceAnnotationInTimelineLane(lanes[targetTrackIndex], annotation, minimumVisibleDurationSeconds)) { + targetTrackIndex += 1; + } + + const resolvedAnnotation = + annotation.trackIndex === targetTrackIndex ? annotation : { ...annotation, trackIndex: targetTrackIndex }; + + lanes[targetTrackIndex] = lanes[targetTrackIndex] ?? []; + lanes[targetTrackIndex].push(resolvedAnnotation); + + return resolvedAnnotation; + }); + + return resolvedAnnotations.sort( + (firstAnnotation, secondAnnotation) => + (originalAnnotationIndexes.get(firstAnnotation.id) ?? 0) - + (originalAnnotationIndexes.get(secondAnnotation.id) ?? 0) + ); +}; + +const ANNOTATION_TIMELINE_TICK_STEPS_SECONDS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1200]; + +const buildAnnotationTimelineTicks = (durationSeconds: number, zoomPercent: number) => { + const safeDurationSeconds = Math.max(1, Math.ceil(durationSeconds)); + const zoomScale = clampTimelineValue(zoomPercent / 100, 0.5, 3); + const targetTickCount = Math.round(18 * zoomScale); + const minimumStepSeconds = safeDurationSeconds / targetTickCount; + const stepSeconds = + ANNOTATION_TIMELINE_TICK_STEPS_SECONDS.find((step) => step >= minimumStepSeconds) ?? + ANNOTATION_TIMELINE_TICK_STEPS_SECONDS[ANNOTATION_TIMELINE_TICK_STEPS_SECONDS.length - 1]; + const ticks: number[] = []; + + for (let seconds = 0; seconds <= safeDurationSeconds; seconds += stepSeconds) { + ticks.push(seconds); + } + + if (ticks[ticks.length - 1] !== safeDurationSeconds) { + ticks.push(safeDurationSeconds); + } + + return ticks; +}; + +const ANNOTATION_TIMELINE_MAX_WIDTH_AT_DEFAULT_ZOOM_PX = 32000; + +const getTimelineContentWidthPx = (durationSeconds: number, zoomPercent: number) => { + const safeDurationSeconds = Math.max(1, durationSeconds); + const zoomScale = clampTimelineValue(zoomPercent / 100, 0.5, 3); + const baseTimelineWidth = Math.max(960, safeDurationSeconds * 4); + const maxTimelineWidth = ANNOTATION_TIMELINE_MAX_WIDTH_AT_DEFAULT_ZOOM_PX * zoomScale; + + return Math.min(maxTimelineWidth, Math.max(760, Math.ceil(baseTimelineWidth * zoomScale))); +}; + +export { + buildAnnotationTimelineMoments, + buildAnnotationTimelineTicks, + clampTimelineValue, + formatAnnotationTime, + getAnnotationTimelineIcon, + getAnnotationTimelineLabel, + getAnnotationTimelineToolLabel, + getTimelineContentWidthPx, + getTimelineDuration, + getTimelinePercent, + resolveAnnotationTimelineLayers, +}; +export { + applyAnnotationCreationStartTimeOffset, + getAnnotationStartTimeWithCreationOffset, +} from "./playlist-annotation-creation-time"; diff --git a/apps/web/core/components/auth-screens/not-authorized-view.tsx b/apps/web/core/components/auth-screens/not-authorized-view.tsx index 58265a41fbb..68c9357422c 100644 --- a/apps/web/core/components/auth-screens/not-authorized-view.tsx +++ b/apps/web/core/components/auth-screens/not-authorized-view.tsx @@ -26,7 +26,7 @@ export const NotAuthorizedView: React.FC<Props> = observer((props) => { <DefaultLayout className={className}> <div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-custom-background-100 text-center"> <div className="h-44 w-72"> - <Image src={asset} height="176" width="288" alt="ProjectSettingImg" /> + <Image src={asset} height="176" width="288" alt="Program access illustration" /> </div> <h1 className="text-xl font-medium text-custom-text-100">Oops! You are not authorized to view this page</h1> {actionButton} diff --git a/apps/web/core/components/auth-screens/project/join-project.tsx b/apps/web/core/components/auth-screens/project/join-project.tsx index 39fbaba3596..822ec401550 100644 --- a/apps/web/core/components/auth-screens/project/join-project.tsx +++ b/apps/web/core/components/auth-screens/project/join-project.tsx @@ -39,17 +39,17 @@ export const JoinProject: React.FC<Props> = (props) => { return ( <div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-custom-background-100 text-center"> <div className="h-44 w-72"> - <Image src={Unauthorized} height="176" width="288" alt="JoinProject" /> + <Image src={Unauthorized} height="176" width="288" alt="Join program illustration" /> </div> <h1 className="text-xl font-medium text-custom-text-100"> - {!isPrivateProject ? `You are not a member of this project yet.` : `You are not a member of this project.`} + {!isPrivateProject ? `You are not a member of this program yet.` : `You are not a member of this program.`} </h1> <div className="w-full max-w-md text-base text-custom-text-200"> <p className="mx-auto w-full text-sm md:w-3/4"> {!isPrivateProject ? `Click the button below to join it.` - : `This is a private project. \n We can't tell you more about this project to protect confidentiality.`} + : `This is a private program. \n We can't tell you more about this program to protect confidentiality.`} </p> </div> {!isPrivateProject && ( diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index 280b2d3bc97..becae6c3694 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -303,7 +303,7 @@ export const CommandModal: React.FC = observer(() => { {searchTerm} {'"'} </span>{" "} - in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: + in {!projectId || isWorkspaceLevel ? "workspace" : "program"}: </h5> )} @@ -365,7 +365,7 @@ export const CommandModal: React.FC = observer(() => { </Command.Group> )} {workspaceSlug && canPerformWorkspaceActions && ( - <Command.Group heading="Project"> + <Command.Group heading="Program"> <Command.Item onSelect={() => { closePalette(); @@ -376,7 +376,7 @@ export const CommandModal: React.FC = observer(() => { > <div className="flex items-center gap-2 text-custom-text-200"> <FolderPlus className="h-3.5 w-3.5" /> - Create new project + Create new program </div> <kbd>P</kbd> </Command.Item> diff --git a/apps/web/core/components/common/activity/helper.tsx b/apps/web/core/components/common/activity/helper.tsx index 48cc39b8742..c2ab2c77053 100644 --- a/apps/web/core/components/common/activity/helper.tsx +++ b/apps/web/core/components/common/activity/helper.tsx @@ -90,7 +90,7 @@ export const messages = (activity: TProjectActivity): { message: string | ReactN }; case "archived_at": return { - message: newValue === "restore" ? "restored the project" : "archived the project", + message: newValue === "restore" ? "restored the program" : "archived the program", customUserName: newValue === "archive" ? "Plane" : undefined, }; case "name": @@ -103,7 +103,7 @@ export const messages = (activity: TProjectActivity): { message: string | ReactN }; case "description": return { - message: newValue ? "updated the project description" : "removed the project description", + message: newValue ? "updated the program description" : "removed the program description", }; case "start_date": return { @@ -215,7 +215,7 @@ export const messages = (activity: TProjectActivity): { message: string | ReactN return { message: ( <> - {newValue ? "created" : "removed"} the project page{" "} + {newValue ? "created" : "removed"} the program page{" "} <span className="font-medium text-custom-text-100">{newValue || oldValue || "Untitled page"}</span> </> ), diff --git a/apps/web/core/components/core/image-picker-popover.tsx b/apps/web/core/components/core/image-picker-popover.tsx index 44057b4994b..edc7f81c2c2 100644 --- a/apps/web/core/components/core/image-picker-popover.tsx +++ b/apps/web/core/components/core/image-picker-popover.tsx @@ -136,7 +136,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => { ) .then((res) => uploadCallback(res.asset_url)) .catch((error) => { - console.error("Error uploading project cover image:", error); + console.error("Error uploading program cover image:", error); setIsImageUploading(false); setToast({ message: error?.error ?? "The image could not be uploaded", @@ -287,7 +287,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => { > <img src={image} - alt={`Default project cover image- ${index}`} + alt={`Default program cover image- ${index}`} className="absolute left-0 top-0 h-full w-full cursor-pointer rounded object-cover" /> </div> diff --git a/apps/web/core/components/core/page-title.tsx b/apps/web/core/components/core/page-title.tsx index fe0a05f7d15..6afb377d4e7 100644 --- a/apps/web/core/components/core/page-title.tsx +++ b/apps/web/core/components/core/page-title.tsx @@ -10,7 +10,7 @@ export const PageHead: React.FC<PageHeadTitleProps> = (props) => { useEffect(() => { if (title) { - document.title = title ?? "Plane | Simple, extensible, open-source project management tool."; + document.title = title ?? "Plane | Simple, extensible, open-source program management tool."; } }, [title]); diff --git a/apps/web/core/components/cycles/archived-cycles/modal.tsx b/apps/web/core/components/cycles/archived-cycles/modal.tsx index 3f83ad540b6..9c61d158fdd 100644 --- a/apps/web/core/components/cycles/archived-cycles/modal.tsx +++ b/apps/web/core/components/cycles/archived-cycles/modal.tsx @@ -43,7 +43,7 @@ export const ArchiveCycleModal: React.FC<Props> = (props) => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Archive success", - message: "Your archives can be found in project archives.", + message: "Your archives can be found in program archives.", }); captureSuccess({ eventName: CYCLE_TRACKER_EVENTS.archive, diff --git a/apps/web/core/components/dropdowns/category-property.tsx b/apps/web/core/components/dropdowns/category-property.tsx new file mode 100644 index 00000000000..73247b0a754 --- /dev/null +++ b/apps/web/core/components/dropdowns/category-property.tsx @@ -0,0 +1,215 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { createPortal } from "react-dom"; +import { usePopper } from "react-popper"; +import { Ban, Search, Tag, X } from "lucide-react"; + +import { ComboDropDown } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { DropdownButton } from "@/components/dropdowns/buttons"; +import { BUTTON_VARIANTS_WITH_TEXT } from "@/components/dropdowns/constants"; +import type { TDropdownProps } from "@/components/dropdowns/types"; +import { useDropdown } from "@/hooks/use-dropdown"; + +type Props = TDropdownProps & { + value?: string | null; + onChange?: (val: string | null) => void; + placeholder?: string; + disabled?: boolean; + renderByDefault?: boolean; + icon?: React.ReactNode; + clearIconClassName?: string; + dropdownClassName?: string; +}; + +export const CategoryDropdown: React.FC<Props> = observer((props) => { + const { + className = "", + buttonClassName = "p-1.5", + buttonContainerClassName = "", + clearIconClassName = "", + placeholder = "Category", + buttonVariant, + renderByDefault = true, + icon = <Tag className="h-3 w-3 flex-shrink-0" />, + hideIcon = false, + showTooltip = false, + disabled = false, + value, + onChange, + dropdownClassName = "", + } = props; + + const [categories, setCategories] = useState<string[]>([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(""); + const [isOpen, setIsOpen] = useState(false); + + const dropdownRef = useRef<HTMLDivElement | null>(null); + const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); + const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-start", + modifiers: [{ name: "preventOverflow", options: { padding: 12 } }], + }); + + const { handleClose, handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + isOpen, + setIsOpen, + }); + + /* Fetch Categories */ + useEffect(() => { + const API_URL = `${process.env.NEXT_PUBLIC_CP_SERVER_URL}/meta-type?key='CATEGORY'`; + setLoading(true); + + fetch(API_URL) + .then(async (res) => { + if (!res.ok) throw new Error("Failed to fetch"); + const data = await res.json(); + + const block = data?.["Gateway Response"]?.result?.[0] ?? []; + const values = block.find((i: any) => i?.field === "values")?.value; + if (!Array.isArray(values)) throw new Error("Invalid response"); + + setCategories(values.sort()); + }) + .catch((e) => setLoadError(e.message)) + .finally(() => setLoading(false)); + }, []); + + const filteredCategories = categories.filter((c) => + c.toLowerCase().includes(search.toLowerCase()) + ); + + const handleSelect = (category: string | null) => { + console.log("[CategoryDropdown] selected:", category); + onChange?.(category); + setSearch(""); + handleClose(); + referenceElement?.blur(); + }; + + const displayValue = value ?? placeholder; + + /* Dropdown Button */ + const comboButton = ( + <button + type="button" + ref={setReferenceElement} + onClick={handleOnClick} + disabled={disabled} + className={cn( + "clickable block h-full max-w-full outline-none", + { + "cursor-default text-custom-text-200": disabled, + "cursor-pointer": !disabled, + }, + buttonContainerClassName + )} + > + <DropdownButton + className={buttonClassName} + isActive={isOpen} + tooltipHeading={placeholder} + tooltipContent={displayValue} + showTooltip={showTooltip} + variant={buttonVariant} + renderToolTipByDefault={renderByDefault} + > + {!hideIcon && icon} + + {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( + <span className="flex-grow truncate">{displayValue}</span> + )} + + {!!value && !disabled && ( + <X + className={cn("h-2.5 w-2.5 flex-shrink-0", clearIconClassName)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onChange?.(null); + }} + /> + )} + </DropdownButton> + </button> + ); + + return ( + <ComboDropDown + as="div" + ref={dropdownRef} + className={cn("h-full", className)} + button={comboButton} + onKeyDown={handleKeyDown} + disabled={disabled} + renderByDefault={renderByDefault} + > + {isOpen && + createPortal( + <div + ref={setPopperElement} + style={styles.popper} + {...attributes.popper} + className={cn( + "my-1 w-52 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden z-30", + dropdownClassName + )} + > + {/* Search */} + <div className="relative p-2"> + <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-3 w-3 text-gray-400" /> + <input + autoFocus + type="text" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search" + className="w-full py-1 pl-8 pr-2 text-xs rounded bg-custom-background-90 outline-none" + /> + </div> + + {/* None */} + <div + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(null); + }} + className="flex items-center gap-2 px-2 py-1 cursor-pointer hover:bg-custom-background-80" + > + <Ban className="w-3.5 h-3.5 text-gray-400" /> + <span className="text-xs text-gray-400">None</span> + </div> + + {loading && <div className="px-2 py-1 text-xs">Loading…</div>} + {loadError && <div className="px-2 py-1 text-xs text-red-500">Failed to load</div>} + + {!loading && + !loadError && + filteredCategories.map((category) => ( + <div + key={category} + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(category); + }} + className="px-2 py-1 cursor-pointer hover:bg-custom-background-80" + > + <span className="text-xs">{category}</span> + </div> + ))} + </div>, + document.body + )} + </ComboDropDown> + ); +}); diff --git a/apps/web/core/components/dropdowns/date-range.tsx b/apps/web/core/components/dropdowns/date-range.tsx index 83f0c904ee8..26dec7237de 100644 --- a/apps/web/core/components/dropdowns/date-range.tsx +++ b/apps/web/core/components/dropdowns/date-range.tsx @@ -63,6 +63,7 @@ type Props = { customTooltipHeading?: string; defaultOpen?: boolean; renderInPortal?: boolean; + usePointerOutsideClick?: boolean; }; export const DateRangeDropdown: React.FC<Props> = observer((props) => { @@ -99,6 +100,7 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => { customTooltipHeading, defaultOpen = false, renderInPortal = false, + usePointerOutsideClick = false, } = props; // states const [isOpen, setIsOpen] = useState(defaultOpen); @@ -133,6 +135,8 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => { isOpen, onOpen, setIsOpen, + useCaptureForOutsideClick: true, + usePointerOutsideClick, }); const disabledDays: Matcher[] = []; diff --git a/apps/web/core/components/dropdowns/date.tsx b/apps/web/core/components/dropdowns/date.tsx index 2600d42b7b7..3ed24024af4 100644 --- a/apps/web/core/components/dropdowns/date.tsx +++ b/apps/web/core/components/dropdowns/date.tsx @@ -119,7 +119,7 @@ export const DateDropdown: React.FC<Props> = observer((props) => { className={cn( "clickable block h-full max-w-full outline-none", { - "cursor-not-allowed text-custom-text-200": disabled, + "cursor-default text-custom-text-200": disabled, "cursor-pointer": !disabled, }, buttonContainerClassName diff --git a/apps/web/core/components/dropdowns/level-property.tsx b/apps/web/core/components/dropdowns/level-property.tsx new file mode 100644 index 00000000000..d757d322c38 --- /dev/null +++ b/apps/web/core/components/dropdowns/level-property.tsx @@ -0,0 +1,237 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { createPortal } from "react-dom"; +import { usePopper } from "react-popper"; +import { Ban, Search, SignalHigh, X } from "lucide-react"; + +import { ComboDropDown } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { DropdownButton } from "@/components/dropdowns/buttons"; +import { BUTTON_VARIANTS_WITH_TEXT } from "@/components/dropdowns/constants"; +import type { TDropdownProps } from "@/components/dropdowns/types"; +import { useDropdown } from "@/hooks/use-dropdown"; + +type Props = TDropdownProps & { + value?: string | null; + onChange?: (val: string | null) => void; + placeholder?: string; + disabled?: boolean; + renderByDefault?: boolean; + icon?: React.ReactNode; + clearIconClassName?: string; + dropdownClassName?: string; +}; + +export const LevelDropdown: React.FC<Props> = observer((props) => { + const { + className = "", + buttonClassName = "p-1.5", + buttonContainerClassName = "", + clearIconClassName = "", + placeholder = "Level", + buttonVariant, + renderByDefault = true, + icon = <SignalHigh className="h-3 w-3 flex-shrink-0" />, + hideIcon = false, + showTooltip = false, + disabled = false, + value, + onChange, + dropdownClassName = "", + } = props; + + const [levels, setLevels] = useState<string[]>([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(""); + const [isOpen, setIsOpen] = useState(false); + + const dropdownRef = useRef<HTMLDivElement | null>(null); + const [referenceElement, setReferenceElement] = + useState<HTMLButtonElement | null>(null); + const [popperElement, setPopperElement] = + useState<HTMLDivElement | null>(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-start", + modifiers: [{ name: "preventOverflow", options: { padding: 12 } }], + }); + + const { handleClose, handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + isOpen, + setIsOpen, + }); + + /* ─────────────── Fetch Levels ─────────────── */ + useEffect(() => { + const API_URL = `${process.env.NEXT_PUBLIC_CP_SERVER_URL}/meta-type?key='LEVEL'`; + setLoading(true); + + fetch(API_URL) + .then(async (res) => { + if (!res.ok) throw new Error("Failed to fetch levels"); + const data = await res.json(); + + const block = data?.["Gateway Response"]?.result?.[0] ?? []; + const values = block.find((i: any) => i?.field === "values")?.value; + + const cleanValues = Array.isArray(values) + ? values.filter((v) => typeof v === "string").sort() + : []; + + setLevels(cleanValues); + }) + .catch((e) => setLoadError(e.message)) + .finally(() => setLoading(false)); + }, []); + + const filteredLevels = levels.filter((l) => + l.toLowerCase().includes(search.toLowerCase()) + ); + + /* ─────────────── ✅ FIXED SELECT HANDLER ─────────────── */ + const handleSelect = (level: string | null) => { + onChange?.(level); + setSearch(""); + handleClose(); + referenceElement?.blur(); + }; + + const displayValue = + typeof value === "string" && value.length > 0 ? value : placeholder; + + /* ─────────────── Button ─────────────── */ + const comboButton = ( + <button + type="button" + ref={setReferenceElement} + onClick={handleOnClick} + disabled={disabled} + className={cn( + "clickable block h-full max-w-full outline-none", + disabled + ? "cursor-default text-custom-text-200" + : "cursor-pointer", + buttonContainerClassName + )} + > + <DropdownButton + className={buttonClassName} + isActive={isOpen} + tooltipHeading={placeholder} + tooltipContent={displayValue} + showTooltip={showTooltip} + variant={buttonVariant} + renderToolTipByDefault={renderByDefault} + > + {!hideIcon && icon} + + {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( + <span className="flex-grow truncate min-w-0"> + {displayValue} + </span> + )} + + {!!value && !disabled && ( + <X + className={cn("h-2.5 w-2.5 flex-shrink-0", clearIconClassName)} + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(null); + }} + /> + )} + </DropdownButton> + </button> + ); + + return ( + <ComboDropDown + ref={dropdownRef} + as="div" + className={cn("h-full", className)} + button={comboButton} + onKeyDown={handleKeyDown} + disabled={disabled} + renderByDefault={renderByDefault} + > + {isOpen && + createPortal( + <div + ref={setPopperElement} + style={styles.popper} + {...attributes.popper} + className={cn( + "my-1 w-52 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden z-30", + dropdownClassName + )} + > + {/* Search */} + <div className="relative p-2"> + <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-3 w-3 text-gray-400" /> + <input + autoFocus + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search" + className="w-full py-1 pl-8 pr-2 text-xs rounded + bg-custom-background-90 outline-none" + /> + </div> + + {/* None */} + <div + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(null); + }} + className="flex items-center gap-2 px-2 py-1 + cursor-pointer hover:bg-custom-background-80" + > + <Ban className="w-3.5 h-3.5 text-gray-400" /> + <span className="text-xs text-gray-400">None</span> + </div> + + {loading && ( + <div className="px-2 py-1 text-xs">Loading…</div> + )} + + {loadError && ( + <div className="px-2 py-1 text-xs text-red-500"> + Failed to load + </div> + )} + + {!loading && + !loadError && + filteredLevels.map((level) => ( + <div + key={level} + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(level); + }} + className={cn( + "px-2 py-1 cursor-pointer text-xs", + "hover:bg-custom-background-80", + value === level && + "bg-custom-background-80 font-medium" + )} + > + {level} + </div> + ))} + </div>, + document.body + )} + </ComboDropDown> + ); +}); + +export default LevelDropdown; diff --git a/apps/web/core/components/dropdowns/program-property.tsx b/apps/web/core/components/dropdowns/program-property.tsx new file mode 100644 index 00000000000..b56a8533d95 --- /dev/null +++ b/apps/web/core/components/dropdowns/program-property.tsx @@ -0,0 +1,215 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { createPortal } from "react-dom"; +import { usePopper } from "react-popper"; +import { Ban, Search, User, X } from "lucide-react"; + +import { ComboDropDown } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { DropdownButton } from "@/components/dropdowns/buttons"; +import { BUTTON_VARIANTS_WITH_TEXT } from "@/components/dropdowns/constants"; +import type { TDropdownProps } from "@/components/dropdowns/types"; +import { useDropdown } from "@/hooks/use-dropdown"; + +type Props = TDropdownProps & { + value?: string | null; + onChange?: (val: string | null) => void; + placeholder?: string; + disabled?: boolean; + renderByDefault?: boolean; + icon?: React.ReactNode; + clearIconClassName?: string; + dropdownClassName?: string; +}; + +export const ProgramDropdown: React.FC<Props> = observer((props) => { + const { + className = "", + buttonClassName = "p-1.5", + buttonContainerClassName = "", + clearIconClassName = "", + placeholder = "Program", + buttonVariant, + renderByDefault = true, + icon = <User className="h-3 w-3 flex-shrink-0" />, + hideIcon = false, + showTooltip = false, + disabled = false, + value, + onChange, + dropdownClassName = "", + } = props; + + const [programs, setPrograms] = useState<string[]>([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(""); + const [isOpen, setIsOpen] = useState(false); + + const dropdownRef = useRef<HTMLDivElement | null>(null); + const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); + const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-start", + modifiers: [{ name: "preventOverflow", options: { padding: 12 } }], + }); + + const { handleClose, handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + isOpen, + setIsOpen, + }); + + /* Fetch Programs */ + useEffect(() => { + const API_URL = `${process.env.NEXT_PUBLIC_CP_SERVER_URL}/meta-type?key='PROGRAM'`; + setLoading(true); + + fetch(API_URL) + .then(async (res) => { + if (!res.ok) throw new Error("Failed to fetch"); + const data = await res.json(); + + const block = data?.["Gateway Response"]?.result?.[0] ?? []; + const values = block.find((i: any) => i?.field === "values")?.value; + if (!Array.isArray(values)) throw new Error("Invalid response"); + + setPrograms(values.sort()); + }) + .catch((e) => setLoadError(e.message)) + .finally(() => setLoading(false)); + }, []); + + const filteredPrograms = programs.filter((p) => + p.toLowerCase().includes(search.toLowerCase()) + ); + + const handleSelect = (program: string | null) => { + console.log("[ProgramDropdown] selected:", program); + onChange?.(program); + setSearch(""); + handleClose(); + referenceElement?.blur(); + }; + + const displayValue = value ?? placeholder; + + /* Dropdown Button */ + const comboButton = ( + <button + type="button" + ref={setReferenceElement} + onClick={handleOnClick} + disabled={disabled} + className={cn( + "clickable block h-full max-w-full outline-none", + { + "cursor-default text-custom-text-200": disabled, + "cursor-pointer": !disabled, + }, + buttonContainerClassName + )} + > + <DropdownButton + className={buttonClassName} + isActive={isOpen} + tooltipHeading={placeholder} + tooltipContent={displayValue} + showTooltip={showTooltip} + variant={buttonVariant} + renderToolTipByDefault={renderByDefault} + > + {!hideIcon && icon} + + {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( + <span className="flex-grow truncate ">{displayValue}</span> + )} + + {!!value && !disabled && ( + <X + className={cn("h-2.5 w-2.5 flex-shrink-0", clearIconClassName)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onChange?.(null); + }} + /> + )} + </DropdownButton> + </button> + ); + + return ( + <ComboDropDown + as="div" + ref={dropdownRef} + className={cn("h-full", className)} + button={comboButton} + onKeyDown={handleKeyDown} + disabled={disabled} + renderByDefault={renderByDefault} + > + {isOpen && + createPortal( + <div + ref={setPopperElement} + style={styles.popper} + {...attributes.popper} + className={cn( + "my-1 w-52 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden z-30", + dropdownClassName + )} + > + {/* Search */} + <div className="relative p-2"> + <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-3 w-3 text-gray-400" /> + <input + autoFocus + type="text" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search" + className="w-full py-1 pl-8 pr-2 text-xs rounded bg-custom-background-90 outline-none" + /> + </div> + + {/* None */} + <div + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(null); + }} + className="flex items-center gap-2 px-2 py-1 cursor-pointer hover:bg-custom-background-80" + > + <Ban className="w-3.5 h-3.5 text-gray-400" /> + <span className="text-xs text-gray-400">None</span> + </div> + + {loading && <div className="px-2 py-1 text-xs">Loading…</div>} + {loadError && <div className="px-2 py-1 text-xs text-red-500">Failed to load</div>} + + {!loading && + !loadError && + filteredPrograms.map((program) => ( + <div + key={program} + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(program); + }} + className="px-2 py-1 cursor-pointer hover:bg-custom-background-80" + > + <span className="text-xs">{program}</span> + </div> + ))} + </div>, + document.body + )} + </ComboDropDown> + ); +}); diff --git a/apps/web/core/components/dropdowns/project/base.tsx b/apps/web/core/components/dropdowns/project/base.tsx index 77a2e85fe2e..c4a307dddb8 100644 --- a/apps/web/core/components/dropdowns/project/base.tsx +++ b/apps/web/core/components/dropdowns/project/base.tsx @@ -59,7 +59,7 @@ export const ProjectDropdownBase: React.FC<Props> = observer((props) => { multiple, onChange, onClose, - placeholder = "Project", + placeholder = "Program", placement, projectIds, renderByDefault = true, @@ -134,7 +134,7 @@ export const ProjectDropdownBase: React.FC<Props> = observer((props) => { const getDisplayName = (value: string | string[] | null, placeholder: string = "") => { if (Array.isArray(value)) { const firstProject = getProjectById(value[0]); - return value.length ? (value.length === 1 ? firstProject?.name : `${value.length} projects`) : placeholder; + return value.length ? (value.length === 1 ? firstProject?.name : `${value.length} programs`) : placeholder; } else { return value ? (getProjectById(value)?.name ?? placeholder) : placeholder; } @@ -196,8 +196,8 @@ export const ProjectDropdownBase: React.FC<Props> = observer((props) => { <DropdownButton className={buttonClassName} isActive={isOpen} - tooltipHeading="Project" - tooltipContent={value?.length ? `${value.length} project${value.length !== 1 ? "s" : ""}` : placeholder} + tooltipHeading="Program" + tooltipContent={value?.length ? `${value.length} program${value.length !== 1 ? "s" : ""}` : placeholder} showTooltip={showTooltip} variant={buttonVariant} renderToolTipByDefault={renderByDefault} diff --git a/apps/web/core/components/dropdowns/sport-property.tsx b/apps/web/core/components/dropdowns/sport-property.tsx new file mode 100644 index 00000000000..ef94310c33c --- /dev/null +++ b/apps/web/core/components/dropdowns/sport-property.tsx @@ -0,0 +1,230 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { createPortal } from "react-dom"; +import { usePopper } from "react-popper"; +import { Ban, Search, Volleyball, X } from "lucide-react"; + +import { ComboDropDown } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { DropdownButton } from "@/components/dropdowns/buttons"; +import { BUTTON_VARIANTS_WITH_TEXT } from "@/components/dropdowns/constants"; +import type { TDropdownProps } from "@/components/dropdowns/types"; +import { useDropdown } from "@/hooks/use-dropdown"; + +type Props = TDropdownProps & { + value?: string | null; + onChange?: (val: string | null) => void; + placeholder?: string; + disabled?: boolean; + renderByDefault?: boolean; + icon?: React.ReactNode; + clearIconClassName?: string; + dropdownClassName?: string; +}; + +export const SportDropdown: React.FC<Props> = observer((props) => { + const { + className = "", + buttonClassName = "p-1.5", + buttonContainerClassName = "", + clearIconClassName = "", + placeholder = "Sport", + buttonVariant, + renderByDefault = true, + icon = <Volleyball className="h-3 w-3 flex-shrink-0" />, + hideIcon = false, + showTooltip = false, + disabled = false, + value, + onChange, + dropdownClassName = "", + } = props; + + const [sports, setSports] = useState<string[]>([]); + const [search, setSearch] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(""); + + const dropdownRef = useRef<HTMLDivElement | null>(null); + const [referenceElement, setReferenceElement] = + useState<HTMLButtonElement | null>(null); + const [popperElement, setPopperElement] = + useState<HTMLDivElement | null>(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-start", + modifiers: [{ name: "preventOverflow", options: { padding: 12 } }], + }); + + const { handleClose, handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + isOpen, + setIsOpen, + }); + + /* ─────────────── Fetch Sports ─────────────── */ + useEffect(() => { + const API_URL = `${process.env.NEXT_PUBLIC_CP_SERVER_URL}/meta-type?key='SPORT'`; + setLoading(true); + + fetch(API_URL) + .then(async (res) => { + if (!res.ok) throw new Error("Failed to fetch sports"); + const data = await res.json(); + const block = data?.["Gateway Response"]?.result?.[0] ?? []; + const values = block.find((i: any) => i?.field === "values")?.value; + + const cleanValues = Array.isArray(values) + ? values.filter((v) => typeof v === "string").sort() + : []; + + setSports(cleanValues); + }) + .catch((e) => setLoadError(e.message)) + .finally(() => setLoading(false)); + }, []); + + const filteredSports = sports.filter((s) => + s.toLowerCase().includes(search.toLowerCase()) + ); + + /* ─────────────── ✅ FIXED SELECT HANDLER ─────────────── */ + const handleSelect = (selectedVal: string | null) => { + console.log("✅ [SportDropdown] Selected:", selectedVal); + + onChange?.(selectedVal); + + setSearch(""); + handleClose(); + referenceElement?.blur(); + }; + + const displayValue = + typeof value === "string" && value.length > 0 ? value : placeholder; + + /* ─────────────── Button ─────────────── */ + const comboButton = ( + <button + type="button" + ref={setReferenceElement} + onClick={handleOnClick} + disabled={disabled} + className={cn( + "clickable block h-full max-w-full outline-none", + disabled + ? "cursor-default text-custom-text-200" + : "cursor-pointer", + buttonContainerClassName + )} + > + <DropdownButton + className={buttonClassName} + isActive={isOpen} + tooltipHeading={placeholder} + tooltipContent={displayValue} + showTooltip={showTooltip} + variant={buttonVariant} + renderToolTipByDefault={renderByDefault} + > + {!hideIcon && icon} + + {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( + <span className="flex-grow truncate min-w-0"> + {displayValue} + </span> + )} + + {!!value && !disabled && ( + <X + className={cn("h-2.5 w-2.5", clearIconClassName)} + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(null); + }} + /> + )} + </DropdownButton> + </button> + ); + + return ( + <ComboDropDown + ref={dropdownRef} + as="div" + className={cn("h-full", className)} + button={comboButton} + onKeyDown={handleKeyDown} + disabled={disabled} + renderByDefault={renderByDefault} + > + {isOpen && + createPortal( + <div + ref={setPopperElement} + style={styles.popper} + {...attributes.popper} + className={cn( + "my-1 w-52 bg-custom-background-100 shadow-custom-shadow-rg border border-custom-border-300 rounded-md z-30", + dropdownClassName + )} + > + {/* Search */} + <div className="relative p-2"> + <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-3 w-3" /> + <input + autoFocus + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search" + className="w-full py-1 pl-8 pr-2 text-xs rounded bg-custom-background-90 outline-none" + /> + </div> + + {/* None */} + <div + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(null); + }} + className="flex items-center gap-2 px-2 py-1 cursor-pointer hover:bg-custom-background-80" + > + <Ban className="h-3.5 w-3.5 text-gray-400" /> + <span className="text-xs text-gray-400">None</span> + </div> + + {/* Sports list */} + <div className="max-h-44 overflow-y-auto"> + {!loading && + !loadError && + filteredSports.map((sport) => ( + <div + key={sport} + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(sport); + }} + className={cn( + "px-2 h-6 flex items-center cursor-pointer text-xs", + "hover:bg-custom-background-80", + value === sport && + "bg-custom-background-80 font-medium" + )} + > + {sport} + </div> + ))} + </div> + </div>, + document.body + )} + </ComboDropDown> + ); +}); + +export default SportDropdown; diff --git a/apps/web/core/components/dropdowns/time-picker.tsx b/apps/web/core/components/dropdowns/time-picker.tsx new file mode 100644 index 00000000000..a395d79f429 --- /dev/null +++ b/apps/web/core/components/dropdowns/time-picker.tsx @@ -0,0 +1,220 @@ +"use client"; + +import React, { useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { createPortal } from "react-dom"; +import { usePopper } from "react-popper"; +import { Clock, X } from "lucide-react"; +import { Combobox } from "@headlessui/react"; +import { ComboDropDown } from "@plane/ui"; +import { cn, isoTo12Hour, isoTo24Hour, updateISOTime } from "@plane/utils"; + +import { useDropdown } from "@/hooks/use-dropdown"; +import { DropdownButton } from "./buttons"; +import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; +import type { TDropdownProps } from "./types"; + +type Props = TDropdownProps & { + onChange: (val: string | null) => void; + value: string | null; + placeholder?: string; + isClearable?: boolean; + icon?: React.ReactNode; + closeOnSelect?: boolean; + clearIconClassName?: string; + renderByDefault?: boolean; + optionsClassName?: string; + useNativePicker?: boolean; +}; + +export const TimeDropdown: React.FC<Props> = observer((props) => { + const { + buttonClassName = "p-1.5", + buttonContainerClassName = "", + className = "", + clearIconClassName = "", + placeholder = "Time", + hideIcon = false, + icon = <Clock className="h-3 w-3 flex-shrink-0" />, + buttonVariant, + isClearable = true, + showTooltip = false, + tabIndex, + disabled = false, + renderByDefault = true, + closeOnSelect: _closeOnSelect = true, + onChange, + value, + optionsClassName = "", + useNativePicker = false, + } = props; + + const [isOpen, setIsOpen] = useState(false); + const [tempTime24, setTempTime24] = useState<string>(""); + + const dropdownRef = useRef<HTMLDivElement | null>(null); + const nativeInputRef = useRef<HTMLInputElement | null>(null); + const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); + const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-start", + modifiers: [{ name: "preventOverflow", options: { padding: 12 } }], + }); + + const isTimeSelected = Boolean(value && value.trim() !== ""); + + const { handleClose, handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + isOpen, + setIsOpen, + }); + + /* ─────────────────────────────── */ + /* Open dropdown & initialize time */ + /* ─────────────────────────────── */ + const handleOpen = (e: React.MouseEvent<HTMLButtonElement>) => { + if (useNativePicker) { + e.preventDefault(); + const input = nativeInputRef.current; + if (!input) return; + + if ("showPicker" in input && typeof input.showPicker === "function") { + input.showPicker(); + } else { + input.focus(); + input.click(); + } + return; + } + + handleOnClick(e); + setTempTime24(value ? isoTo24Hour(value) : ""); + }; + + /* ─────────────────────────────── */ + /* Apply time immediately on change */ + /* ─────────────────────────────── */ + const handlePickTime = (e: React.ChangeEvent<HTMLInputElement>) => { + const newTime = e.target.value; + setTempTime24(newTime); + + if (newTime) { + const updatedISO = updateISOTime(value, newTime); + onChange(updatedISO); + } + }; + + const displayValue = value ? isoTo12Hour(value) ?? placeholder : placeholder; + + const comboButton = ( + <button + type="button" + ref={setReferenceElement} + onClick={handleOpen} + disabled={disabled} + className={cn( + "clickable relative block h-full max-w-full outline-none", + { + "cursor-default text-custom-text-200": disabled, + "cursor-pointer": !disabled, + }, + buttonContainerClassName + )} + > + <DropdownButton + className={buttonClassName} + isActive={isOpen} + tooltipHeading={placeholder} + tooltipContent={displayValue} + showTooltip={showTooltip} + variant={buttonVariant} + renderToolTipByDefault={renderByDefault} + > + {!hideIcon && icon} + + {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( + <span className="flex-grow truncate">{displayValue}</span> + )} + + {isClearable && isTimeSelected && !disabled && ( + <X + className={cn("h-2.5 w-2.5 flex-shrink-0", clearIconClassName)} + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + onChange(null); + }} + /> + )} + </DropdownButton> + {useNativePicker ? ( + <input + ref={nativeInputRef} + type="time" + value={value ? isoTo24Hour(value) : ""} + onChange={handlePickTime} + tabIndex={-1} + aria-hidden + className="pointer-events-none absolute inset-0 h-full w-full opacity-0" + /> + ) : null} + </button> + ); + + return ( + <ComboDropDown + as="div" + ref={dropdownRef} + className={cn("h-full", className)} + button={comboButton} + tabIndex={tabIndex} + onKeyDown={handleKeyDown} + disabled={disabled} + renderByDefault={renderByDefault} + > + {isOpen && + !useNativePicker && + createPortal( + <Combobox.Options data-prevent-outside-click static> + <div + ref={setPopperElement} + style={styles.popper} + {...attributes.popper} + className={cn( + "my-1 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden z-30", + optionsClassName + )} + > + <div className="flex p-2 justify-between items-center space-x-2 min-w-[130px] "> + <input + type="time" + value={tempTime24} + onChange={handlePickTime} + onClick={(e) => e.stopPropagation()} + autoFocus + className="w-full bg-custom-background-100 text-sm rounded px-2 py-1 outline-none" + /> + <X + className="h-3.5 w-3.5 flex-shrink-0" + onClick={() => { + handleClose(); + referenceElement?.blur(); + }} + /> + + {/* <button + className="text-xs px-3 py-1 rounded border border-custom-border-200 text-custom-text-300 " + onClick={handleApply} + disabled={!tempTime24} + > + OK + </button> */} + </div> + </div> + </Combobox.Options>, + document.body + )} + </ComboDropDown> + ); +}); diff --git a/apps/web/core/components/dropdowns/year-property.tsx b/apps/web/core/components/dropdowns/year-property.tsx new file mode 100644 index 00000000000..ff2f952e490 --- /dev/null +++ b/apps/web/core/components/dropdowns/year-property.tsx @@ -0,0 +1,200 @@ +"use client"; + +import React, { useEffect, useState, useRef } from "react"; +import { observer } from "mobx-react"; +import { createPortal } from "react-dom"; +import { usePopper } from "react-popper"; +import { Ban, Calendar, Search, X } from "lucide-react"; + +import { ComboDropDown } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { DropdownButton } from "@/components/dropdowns/buttons"; +import { BUTTON_VARIANTS_WITH_TEXT } from "@/components/dropdowns/constants"; +import type { TDropdownProps } from "@/components/dropdowns/types"; +import { useDropdown } from "@/hooks/use-dropdown"; + +type Props = TDropdownProps & { + value?: string | null; + onChange?: (val: string | null) => void; + placeholder?: string; + disabled?: boolean; + startYear?: number; + renderByDefault?: boolean; + icon?: React.ReactNode; + clearIconClassName?: string; + dropdownClassName?: string; +}; + +export const YearRangeDropdown: React.FC<Props> = observer((props) => { + const { + className = "", + buttonClassName = "p-1.5", + buttonContainerClassName = "", + clearIconClassName = "", + placeholder = "Season", + buttonVariant, + renderByDefault = true, + icon = <Calendar className="h-3 w-3 flex-shrink-0" />, + hideIcon = false, + showTooltip = false, + disabled = false, + value, + onChange, + startYear = 2020, + dropdownClassName = "", + } = props; + + const [search, setSearch] = useState(""); + const [isOpen, setIsOpen] = useState(false); + + const dropdownRef = useRef<HTMLDivElement | null>(null); + const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); + const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-start", + modifiers: [{ name: "preventOverflow", options: { padding: 12 } }], + }); + + const { handleClose, handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + isOpen, + setIsOpen, + }); + + /* Generate Year Ranges */ + const generateYearSessions = (startYear: number): string[] => { + const currentYear = new Date().getFullYear(); + const sessions: string[] = []; + for (let year = currentYear; year >= startYear; year--) { + sessions.push(`${year}-${year + 1}`); + } + return sessions; + }; + + const yearRanges = generateYearSessions(startYear); + const filteredRanges = yearRanges.filter((y) => + y.toLowerCase().includes(search.toLowerCase()) + ); + + const handleSelect = (range: string | null) => { + console.log("[YearRangeDropdown] selected:", range); + onChange?.(range); + setSearch(""); + handleClose(); + referenceElement?.blur(); + }; + + const displayValue = value ?? placeholder; + + /* Button */ + const comboButton = ( + <button + type="button" + ref={setReferenceElement} + onClick={handleOnClick} + disabled={disabled} + className={cn( + "clickable block h-full max-w-full outline-none", + { + "cursor-default text-custom-text-200": disabled, + "cursor-pointer": !disabled, + }, + buttonContainerClassName + )} + > + <DropdownButton + className={buttonClassName} + isActive={isOpen} + tooltipHeading={placeholder} + tooltipContent={displayValue} + showTooltip={showTooltip} + variant={buttonVariant} + renderToolTipByDefault={renderByDefault} + > + {!hideIcon && icon} + + {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( + <span className="flex-grow truncate">{displayValue}</span> + )} + + {!!value && !disabled && ( + <X + className={cn("h-2.5 w-2.5 flex-shrink-0", clearIconClassName)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onChange?.(null); + }} + /> + )} + </DropdownButton> + </button> + ); + + return ( + <ComboDropDown + as="div" + ref={dropdownRef} + className={cn("h-full", className)} + button={comboButton} + onKeyDown={handleKeyDown} + disabled={disabled} + renderByDefault={renderByDefault} + > + {isOpen && + createPortal( + <div + ref={setPopperElement} + style={styles.popper} + {...attributes.popper} + className={cn( + "my-1 w-52 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden z-30", + dropdownClassName + )} + > + {/* Search */} + <div className="relative p-2"> + <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-3 w-3 text-gray-400" /> + <input + autoFocus + type="text" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search" + className="w-full py-1 pl-8 pr-2 text-xs rounded bg-custom-background-90 outline-none" + /> + </div> + + {/* None */} + <div + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(null); + }} + className="flex items-center gap-2 px-2 py-1 cursor-pointer hover:bg-custom-background-80" + > + <Ban className="w-3.5 h-3.5 text-gray-400" /> + <span className="text-xs text-gray-400">None</span> + </div> + + {filteredRanges.map((range) => ( + <div + key={range} + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleSelect(range); + }} + className="px-2 py-1 cursor-pointer hover:bg-custom-background-80" + > + <span className="text-xs">{range}</span> + </div> + ))} + </div>, + document.body + )} + </ComboDropDown> + ); +}); diff --git a/apps/web/core/components/estimates/delete/modal.tsx b/apps/web/core/components/estimates/delete/modal.tsx index ea38b3f36ec..ecd0bc0b986 100644 --- a/apps/web/core/components/estimates/delete/modal.tsx +++ b/apps/web/core/components/estimates/delete/modal.tsx @@ -51,7 +51,7 @@ export const DeleteEstimateModal: FC<TDeleteEstimateModal> = observer((props) => setToast({ type: TOAST_TYPE.SUCCESS, title: "Estimate deleted", - message: "Estimate has been removed from your project.", + message: "Estimate has been removed from your program.", }); handleClose(); } catch (error) { diff --git a/apps/web/core/components/exporter/column.tsx b/apps/web/core/components/exporter/column.tsx index 429d0bb2977..4b41a469a3d 100644 --- a/apps/web/core/components/exporter/column.tsx +++ b/apps/web/core/components/exporter/column.tsx @@ -46,8 +46,8 @@ export const useExportColumns = () => { }, { - key: "Exported projects", - content: "Exported projects", + key: "Exported programs", + content: "Exported programs", tdRender: (rowData: RowData) => <div className="text-sm">{rowData.project.length} project(s)</div>, }, { diff --git a/apps/web/core/components/exporter/export-form.tsx b/apps/web/core/components/exporter/export-form.tsx index 1005a81e7d5..30d291f5cbd 100644 --- a/apps/web/core/components/exporter/export-form.tsx +++ b/apps/web/core/components/exporter/export-form.tsx @@ -148,7 +148,7 @@ export const ExportForm = (props: Props) => { return projectDetails?.identifier; }) .join(", ") - : "All projects" + : "All programs" } optionsClassName="max-w-48 sm:max-w-[532px]" placement="bottom-end" diff --git a/apps/web/core/components/exporter/export-modal.tsx b/apps/web/core/components/exporter/export-modal.tsx index 73cb92ad877..6f6002efa99 100644 --- a/apps/web/core/components/exporter/export-modal.tsx +++ b/apps/web/core/components/exporter/export-modal.tsx @@ -156,7 +156,7 @@ export const Exporter: React.FC<Props> = observer((props) => { return projectDetails?.identifier; }) .join(", ") - : "All projects" + : "All programs" } onOpen={() => setIsSelectOpen(true)} onClose={() => setIsSelectOpen(false)} diff --git a/apps/web/core/components/inbox/content/inbox-issue-header.tsx b/apps/web/core/components/inbox/content/inbox-issue-header.tsx index 7f375664e81..a9c489132bb 100644 --- a/apps/web/core/components/inbox/content/inbox-issue-header.tsx +++ b/apps/web/core/components/inbox/content/inbox-issue-header.tsx @@ -405,7 +405,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p handleActionWithPermission( isProjectAdmin, () => setSelectDuplicateIssue(true), - "Only project admins can mark work item as duplicate" + "Only program admins can mark work item as duplicate" ) } > diff --git a/apps/web/core/components/inbox/content/inbox-issue-mobile-header.tsx b/apps/web/core/components/inbox/content/inbox-issue-mobile-header.tsx index d389758647b..bccfe583a49 100644 --- a/apps/web/core/components/inbox/content/inbox-issue-mobile-header.tsx +++ b/apps/web/core/components/inbox/content/inbox-issue-mobile-header.tsx @@ -173,7 +173,7 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) = handleActionWithPermission( isProjectAdmin, () => setSelectDuplicateIssue(true), - "Only project admins can mark work items as duplicate" + "Only program admins can mark work items as duplicate" ) } > @@ -189,7 +189,7 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) = handleActionWithPermission( isProjectAdmin, () => setAcceptIssueModal(true), - "Only project admins can accept work items" + "Only program admins can accept work items" ) } > @@ -205,7 +205,7 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) = handleActionWithPermission( isProjectAdmin, () => setDeclineIssueModal(true), - "Only project admins can deny work items" + "Only program admins can deny work items" ) } > diff --git a/apps/web/core/components/instance/maintenance-view.tsx b/apps/web/core/components/instance/maintenance-view.tsx index 87f243497df..aef50958558 100644 --- a/apps/web/core/components/instance/maintenance-view.tsx +++ b/apps/web/core/components/instance/maintenance-view.tsx @@ -24,7 +24,7 @@ export const MaintenanceView: FC = () => { src={maintenanceModeImage} height="176" width="288" - alt="ProjectSettingImg" + alt="Maintenance illustration" className="w-full h-full object-fill object-center" /> </div> diff --git a/apps/web/core/components/integration/delete-import-modal.tsx b/apps/web/core/components/integration/delete-import-modal.tsx index 04b06b77af0..ff8f53ad981 100644 --- a/apps/web/core/components/integration/delete-import-modal.tsx +++ b/apps/web/core/components/integration/delete-import-modal.tsx @@ -98,7 +98,7 @@ export const DeleteImportModal: React.FC<Props> = ({ isOpen, handleClose, data } <AlertTriangle className="h-6 w-6 text-red-500" aria-hidden="true" /> </span> <span className="flex items-center justify-start"> - <h3 className="text-xl font-medium 2xl:text-2xl">Delete project</h3> + <h3 className="text-xl font-medium 2xl:text-2xl">Delete import</h3> </span> </div> <span> @@ -136,7 +136,7 @@ export const DeleteImportModal: React.FC<Props> = ({ isOpen, handleClose, data } disabled={!confirmDeleteImport} loading={deleteLoading} > - {deleteLoading ? "Deleting..." : "Delete Project"} + {deleteLoading ? "Deleting..." : "Delete import"} </Button> </div> </div> diff --git a/apps/web/core/components/integration/github/auth.tsx b/apps/web/core/components/integration/github/auth.tsx index 6706e6aa0c9..c7150f1c9b4 100644 --- a/apps/web/core/components/integration/github/auth.tsx +++ b/apps/web/core/components/integration/github/auth.tsx @@ -1,4 +1,4 @@ -"use client"; + "use client"; import { observer } from "mobx-react"; // types diff --git a/apps/web/core/components/integration/github/import-data.tsx b/apps/web/core/components/integration/github/import-data.tsx index a5f72f9ea59..5b389acde4a 100644 --- a/apps/web/core/components/integration/github/import-data.tsx +++ b/apps/web/core/components/integration/github/import-data.tsx @@ -71,8 +71,8 @@ export const GithubImportData: FC<Props> = observer((props) => { </div> <div className="grid grid-cols-12 gap-4 sm:gap-16"> <div className="col-span-12 sm:col-span-8"> - <h4 className="font-semibold">Select Project</h4> - <p className="text-xs text-custom-text-200">Select the project to import the work item to.</p> + <h4 className="font-semibold">Select Program</h4> + <p className="text-xs text-custom-text-200">Select the program to import the work item to.</p> </div> <div className="col-span-12 sm:col-span-4"> {workspaceProjectIds && ( @@ -83,7 +83,7 @@ export const GithubImportData: FC<Props> = observer((props) => { <CustomSearchSelect value={value} label={ - value ? getProjectById(value)?.name : <span className="text-custom-text-200">Select Project</span> + value ? getProjectById(value)?.name : <span className="text-custom-text-200">Select Program</span> } onChange={onChange} options={options} diff --git a/apps/web/core/components/integration/github/single-user-select.tsx b/apps/web/core/components/integration/github/single-user-select.tsx index 04212ccdd41..9f050786b3f 100644 --- a/apps/web/core/components/integration/github/single-user-select.tsx +++ b/apps/web/core/components/integration/github/single-user-select.tsx @@ -119,7 +119,7 @@ export const SingleUserSelect: React.FC<Props> = ({ collaborator, index, users, {users[index].import === "map" && members && ( <CustomSearchSelect value={users[index].email} - label={users[index].email !== "" ? users[index].email : "Select user from project"} + label={users[index].email !== "" ? users[index].email : "Select user from program"} options={options} onChange={(val: string) => { const newUsers = [...users]; diff --git a/apps/web/core/components/integration/jira/give-details.tsx b/apps/web/core/components/integration/jira/give-details.tsx index 5067dc816e6..24ef08b05b5 100644 --- a/apps/web/core/components/integration/jira/give-details.tsx +++ b/apps/web/core/components/integration/jira/give-details.tsx @@ -67,7 +67,7 @@ export const JiraGetImportDetail: React.FC = observer(() => { <div className="grid grid-cols-1 gap-10 md:grid-cols-2"> <div className="col-span-1"> - <h3 className="font-semibold">Jira Project Key</h3> + <h3 className="font-semibold">Jira Program Key</h3> <p className="text-sm text-custom-text-200">If XXX-123 is your work item, then enter XXX</p> </div> <div className="col-span-1"> @@ -75,7 +75,7 @@ export const JiraGetImportDetail: React.FC = observer(() => { control={control} name="metadata.project_key" rules={{ - required: "Please enter your project key.", + required: "Please enter your program key.", }} render={({ field: { value, onChange, ref } }) => ( <Input @@ -159,14 +159,14 @@ export const JiraGetImportDetail: React.FC = observer(() => { <div className="grid grid-cols-1 gap-10 md:grid-cols-2"> <div className="col-span-1"> - <h3 className="font-semibold">Import to project</h3> - <p className="text-sm text-custom-text-200">Select which project you want to import to.</p> + <h3 className="font-semibold">Import to program</h3> + <p className="text-sm text-custom-text-200">Select which program you want to import to.</p> </div> <div className="col-span-1"> <Controller control={control} name="project_id" - rules={{ required: "Please select a project." }} + rules={{ required: "Please select a program." }} render={({ field: { value, onChange } }) => ( <CustomSelect value={value} @@ -177,7 +177,7 @@ export const JiraGetImportDetail: React.FC = observer(() => { {value && value.trim() !== "" ? ( getProjectById(value)?.name ) : ( - <span className="text-custom-text-200">Select a project</span> + <span className="text-custom-text-200">Select a program</span> )} </span> } @@ -196,7 +196,7 @@ export const JiraGetImportDetail: React.FC = observer(() => { }) ) : ( <div className="flex cursor-pointer select-none items-center space-x-2 truncate rounded px-1 py-1.5 text-custom-text-200"> - <p>You don{"'"}t have any project. Please create a project first.</p> + <p>You don{"'"}t have any program. Please create a program first.</p> </div> )} <div> @@ -210,7 +210,7 @@ export const JiraGetImportDetail: React.FC = observer(() => { className="flex cursor-pointer select-none items-center space-x-2 truncate rounded px-1 py-1.5 text-custom-text-200" > <Plus className="h-4 w-4 text-custom-text-200" /> - <span>Create new project</span> + <span>Create new program</span> </button> </div> </CustomSelect> diff --git a/apps/web/core/components/integration/jira/import-users.tsx b/apps/web/core/components/integration/jira/import-users.tsx index f9c543646ba..0ef01e780ea 100644 --- a/apps/web/core/components/integration/jira/import-users.tsx +++ b/apps/web/core/components/integration/jira/import-users.tsx @@ -135,7 +135,7 @@ export const JiraImportUsers: FC = () => { <CustomSearchSelect value={value} input - label={value !== "" ? value : "Select user from project"} + label={value !== "" ? value : "Select user from program"} options={options} onChange={onChange} optionsClassName="w-48" diff --git a/apps/web/core/components/integration/single-integration-card.tsx b/apps/web/core/components/integration/single-integration-card.tsx index b6169d83dc7..70253d9aa96 100644 --- a/apps/web/core/components/integration/single-integration-card.tsx +++ b/apps/web/core/components/integration/single-integration-card.tsx @@ -33,13 +33,13 @@ type Props = { const integrationDetails: { [key: string]: any } = { github: { logo: GithubLogo, - installed: "Activate GitHub on individual projects to sync with specific repositories.", - notInstalled: "Connect with GitHub with your Plane workspace to sync project work items.", + installed: "Activate GitHub on individual programs to sync with specific repositories.", + notInstalled: "Connect with GitHub with your Plane workspace to sync program work items.", }, slack: { logo: SlackLogo, - installed: "Activate Slack on individual projects to sync with specific channels.", - notInstalled: "Connect with Slack with your Plane workspace to sync project work items.", + installed: "Activate Slack on individual programs to sync with specific channels.", + notInstalled: "Connect with Slack with your Plane workspace to sync program work items.", }, }; diff --git a/apps/web/core/components/issues/attachment/attachment-item-list.tsx b/apps/web/core/components/issues/attachment/attachment-item-list.tsx index 25da1638879..bb8de4a6671 100644 --- a/apps/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/apps/web/core/components/issues/attachment/attachment-item-list.tsx @@ -25,6 +25,7 @@ type TIssueAttachmentItemList = { projectId: string; issueId: string; attachmentHelpers: TAttachmentHelpers; + confirmManifestOnDelete?: boolean; disabled?: boolean; issueServiceType?: TIssueServiceType; }; @@ -35,6 +36,7 @@ export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((p projectId, issueId, attachmentHelpers, + confirmManifestOnDelete = false, disabled, issueServiceType = EIssueServiceType.ISSUES, } = props; @@ -95,7 +97,7 @@ export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((p }); return; }, - [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities] + [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities, t] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ @@ -118,6 +120,9 @@ export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((p onClose={() => toggleDeleteAttachmentModal(null)} attachmentOperations={attachmentOperations} attachmentId={attachmentDeleteModalId} + workspaceSlug={workspaceSlug} + projectId={projectId} + confirmManifestOnDelete={confirmManifestOnDelete} issueServiceType={issueServiceType} /> )} diff --git a/apps/web/core/components/issues/attachment/delete-attachment-modal.tsx b/apps/web/core/components/issues/attachment/delete-attachment-modal.tsx index 4eb79fc391a..2d63ed044f4 100644 --- a/apps/web/core/components/issues/attachment/delete-attachment-modal.tsx +++ b/apps/web/core/components/issues/attachment/delete-attachment-modal.tsx @@ -1,5 +1,5 @@ import type { FC } from "react"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { observer } from "mobx-react"; // plane-i18n import { useTranslation } from "@plane/i18n"; @@ -12,8 +12,10 @@ import { AlertModalCore } from "@plane/ui"; import { getFileName } from "@plane/utils"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { MediaLibraryService } from "@/services/media-library.service"; // types import type { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper"; +import { buildArtifactName, resolveAttachmentFileName } from "../issue-detail-widgets/media-library-utils"; export type TAttachmentOperationsRemoveModal = Pick<TAttachmentOperations, "remove">; @@ -22,14 +24,30 @@ type Props = { onClose: () => void; attachmentId: string; attachmentOperations: TAttachmentOperationsRemoveModal; + workspaceSlug?: string; + projectId?: string; + confirmManifestOnDelete?: boolean; issueServiceType?: TIssueServiceType; }; export const IssueAttachmentDeleteModal: FC<Props> = observer((props) => { const { t } = useTranslation(); - const { isOpen, onClose, attachmentId, attachmentOperations, issueServiceType = EIssueServiceType.ISSUES } = props; + const { + isOpen, + onClose, + attachmentId, + attachmentOperations, + workspaceSlug, + projectId, + confirmManifestOnDelete = false, + issueServiceType = EIssueServiceType.ISSUES, + } = props; // states const [loader, setLoader] = useState(false); + const [removeFromManifest, setRemoveFromManifest] = useState(true); + const [hasMediaLibraryArtifact, setHasMediaLibraryArtifact] = useState(false); + const [isMediaLibraryCheckLoading, setIsMediaLibraryCheckLoading] = useState(false); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); // store hooks const { @@ -38,16 +56,67 @@ export const IssueAttachmentDeleteModal: FC<Props> = observer((props) => { // derived values const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; + const artifactName = attachment ? buildArtifactName(resolveAttachmentFileName(attachment), attachment.id) : ""; + + useEffect(() => { + if (isOpen) { + setRemoveFromManifest(true); + } else { + setHasMediaLibraryArtifact(false); + setIsMediaLibraryCheckLoading(false); + } + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + if (!confirmManifestOnDelete || !workspaceSlug || !projectId || !artifactName) { + setHasMediaLibraryArtifact(false); + setIsMediaLibraryCheckLoading(false); + return; + } + + let isMounted = true; + setIsMediaLibraryCheckLoading(true); + setHasMediaLibraryArtifact(false); + + const checkManifestArtifact = async () => { + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + if (!packageId) return; + await mediaLibraryService.getArtifactDetail(workspaceSlug, projectId, packageId, artifactName); + if (isMounted) setHasMediaLibraryArtifact(true); + } catch { + if (isMounted) setHasMediaLibraryArtifact(false); + } finally { + if (isMounted) setIsMediaLibraryCheckLoading(false); + } + }; + + void checkManifestArtifact(); + + return () => { + isMounted = false; + }; + }, [artifactName, confirmManifestOnDelete, isOpen, mediaLibraryService, projectId, workspaceSlug]); // handlers const handleClose = () => { onClose(); setLoader(false); + setRemoveFromManifest(true); + setHasMediaLibraryArtifact(false); + setIsMediaLibraryCheckLoading(false); }; const handleDeletion = async (assetId: string) => { setLoader(true); - attachmentOperations.remove(assetId).finally(() => handleClose()); + const removeOptions = confirmManifestOnDelete + ? { removeFromManifest: hasMediaLibraryArtifact ? removeFromManifest : false } + : undefined; + attachmentOperations + .remove(assetId, removeOptions) + .finally(() => handleClose()); }; if (!attachment) return <></>; @@ -55,7 +124,7 @@ export const IssueAttachmentDeleteModal: FC<Props> = observer((props) => { <AlertModalCore handleClose={handleClose} handleSubmit={() => handleDeletion(attachment.id)} - isSubmitting={loader} + isSubmitting={loader || (confirmManifestOnDelete && isMediaLibraryCheckLoading)} isOpen={isOpen} title={t("attachment.delete")} content={ @@ -64,6 +133,17 @@ export const IssueAttachmentDeleteModal: FC<Props> = observer((props) => { Are you sure you want to delete attachment-{" "} <span className="font-bold">{getFileName(attachment.attributes.name)}</span>? This attachment will be permanently removed. This action cannot be undone. + {confirmManifestOnDelete && hasMediaLibraryArtifact && ( + <label className="mt-3 flex items-start gap-2 text-sm text-custom-text-200"> + <input + type="checkbox" + className="mt-0.5" + checked={removeFromManifest} + onChange={() => setRemoveFromManifest((prev) => !prev)} + /> + <span>Also remove from media library</span> + </label> + )} </> } /> diff --git a/apps/web/core/components/issues/description-input.tsx b/apps/web/core/components/issues/description-input.tsx index dca9623c751..067a43b2b24 100644 --- a/apps/web/core/components/issues/description-input.tsx +++ b/apps/web/core/components/issues/description-input.tsx @@ -8,7 +8,7 @@ import { Controller, useForm } from "react-hook-form"; // plane imports import type { EditorRefApi } from "@plane/editor"; import { useTranslation } from "@plane/i18n"; -import type { TIssue, TNameDescriptionLoader } from "@plane/types"; +import type { TFileEntityInfo, TIssue, TNameDescriptionLoader } from "@plane/types"; import { EFileAssetType } from "@plane/types"; import { Loader } from "@plane/ui"; // components @@ -35,6 +35,8 @@ export type IssueDescriptionInputProps = { placeholder?: string | ((isFocused: boolean, value: string) => string); setIsSubmitting: (initialValue: TNameDescriptionLoader) => void; swrIssueDescription?: string | null | undefined; + onDescriptionChange?: (value: string) => void; + assetUploadEntityInfo?: TFileEntityInfo; }; export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => { @@ -50,6 +52,8 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p issueOperations, setIsSubmitting, placeholder, + onDescriptionChange, + assetUploadEntityInfo, } = props; // states const [localIssueDescription, setLocalIssueDescription] = useState({ @@ -151,6 +155,7 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p onChange={(_description: object, description_html: string) => { setIsSubmitting("submitting"); onChange(description_html); + onDescriptionChange?.(description_html); hasUnsavedChanges.current = true; debouncedFormSave(); }} @@ -169,12 +174,14 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p containerClassName={containerClassName} uploadFile={async (blockId, file) => { try { + const resolvedEntityInfo = assetUploadEntityInfo ?? { + entity_identifier: issueId, + entity_type: EFileAssetType.ISSUE_DESCRIPTION, + }; + const { asset_id } = await uploadEditorAsset({ blockId, - data: { - entity_identifier: issueId, - entity_type: EFileAssetType.ISSUE_DESCRIPTION, - }, + data: resolvedEntityInfo, file, projectId, workspaceSlug, diff --git a/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx b/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx index ae8387efc45..1422cbdfffb 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx @@ -1,17 +1,39 @@ "use client"; import type { FC } from "react"; -import React from "react"; -import { Link, Paperclip, Waypoints } from "lucide-react"; +import React, { useCallback, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { Link, Paperclip, UploadCloud, Waypoints } from "lucide-react"; import { useTranslation } from "@plane/i18n"; import { ViewsIcon } from "@plane/propel/icons"; +import { setPromiseToast } from "@plane/propel/toast"; // plane imports -import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import type { TIssueAttachment, TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import { getFileName, getFileURL } from "@plane/utils"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useMember } from "@/hooks/store/use-member"; // plane web imports import { WorkItemAdditionalWidgetActionButtons } from "@/plane-web/components/issues/issue-detail-widgets/action-buttons"; +// services +import { MediaLibraryService } from "@/services/media-library.service"; // local imports import { IssueAttachmentActionButton } from "./attachments"; import { IssueLinksActionButton } from "./links"; +import { + DOC_FORMATS, + IMAGE_FORMATS, + VIDEO_FORMATS, + buildArtifactName, + buildEventMeta, + getErrorMessage, + isDuplicateArtifactError, + resolveArtifactAction, + resolveArtifactFormat, + resolveArtifactPathFromAssetUrl, + resolveAttachmentDownloadUrl, + resolveAttachmentFileName, +} from "./media-library-utils"; import { RelationActionButton } from "./relations"; import { SubIssuesActionButton } from "./sub-issues"; import { IssueDetailWidgetButton } from "./widget-button"; @@ -23,12 +45,216 @@ type Props = { disabled: boolean; issueServiceType: TIssueServiceType; hideWidgets?: TWorkItemWidgets[]; + hideMediaLibraryButton?: boolean; }; -export const IssueDetailWidgetActionButtons: FC<Props> = (props) => { - const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props; +type TMediaLibraryAddResult = { + total: number; + successCount: number; + skippedCount: number; + failedCount: number; +}; + +export { + DOC_FORMATS, + IMAGE_FORMATS, + VIDEO_FORMATS, + buildArtifactName, + buildEventMeta, + getErrorMessage, + isDuplicateArtifactError, + resolveArtifactAction, + resolveArtifactFormat, + resolveArtifactPathFromAssetUrl, + resolveAttachmentDownloadUrl, + resolveAttachmentFileName, +}; +const EMPTY_ATTACHMENT_IDS: string[] = []; + +export const IssueDetailWidgetActionButtons: FC<Props> = observer((props) => { + const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets, hideMediaLibraryButton } = + props; // translation const { t } = useTranslation(); + // store hooks + const { issue: issueStore, attachment, fetchAttachments } = useIssueDetail(issueServiceType); + const { getUserDetails } = useMember(); + // state + const [isAddingToMediaLibrary, setIsAddingToMediaLibrary] = useState(false); + // services + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + + const issue = issueStore.getIssueById(issueId); + const createdByDetails = issue?.created_by ? getUserDetails(issue.created_by) : undefined; + const createdByName = createdByDetails?.display_name?.includes("-intake") + ? "Plane" + : createdByDetails?.display_name ?? issue?.created_by ?? ""; + const baseEventMeta = useMemo(() => buildEventMeta(issue, createdByName), [issue, createdByName]); + const attachmentIds = attachment.getAttachmentsByIssueId(issueId) ?? EMPTY_ATTACHMENT_IDS; + const attachmentCount = issue?.attachment_count ?? attachmentIds.length; + const showMediaLibraryButton = + attachmentCount > 0 && !hideWidgets?.includes("attachments") && !hideMediaLibraryButton; + + const handleAddAssetsToMediaLibrary = useCallback(async (): Promise<TMediaLibraryAddResult> => { + if (!workspaceSlug || !projectId || !issueId) { + throw new Error("Missing required fields."); + } + + setIsAddingToMediaLibrary(true); + try { + let resolvedAttachments = attachmentIds + .map((attachmentId) => attachment.getAttachmentById(attachmentId)) + .filter((item): item is TIssueAttachment => Boolean(item)); + + if (resolvedAttachments.length === 0) { + resolvedAttachments = await fetchAttachments(workspaceSlug, projectId, issueId); + } + + if (resolvedAttachments.length === 0) { + throw new Error("No attachments found for this work item."); + } + + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + if (!packageId) { + throw new Error("Media library package not available."); + } + + const result: TMediaLibraryAddResult = { + total: resolvedAttachments.length, + successCount: 0, + skippedCount: 0, + failedCount: 0, + }; + + for (const attachmentItem of resolvedAttachments) { + const fileName = resolveAttachmentFileName(attachmentItem); + const format = resolveArtifactFormat(fileName); + if (!format) { + result.skippedCount += 1; + continue; + } + + const assetUrl = getFileURL(attachmentItem.asset_url ?? ""); + if (!assetUrl) { + result.failedCount += 1; + continue; + } + + try { + const directPath = resolveArtifactPathFromAssetUrl(assetUrl); + const artifactName = buildArtifactName(fileName, attachmentItem.id); + const title = getFileName(fileName) || "Attachment"; + const action = resolveArtifactAction(format); + const meta: Record<string, unknown> = { ...baseEventMeta }; + + if (DOC_FORMATS.has(format)) { + meta.kind = "document_file"; + meta.file_size = attachmentItem.attributes?.size; + meta.file_type = format; + } + + if (directPath) { + await mediaLibraryService.createArtifact(workspaceSlug, projectId, packageId, { + name: artifactName, + title, + format, + link: null, + action, + meta, + work_item_id: issueId, + path: directPath, + }); + result.successCount += 1; + continue; + } + + const downloadUrl = await resolveAttachmentDownloadUrl(assetUrl); + if (!downloadUrl) { + throw new Error(`Unable to fetch "${fileName}".`); + } + const response = await fetch(downloadUrl); + if (!response.ok) { + throw new Error(`Unable to fetch "${fileName}".`); + } + const blob = await response.blob(); + const file = new File([blob], fileName, { type: blob.type || undefined }); + + await mediaLibraryService.uploadArtifact( + workspaceSlug, + projectId, + packageId, + { + name: artifactName, + title, + format, + link: null, + action, + meta, + work_item_id: issueId, + }, + file + ); + result.successCount += 1; + } catch (error) { + if (isDuplicateArtifactError(error)) { + result.skippedCount += 1; + } else { + result.failedCount += 1; + } + } + } + + if (result.successCount === 0) { + if (result.skippedCount > 0 && result.failedCount === 0) { + throw new Error("Assets already exist in the media library."); + } + if (result.skippedCount > 0 && result.failedCount > 0) { + throw new Error("Some assets could not be added to the media library."); + } + throw new Error("Unable to add assets to the media library."); + } + + return result; + } finally { + setIsAddingToMediaLibrary(false); + } + }, [ + attachment, + attachmentIds, + baseEventMeta, + fetchAttachments, + issueId, + mediaLibraryService, + projectId, + workspaceSlug, + ]); + + const handleAddAssetsClick = useCallback(() => { + if (disabled || isAddingToMediaLibrary) return; + const addAssetsPromise = handleAddAssetsToMediaLibrary(); + setPromiseToast(addAssetsPromise, { + loading: "Adding assets to media library...", + success: { + title: "Assets added", + message: (data) => { + if (!data) return "Assets added to the media library."; + const { total, successCount, skippedCount, failedCount } = data; + if (failedCount === 0 && skippedCount === 0) { + return `${successCount} of ${total} assets added to the media library.`; + } + if (failedCount === 0) { + return `${successCount} of ${total} assets added. ${skippedCount} skipped.`; + } + return `${successCount} of ${total} assets added. ${skippedCount} skipped, ${failedCount} failed.`; + }, + }, + error: { + title: "Assets not added", + message: (error) => getErrorMessage(error) || "Unable to add assets to the media library.", + }, + }); + }, [disabled, handleAddAssetsToMediaLibrary, isAddingToMediaLibrary]); return ( <div className="flex items-center flex-wrap gap-2"> @@ -89,6 +315,20 @@ export const IssueDetailWidgetActionButtons: FC<Props> = (props) => { issueServiceType={issueServiceType} /> )} + {showMediaLibraryButton && ( + <button + type="button" + onClick={handleAddAssetsClick} + disabled={disabled || isAddingToMediaLibrary} + className="disabled:cursor-not-allowed" + > + <IssueDetailWidgetButton + title="Add assets in media library" + icon={<UploadCloud className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />} + disabled={disabled || isAddingToMediaLibrary} + /> + </button> + )} <WorkItemAdditionalWidgetActionButtons disabled={disabled} hideWidgets={hideWidgets ?? []} @@ -99,4 +339,4 @@ export const IssueDetailWidgetActionButtons: FC<Props> = (props) => { /> </div> ); -}; +}); diff --git a/apps/web/core/components/issues/issue-detail-widgets/attachments/content.tsx b/apps/web/core/components/issues/issue-detail-widgets/attachments/content.tsx index ff986df04fe..f95fdbb69cd 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/attachments/content.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/attachments/content.tsx @@ -13,11 +13,19 @@ type Props = { projectId: string; issueId: string; disabled: boolean; + confirmManifestOnDelete?: boolean; issueServiceType?: TIssueServiceType; }; export const IssueAttachmentsCollapsibleContent: FC<Props> = observer((props) => { - const { workspaceSlug, projectId, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props; + const { + workspaceSlug, + projectId, + issueId, + disabled, + confirmManifestOnDelete = false, + issueServiceType = EIssueServiceType.ISSUES, + } = props; // helper const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId, issueServiceType); return ( @@ -27,6 +35,7 @@ export const IssueAttachmentsCollapsibleContent: FC<Props> = observer((props) => issueId={issueId} disabled={disabled} attachmentHelpers={attachmentHelpers} + confirmManifestOnDelete={confirmManifestOnDelete} issueServiceType={issueServiceType} /> ); diff --git a/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx b/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx index 4f2c0d35feb..de00a2e196c 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx @@ -7,12 +7,14 @@ import { EIssueServiceType } from "@plane/types"; // hooks import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { MediaLibraryService } from "@/services/media-library.service"; // types import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store"; +import { buildArtifactName, resolveAttachmentFileName } from "../media-library-utils"; export type TAttachmentOperations = { create: (file: File) => Promise<void>; - remove: (attachmentId: string) => Promise<void>; + remove: (attachmentId: string, options?: { removeFromManifest?: boolean }) => Promise<void>; }; export type TAttachmentSnapshot = { @@ -31,8 +33,9 @@ export const useAttachmentOperations = ( issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES ): TAttachmentHelpers => { const { - attachment: { createAttachment, removeAttachment, getAttachmentsUploadStatusByIssueId }, + attachment: { createAttachment, removeAttachment, getAttachmentsUploadStatusByIssueId, getAttachmentById }, } = useIssueDetail(issueServiceType); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); const attachmentOperations: TAttachmentOperations = useMemo( () => ({ @@ -66,15 +69,29 @@ export const useAttachmentOperations = ( throw error; } }, - remove: async (attachmentId) => { + remove: async (attachmentId, options) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + const removeFromManifest = options?.removeFromManifest ?? true; + const attachment = getAttachmentById(attachmentId); + const artifactName = attachment ? buildArtifactName(resolveAttachmentFileName(attachment), attachmentId) : ""; await removeAttachment(workspaceSlug, projectId, issueId, attachmentId); setToast({ message: "The attachment has been successfully removed", type: TOAST_TYPE.SUCCESS, title: "Attachment removed", }); + if (artifactName && removeFromManifest) { + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + if (packageId) { + await mediaLibraryService.deleteArtifact(workspaceSlug, projectId, packageId, artifactName); + } + } catch { + // Ignore media library cleanup errors to avoid blocking attachment removal. + } + } captureSuccess({ eventName: WORK_ITEM_TRACKER_EVENTS.attachment.remove, payload: { id: issueId }, @@ -93,7 +110,7 @@ export const useAttachmentOperations = ( } }, }), - [workspaceSlug, projectId, issueId, createAttachment, removeAttachment] + [workspaceSlug, projectId, issueId, createAttachment, removeAttachment, getAttachmentById, mediaLibraryService] ); const attachmentsUploadStatus = getAttachmentsUploadStatusByIssueId(issueId); diff --git a/apps/web/core/components/issues/issue-detail-widgets/attachments/root.tsx b/apps/web/core/components/issues/issue-detail-widgets/attachments/root.tsx index a40fc909eb4..70569fe4fdc 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/attachments/root.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/attachments/root.tsx @@ -16,11 +16,19 @@ type Props = { projectId: string; issueId: string; disabled?: boolean; + confirmManifestOnDelete?: boolean; issueServiceType: TIssueServiceType; }; export const AttachmentsCollapsible: FC<Props> = observer((props) => { - const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props; + const { + workspaceSlug, + projectId, + issueId, + disabled = false, + confirmManifestOnDelete = false, + issueServiceType, + } = props; // store hooks const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType); @@ -48,6 +56,7 @@ export const AttachmentsCollapsible: FC<Props> = observer((props) => { projectId={projectId} issueId={issueId} disabled={disabled} + confirmManifestOnDelete={confirmManifestOnDelete} issueServiceType={issueServiceType} /> </Collapsible> diff --git a/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx b/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx index 1e138f854b3..d66041e2eca 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx @@ -22,10 +22,19 @@ type Props = { disabled: boolean; issueServiceType: TIssueServiceType; hideWidgets?: TWorkItemWidgets[]; + confirmManifestOnDelete?: boolean; }; export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => { - const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props; + const { + workspaceSlug, + projectId, + issueId, + disabled, + issueServiceType, + hideWidgets, + confirmManifestOnDelete = false, + } = props; // store hooks const { issue: { getIssueById }, @@ -82,6 +91,7 @@ export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => { projectId={projectId} issueId={issueId} disabled={disabled} + confirmManifestOnDelete={confirmManifestOnDelete} issueServiceType={issueServiceType} /> )} diff --git a/apps/web/core/components/issues/issue-detail-widgets/media-library-utils.ts b/apps/web/core/components/issues/issue-detail-widgets/media-library-utils.ts new file mode 100644 index 00000000000..3762b42dec8 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail-widgets/media-library-utils.ts @@ -0,0 +1,186 @@ +"use client"; + +import { API_BASE_URL } from "@plane/constants"; +import type { TIssue, TIssueAttachment } from "@plane/types"; +import { getFileExtension, getFileName } from "@plane/utils"; + +export const IMAGE_FORMATS = new Set([ + "jpg", + "jpeg", + "png", + "svg", + "webp", + "gif", + "bmp", + "tif", + "tiff", + "avif", + "heic", + "heif", +]); + +export const VIDEO_FORMATS = new Set(["mp4", "m3u8", "mov", "webm", "avi", "mkv", "mpeg", "mpg", "m4v"]); +export const DOC_FORMATS = new Set(["json", "csv", "pdf", "docx", "xlsx", "pptx", "txt"]); + +export const resolveArtifactFormat = (fileName: string) => { + const extension = getFileExtension(fileName).toLowerCase(); + if (IMAGE_FORMATS.has(extension)) return extension; + if (VIDEO_FORMATS.has(extension)) return extension; + if (DOC_FORMATS.has(extension)) return extension; + return ""; +}; + +export const resolveArtifactAction = (format: string) => { + if (VIDEO_FORMATS.has(format)) return "play"; + if (IMAGE_FORMATS.has(format)) return "view"; + return "download"; +}; + +const sanitizeArtifactSegment = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + +export const buildArtifactName = (fileName: string, attachmentId: string) => { + const baseName = sanitizeArtifactSegment(getFileName(fileName) || "attachment"); + const suffix = sanitizeArtifactSegment(attachmentId) || `${Date.now()}`; + return baseName ? `${baseName}-${suffix}` : `attachment-${suffix}`; +}; + +export const resolveAttachmentFileName = (attachment: TIssueAttachment) => { + if (attachment.attributes?.name) return attachment.attributes.name; + const rawUrl = attachment.asset_url ?? ""; + const baseUrl = rawUrl.split("?")[0]; + const segments = baseUrl.split("/").filter(Boolean); + return segments[segments.length - 1] || "attachment"; +}; + +const resolveAbsoluteAssetUrl = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + const origin = + typeof window !== "undefined" + ? window.location.origin + : API_BASE_URL + ? (() => { + try { + return new URL(API_BASE_URL).origin; + } catch { + return API_BASE_URL; + } + })() + : ""; + if (!origin) return trimmed; + try { + return new URL(trimmed, origin).toString(); + } catch { + return trimmed; + } +}; + +export const resolveArtifactPathFromAssetUrl = (rawUrl: string) => { + const trimmed = rawUrl.trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("data:") || trimmed.startsWith("blob:")) return ""; + const absolute = resolveAbsoluteAssetUrl(trimmed); + if (!/^https?:\/\//i.test(absolute)) return ""; + const origins = new Set<string>(); + if (typeof window !== "undefined") { + origins.add(window.location.origin); + } + if (API_BASE_URL) { + try { + origins.add(new URL(API_BASE_URL).origin); + } catch { + // ignore invalid API base url + } + } + try { + const parsed = new URL(absolute); + if (!origins.has(parsed.origin)) return ""; + } catch { + return ""; + } + return absolute; +}; + +export const getErrorMessage = (error: unknown) => { + if (!error) return ""; + if (typeof error === "string") return error; + if (error instanceof Error) return error.message; + if (typeof error === "object") { + const errorObj = error as { error?: string; message?: string }; + if (typeof errorObj.error === "string") return errorObj.error; + if (typeof errorObj.message === "string") return errorObj.message; + } + return ""; +}; + +const toAbsoluteApiUrl = (rawUrl: string) => { + if (!rawUrl) return ""; + if (/^https?:\/\//i.test(rawUrl)) return rawUrl; + if (!API_BASE_URL) return rawUrl; + try { + return new URL(rawUrl, API_BASE_URL).toString(); + } catch { + return rawUrl; + } +}; + +export const resolveAttachmentDownloadUrl = async (rawUrl: string) => { + if (!rawUrl) return ""; + const normalizedUrl = toAbsoluteApiUrl(rawUrl); + if (!API_BASE_URL || !normalizedUrl.startsWith(API_BASE_URL)) { + return normalizedUrl || rawUrl; + } + + const url = new URL(normalizedUrl); + url.searchParams.set("response", "json"); + const response = await fetch(url.toString(), { credentials: "include" }); + if (!response.ok) { + throw new Error("Unable to access attachment."); + } + const contentType = response.headers.get("content-type") ?? ""; + const contentLength = Number(response.headers.get("content-length") ?? "NaN"); + const shouldAttemptJson = + contentType.includes("application/json") || + (Number.isFinite(contentLength) && contentLength > 0 && contentLength < 1024 * 1024); + + if (!shouldAttemptJson) { + response.body?.cancel?.(); + return normalizedUrl; + } + + try { + const data = (await response.json()) as { url?: string }; + return data.url ?? normalizedUrl; + } catch { + response.body?.cancel?.(); + return normalizedUrl; + } +}; + +export const buildEventMeta = (issue?: TIssue, createdBy?: string) => { + const meta: Record<string, unknown> = { + category: issue?.category || "Work items", + source: "work_item_attachment", + }; + + if (createdBy) meta.created_by = createdBy; + if (issue?.start_date) meta.start_date = issue.start_date; + if (issue?.start_time) meta.start_time = issue.start_time; + if (issue?.level) meta.level = issue.level; + if (issue?.program) meta.program = issue.program; + if (issue?.sport) meta.sport = issue.sport; + if (issue?.opposition_team) meta.opposition = issue.opposition_team; + if (issue?.year) meta.season = issue.year; + + return meta; +}; + +export const isDuplicateArtifactError = (error: unknown) => { + const message = getErrorMessage(error).toLowerCase(); + return message.includes("already exists") || message.includes("duplicate"); +}; diff --git a/apps/web/core/components/issues/issue-detail-widgets/root.tsx b/apps/web/core/components/issues/issue-detail-widgets/root.tsx index 12ac4974441..237c1c474e7 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/root.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/root.tsx @@ -17,6 +17,8 @@ type Props = { renderWidgetModals?: boolean; issueServiceType: TIssueServiceType; hideWidgets?: TWorkItemWidgets[]; + hideMediaLibraryButton?: boolean; + confirmManifestOnDelete?: boolean; }; export const IssueDetailWidgets: FC<Props> = (props) => { @@ -28,6 +30,8 @@ export const IssueDetailWidgets: FC<Props> = (props) => { renderWidgetModals = true, issueServiceType, hideWidgets, + hideMediaLibraryButton, + confirmManifestOnDelete = false, } = props; return ( @@ -40,6 +44,7 @@ export const IssueDetailWidgets: FC<Props> = (props) => { disabled={disabled} issueServiceType={issueServiceType} hideWidgets={hideWidgets} + hideMediaLibraryButton={hideMediaLibraryButton} /> <IssueDetailWidgetCollapsibles workspaceSlug={workspaceSlug} @@ -48,6 +53,7 @@ export const IssueDetailWidgets: FC<Props> = (props) => { disabled={disabled} issueServiceType={issueServiceType} hideWidgets={hideWidgets} + confirmManifestOnDelete={confirmManifestOnDelete} /> </div> {renderWidgetModals && ( @@ -58,6 +64,7 @@ export const IssueDetailWidgets: FC<Props> = (props) => { issueServiceType={issueServiceType} hideWidgets={hideWidgets} /> + // <></> )} </> ); diff --git a/apps/web/core/components/issues/issue-detail/label/root.tsx b/apps/web/core/components/issues/issue-detail/label/root.tsx index 5511eb0a615..c0285234d7c 100644 --- a/apps/web/core/components/issues/issue-detail/label/root.tsx +++ b/apps/web/core/components/issues/issue-detail/label/root.tsx @@ -80,7 +80,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => { return labelResponse; } catch (error) { let errMessage = t("label.create.failed"); - if (error && (error as any).error === "Label with the same name already exists in the project") + if (error && (error as any).error === "Label with the same name already exists in the program") errMessage = t("label.create.already_exists"); setToast({ diff --git a/apps/web/core/components/issues/issue-detail/main-content.tsx b/apps/web/core/components/issues/issue-detail/main-content.tsx index 963e6f647ba..29ed6744ab6 100644 --- a/apps/web/core/components/issues/issue-detail/main-content.tsx +++ b/apps/web/core/components/issues/issue-detail/main-content.tsx @@ -85,6 +85,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => { if (!issue || !issue.project_id) return <></>; const isPeekModeActive = Boolean(peekIssue); + const isContentReadOnly = isArchived || !isEditable; return ( <> @@ -100,7 +101,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => { )} <div className="mb-2.5 flex items-center justify-between gap-4"> - <IssueTypeSwitcher issueId={issueId} disabled={isArchived || !isEditable} /> + <IssueTypeSwitcher issueId={issueId} disabled={isContentReadOnly} /> <div className="flex items-center gap-3"> <NameDescriptionUpdateStatus isSubmitting={isSubmitting} /> {duplicateIssues?.length > 0 && ( @@ -123,7 +124,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => { isSubmitting={isSubmitting} setIsSubmitting={(value) => setIsSubmitting(value)} issueOperations={issueOperations} - disabled={isArchived || !isEditable} + disabled={isContentReadOnly} value={issue.name} containerClassName="-ml-3" /> @@ -134,7 +135,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => { projectId={issue.project_id} issueId={issue.id} initialValue={issue.description_html} - disabled={isArchived || !isEditable} + disabled={isContentReadOnly} issueOperations={issueOperations} setIsSubmitting={(value) => setIsSubmitting(value)} containerClassName="-ml-3 border-none" @@ -158,7 +159,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => { createdAt: issue.created_at ? new Date(issue.created_at) : new Date(), createdByDisplayName: getUserDetails(issue.created_by ?? "")?.display_name ?? "", id: issueId, - isRestoreDisabled: !isEditable || isArchived, + isRestoreDisabled: isContentReadOnly, }} fetchHandlers={{ listDescriptionVersions: (issueId) => @@ -178,7 +179,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} - disabled={!isEditable || isArchived} + disabled={isContentReadOnly} renderWidgetModals={!isPeekModeActive} issueServiceType={EIssueServiceType.ISSUES} /> @@ -189,7 +190,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={!isEditable || isArchived} + disabled={isContentReadOnly} /> )} diff --git a/apps/web/core/components/issues/issue-detail/root.tsx b/apps/web/core/components/issues/issue-detail/root.tsx index 10c77beb560..24baaf0bf1a 100644 --- a/apps/web/core/components/issues/issue-detail/root.tsx +++ b/apps/web/core/components/issues/issue-detail/root.tsx @@ -23,6 +23,7 @@ import emptyIssue from "@/public/empty-state/issue.svg"; // local components import { IssuePeekOverview } from "../peek-overview"; import { IssueMainContent } from "./main-content"; +import { SgEventDetailPage } from "./sg-event-detail-page"; import { IssueDetailsSidebar } from "./sidebar"; export type TIssueOperations = { @@ -56,6 +57,8 @@ export type TIssueDetailRoot = { is_archived?: boolean; }; +type SgIssue = TIssue & { sg_event_id?: string | number | null }; + export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => { const { t } = useTranslation(); const { workspaceSlug, projectId, issueId, is_archived = false } = props; @@ -281,6 +284,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => { // issue details const issue = getIssueById(issueId); + const sgIssue = issue as SgIssue | undefined; // checking if issue is editable, based on user role const isEditable = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -288,6 +292,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => { workspaceSlug, projectId ); + const hasSgEventId = sgIssue?.sg_event_id != null && String(sgIssue.sg_event_id).trim().length > 0; return ( <> @@ -302,30 +307,34 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => { }} /> ) : ( - <div className="flex h-full w-full overflow-hidden"> - <div className="max-w-2/3 h-full w-full space-y-8 overflow-y-auto px-9 py-5"> - <IssueMainContent - workspaceSlug={workspaceSlug} - projectId={projectId} - issueId={issueId} - issueOperations={issueOperations} - isEditable={isEditable} - isArchived={is_archived} - /> - </div> - <div - className="fixed right-0 z-[5] h-full w-full min-w-[300px] border-l border-custom-border-200 bg-custom-sidebar-background-100 py-5 sm:w-1/2 md:relative md:w-1/3 lg:min-w-80 xl:min-w-96" - style={issueDetailSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}} - > - <IssueDetailsSidebar - workspaceSlug={workspaceSlug} - projectId={projectId} - issueId={issueId} - issueOperations={issueOperations} - isEditable={!is_archived && isEditable} - /> + hasSgEventId ? ( + <SgEventDetailPage issue={issue} projectId={projectId} workspaceSlug={workspaceSlug} /> + ) : ( + <div className="flex h-full w-full overflow-hidden"> + <div className="max-w-2/3 h-full w-full space-y-8 overflow-y-auto px-9 py-5"> + <IssueMainContent + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + issueOperations={issueOperations} + isEditable={isEditable} + isArchived={is_archived} + /> + </div> + <div + className="fixed right-0 z-[5] h-full w-full min-w-[300px] border-l border-custom-border-200 bg-custom-sidebar-background-100 py-5 sm:w-1/2 md:relative md:w-1/3 lg:min-w-80 xl:min-w-96" + style={issueDetailSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}} + > + <IssueDetailsSidebar + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + issueOperations={issueOperations} + isEditable={!is_archived && isEditable} + /> + </div> </div> - </div> + ) )} {/* peek overview */} diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page.tsx new file mode 100644 index 00000000000..d7a61db1c6b --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export * from "./sg-event-detail-page/page"; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/constants.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/constants.ts new file mode 100644 index 00000000000..3359eeb52f2 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/constants.ts @@ -0,0 +1,425 @@ +import type { RowFilterMode, SportTableConfig, SportTableKind } from "./types"; + +export const SG_PLAYER_STYLE = ` + .sg-event-player .video-js { + width: 100%; + height: 100%; + background: #0f1014; + border-radius: 5px; + overflow: hidden; + } + .sg-event-player .video-js .vjs-tech { + object-fit: contain; + background: #05060a; + } + .sg-event-player .video-js .vjs-big-play-button { + display: none; + } + .sg-event-player .video-js .sg-event-annotation-button { + position: absolute; + top: 14px; + right: 14px; + z-index: 30; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 38px; + min-width: 118px; + padding: 0 14px; + border: 1px solid #2d9fdb; + border-radius: 7px; + background: #2296d3; + color: #ffffff; + font-size: 13px; + font-weight: 700; + line-height: 1; + letter-spacing: 0; + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.38); + cursor: pointer; + transition: + background-color 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; + } + .sg-event-player .video-js .sg-event-annotation-button:hover { + transform: translateY(-1px); + border-color: #39aeea; + background: #258cca; + box-shadow: 0 16px 38px rgba(0, 0, 0, 0.44); + } + .sg-event-player .video-js .sg-event-annotation-button:active { + transform: translateY(0); + } + .sg-event-player .video-js .sg-event-annotation-button:focus-visible { + outline: 2px solid rgba(56, 189, 248, 0.72); + outline-offset: 2px; + } + .sg-event-player .video-js .sg-event-annotation-button svg { + width: 16px; + height: 16px; + flex: 0 0 auto; + stroke-width: 2; + } + .sg-event-player .video-js .sg-event-settings-panel { + position: absolute; + right: 12px; + bottom: 58px; + z-index: 40; + width: min(220px, calc(100% - 24px)); + max-height: calc(100% - 96px); + overflow-y: auto; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: rgba(18, 18, 18, 0.96); + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.42); + color: #f9fafb; + } + .sg-event-player .video-js .sg-event-settings-title { + margin-bottom: 8px; + color: rgba(249, 250, 251, 0.62); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.16em; + line-height: 1; + text-transform: uppercase; + } + .sg-event-player .video-js .sg-event-settings-options { + display: flex; + flex-direction: column; + gap: 2px; + } + .sg-event-player .video-js .sg-event-settings-option { + display: flex; + width: 100%; + min-height: 32px; + align-items: center; + gap: 8px; + border: 0; + border-radius: 6px; + background: transparent; + padding: 7px 8px; + color: rgba(249, 250, 251, 0.76); + cursor: pointer; + font-size: 12px; + font-weight: 600; + line-height: 1.2; + text-align: left; + transition: + background-color 160ms ease, + color 160ms ease; + } + .sg-event-player .video-js .sg-event-settings-option:hover { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + } + .sg-event-player .video-js .sg-event-settings-option.is-active { + background: rgba(45, 159, 219, 0.18); + color: #ffffff; + } + .sg-event-player .video-js .sg-event-settings-option.is-disabled { + cursor: not-allowed; + opacity: 0.6; + } + .sg-event-player .video-js .sg-event-settings-check { + display: inline-flex; + width: 14px; + height: 14px; + flex: 0 0 14px; + align-items: center; + justify-content: center; + color: #4fc3ff; + opacity: 0; + } + .sg-event-player .video-js .sg-event-settings-option.is-active .sg-event-settings-check { + opacity: 1; + } + .sg-event-player .video-js .sg-event-settings-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .sg-event-player .video-js .vjs-control-bar { + display: flex; + align-items: center; + gap: 4px; + height: 78px; + padding: 34px 12px 8px; + background: rgba(12, 12, 12, 0.78); + inset-inline: 0; + bottom: 0; + z-index: 30; + } + .sg-event-player .video-js .vjs-control, + .sg-event-player .video-js .vjs-time-control { + color: #ffffff; + font-size: 11px; + } + .sg-event-player .video-js .vjs-button { + width: 24px; + min-width: 24px; + height: 26px; + padding: 0; + } + .sg-event-player .video-js .vjs-button > .vjs-icon-placeholder:before { + font-size: 15px; + line-height: 26px; + } + .sg-event-player .video-js .vjs-current-time, + .sg-event-player .video-js .vjs-duration { + display: block; + min-width: auto; + width: auto; + height: 26px; + padding: 0; + line-height: 26px; + order: 1; + } + .sg-event-player .video-js .vjs-current-time { + margin-right: 1px; + } + .sg-event-player .video-js .vjs-duration { + margin-left: 0; + } + .sg-event-player .video-js .vjs-duration:before { + content: "/"; + padding: 0 2px; + } + .sg-event-player .video-js .vjs-progress-control { + position: absolute; + inset: 23px 12px auto 12px; + width: auto; + height: 12px; + padding: 0; + margin: 0; + display: block; + order: 0; + } + .sg-event-player .video-js .vjs-progress-holder, + .sg-event-player .video-js .vjs-volume-bar { + border-radius: 999px; + background: rgba(255, 255, 255, 0.18); + } + .sg-event-player .video-js .vjs-slider-horizontal { + height: 3px; + } + .sg-event-player .video-js .vjs-progress-control .vjs-progress-holder { + height: 3px; + margin: 4px 0; + background: rgba(255, 255, 255, 0.72); + } + .sg-event-player .video-js .vjs-play-progress, + .sg-event-player .video-js .vjs-volume-level { + border-radius: 999px; + background: #ffffff; + } + .sg-event-player .video-js .vjs-play-progress:before, + .sg-event-player .video-js .vjs-volume-level:before { + display: none; + } + .sg-event-player .video-js .vjs-volume-panel { + order: 2; + width: 28px; + height: 26px; + margin-left: 4px; + } + .sg-event-player .video-js .vjs-volume-panel .vjs-volume-control { + display: none; + } + .sg-event-player .video-js .vjs-previous-button, + .sg-event-player .video-js .vjs-skip-backward-button, + .sg-event-player .video-js .vjs-play-control, + .sg-event-player .video-js .vjs-skip-forward-button, + .sg-event-player .video-js .vjs-next-button { + position: absolute; + display: inline-flex; + align-items: center; + justify-content: center; + left: 50%; + bottom: 8px; + margin: 0; + } + .sg-event-player .video-js .vjs-previous-button .vjs-icon-placeholder, + .sg-event-player .video-js .vjs-skip-backward-button .vjs-icon-placeholder, + .sg-event-player .video-js .vjs-play-control .vjs-icon-placeholder, + .sg-event-player .video-js .vjs-skip-forward-button .vjs-icon-placeholder, + .sg-event-player .video-js .vjs-next-button .vjs-icon-placeholder { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + .sg-event-player .video-js .vjs-play-control .vjs-icon-placeholder:before { + position: static; + width: auto; + height: auto; + } + .sg-event-player .video-js .vjs-previous-button { + transform: translateX(calc(-50% - 48px)); + } + .sg-event-player .video-js .vjs-skip-backward-button { + transform: translateX(calc(-50% - 24px)); + } + .sg-event-player .video-js .vjs-play-control { + transform: translateX(-50%); + } + .sg-event-player .video-js .vjs-skip-forward-button { + transform: translateX(calc(-50% + 24px)); + } + .sg-event-player .video-js .vjs-next-button { + transform: translateX(calc(-50% + 48px)); + } + .sg-event-player .video-js .vjs-subs-caps-button, + .sg-event-player .video-js .vjs-loop-button, + .sg-event-player .video-js .vjs-picture-in-picture-control, + .sg-event-player .video-js .vjs-fullscreen-control, + .sg-event-player .video-js .vjs-settings-button { + position: relative; + bottom: auto; + margin: 0; + order: 20; + } + .sg-event-player .video-js .vjs-loop-button { + margin-left: auto; + } + .sg-event-player .video-js .vjs-subs-caps-button { + margin-left: 4px; + } + .sg-event-player .video-js .vjs-picture-in-picture-control { + margin-left: 4px; + } + .sg-event-player .video-js .vjs-fullscreen-control { + margin-left: 4px; + } + .sg-event-player .video-js .vjs-settings-button { + margin-left: 4px; + } + .sg-event-player .video-js .vjs-skip-backward-button .vjs-icon-placeholder:before, + .sg-event-player .video-js .vjs-skip-forward-button .vjs-icon-placeholder:before, + .sg-event-player .video-js .vjs-previous-button .vjs-icon-placeholder:before, + .sg-event-player .video-js .vjs-next-button .vjs-icon-placeholder:before, + .sg-event-player .video-js .vjs-loop-button .vjs-icon-placeholder:before, + .sg-event-player .video-js .vjs-settings-button .vjs-icon-placeholder:before { + content: ""; + } + .sg-event-player .video-js .vjs-skip-backward-button .vjs-icon-placeholder, + .sg-event-player .video-js .vjs-skip-forward-button .vjs-icon-placeholder, + .sg-event-player .video-js .vjs-previous-button .vjs-icon-placeholder, + .sg-event-player .video-js .vjs-next-button .vjs-icon-placeholder, + .sg-event-player .video-js .vjs-loop-button .vjs-icon-placeholder, + .sg-event-player .video-js .vjs-settings-button .vjs-icon-placeholder { + display: block; + width: 13px; + height: 13px; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + } + .sg-event-player .video-js .vjs-previous-button .vjs-icon-placeholder { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='11' height='12' viewBox='0 0 11 12' fill='none'><path d='M9.5 10.75L3.25 5.75L9.5 0.75V10.75Z' stroke='%23E5E7EB' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M0.75 10.125V1.375' stroke='%23E5E7EB' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>"); + } + .sg-event-player .video-js .vjs-skip-backward-button .vjs-icon-placeholder { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='m11 17-5-5 5-5'/><path d='m18 17-5-5 5-5'/></svg>"); + } + .sg-event-player .video-js .vjs-skip-forward-button .vjs-icon-placeholder { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='m13 17 5-5-5-5'/><path d='m6 17 5-5-5-5'/></svg>"); + } + .sg-event-player .video-js .vjs-next-button .vjs-icon-placeholder { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='11' height='12' viewBox='0 0 11 12' fill='none'><path d='M1.5 10.75L7.75 5.75L1.5 0.75V10.75Z' stroke='%23E5E7EB' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M10.25 10.125V1.375' stroke='%23E5E7EB' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>"); + } + .sg-event-player .video-js .vjs-loop-button .vjs-icon-placeholder { + opacity: 0.58; + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M17 2l4 4-4 4'/><path d='M3 11V9a3 3 0 0 1 3-3h15'/><path d='M7 22l-4-4 4-4'/><path d='M21 13v2a3 3 0 0 1-3 3H3'/></svg>"); + } + .sg-event-player .video-js .vjs-loop-button.vjs-control-active .vjs-icon-placeholder { + opacity: 1; + } + .sg-event-player .video-js .vjs-settings-button .vjs-icon-placeholder { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='3'/><path d='M19.4 15a1.7 1.7 0 0 0 .34 1.87l.09.09a2.1 2.1 0 1 1-2.97 2.97l-.09-.09A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2.1 2.1 0 1 1-4.2 0v-.05a1.7 1.7 0 0 0-1-1.55 1.7 1.7 0 0 0-1.83.44l-.09.09a2.1 2.1 0 1 1-2.97-2.97l.09-.09A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2.1 2.1 0 1 1 0-4.2h.05A1.7 1.7 0 0 0 4.6 8a1.7 1.7 0 0 0-.44-1.83l-.09-.09A2.1 2.1 0 1 1 6.99 3.1l.09.09A1.7 1.7 0 0 0 8.9 3.6a1.7 1.7 0 0 0 1-1.55V2a2.1 2.1 0 1 1 4.2 0v.05a1.7 1.7 0 0 0 1 1.55 1.7 1.7 0 0 0 1.83-.44l.09-.09A2.1 2.1 0 1 1 20.9 6.08l-.09.09A1.7 1.7 0 0 0 19.4 8c0 .7.42 1.34 1.05 1.55H21a2.1 2.1 0 1 1 0 4.2h-.05A1.7 1.7 0 0 0 19.4 15Z'/></svg>"); + } + @media (max-width: 640px) { + .sg-event-player .video-js .sg-event-settings-panel { + right: 8px; + bottom: 54px; + width: min(200px, calc(100% - 16px)); + } + } +`; + +export const SURFACE_CLASS = "rounded-lg border border-custom-border-200 bg-custom-background-100"; + +export const ICON_BUTTON_CLASS = + "inline-flex h-9 w-9 items-center justify-center rounded-lg border border-custom-border-200 bg-custom-background-100 text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100"; + +export const PLAYER_FRAME_CLASS = "h-[clamp(260px,42vw,505px)] w-full"; + +export const PLAYER_STAGE_CLASS = "mx-auto h-full w-full max-w-full overflow-hidden rounded-[5px]"; + +export const TAG_TABLE_GRID_CLASS = + "grid-cols-[56px_minmax(120px,150px)_minmax(96px,0.7fr)_minmax(150px,1.15fr)_minmax(110px,0.8fr)_minmax(150px,1fr)_minmax(130px,0.9fr)_minmax(120px,0.8fr)_96px]"; + +export const FOOTBALL_TAG_TABLE_GRID_CLASS = + "grid-cols-[56px_minmax(120px,150px)_minmax(96px,0.7fr)_minmax(150px,1.15fr)_minmax(110px,0.8fr)_minmax(150px,1fr)_minmax(130px,0.9fr)_minmax(120px,0.8fr)_96px]"; + +export const SPORT_TABLE_CONFIGS: Record<SportTableKind, SportTableConfig> = { + "american-football": { + actionLabel: "Primary Action", + defaultGroupValue: "Quarter 1", + groupByLabel: "Quarter", + isCompactFootballTable: true, + playerLabel: "Players", + primaryDetailLabel: "Yard", + secondaryDetailLabel: "", + sport: "american-football", + }, + baseball: { + actionLabel: "Action", + defaultGroupValue: "Top 1st", + groupByLabel: "Inning", + primaryDetailLabel: "Inning", + secondaryDetailLabel: "Count", + sport: "baseball", + }, + soccer: { + actionLabel: "Action", + defaultGroupValue: "All tags", + groupByLabel: "Period", + primaryDetailLabel: "Match Time", + secondaryDetailLabel: "Zone", + sport: "soccer", + }, + basketball: { + actionLabel: "Action", + defaultGroupValue: "Q1", + groupByLabel: "Quarter", + primaryDetailLabel: "Game Clock", + secondaryDetailLabel: "Value", + sport: "basketball", + }, + cricket: { + actionLabel: "Action", + defaultGroupValue: "Over 0", + groupByLabel: "Over", + primaryDetailLabel: "Over", + secondaryDetailLabel: "Runs", + sport: "cricket", + }, + default: { + actionLabel: "Action", + defaultGroupValue: "All tags", + groupByLabel: "Group", + primaryDetailLabel: "Phase", + secondaryDetailLabel: "Value", + sport: "default", + }, +}; + +export const ROW_FILTER_LABELS: Record<RowFilterMode, string> = { + all: "All rows", + favorites: "Favorites only", + selected: "Selected rows", +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/data.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/data.ts new file mode 100644 index 00000000000..d37d4bd08ab --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/data.ts @@ -0,0 +1,247 @@ +import type { MediaLibraryService } from "@/services/media-library.service"; +import type { TMediaItem } from "ce/features/media-library/types/media-library.types"; +import { getEventMediaDetails } from "ce/features/media-library/utils/media-event"; +import { mapArtifactsToMediaItems } from "ce/features/media-library/utils/media-items"; +import type { SgEventDevice, SgEventPayloadLoadResult, SgMediaPayload } from "./types"; +import { + asArray, + asRecord, + buildArchivedStreamUrl, + isCoachCompletedEventJsonItem, + parseGatewayRows, + toNumber, + toText, +} from "./utils"; + +const fetchEventJsonPayload = async (item: TMediaItem | null): Promise<SgEventPayloadLoadResult> => { + if (!isCoachCompletedEventJsonItem(item)) { + return { + eventPayload: null, + eventPayloadErrorMessage: null, + eventPayloadStatus: "unavailable", + }; + } + + const sourceUrl = item?.fileSrc || item?.downloadSrc || ""; + if (!sourceUrl) { + return { + eventPayload: null, + eventPayloadErrorMessage: null, + eventPayloadStatus: "unavailable", + }; + } + + let errorMessage = "Unable to load the completed event JSON."; + + for (const credentials of ["include", "omit"] as const) { + try { + const response = await fetch(sourceUrl, { credentials }); + if (!response.ok) { + errorMessage = `Unable to load the completed event JSON (HTTP ${response.status}).`; + continue; + } + + let payload: unknown; + try { + payload = await response.json(); + } catch (error) { + errorMessage = + error instanceof Error && error.message + ? `Unable to parse the completed event JSON: ${error.message}` + : "Unable to parse the completed event JSON."; + continue; + } + + const record = asRecord(payload); + if (Object.keys(record).length > 0) { + return { + eventPayload: record, + eventPayloadErrorMessage: null, + eventPayloadStatus: "loaded", + }; + } + + errorMessage = "The completed event JSON payload is empty or invalid."; + } catch (error) { + errorMessage = + error instanceof Error && error.message + ? `Unable to load the completed event JSON: ${error.message}` + : "Unable to load the completed event JSON."; + continue; + } + } + + return { + eventPayload: null, + eventPayloadErrorMessage: errorMessage, + eventPayloadStatus: "error", + }; +}; + +export const fetchSgEventDevices = async (cpServerBaseUrl: string, sgEventId: string): Promise<SgEventDevice[]> => { + const response = await fetch(`${cpServerBaseUrl}/event-device?event_id=${encodeURIComponent(sgEventId)}`, { + cache: "no-store", + }); + + if (!response.ok) { + return []; + } + + const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null; + + return parseGatewayRows(payload) + .map((row) => { + const id = toNumber(row.id); + const streamId = toText(row.stream_id ?? row.streamId); + const streamName = toText(row.stream_name ?? row.streamName); + + if (id === null || !streamName) { + return null; + } + + const name = toText(row.name); + + return { + hlsUrl: buildArchivedStreamUrl(streamName), + id, + name: name || `View ${id}`, + streamId: streamId || null, + streamName, + } satisfies SgEventDevice; + }) + .filter((device): device is SgEventDevice => Boolean(device)) + .sort((left, right) => left.id - right.id); +}; + +export const buildEventPayloadDevices = (payload: Record<string, unknown> | null): SgEventDevice[] => { + const root = asRecord(payload); + const deviceEntries = [...asArray(root.devices), ...asArray(root.mediaReferences)]; + const seenKeys = new Set<string>(); + + return deviceEntries + .map((entry, index) => { + const record = asRecord(entry); + const streamId = toText(record.streamId ?? record.stream_id); + const streamName = toText(record.streamName ?? record.stream_name); + const previewUrl = toText(record.previewUrl ?? record.preview_url); + + if (!streamName && !previewUrl) { + return null; + } + + const id = toNumber(record.activeDeviceId ?? record.deviceId ?? record.device_id) ?? index + 1; + const name = toText(record.name ?? record.appName ?? record.app_name) || `View ${Math.max(index + 1, 1)}`; + const hlsUrl = buildArchivedStreamUrl(streamName) || previewUrl || null; + const dedupeKey = streamName || previewUrl; + + if (!dedupeKey || seenKeys.has(dedupeKey)) { + return null; + } + seenKeys.add(dedupeKey); + + return { + hlsUrl, + id, + name, + streamId: streamId || null, + streamName, + } satisfies SgEventDevice; + }) + .filter((device): device is SgEventDevice => Boolean(device)) + .sort((left, right) => left.id - right.id); +}; + +export const loadSgMediaPayload = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + mediaItem: TMediaItem | null, + mediaLibraryService: MediaLibraryService +): Promise<SgMediaPayload> => { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : ""; + const manifestArtifacts = Array.isArray(manifest?.artifacts) ? manifest.artifacts : []; + const metadata = + manifest && typeof manifest === "object" && manifest.metadata && typeof manifest.metadata === "object" + ? (manifest.metadata as Record<string, Record<string, unknown>>) + : undefined; + + if (!packageId || manifestArtifacts.length === 0) { + return { + eventDetails: null, + eventPayload: null, + eventPayloadErrorMessage: null, + eventPayloadStatus: "unavailable", + eventItem: null, + manifestArtifacts, + mediaItems: [], + packageId, + videoItems: [], + }; + } + + const mediaItems = mapArtifactsToMediaItems(manifestArtifacts, { + metadata, + packageId, + projectId, + workspaceSlug, + }).filter((item) => item.format !== "thumbnail"); + + const relatedEventIdentifiers = new Set( + [mediaItem?.meta, mediaItem] + .flatMap((source) => { + const record = asRecord(source); + return [ + toText(record.sg_event_id), + toText(record.event_id), + toText(record.plane_event_id), + toText(record.eventId), + toText(record.planeEventId), + ]; + }) + .map((value) => value.trim()) + .filter(Boolean) + ); + + const scopedItems = mediaItems.filter((candidate) => { + if (issueId && candidate.workItemId === issueId) return true; + if (relatedEventIdentifiers.size === 0) return false; + const candidateMeta = asRecord(candidate.meta); + const candidateIdentifiers = [ + toText(candidateMeta.sg_event_id), + toText(candidateMeta.event_id), + toText(candidateMeta.plane_event_id), + toText(candidateMeta.eventId), + toText(candidateMeta.planeEventId), + ] + .map((value) => value.trim()) + .filter(Boolean); + + return candidateIdentifiers.some((identifier) => relatedEventIdentifiers.has(identifier)); + }); + + const filteredItems = + scopedItems.length > 0 + ? scopedItems + : issueId + ? mediaItems.filter((candidate) => candidate.workItemId === issueId) + : mediaItems; + const eventItem = + filteredItems.find((candidate) => candidate.id === mediaItem?.id) ?? + filteredItems.find((candidate) => isCoachCompletedEventJsonItem(candidate)) ?? + filteredItems.find((candidate) => Boolean(getEventMediaDetails(candidate))) ?? + mediaItem ?? + null; + const videoItems = filteredItems.filter((candidate) => candidate.mediaType === "video"); + const eventPayloadResult = await fetchEventJsonPayload(eventItem); + + return { + eventDetails: eventItem ? getEventMediaDetails(eventItem) : null, + ...eventPayloadResult, + eventItem, + mediaItems: filteredItems, + manifestArtifacts, + packageId, + videoItems, + }; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/details-card.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/details-card.tsx new file mode 100644 index 00000000000..88054f5892a --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/details-card.tsx @@ -0,0 +1,43 @@ +import { CalendarDays, MapPin, Trophy } from "lucide-react"; +import { cn } from "@plane/utils"; +import { SURFACE_CLASS } from "./constants"; + +type SgEventDetailsCardProps = { + eventDateTimeLabel: string; + levelLabel: string; + venueAddress: string; + venueName: string; +}; + +export const SgEventDetailsCard = ({ + eventDateTimeLabel, + levelLabel, + venueAddress, + venueName, +}: SgEventDetailsCardProps) => ( + <section className={cn(SURFACE_CLASS, "overflow-hidden")}> + <div className="border-b border-custom-border-200 px-4 py-3 text-sm font-semibold text-custom-text-100"> + Event details + </div> + <div className="grid gap-4 px-4 py-3.5 md:grid-cols-[minmax(180px,0.9fr)_minmax(260px,1.3fr)_minmax(120px,0.55fr)]"> + <div className="flex items-start gap-3 text-sm"> + <CalendarDays className="mt-0.5 h-4 w-4 text-custom-text-400" /> + <div className="min-w-0"> + <div className="text-custom-text-300">{eventDateTimeLabel}</div> + </div> + </div> + <div className="flex items-start gap-3 text-sm"> + <MapPin className="mt-0.5 h-4 w-4 text-custom-text-400" /> + <div className="min-w-0 text-custom-text-300"> + <div className="truncate text-custom-text-300" title={[venueName, venueAddress].filter(Boolean).join(", ")}> + {[venueName, venueAddress].filter(Boolean).join(", ") || "Venue unavailable"} + </div> + </div> + </div> + <div className="flex items-start gap-3 text-sm"> + <Trophy className="mt-0.5 h-4 w-4 text-custom-text-400" /> + <div className="text-custom-text-300">{levelLabel}</div> + </div> + </div> + </section> +); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/header.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/header.tsx new file mode 100644 index 00000000000..a24890def7a --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/header.tsx @@ -0,0 +1,173 @@ +import { Aperture, ArrowLeft, ChevronDown, Grid3x3, List, SlidersHorizontal } from "lucide-react"; +import { EPillSize, EPillVariant, Pill } from "@plane/propel/pill"; +import { Tooltip } from "@plane/propel/tooltip"; +import { CustomSelect } from "@plane/ui"; +import { cn } from "@plane/utils"; +import type { TMediaItem } from "ce/features/media-library/types/media-library.types"; +import type { SgEventDevice, SgEventTagViewMode } from "./types"; +import { formatLooseLabel } from "./utils"; + +type SgEventHeaderProps = { + eventStatus: string; + eventTitle: string; + fullStreamPlaybackItem: TMediaItem | null; + handleBack: () => void; + handleSwitchToFullStream: () => void; + isMatrixViewEnabled?: boolean; + isLoadingViews: boolean; + isTagClipActive: boolean; + selectedViewId: string; + selectedViewLabel: string; + setSelectedViewId: (value: string) => void; + setTagViewMode: (value: SgEventTagViewMode) => void; + tagViewMode: SgEventTagViewMode; + viewDevices: SgEventDevice[]; +}; + +const getViewModeButtonClass = (isActive: boolean, hasBorder = true) => + cn( + "inline-flex h-8 w-8 items-center justify-center transition-colors", + hasBorder && "border-l border-[var(--sg-matrix-border)]", + isActive + ? "bg-[var(--sg-matrix-selected-nav)] text-[var(--sg-matrix-text)]" + : "text-[var(--sg-matrix-text-muted)] hover:bg-[var(--sg-matrix-hover)] hover:text-[var(--sg-matrix-text)]" + ); + +export const SgEventHeader = ({ + eventTitle, + fullStreamPlaybackItem, + handleBack, + handleSwitchToFullStream, + isMatrixViewEnabled = false, + isLoadingViews, + isTagClipActive, + selectedViewId, + selectedViewLabel, + setSelectedViewId, + setTagViewMode, + tagViewMode, + viewDevices, +}: SgEventHeaderProps) => ( + <div className="flex min-h-11 flex-wrap items-center justify-between gap-3"> + <div className="flex min-w-0 items-center gap-2"> + <button + type="button" + onClick={handleBack} + className="inline-flex h-8 items-center gap-2 rounded-[5px] px-2 text-[12px] text-[var(--sg-matrix-text-secondary)] transition-colors hover:bg-[var(--sg-matrix-hover)] hover:text-[var(--sg-matrix-text)]" + > + <ArrowLeft className="h-4 w-4" /> + <span>Back</span> + </button> + <div className="min-w-0 border-l border-[var(--sg-matrix-border)] pl-3"> + <h1 className="truncate text-[13px] font-medium text-[var(--sg-matrix-text)]">{eventTitle}</h1> + </div> + </div> + + <div className="flex items-center gap-2"> + <div className="flex items-center gap-2"> + {viewDevices.length > 0 ? ( + <CustomSelect + value={selectedViewId} + onChange={(value: string) => setSelectedViewId(value)} + label={<span className="truncate">{selectedViewLabel}</span>} + placement="bottom-end" + className="h-9" + buttonClassName="inline-flex h-8 min-w-[92px] items-center gap-2 rounded-[5px] border border-[var(--sg-matrix-border)] bg-[var(--sg-matrix-panel)] px-3 text-[12px] text-[var(--sg-matrix-text-secondary)] hover:bg-[var(--sg-matrix-hover)]" + optionsClassName="min-w-[140px]" + > + {viewDevices.map((device, index) => ( + <CustomSelect.Option key={device.id} value={String(device.id)}> + <div className="flex min-w-0 flex-col"> + <span className="text-sm">{`View ${index + 1}`}</span> + <span className="truncate text-xs text-custom-text-400">{device.streamName}</span> + </div> + </CustomSelect.Option> + ))} + </CustomSelect> + ) : ( + <button className="inline-flex h-8 items-center gap-2 rounded-[5px] border border-[var(--sg-matrix-border)] bg-[var(--sg-matrix-panel)] px-3 text-[12px] text-[var(--sg-matrix-text-secondary)]"> + <span>{isLoadingViews ? "Loading views" : "View 1"}</span> + <ChevronDown className="h-4 w-4 text-[var(--sg-matrix-text-muted)]" /> + </button> + )} + {fullStreamPlaybackItem && isTagClipActive && ( + <Tooltip tooltipContent="Switch to full stream" isMobile={false}> + <button + type="button" + onClick={handleSwitchToFullStream} + className="inline-flex h-8 w-8 items-center justify-center rounded-[5px] border border-[var(--sg-matrix-border)] bg-[var(--sg-matrix-panel)] text-[var(--sg-matrix-text-secondary)] transition-colors hover:bg-[var(--sg-matrix-hover)] hover:text-[var(--sg-matrix-text)]" + > + <Aperture className="h-4 w-4" /> + </button> + </Tooltip> + )} + <div className="inline-flex h-8 overflow-hidden rounded-[5px] border border-[var(--sg-matrix-border)] bg-[var(--sg-matrix-panel)]"> + <Tooltip tooltipContent="List view" isMobile={false}> + <button + type="button" + onClick={() => setTagViewMode("list")} + className={getViewModeButtonClass(tagViewMode === "list", false)} + > + <List className="h-4 w-4" /> + </button> + </Tooltip> + <Tooltip tooltipContent="Timeline view" isMobile={false}> + <button + type="button" + onClick={() => setTagViewMode("timeline")} + className={getViewModeButtonClass(tagViewMode === "timeline")} + > + <SlidersHorizontal className="h-3.5 w-3.5" /> + </button> + </Tooltip> + {isMatrixViewEnabled && ( + <Tooltip tooltipContent="Matrix view" isMobile={false}> + <button + type="button" + onClick={() => setTagViewMode("matrix")} + className={getViewModeButtonClass(tagViewMode === "matrix")} + > + <Grid3x3 className="h-3.5 w-3.5" /> + </button> + </Tooltip> + )} + </div> + </div> + </div> + </div> +); + +export const SgEventTitleBar = ({ + eventStatus, + eventTitle, + handleSwitchToFullStream, + isTagClipActive, +}: Pick<SgEventHeaderProps, "eventStatus" | "eventTitle" | "handleSwitchToFullStream" | "isTagClipActive">) => ( + <div className="flex flex-col gap-3 px-0.5 lg:flex-row lg:items-center lg:justify-between"> + <div className="flex min-w-0 flex-wrap items-center gap-3"> + <h1 className="truncate text-base font-semibold text-custom-text-100">{eventTitle}</h1> + {isTagClipActive && ( + <button + type="button" + onClick={handleSwitchToFullStream} + className="inline-flex h-7 items-center gap-1.5 rounded-full border border-custom-border-200 bg-custom-background-100 px-3 text-xs text-custom-text-100 transition-colors hover:bg-custom-background-90" + > + <Aperture className="h-3.5 w-3.5" /> + <span>Switch to full stream</span> + </button> + )} + <Pill variant={EPillVariant.PRIMARY} size={EPillSize.SM} className="border-none"> + Scheduled event tagged + </Pill> + </div> + <Pill + variant={eventStatus.toLowerCase().includes("complete") ? EPillVariant.SUCCESS : EPillVariant.PRIMARY} + size={EPillSize.SM} + className={cn("w-fit border border-[#178c4d] bg-[#062f1d] px-3 py-1 text-[#22c55e]", { + "border-red-700 bg-red-950/40 text-red-500": eventStatus.toLowerCase().includes("cancel"), + })} + > + Status: {formatLooseLabel(eventStatus)} + </Pill> + </div> +); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/hooks/use-sg-event-playback-state.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/hooks/use-sg-event-playback-state.ts new file mode 100644 index 00000000000..8a9e79b5302 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/hooks/use-sg-event-playback-state.ts @@ -0,0 +1,418 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { MediaLibraryService } from "@/services/media-library.service"; +import type { TMediaItem } from "ce/features/media-library/types/media-library.types"; +import { getTimelinePanelInputPlayheadSeconds } from "../timeline-view"; +import type { SgEventDevice, SgTagRow } from "../types"; +import { + asRecord, + buildArchivedPlaylistUrl, + getSgTagRowStreamName, + parseTimecodeToSeconds, + playlistHasMediaSegments, + toText, +} from "../utils"; + +type UseSgEventPlaybackStateArgs = { + eventItem?: TMediaItem | null; + mediaItem: TMediaItem | null; + mediaLibraryService: MediaLibraryService; + primaryStreamName: string; + resolvedWorkItemId: string; + videoItems: TMediaItem[] | undefined; + viewDevices: SgEventDevice[]; +}; + +const normalizeComparableMediaValue = (value: unknown) => toText(value).trim().replace(/\/+$/, "").toLowerCase(); + +const getMediaItemStreamNames = (item: TMediaItem) => { + const meta = asRecord(item.meta); + + return [ + meta.streamName, + meta.stream_name, + meta.originalStreamName, + meta.original_stream_name, + meta.primaryStreamName, + meta.primary_stream_name, + ] + .map(normalizeComparableMediaValue) + .filter(Boolean); +}; + +const getMediaItemSources = (item: TMediaItem) => { + const meta = asRecord(item.meta); + + return [ + item.videoSrc, + item.fileSrc, + item.downloadSrc, + item.link, + meta.hlsUrl, + meta.hls_url, + meta.previewUrl, + meta.preview_url, + meta.url, + ] + .map(normalizeComparableMediaValue) + .filter(Boolean); +}; + +const findSelectedViewVideoItem = (selectedViewDevice: SgEventDevice | null, videoItems: TMediaItem[] | undefined) => { + if (!selectedViewDevice || !videoItems?.length) return null; + + const selectedStreamName = normalizeComparableMediaValue(selectedViewDevice.streamName); + const selectedHlsUrl = normalizeComparableMediaValue(selectedViewDevice.hlsUrl); + + return ( + videoItems.find((item) => selectedStreamName && getMediaItemStreamNames(item).includes(selectedStreamName)) ?? + videoItems.find((item) => selectedHlsUrl && getMediaItemSources(item).includes(selectedHlsUrl)) ?? + null + ); +}; + +export const useSgEventPlaybackState = ({ + eventItem = null, + mediaItem, + mediaLibraryService, + primaryStreamName, + resolvedWorkItemId, + videoItems, + viewDevices, +}: UseSgEventPlaybackStateArgs) => { + const [activeVideoId, setActiveVideoId] = useState<string>(""); + const [activePlaybackOverride, setActivePlaybackOverride] = useState<TMediaItem | null>(null); + const [activeTimelineTagId, setActiveTimelineTagId] = useState<string | null>(null); + const [pendingSeekSeconds, setPendingSeekSeconds] = useState<number | null>(null); + const [pendingSeekRequestId, setPendingSeekRequestId] = useState(0); + const [playerLocalSeconds, setPlayerLocalSeconds] = useState(0); + const [playerDurationSeconds, setPlayerDurationSeconds] = useState<number | null>(null); + const [isPlayerPlaying, setIsPlayerPlaying] = useState(false); + const [playerPlaybackRate, setPlayerPlaybackRate] = useState(1); + const [playheadBaseSeconds, setPlayheadBaseSeconds] = useState(0); + const [selectedViewId, setSelectedViewId] = useState<string>(""); + + useEffect(() => { + const primaryVideo = videoItems?.[0]; + if (!primaryVideo) return; + if (!activeVideoId || !videoItems?.some((item) => item.id === activeVideoId)) { + setActiveVideoId(primaryVideo.id); + } + }, [activeVideoId, videoItems]); + + useEffect(() => { + if (viewDevices.length === 0) { + if (selectedViewId) { + setSelectedViewId(""); + } + return; + } + + const hasCurrentSelection = viewDevices.some((device) => String(device.id) === selectedViewId); + if (hasCurrentSelection) { + return; + } + + const preferredDevice = + viewDevices.find((device) => device.streamName === primaryStreamName.trim()) ?? viewDevices[0]; + setSelectedViewId(String(preferredDevice.id)); + }, [primaryStreamName, selectedViewId, viewDevices]); + + const activeVideo = videoItems?.find((item) => item.id === activeVideoId) ?? videoItems?.[0] ?? null; + const selectedViewDevice = + viewDevices.find((device) => String(device.id) === selectedViewId) ?? viewDevices[0] ?? null; + const selectedViewVideoItem = useMemo( + () => findSelectedViewVideoItem(selectedViewDevice, videoItems), + [selectedViewDevice, videoItems] + ); + const selectedViewLabel = selectedViewDevice + ? `View ${Math.max(viewDevices.findIndex((device) => device.id === selectedViewDevice.id) + 1, 1)}` + : "View 1"; + const fullStreamPlaybackItem = useMemo<TMediaItem | null>(() => { + if (!selectedViewDevice?.hlsUrl) { + return null; + } + + const baseItem = { + action: "play_streaming", + author: "", + createdAt: "", + description: "", + docs: [], + duration: "", + format: "m3u8", + id: `sg-view-${selectedViewDevice.id}`, + itemsCount: 0, + mediaType: "video" as const, + meta: {}, + primaryTag: "", + secondaryTag: "", + thumbnail: "", + title: selectedViewDevice.name || `View ${selectedViewDevice.id}`, + views: 0, + workItemId: resolvedWorkItemId || null, + }; + + return { + ...baseItem, + action: "play_streaming", + downloadSrc: selectedViewDevice.hlsUrl, + fileSrc: selectedViewDevice.hlsUrl, + format: "m3u8", + id: `sg-view-${selectedViewDevice.id}`, + link: selectedViewDevice.hlsUrl, + linkedFormat: "m3u8", + linkedMediaType: "video", + mediaType: "video", + meta: { + ...(baseItem.meta ?? {}), + hls: true, + hls_direct: true, + streamId: selectedViewDevice.streamId, + stream_id: selectedViewDevice.streamId, + streamName: selectedViewDevice.streamName, + stream_name: selectedViewDevice.streamName, + }, + title: selectedViewDevice.name || baseItem.title, + videoSrc: selectedViewDevice.hlsUrl, + } satisfies TMediaItem; + }, [resolvedWorkItemId, selectedViewDevice]); + const playbackItem = useMemo<TMediaItem | null>(() => { + if (activePlaybackOverride) { + return activePlaybackOverride; + } + + if (fullStreamPlaybackItem) { + return fullStreamPlaybackItem; + } + + if (activeVideo) { + return activeVideo; + } + + return null; + }, [activePlaybackOverride, activeVideo, fullStreamPlaybackItem]); + const playbackAnnotationItem = useMemo<TMediaItem | null>(() => { + if (activePlaybackOverride) return activePlaybackOverride.packageId ? activePlaybackOverride : null; + if (selectedViewDevice) { + return selectedViewVideoItem ?? eventItem ?? mediaItem ?? null; + } + return activeVideo ?? eventItem ?? mediaItem ?? null; + }, [activePlaybackOverride, activeVideo, eventItem, mediaItem, selectedViewDevice, selectedViewVideoItem]); + const activePlaybackOverrideId = activePlaybackOverride?.id ?? null; + const isPlaybackOverrideActive = Boolean(activePlaybackOverride); + const hasPlayableVideo = Boolean(playbackItem); + const timelinePanelPlayheadSeconds = getTimelinePanelInputPlayheadSeconds({ + playbackOverrideId: activePlaybackOverrideId, + playheadBaseSeconds, + playerLocalSeconds, + }); + + useEffect(() => { + setIsPlayerPlaying(false); + }, [playbackItem?.id]); + + const requestPlayerSeek = useCallback((seconds: number | null) => { + setPendingSeekSeconds(seconds); + + if (seconds !== null && seconds >= 0) { + setPendingSeekRequestId((currentValue) => currentValue + 1); + } + }, []); + + const handleSwitchToFullStream = useCallback(() => { + setActivePlaybackOverride(null); + setActiveTimelineTagId(null); + setIsPlayerPlaying(false); + setPlayheadBaseSeconds(0); + requestPlayerSeek(null); + }, [requestPlayerSeek]); + + const handleResetTimelinePlayback = useCallback(() => { + setActivePlaybackOverride(null); + setActiveTimelineTagId(null); + setIsPlayerPlaying(false); + setPlayheadBaseSeconds(0); + setPlayerLocalSeconds(0); + requestPlayerSeek(null); + window.setTimeout(() => requestPlayerSeek(0), 0); + }, [requestPlayerSeek]); + + const handleSeekTimelineSeconds = useCallback( + (seconds: number) => { + const nextSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0; + + setActivePlaybackOverride(null); + setActiveTimelineTagId(null); + setPlayheadBaseSeconds(0); + setPlayerLocalSeconds(nextSeconds); + setIsPlayerPlaying(true); + requestPlayerSeek(nextSeconds); + }, + [requestPlayerSeek] + ); + + const handlePlaybackTimeChange = useCallback( + ( + seconds: number, + durationSeconds: number | null, + playbackState?: { + isPlaying?: boolean; + playbackRate?: number; + } + ) => { + setPlayerLocalSeconds(seconds); + setPlayerDurationSeconds(durationSeconds); + + if (typeof playbackState?.isPlaying === "boolean") { + setIsPlayerPlaying(playbackState.isPlaying); + } + + if ( + typeof playbackState?.playbackRate === "number" && + Number.isFinite(playbackState.playbackRate) && + playbackState.playbackRate > 0 + ) { + setPlayerPlaybackRate(playbackState.playbackRate); + } + }, + [] + ); + + const handlePlayTagRow = useCallback( + async (row: SgTagRow) => { + setActiveTimelineTagId(row.id); + const originalStreamName = getSgTagRowStreamName(row, selectedViewDevice?.streamName || primaryStreamName); + const playlistTimestamp = row.playlistTimestamp?.trim() || ""; + const playlistFallbackTimestamp = row.playlistFallbackTimestamp?.trim() || ""; + const displayTimecode = (row.timecode.split("-")[0] ?? row.timecode).trim(); + const fallbackSeekSeconds = row.clipStartSeconds ?? parseTimecodeToSeconds(displayTimecode) ?? 0; + + if (!originalStreamName || !playlistTimestamp) { + setActivePlaybackOverride(null); + setPlayheadBaseSeconds(0); + requestPlayerSeek(fallbackSeekSeconds); + return; + } + + try { + const timestampCandidates = Array.from(new Set([playlistTimestamp, playlistFallbackTimestamp].filter(Boolean))); + + for (const candidateTimestamp of timestampCandidates) { + const playlistFileName = await mediaLibraryService.createPlaylist([ + { + original_stream_name: originalStreamName, + timestamp: candidateTimestamp, + }, + ]); + + const playlistUrl = playlistFileName ? buildArchivedPlaylistUrl(playlistFileName) : null; + if (!playlistUrl) { + continue; + } + + const hasMediaSegments = await playlistHasMediaSegments(playlistUrl); + if (!hasMediaSegments) { + continue; + } + + requestPlayerSeek(null); + setPlayerLocalSeconds(0); + setPlayerDurationSeconds(null); + setPlayheadBaseSeconds(fallbackSeekSeconds); + setActivePlaybackOverride({ + action: "play_streaming", + author: "", + createdAt: "", + description: "", + docs: [], + duration: "", + downloadSrc: playlistUrl, + fileSrc: playlistUrl, + format: "m3u8", + id: `sg-tag-${row.id}`, + itemsCount: 0, + link: playlistUrl, + linkedFormat: "m3u8", + linkedMediaType: "video", + mediaType: "video", + meta: { + hls: true, + hls_direct: true, + original_stream_name: originalStreamName, + playlistFileName, + tagAction: row.action, + tagPlayer: row.player, + playlistTimestamp: candidateTimestamp, + tagTimecode: row.timecode, + timestamp: candidateTimestamp, + }, + primaryTag: "", + secondaryTag: "", + thumbnail: row.thumbnailUrl || activeVideo?.thumbnail || mediaItem?.thumbnail || "", + title: `${row.action} - ${row.player}`.trim(), + videoSrc: playlistUrl, + views: 0, + workItemId: resolvedWorkItemId || null, + }); + return; + } + } catch (error) { + console.error("Failed to create playlist for tag row.", error); + } + + setActivePlaybackOverride(null); + setPlayheadBaseSeconds(0); + requestPlayerSeek(fallbackSeekSeconds); + }, + [ + activeVideo?.thumbnail, + mediaItem?.thumbnail, + mediaLibraryService, + primaryStreamName, + requestPlayerSeek, + resolvedWorkItemId, + selectedViewDevice?.streamName, + ] + ); + + const clearActiveTimelineTag = useCallback((tagId: string) => { + setActiveTimelineTagId((currentValue) => (currentValue === tagId ? null : currentValue)); + }, []); + + const playPlaybackOverride = useCallback( + (item: TMediaItem) => { + requestPlayerSeek(null); + setIsPlayerPlaying(false); + setActivePlaybackOverride(item); + }, + [requestPlayerSeek] + ); + + return { + activePlaybackOverrideId, + activeTimelineTagId, + activeVideo, + clearActiveTimelineTag, + fullStreamPlaybackItem, + handlePlayTagRow, + handlePlaybackTimeChange, + handleResetTimelinePlayback, + handleSeekTimelineSeconds, + handleSwitchToFullStream, + hasPlayableVideo, + isPlayerPlaying, + isPlaybackOverrideActive, + pendingSeekRequestId, + pendingSeekSeconds, + playbackAnnotationItem, + playbackItem, + playPlaybackOverride, + playerDurationSeconds, + playerPlaybackRate, + selectedViewDevice, + selectedViewId, + selectedViewLabel, + setSelectedViewId, + timelinePanelPlayheadSeconds, + }; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/hooks/use-sg-event-tag-state.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/hooks/use-sg-event-tag-state.ts new file mode 100644 index 00000000000..3b7a42a0de9 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/hooks/use-sg-event-tag-state.ts @@ -0,0 +1,215 @@ +import { useEffect, useMemo, useState } from "react"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TMediaArtifact } from "@/services/media-library.service"; +import type { TMediaItem } from "ce/features/media-library/types/media-library.types"; +import { buildMediaThumbnailLookup, resolveTagRowArtifactThumbnail } from "../media-thumbnail-lookup"; +import type { RowFilterMode, SgTagRow, SgTagRowEditPayload } from "../types"; + +type UseSgEventTagStateArgs = { + cpServerBaseUrl: string; + manifestArtifacts: TMediaArtifact[] | undefined; + mediaItems: TMediaItem[] | undefined; + onActiveTagRemoved: (tagId: string) => void; + packageId: string | undefined; + projectId: string; + tagRows: SgTagRow[]; + workspaceSlug: string; +}; + +export const useSgEventTagState = ({ + cpServerBaseUrl, + manifestArtifacts, + mediaItems, + onActiveTagRemoved, + packageId, + projectId, + tagRows, + workspaceSlug, +}: UseSgEventTagStateArgs) => { + const [selectedGroupValue, setSelectedGroupValue] = useState<string>("All tags"); + const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]); + const [favoriteTagIds, setFavoriteTagIds] = useState<string[]>([]); + const [removedTagIds, setRemovedTagIds] = useState<string[]>([]); + const [searchQuery, setSearchQuery] = useState(""); + const [rowFilterMode, setRowFilterMode] = useState<RowFilterMode>("all"); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [focusedMatrixRows, setFocusedMatrixRows] = useState<SgTagRow[]>([]); + const [editedTagRowsById, setEditedTagRowsById] = useState<Record<string, Partial<SgTagRow>>>({}); + + const mediaThumbnailLookup = useMemo( + () => + buildMediaThumbnailLookup(mediaItems, manifestArtifacts, { + packageId, + projectId, + workspaceSlug, + }), + [manifestArtifacts, mediaItems, packageId, projectId, workspaceSlug] + ); + const tagRowsWithThumbnails = useMemo( + () => + tagRows.map((row) => { + const editedRow = editedTagRowsById[row.id]; + const mergedRow = editedRow ? { ...row, ...editedRow } : row; + const thumbnailUrl = resolveTagRowArtifactThumbnail(mergedRow, mediaThumbnailLookup, cpServerBaseUrl); + return thumbnailUrl && thumbnailUrl !== mergedRow.thumbnailUrl ? { ...mergedRow, thumbnailUrl } : mergedRow; + }), + [cpServerBaseUrl, editedTagRowsById, mediaThumbnailLookup, tagRows] + ); + const availableGroups = useMemo( + () => Array.from(new Set(tagRowsWithThumbnails.map((row) => row.groupValue))), + [tagRowsWithThumbnails] + ); + const tagTypeRows = useMemo( + () => tagRowsWithThumbnails.filter((row) => !removedTagIds.includes(row.id)), + [removedTagIds, tagRowsWithThumbnails] + ); + const effectiveGroupValue = + selectedGroupValue === "All tags" || availableGroups.includes(selectedGroupValue) + ? selectedGroupValue + : availableGroups[0] || "All tags"; + const filteredRows = useMemo( + () => + tagRowsWithThumbnails.filter((row) => { + if (removedTagIds.includes(row.id)) return false; + if (effectiveGroupValue !== "All tags" && row.groupValue !== effectiveGroupValue) return false; + if (rowFilterMode === "favorites" && !favoriteTagIds.includes(row.id)) return false; + if (rowFilterMode === "selected" && !selectedTagIds.includes(row.id)) return false; + if (!searchQuery.trim()) return true; + + const haystack = [ + row.player, + row.action, + row.groupValue, + row.result, + row.team, + row.timecode, + row.primaryDetail, + row.secondaryDetail, + ] + .join(" ") + .toLowerCase(); + + return haystack.includes(searchQuery.trim().toLowerCase()); + }), + [ + effectiveGroupValue, + favoriteTagIds, + removedTagIds, + rowFilterMode, + searchQuery, + selectedTagIds, + tagRowsWithThumbnails, + ] + ); + const allVisibleSelected = filteredRows.length > 0 && filteredRows.every((row) => selectedTagIds.includes(row.id)); + const selectedRows = useMemo( + () => tagRowsWithThumbnails.filter((row) => selectedTagIds.includes(row.id) && !removedTagIds.includes(row.id)), + [removedTagIds, selectedTagIds, tagRowsWithThumbnails] + ); + const matrixRows = useMemo( + () => tagRowsWithThumbnails.filter((row) => !removedTagIds.includes(row.id)), + [removedTagIds, tagRowsWithThumbnails] + ); + const playlistPanelRows = focusedMatrixRows.filter((row) => !removedTagIds.includes(row.id)); + + useEffect(() => { + if (selectedGroupValue === "All tags") return; + if (availableGroups.length === 0) return; + if (!availableGroups.includes(selectedGroupValue)) { + setSelectedGroupValue(availableGroups[0]); + } + }, [availableGroups, selectedGroupValue]); + + const handleSelectAll = () => { + if (allVisibleSelected) { + setSelectedTagIds((currentValue) => currentValue.filter((id) => !filteredRows.some((row) => row.id === id))); + return; + } + + setSelectedTagIds((currentValue) => Array.from(new Set([...currentValue, ...filteredRows.map((row) => row.id)]))); + }; + + const handleToggleTagSelection = (tagId: string) => { + setSelectedTagIds((currentValue) => + currentValue.includes(tagId) ? currentValue.filter((id) => id !== tagId) : [...currentValue, tagId] + ); + }; + + const handleToggleFavorite = (tagId: string) => { + setFavoriteTagIds((currentValue) => + currentValue.includes(tagId) ? currentValue.filter((value) => value !== tagId) : [...currentValue, tagId] + ); + }; + + const handleToggleSearch = () => { + if (isSearchOpen && !searchQuery) { + setIsSearchOpen(false); + return; + } + + setIsSearchOpen(true); + }; + + const handleRemoveTag = (tagId: string) => { + setRemovedTagIds((currentValue) => (currentValue.includes(tagId) ? currentValue : [...currentValue, tagId])); + onActiveTagRemoved(tagId); + setSelectedTagIds((currentValue) => currentValue.filter((id) => id !== tagId)); + setFavoriteTagIds((currentValue) => currentValue.filter((id) => id !== tagId)); + }; + + const handleUpdateTag = (tagId: string, updates: SgTagRowEditPayload) => { + setEditedTagRowsById((currentValue) => ({ + ...currentValue, + [tagId]: { + ...(currentValue[tagId] ?? {}), + ...updates, + }, + })); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Tag updated", + message: "The list row has been updated.", + }); + }; + + const handleCreateMatrixCard = (rows: SgTagRow[]) => { + setSelectedTagIds(rows.map((row) => row.id)); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Card selection ready", + message: `${rows.length} tag${rows.length === 1 ? "" : "s"} selected for card creation.`, + }); + }; + + const clearSelectedTagIds = () => { + setSelectedTagIds([]); + }; + + return { + allVisibleSelected, + availableGroups, + clearSelectedTagIds, + effectiveGroupValue, + favoriteTagIds, + filteredRows, + handleCreateMatrixCard, + handleRemoveTag, + handleSelectAll, + handleToggleFavorite, + handleToggleSearch, + handleToggleTagSelection, + handleUpdateTag, + isSearchOpen, + matrixRows, + playlistPanelRows, + rowFilterMode, + searchQuery, + selectedRows, + selectedTagIds, + setFocusedMatrixRows, + setRowFilterMode, + setSearchQuery, + setSelectedGroupValue, + tagTypeRows, + }; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/index.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/index.ts new file mode 100644 index 00000000000..4962a1f6fa7 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/index.ts @@ -0,0 +1 @@ +export * from "./page"; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/kanavio-tag-payload.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/kanavio-tag-payload.ts new file mode 100644 index 00000000000..f6c2b7ee0cc --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/kanavio-tag-payload.ts @@ -0,0 +1,129 @@ +import { asArray, asRecord, firstNonEmptyRecord, parseGatewayRows, toText } from "./utils"; +import { joinApiPath } from "./page-url"; + +const readApiErrorMessage = async (response: Response, fallbackMessage: string) => { + const responseText = await response.text(); + + try { + const data = JSON.parse(responseText) as { + detail?: string; + error?: string; + errorMessage?: string; + error_message?: string; + message?: string; + }; + + return data.error || data.detail || data.message || data.errorMessage || data.error_message || fallbackMessage; + } catch { + return responseText || fallbackMessage; + } +}; + +export const fetchKanavioTagRowsPayload = async (cpServerBaseUrl: string, sgEventId: string) => { + const normalizedCpServerBaseUrl = cpServerBaseUrl.trim(); + const eventId = Number(sgEventId.trim()); + + if (!normalizedCpServerBaseUrl) { + throw new Error("NEXT_PUBLIC_CP_SERVER_URL is required to fetch tags."); + } + + if (!Number.isFinite(eventId)) { + throw new Error("A numeric SG event id is required to fetch tags."); + } + + const response = await fetch(joinApiPath(normalizedCpServerBaseUrl, "/tagging-session/fetch-tags"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ event_id: eventId }), + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(await readApiErrorMessage(response, "Unable to fetch event tags.")); + } + + return response.json() as Promise<unknown>; +}; + +const isFetchedTagRecord = (record: Record<string, unknown>) => + Boolean( + toText( + record.tag ?? + record.action ?? + record.event_code ?? + record.play ?? + record.timestamp ?? + record.video_time ?? + record.original_stream_name ?? + record.stream_name ?? + record.thumbnail + ) + ); + +const extractFetchedTagRows = (value: unknown): unknown[] => { + if (!Array.isArray(value)) return []; + + return value.flatMap((entry): unknown[] => { + if (Array.isArray(entry)) return extractFetchedTagRows(entry); + + const record = asRecord(entry); + if (Object.keys(record).length === 0) return []; + if (isFetchedTagRecord(record)) return [record]; + + const dataRows = asArray(record.data); + if (dataRows.length > 0) return extractFetchedTagRows(dataRows); + + return [record]; + }); +}; + +export const normalizeFetchedTagPayload = (payload: unknown): Record<string, unknown> | null => { + if (Array.isArray(payload)) { + const rows = extractFetchedTagRows(payload); + return rows.length > 0 ? { tags: rows } : null; + } + + const record = asRecord(payload); + if (Object.keys(record).length === 0) return null; + + const resultRows = [ + ...extractFetchedTagRows(asRecord(record["Gateway Response"]).result), + ...extractFetchedTagRows(record.result), + ]; + if (resultRows.length > 0) return { ...record, tags: resultRows }; + + const gatewayRows = parseGatewayRows(payload); + if (gatewayRows.length > 0) return { tags: gatewayRows }; + + const tags = record.tags ?? record.tagRows ?? record.tag_rows ?? record.records ?? record.data ?? record.result; + if (Array.isArray(tags)) { + const rows = extractFetchedTagRows(tags); + return rows.length > 0 ? { ...record, tags: rows } : tags.length > 0 ? { ...record, tags } : record; + } + + const nestedRecord = firstNonEmptyRecord(record.data, record.result, record.response); + if (!nestedRecord) return record; + + const nestedResultRows = extractFetchedTagRows(nestedRecord.result); + if (nestedResultRows.length > 0) return { ...record, tags: nestedResultRows }; + + const nestedRows = parseGatewayRows(nestedRecord); + if (nestedRows.length > 0) return { ...record, tags: nestedRows }; + + const nestedTags = + nestedRecord.tags ?? + nestedRecord.tagRows ?? + nestedRecord.tag_rows ?? + nestedRecord.records ?? + nestedRecord.data ?? + nestedRecord.result; + + if (Array.isArray(nestedTags)) { + const rows = extractFetchedTagRows(nestedTags); + return rows.length > 0 ? { ...record, tags: rows } : { ...record, tags: nestedTags }; + } + + return record; +}; + +export const isNumericEventId = (value: string) => Number.isFinite(Number(value.trim())); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/__tests__/matrix-model.test.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/__tests__/matrix-model.test.ts new file mode 100644 index 00000000000..6513e182c0e --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/__tests__/matrix-model.test.ts @@ -0,0 +1,499 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Node's type-stripping test runner requires explicit TypeScript extensions. +// @ts-expect-error See comment above. +import { findExactRawTagFieldValue } from "../../raw-tag-fields.ts"; +// Node's type-stripping test runner requires explicit TypeScript extensions. +// @ts-expect-error See comment above. +import * as sportMatrixConfig from "../config/sport-matrix-config.ts"; +import type { MatrixSourceTag } from "../types/matrix.types"; +// @ts-expect-error See comment above. +import { buildMatrixData, orientMatrixData, transposeMatrixData } from "../utils/build-matrix-data.ts"; +// @ts-expect-error See comment above. +import * as matrixFilters from "../utils/matrix-filters.ts"; +// @ts-expect-error See comment above. +import * as matrixSelection from "../utils/matrix-selection.ts"; +// @ts-expect-error See comment above. +import { getMatrixColumnVirtualRange, MATRIX_COLUMN_WIDTH } from "../utils/matrix-virtualization.ts"; + +const { + SPORT_MATRIX_CONFIGS, + SUPPORTED_MATRIX_SPORTS, + getSportMatrixConfig, + normalizeMatrixSport, + resolveSportMatrixConfig, +} = sportMatrixConfig; +const { + buildMatrixFilterOptions, + clearMatrixFilters, + createEmptyMatrixFilters, + filterMatrixSourceTags, + hasActiveMatrixFilters, +} = matrixFilters; +const { + clearMatrixCellSelection, + getSelectedMatrixClipIds, + getSelectedMatrixSourceRowIds, + getSelectedMatrixTagIds, + pruneMatrixCellSelection, + toggleMatrixCellSelection, +} = matrixSelection; + +const footballConfig = SPORT_MATRIX_CONFIGS["american-football"]; + +test("matrix column virtualization preserves small tables and windows wide horizontal axes", () => { + assert.deepEqual( + getMatrixColumnVirtualRange({ columnCount: 17, scrollLeft: 800, viewportWidth: 1024, virtualize: false }), + { end: 17, start: 0 } + ); + + const initialRange = getMatrixColumnVirtualRange({ + columnCount: 300, + scrollLeft: 0, + viewportWidth: 1024, + virtualize: true, + }); + assert.deepEqual(initialRange, { end: 23, start: 0 }); + + const middleRange = getMatrixColumnVirtualRange({ + columnCount: 300, + scrollLeft: MATRIX_COLUMN_WIDTH * 140, + viewportWidth: 1024, + virtualize: true, + }); + assert.deepEqual(middleRange, { end: 163, start: 137 }); + assert.ok(middleRange.end - middleRange.start < 30, "wide matrices should render a bounded column window"); + + const finalRange = getMatrixColumnVirtualRange({ + columnCount: 300, + scrollLeft: MATRIX_COLUMN_WIDTH * 299, + viewportWidth: 1024, + virtualize: true, + }); + assert.equal(finalRange.end, 300); + assert.ok(finalRange.start < finalRange.end); +}); + +const footballTags: MatrixSourceTag[] = [ + { + id: "row-1", + sourceTagId: "tag-1", + clipId: "clip-1", + action: "pass_complete", + player: "A. Nelson", + team: "Offense", + groupValue: "Quarter 1", + sourceUrl: "https://media.test/clip-1.m3u8", + }, + { + id: "row-2", + clipId: "clip-1", + action: "Pass Complete", + player: "A. Nelson", + team: "Offense", + groupValue: "Quarter 1", + }, + { + id: "row-3", + sourceTagId: "tag-3", + action: "mystery_code", + player: "B. Helper", + team: "Defense", + groupValue: "Quarter 2", + }, + { + id: "row-4", + sourceTagId: "tag-4", + action: "field_goal", + player: "B. Helper", + team: "Special", + groupValue: "Quarter 2", + }, + { + id: "row-5", + sourceTagId: "tag-5", + action: "pass_incomplete", + player: "--", + team: "Offense", + groupValue: "Quarter 3", + }, +]; + +const findRow = (matrix: ReturnType<typeof buildMatrixData>, label: string) => { + const row = matrix.rows.find((candidate) => candidate.label === label); + assert.ok(row, `Expected row ${label}`); + return row; +}; + +const findColumn = (matrix: ReturnType<typeof buildMatrixData>, label: string) => { + const column = matrix.columns.find((candidate) => candidate.label === label); + assert.ok(column, `Expected column ${label}`); + return column; +}; + +test("exact raw tag fields accept normalized spellings without fuzzy participant or clip matches", () => { + const rawTag = { + clipId: "clip-top-level", + player_position: "Quarterback", + thumbnail_url: "https://media.test/thumb.jpg", + data: [ + { tag: "player_name_suffix", value: "Jr." }, + { tag: "Primary Actor", value: "A. Nelson" }, + { tag: "source_clip_id", value: "wrong-clip" }, + ], + }; + + assert.equal(findExactRawTagFieldValue(rawTag, ["clip_id"]), "clip-top-level"); + assert.equal(findExactRawTagFieldValue(rawTag, ["thumbnail_url"]), "https://media.test/thumb.jpg"); + assert.equal( + findExactRawTagFieldValue(rawTag, ["player", "player_name", "athlete", "athlete_name", "primary_actor"]), + "A. Nelson" + ); + assert.equal(findExactRawTagFieldValue({ player_position: "Quarterback" }, ["player"]), ""); + assert.equal(findExactRawTagFieldValue({ source_clip_id: "wrong-clip" }, ["clip_id"]), ""); + assert.equal( + findExactRawTagFieldValue({ data: [{ fieldName: "thumbnailUrl", fieldValue: "nested-thumb.jpg" }] }, [ + "thumbnail_url", + ]), + "nested-thumb.jpg" + ); +}); + +test("buildMatrixData creates zero-filled cells, explicit ids, totals, averages, and appended actions", () => { + const original = structuredClone(footballTags); + const matrix = buildMatrixData(footballTags, footballConfig); + + assert.deepEqual(footballTags, original, "source tags must not be mutated"); + assert.equal(matrix.orientation, "entities-by-actions"); + assert.equal(matrix.sourceTagCount, 5); + assert.deepEqual( + matrix.entities.map((entity) => [entity.label, entity.dimension]), + [ + ["Defense", "team"], + ["Offense", "team"], + ["Special", "team"], + ["Quarter 1", "period"], + ["Quarter 2", "period"], + ["Quarter 3", "period"], + ["A. Nelson", "player"], + ["B. Helper", "player"], + ["Unassigned", "unassigned"], + ] + ); + assert.equal(matrix.actions.length, footballConfig.actions.length + 1); + assert.equal(matrix.actions.at(-1)?.label, "Mystery Code"); + assert.equal(Object.keys(matrix.cells).length, matrix.entities.length * matrix.actions.length); + + const nelson = findRow(matrix, "A. Nelson"); + const passComplete = findColumn(matrix, "Pass Complete"); + const passCell = nelson.cells[passComplete.id]; + assert.equal(passCell.count, 2); + assert.deepEqual(passCell.sourceRowIds, ["row-1", "row-2"]); + assert.deepEqual(passCell.tagIds, ["tag-1", "row-2"]); + assert.deepEqual(passCell.sourceUrls, ["https://media.test/clip-1.m3u8"]); + assert.deepEqual(passCell.clipIds, ["clip-1"]); + assert.equal(nelson.total, 2); + assert.equal(nelson.average, 2); + assert.equal(findRow(matrix, "B. Helper").average, 1); + + const zeroCell = nelson.cells[findColumn(matrix, "Touchdown").id]; + assert.equal(zeroCell.count, 0); + assert.deepEqual(zeroCell.sourceRowIds, []); + + const grandTotal = matrix.rows.reduce((sum, row) => sum + row.total, 0); + assert.equal(grandTotal, 15, "rollups and a metric fallback retain every explicit source membership"); + assert.deepEqual( + matrix.entities.filter((entity) => entity.isMetric).map((entity) => entity.label), + ["A. Nelson", "B. Helper", "Unassigned"] + ); +}); + +test("sport configs expose exactly the requested sports, columns, ordering, aliases, and priorities", () => { + assert.deepEqual(SUPPORTED_MATRIX_SPORTS, ["american-football", "cricket", "basketball", "baseball", "soccer"]); + assert.deepEqual( + footballConfig.actions.map((action) => action.label), + [ + "Pass Complete", + "Pass Incomplete", + "Run", + "Sack", + "Field Goal", + "Punt", + "Kickoff", + "Two Point", + "Penalty", + "Turnover", + "Interception", + "First Down", + "Touchdown", + "Fumble", + "Blocked", + "Offside", + "Holding", + ] + ); + assert.deepEqual(footballConfig.rowDimensionPriority, ["team", "period", "player"]); + const expectedColumns = { + cricket: [ + "Dot Ball", + "Single", + "Two Runs", + "Three Runs", + "Four", + "Six", + "Wide", + "No Ball", + "Bye", + "Leg Bye", + "Wicket", + "Run Out", + "End Over", + "End Innings", + ], + basketball: [ + "Two Point Made", + "Two Point Missed", + "Three Point Made", + "Three Point Missed", + "Free Throw", + "Rebound", + "Assist", + "Steal", + "Block", + "Foul", + "Turnover", + ], + baseball: [ + "Single", + "Double", + "Triple", + "Home Run", + "Strikeout", + "Walk", + "Hit by Pitch", + "Stolen Base", + "Error", + "Run", + "RBI", + ], + soccer: [ + "Goal", + "Shot", + "Shot on Target", + "Pass", + "Assist", + "Tackle", + "Interception", + "Save", + "Corner", + "Foul", + "Yellow Card", + "Red Card", + "Offside", + ], + } as const; + Object.entries(expectedColumns).forEach(([sport, labels]) => { + assert.deepEqual( + SPORT_MATRIX_CONFIGS[sport as keyof typeof SPORT_MATRIX_CONFIGS].actions.map((action) => action.label), + labels + ); + }); + assert.ok( + footballConfig.actions.find((action) => action.label === "Pass Complete")?.aliases.includes("pass_complete") + ); + assert.ok(footballConfig.actions.find((action) => action.label === "Two Point")?.aliases.includes("two_point_conv")); + assert.ok( + SPORT_MATRIX_CONFIGS.cricket.actions.find((action) => action.label === "Four")?.aliases.includes("boundary_four") + ); + assert.ok( + SPORT_MATRIX_CONFIGS.basketball.actions + .find((action) => action.label === "Two Point Made") + ?.aliases.includes("field_goal_made_2") + ); + assert.ok( + SPORT_MATRIX_CONFIGS.basketball.actions + .find((action) => action.label === "Two Point Missed") + ?.aliases.includes("field_goal_attempt_2") + ); + Object.values(SPORT_MATRIX_CONFIGS).forEach((config) => { + assert.deepEqual(config.metricDimensionPriority, ["player", "team", "period"]); + assert.deepEqual( + config.actions.map((action) => action.order), + config.actions.map((_, index) => index) + ); + assert.ok(config.actions.every((action) => action.category && action.color && action.visible)); + }); +}); + +test("explicit aliases and context rules canonicalize codes without inferring unavailable outcomes", () => { + const cricket = buildMatrixData( + [ + { id: "c1", action: "boundary_four", player: "Batter" }, + { id: "c2", action: "runs_scored", player: "Batter" }, + { id: "c3", action: "runs_scored", context: { exact_runs: "1" }, player: "Batter" }, + ], + SPORT_MATRIX_CONFIGS.cricket + ); + assert.equal(findRow(cricket, "Batter").cells[findColumn(cricket, "Four").id].count, 1); + assert.equal(findRow(cricket, "Batter").cells[findColumn(cricket, "Single").id].count, 1); + assert.equal(findRow(cricket, "Batter").cells[findColumn(cricket, "Runs Scored").id].count, 1); + + const basketball = buildMatrixData( + [ + { id: "b1", action: "field_goal_made_2", player: "Guard" }, + { id: "b2", action: "field_goal_attempt_2", player: "Guard" }, + { id: "b3", action: "field_goal_attempt_2", context: { shot_result: "missed" }, player: "Guard" }, + ], + SPORT_MATRIX_CONFIGS.basketball + ); + assert.equal(findRow(basketball, "Guard").cells[findColumn(basketball, "Two Point Made").id].count, 1); + assert.equal(findRow(basketball, "Guard").cells[findColumn(basketball, "Two Point Missed").id].count, 2); + assert.equal( + basketball.actions.some((action) => action.label === "Field Goal Attempt 2"), + false + ); + + const football = buildMatrixData( + [{ id: "f1", action: "pass_complete", context: { touchdown: "true" }, team: "Home" }], + footballConfig + ); + assert.equal(findRow(football, "Home").cells[findColumn(football, "Pass Complete").id].count, 1); + assert.equal(findRow(football, "Home").cells[findColumn(football, "Touchdown").id].count, 1); +}); + +test("orientation transposes the same canonical cells and recomputes action totals and averages", () => { + const canonical = buildMatrixData(footballTags, footballConfig); + const transposed = orientMatrixData(canonical, "actions-by-entities"); + const passRow = findRow(transposed, "Pass Complete"); + const nelsonColumn = findColumn(transposed, "A. Nelson"); + const offenseColumn = findColumn(transposed, "Offense"); + const transposedCell = passRow.cells[nelsonColumn.id]; + const canonicalCell = findRow(canonical, "A. Nelson").cells[findColumn(canonical, "Pass Complete").id]; + + assert.equal(transposed.orientation, "actions-by-entities"); + assert.equal(transposed.cells, canonical.cells); + assert.equal(transposedCell, canonicalCell); + assert.equal(transposedCell.id, canonicalCell.id); + assert.equal(passRow.cells[offenseColumn.id].count, 2, "rollup cells remain visible"); + assert.equal(passRow.total, 2); + assert.equal(passRow.average, 2); + + const restored = transposeMatrixData(transposed); + assert.equal(restored.orientation, "entities-by-actions"); + assert.equal(restored.cells, canonical.cells); + assert.deepEqual( + restored.rows.map((row) => [row.id, row.total, row.average]), + canonical.rows.map((row) => [row.id, row.total, row.average]) + ); +}); + +test("empty source data retains configured actions without fabricating entities or cells", () => { + const matrix = buildMatrixData([], footballConfig); + assert.equal(matrix.sourceTagCount, 0); + assert.deepEqual(matrix.entities, []); + assert.deepEqual(matrix.rows, []); + assert.equal(matrix.actions.length, footballConfig.actions.length); + assert.deepEqual(matrix.cells, {}); +}); + +test("filter options and filters use only explicit tag fields and canonical action categories", () => { + const tags: MatrixSourceTag[] = [ + { id: "1", action: "field_goal_made_2", player: "Asha", team: "Home", groupValue: "Q1" }, + { id: "2", action: "steal", player: "Bea", team: "Away", groupValue: "Q2" }, + { id: "3", action: "custom_hustle", player: "Asha", team: "Home", groupValue: "Q2" }, + ]; + const config = SPORT_MATRIX_CONFIGS.basketball; + const options = buildMatrixFilterOptions(tags, config); + assert.deepEqual( + options.teams.map((option) => option.value), + ["Away", "Home"] + ); + assert.deepEqual( + options.players.map((option) => option.value), + ["Asha", "Bea"] + ); + assert.deepEqual( + options.periods.map((option) => option.value), + ["Q1", "Q2"] + ); + assert.deepEqual( + options.categories.map((option) => option.value), + ["scoring", "defense", "other"] + ); + + assert.deepEqual( + filterMatrixSourceTags(tags, { ...createEmptyMatrixFilters(), teams: ["HOME"] }, config).map((tag) => tag.id), + ["1", "3"] + ); + assert.deepEqual( + filterMatrixSourceTags(tags, { ...createEmptyMatrixFilters(), categories: ["defense"] }, config).map( + (tag) => tag.id + ), + ["2"] + ); + assert.deepEqual( + filterMatrixSourceTags(tags, { ...createEmptyMatrixFilters(), search: "made" }, config).map((tag) => tag.id), + ["1"] + ); + assert.deepEqual( + filterMatrixSourceTags(tags, { ...createEmptyMatrixFilters(), search: "custom hustle" }, config).map( + (tag) => tag.id + ), + ["3"] + ); + assert.equal(hasActiveMatrixFilters(createEmptyMatrixFilters()), false); + assert.equal(hasActiveMatrixFilters({ ...createEmptyMatrixFilters(), periods: ["Q2"] }), true); + assert.deepEqual(clearMatrixFilters(), createEmptyMatrixFilters()); +}); + +test("cell selection supports multiple non-empty cells, pruning, clearing, and source id deduplication", () => { + const matrix = buildMatrixData(footballTags, footballConfig); + const nelson = findRow(matrix, "A. Nelson"); + const helper = findRow(matrix, "B. Helper"); + const passCell = nelson.cells[findColumn(matrix, "Pass Complete").id]; + const fieldGoalCell = helper.cells[findColumn(matrix, "Field Goal").id]; + const zeroCell = nelson.cells[findColumn(matrix, "Touchdown").id]; + + let selection = clearMatrixCellSelection(); + selection = toggleMatrixCellSelection(selection, zeroCell); + assert.deepEqual(selection, []); + selection = toggleMatrixCellSelection(selection, passCell); + selection = toggleMatrixCellSelection(selection, fieldGoalCell); + assert.deepEqual(selection, [passCell.id, fieldGoalCell.id]); + assert.deepEqual(getSelectedMatrixSourceRowIds(selection, matrix), ["row-1", "row-2", "row-4"]); + assert.deepEqual(getSelectedMatrixTagIds(selection, matrix), ["tag-1", "row-2", "tag-4"]); + assert.deepEqual(getSelectedMatrixClipIds(selection, matrix), ["clip-1"]); + assert.deepEqual(pruneMatrixCellSelection([...selection, "missing", zeroCell.id], matrix), selection); + assert.deepEqual(toggleMatrixCellSelection(selection, passCell), [fieldGoalCell.id]); +}); + +test("unsupported sports resolve explicitly instead of falling back", () => { + assert.equal(normalizeMatrixSport("Football"), "american-football"); + assert.equal(normalizeMatrixSport("NCAA American Football"), "american-football"); + assert.equal(normalizeMatrixSport("association football"), "soccer"); + assert.equal(normalizeMatrixSport("U18 Association Football"), "soccer"); + assert.equal(getSportMatrixConfig("ice hockey"), null); + assert.deepEqual(resolveSportMatrixConfig(" Ice Hockey "), { + input: " Ice Hockey ", + normalizedInput: "ice-hockey", + sport: null, + config: null, + isSupported: false, + }); +}); + +test("entity ordering keeps numbered periods in chronological order", () => { + const matrix = buildMatrixData( + [ + { id: "q10", action: "run", groupValue: "Quarter 10" }, + { id: "q2", action: "run", groupValue: "Quarter 2" }, + { id: "q1", action: "run", groupValue: "Quarter 1" }, + ], + footballConfig + ); + assert.deepEqual( + matrix.entities.map((entity) => entity.label), + ["Quarter 1", "Quarter 2", "Quarter 10"] + ); +}); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/axis-view-toggle.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/axis-view-toggle.tsx new file mode 100644 index 00000000000..4941a4d62e9 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/axis-view-toggle.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { ArrowRightLeft } from "lucide-react"; +import { ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; + +type AxisViewToggleProps = { + className?: string; + disabled?: boolean; + isSwitched: boolean; + onChange: (isSwitched: boolean) => void; +}; + +export const AxisViewToggle = ({ className, disabled = false, isSwitched, onChange }: AxisViewToggleProps) => ( + <label + className={cn("flex min-h-8 items-center gap-2", disabled ? "cursor-not-allowed" : "cursor-pointer", className)} + > + <ToggleSwitch + value={isSwitched} + onChange={onChange} + label="Switch axis view" + size="sm" + disabled={disabled} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--sg-matrix-active-border)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--sg-matrix-panel-secondary)]" + /> + <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-[var(--sg-matrix-text-secondary)]"> + <ArrowRightLeft aria-hidden="true" className="hidden h-3.5 w-3.5 flex-shrink-0" /> + <span className="whitespace-nowrap">Switch Axis View</span> + <span className="sr-only">{isSwitched ? "Actions are rows" : "Event entities are rows"}</span> + </div> + </label> +); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-cell.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-cell.tsx new file mode 100644 index 00000000000..324259e1448 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-cell.tsx @@ -0,0 +1,93 @@ +import { memo } from "react"; +import { cn } from "@plane/utils"; +import type { MatrixCell as MatrixCellData } from "../types/matrix.types"; + +type MatrixCellProps = { + ariaColumnIndex?: number; + cell?: MatrixCellData; + columnLabel: string; + isActive: boolean; + isGroupStart?: boolean; + isPanelOpen: boolean; + isRowGroupStart?: boolean; + isSelected: boolean; + maxVisibleCount: number; + onActivate: ( + cell: MatrixCellData, + trigger: HTMLButtonElement, + options?: { additive?: boolean; range?: boolean } + ) => void; + onDoubleClick?: (cell: MatrixCellData) => void; + rowLabel: string; +}; + +export const MatrixCell = memo(function MatrixCell({ + ariaColumnIndex, + cell, + columnLabel, + isActive, + isGroupStart = false, + isPanelOpen, + isRowGroupStart = false, + isSelected, + maxVisibleCount, + onActivate, + onDoubleClick, + rowLabel, +}: MatrixCellProps) { + const count = cell?.count ?? 0; + const isInteractive = Boolean(cell && count > 0 && cell.sourceRowIds.length > 0); + const intensityLevel = maxVisibleCount > 0 ? Math.max(1, Math.ceil((count / maxVisibleCount) * 4)) : 0; + const cellBackground = + intensityLevel >= 4 + ? "var(--sg-matrix-cell-l4)" + : intensityLevel === 3 + ? "var(--sg-matrix-cell-l3)" + : intensityLevel === 2 + ? "var(--sg-matrix-cell-l2)" + : intensityLevel === 1 + ? "var(--sg-matrix-cell-l1)" + : "var(--sg-matrix-cell-empty)"; + const isHighlighted = isSelected || isPanelOpen; + + return ( + <td + aria-colindex={ariaColumnIndex} + className={cn( + "h-11 w-[var(--sg-matrix-column-width)] min-w-[var(--sg-matrix-column-width)] border-b border-r border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-cell-empty)] p-0 text-center", + isGroupStart && "border-l border-l-[var(--sg-matrix-grid-border)]", + isRowGroupStart && "border-t border-t-[var(--sg-matrix-grid-border)]" + )} + > + {isInteractive && cell ? ( + <button + type="button" + aria-current={isActive ? "true" : undefined} + aria-label={`${count} ${count === 1 ? "tag" : "tags"} for ${rowLabel} and ${columnLabel}`} + aria-pressed={isSelected} + className={cn( + "group relative flex h-full w-full items-center justify-center gap-1 text-[14px] font-medium text-[var(--sg-matrix-cell-text)] transition-[background-color,border-color,color,box-shadow] duration-150", + "hover:brightness-110 focus-visible:z-[1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--sg-matrix-active-border)]", + isHighlighted && + "text-[var(--sg-matrix-selected-cell-text)] ring-2 ring-inset ring-[var(--sg-matrix-selected-cell)] after:absolute after:inset-[2px] after:border after:border-[var(--sg-matrix-selected-cell-inner)] after:content-['']" + )} + data-matrix-cell-id={cell.id} + onClick={(event) => + onActivate(cell, event.currentTarget, { + additive: event.ctrlKey || event.metaKey, + range: event.shiftKey, + }) + } + onDoubleClick={() => onDoubleClick?.(cell)} + style={{ backgroundColor: cellBackground }} + > + <span className="relative z-[1]">{String(count).padStart(2, "0")}</span> + </button> + ) : ( + <span aria-label={`No tags for ${rowLabel} and ${columnLabel}`} className="sr-only"> + No tags + </span> + )} + </td> + ); +}); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-columns-panel.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-columns-panel.tsx new file mode 100644 index 00000000000..08041fa0c7a --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-columns-panel.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronDown, Search, X } from "lucide-react"; +import { cn } from "@plane/utils"; +import type { MatrixColumn } from "../types/matrix.types"; + +type MatrixColumnsPanelProps = { + columns: MatrixColumn[]; + defaultVisibleColumnIds: readonly string[]; + onChange: (visibleColumnIds: string[]) => void; + onClose: () => void; + visibleColumnIds: readonly string[]; +}; + +const getColumnGroup = (column: MatrixColumn) => column.group ?? column.category ?? column.dimension ?? "Other"; + +export const MatrixColumnsPanel = ({ + columns, + defaultVisibleColumnIds, + onChange, + onClose, + visibleColumnIds, +}: MatrixColumnsPanelProps) => { + const [searchQuery, setSearchQuery] = useState(""); + const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({}); + const visibleColumnIdSet = useMemo(() => new Set(visibleColumnIds), [visibleColumnIds]); + const allColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const columnGroups = useMemo(() => { + const groupsByName = new Map<string, MatrixColumn[]>(); + + columns.forEach((column) => { + const groupName = getColumnGroup(column); + if ( + normalizedSearchQuery && + !`${column.label} ${groupName} ${column.id}`.toLowerCase().includes(normalizedSearchQuery) + ) { + return; + } + + const currentColumns = groupsByName.get(groupName) ?? []; + currentColumns.push(column); + groupsByName.set(groupName, currentColumns); + }); + + return Array.from(groupsByName.entries()) + .map(([name, groupColumns]) => ({ + columns: groupColumns.sort((left, right) => left.order - right.order || left.label.localeCompare(right.label)), + name, + order: Math.min(...groupColumns.map((column) => column.order)), + })) + .sort((left, right) => left.order - right.order || left.name.localeCompare(right.name)); + }, [columns, normalizedSearchQuery]); + + const handleToggleColumn = (columnId: string) => { + const nextColumnIds = new Set(visibleColumnIds); + + if (nextColumnIds.has(columnId)) { + nextColumnIds.delete(columnId); + } else { + nextColumnIds.add(columnId); + } + + onChange(Array.from(nextColumnIds)); + }; + + return ( + <div className="fixed inset-0 z-30 flex justify-end bg-black/50" role="presentation" onClick={onClose}> + <aside + aria-label="Columns" + aria-modal="true" + className="flex h-full w-full max-w-[340px] flex-col border-l border-custom-border-200 bg-custom-background-100 shadow-xl" + role="dialog" + onClick={(event) => event.stopPropagation()} + > + <div className="border-b border-custom-border-200 px-4 py-4"> + <div className="mb-3 flex items-center justify-between gap-3"> + <div className="min-w-0"> + <h3 className="text-sm font-semibold text-custom-text-100">Columns</h3> + <p className="mt-0.5 text-xs text-custom-text-400"> + {columns.filter((column) => visibleColumnIdSet.has(column.id)).length} of {columns.length} shown + </p> + </div> + <button + type="button" + onClick={onClose} + className="inline-flex h-8 w-8 items-center justify-center rounded-md text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100" + > + <X className="h-4 w-4" /> + </button> + </div> + <label className="flex h-9 items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-3 text-sm text-custom-text-300"> + <Search className="h-4 w-4" /> + <input + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder="Search columns" + className="min-w-0 flex-1 bg-transparent text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400" + /> + </label> + </div> + + <div className="flex gap-3 border-b border-custom-border-200 px-4 py-2.5"> + <button + type="button" + onClick={() => onChange(allColumnIds)} + className="text-xs font-medium text-custom-primary-100 hover:underline" + > + Select all + </button> + <button + type="button" + onClick={() => onChange([])} + className="text-xs font-medium text-custom-primary-100 hover:underline" + > + Clear all + </button> + <button + type="button" + onClick={() => onChange(Array.from(defaultVisibleColumnIds))} + className="text-xs font-medium text-custom-primary-100 hover:underline" + > + Reset to default + </button> + </div> + + <div className="vertical-scrollbar scrollbar-md min-h-0 flex-1 overflow-y-auto px-2 py-2"> + {columnGroups.length === 0 ? ( + <div className="px-3 py-8 text-center text-sm text-custom-text-400">No matching columns.</div> + ) : ( + columnGroups.map((group) => { + const isCollapsed = Boolean(collapsedGroups[group.name]); + + return ( + <div key={group.name} className="mb-1"> + <button + type="button" + onClick={() => + setCollapsedGroups((currentValue) => ({ + ...currentValue, + [group.name]: !currentValue[group.name], + })) + } + className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-custom-text-400 transition-colors hover:bg-custom-background-90" + > + <ChevronDown className={cn("h-3.5 w-3.5 transition-transform", isCollapsed && "-rotate-90")} /> + <span>{group.name}</span> + </button> + {!isCollapsed && ( + <div className="flex flex-col"> + {group.columns.map((column) => ( + <label + key={column.id} + className="flex cursor-pointer items-center gap-2 rounded-md px-7 py-1.5 text-sm text-custom-text-200 transition-colors hover:bg-custom-background-90" + > + <input + type="checkbox" + checked={visibleColumnIdSet.has(column.id)} + onChange={() => handleToggleColumn(column.id)} + className="h-4 w-4 rounded border-custom-border-200 accent-custom-primary-100" + /> + <span className="min-w-0 flex-1 truncate" title={column.label}> + {column.label} + </span> + </label> + ))} + </div> + )} + </div> + ); + }) + )} + </div> + </aside> + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-empty-state.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-empty-state.tsx new file mode 100644 index 00000000000..cce24468218 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-empty-state.tsx @@ -0,0 +1,70 @@ +import type { LucideIcon } from "lucide-react"; +import { CalendarX2, CircleAlert, SearchX, Tags } from "lucide-react"; +import { cn } from "@plane/utils"; + +export type MatrixStateKind = "empty-event" | "error" | "no-filter-results" | "no-tags" | "unsupported-sport"; + +type MatrixEmptyStateProps = { + className?: string; + description?: string; + kind: MatrixStateKind; + title?: string; +}; + +const STATE_CONTENT: Record<MatrixStateKind, { description: string; icon: LucideIcon; title: string }> = { + "empty-event": { + description: "Select an event with completed tag data to view its matrix.", + icon: CalendarX2, + title: "No event selected", + }, + error: { + description: "The event tags could not be loaded. Try refreshing the event.", + icon: CircleAlert, + title: "Unable to load matrix", + }, + "no-filter-results": { + description: "No tags match the current matrix filters.", + icon: SearchX, + title: "No matching tags", + }, + "no-tags": { + description: "No tags were returned for this event, so the matrix cannot be built.", + icon: Tags, + title: "No tags available", + }, + "unsupported-sport": { + description: "Matrix View is not configured for this event's sport.", + icon: CircleAlert, + title: "Sport not supported", + }, +}; + +export const MatrixEmptyState = ({ className, description, kind, title }: MatrixEmptyStateProps) => { + const state = STATE_CONTENT[kind]; + const Icon = state.icon; + const isError = kind === "error"; + + return ( + <div + aria-live={isError ? "assertive" : "polite"} + className={cn( + "flex min-h-52 w-full flex-col items-center justify-center gap-3 px-6 py-10 text-center", + className + )} + role={isError ? "alert" : "status"} + > + <div + className={cn( + "grid h-9 w-9 place-items-center rounded-md border border-custom-border-200 bg-custom-background-90 text-custom-text-300", + isError && "border-red-500/30 bg-red-500/10 text-red-400" + )} + > + <Icon aria-hidden="true" className="h-4 w-4" /> + </div> + <div className="max-w-md"> + <h3 className="text-sm font-medium text-custom-text-100">{title ?? state.title}</h3> + <p className="mt-1 text-xs leading-5 text-custom-text-300">{description ?? state.description}</p> + </div> + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-filters.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-filters.tsx new file mode 100644 index 00000000000..03f13e11866 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-filters.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { Search, X } from "lucide-react"; +import { Button } from "@plane/propel/button"; +import { CustomSelect, Input } from "@plane/ui"; +import type { MatrixFilterOption, MatrixFilterOptions, MatrixFilterState } from "../types/matrix.types"; + +type MatrixFiltersProps = { + disabled?: boolean; + filters: MatrixFilterState; + hasActiveFilters: boolean; + onChange: (filters: MatrixFilterState) => void; + onClear: () => void; + options: MatrixFilterOptions; +}; + +type ArrayFilterKey = "categories" | "periods" | "players" | "teams"; + +type MatrixFilterSelectProps = { + allLabel: string; + disabled: boolean; + label: string; + onChange: (value: string) => void; + options: MatrixFilterOption[]; + value: string; +}; + +const MatrixFilterSelect = ({ allLabel, disabled, label, onChange, options, value }: MatrixFilterSelectProps) => { + if (options.length === 0) return null; + + const selectedLabel = options.find((option) => option.value === value)?.label; + + return ( + <CustomSelect + buttonClassName="h-8 min-w-28 max-w-44 bg-custom-background-100 text-custom-text-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100" + disabled={disabled} + label={ + <span className="truncate"> + <span className="sr-only">{label} filter: </span> + {selectedLabel ?? label} + </span> + } + maxHeight="lg" + onChange={(nextValue: string) => onChange(nextValue)} + optionsClassName="min-w-44" + value={value} + > + <CustomSelect.Option value="">{allLabel}</CustomSelect.Option> + {options.map((option) => ( + <CustomSelect.Option key={option.value} value={option.value}> + <span className="max-w-52 truncate" title={option.label}> + {option.label} + </span> + </CustomSelect.Option> + ))} + </CustomSelect> + ); +}; + +export const MatrixFilters = ({ + disabled = false, + filters, + hasActiveFilters, + onChange, + onClear, + options, +}: MatrixFiltersProps) => { + const updateArrayFilter = (key: ArrayFilterKey, value: string) => + onChange({ ...filters, [key]: value ? [value] : [] }); + + return ( + <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2" aria-label="Matrix filters"> + <label className="relative block w-48 min-w-40 sm:w-56"> + <span className="sr-only">Search matrix tags</span> + <Search + aria-hidden="true" + className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-custom-text-400" + /> + <Input + aria-label="Search matrix tags" + className="h-8 w-full pl-8 text-xs text-custom-text-100" + disabled={disabled} + inputSize="xs" + onChange={(event) => onChange({ ...filters, search: event.target.value })} + placeholder="Search tags" + value={filters.search} + /> + </label> + <MatrixFilterSelect + allLabel="All teams" + disabled={disabled} + label="Team" + onChange={(value) => updateArrayFilter("teams", value)} + options={options.teams} + value={filters.teams[0] ?? ""} + /> + <MatrixFilterSelect + allLabel="All participants" + disabled={disabled} + label="Participant" + onChange={(value) => updateArrayFilter("players", value)} + options={options.players} + value={filters.players[0] ?? ""} + /> + <MatrixFilterSelect + allLabel="All categories" + disabled={disabled} + label="Category" + onChange={(value) => updateArrayFilter("categories", value)} + options={options.categories} + value={filters.categories[0] ?? ""} + /> + <MatrixFilterSelect + allLabel="All periods" + disabled={disabled} + label="Period" + onChange={(value) => updateArrayFilter("periods", value)} + options={options.periods} + value={filters.periods[0] ?? ""} + /> + {hasActiveFilters ? ( + <Button + aria-label="Clear matrix filters" + disabled={disabled} + onClick={onClear} + prependIcon={<X />} + size="sm" + variant="link-neutral" + > + Clear filters + </Button> + ) : null} + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-header.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-header.tsx new file mode 100644 index 00000000000..e8f38b09821 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-header.tsx @@ -0,0 +1,128 @@ +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; +import type { MatrixColumn } from "../types/matrix.types"; + +type MatrixHeaderProps = { + columnStartIndex?: number; + columns: MatrixColumn[]; + firstColumnLabel: string; + leadingSpacerWidth?: number; + previousColumnGroup?: string; + stickySummaries?: boolean; + totalColumnCount: number; + trailingSpacerWidth?: number; +}; + +const getColumnGroup = (column: MatrixColumn) => column.group ?? column.category ?? column.dimension ?? ""; + +const getColumnPalette = (column: MatrixColumn) => { + const group = getColumnGroup(column).toLowerCase(); + const label = column.label.toLowerCase(); + if (column.dimension === "period" || group.includes("period") || label.includes("quarter")) { + return { accent: "var(--sg-matrix-period-accent)", background: "var(--sg-matrix-period-bg)" }; + } + if (group.includes("defense") || label.includes("defense")) { + return { accent: "var(--sg-matrix-defense-accent)", background: "var(--sg-matrix-defense-bg)" }; + } + if (group.includes("offense") || label.includes("offense")) { + return { accent: "var(--sg-matrix-offense-accent)", background: "var(--sg-matrix-offense-bg)" }; + } + if (group.includes("special") || label.includes("special")) { + return { accent: "var(--sg-matrix-special-accent)", background: "var(--sg-matrix-special-bg)" }; + } + if (column.dimension === "player") { + return column.order % 3 === 1 + ? { accent: "var(--sg-matrix-defense-accent)", background: "var(--sg-matrix-defense-bg)" } + : column.order % 3 === 2 + ? { accent: "var(--sg-matrix-neutral-accent)", background: "var(--sg-matrix-neutral-bg)" } + : { accent: "var(--sg-matrix-offense-accent)", background: "var(--sg-matrix-offense-bg)" }; + } + return { accent: "var(--sg-matrix-neutral-accent)", background: "var(--sg-matrix-neutral-bg)" }; +}; + +export const MatrixHeader = ({ + columnStartIndex = 0, + columns, + firstColumnLabel, + leadingSpacerWidth = 0, + previousColumnGroup = "", + stickySummaries = true, + totalColumnCount, + trailingSpacerWidth = 0, +}: MatrixHeaderProps) => ( + <thead> + <tr> + <th + aria-colindex={1} + scope="col" + className="sticky left-0 top-0 z-30 h-[180px] w-[140px] min-w-[140px] border-b border-r border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-panel-secondary)] p-0 text-center align-middle text-[12px] font-medium text-[var(--sg-matrix-text-secondary)]" + > + <span className="flex h-full w-full items-center justify-center"> + <span className="-rotate-90 whitespace-nowrap">{firstColumnLabel}</span> + </span> + </th> + {leadingSpacerWidth > 0 ? ( + <th + aria-hidden="true" + className="h-[180px] border-b border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-panel-secondary)] p-0" + style={{ minWidth: leadingSpacerWidth, width: leadingSpacerWidth }} + /> + ) : null} + {columns.map((column, columnIndex) => { + const group = getColumnGroup(column); + const previousGroup = columnIndex > 0 ? getColumnGroup(columns[columnIndex - 1]) : previousColumnGroup; + const isGroupStart = (columnStartIndex > 0 || columnIndex > 0) && group !== previousGroup; + const tooltipContent = group ? `${column.label} · ${group}` : column.label; + const palette = getColumnPalette(column); + + return ( + <th + key={column.id} + aria-colindex={columnStartIndex + columnIndex + 2} + scope="col" + className={cn( + "sticky top-0 z-20 h-[180px] w-[var(--sg-matrix-column-width)] min-w-[var(--sg-matrix-column-width)] border-b border-r border-[var(--sg-matrix-grid-border)] p-0 align-bottom text-[12px] font-medium text-[var(--sg-matrix-header-text)]", + isGroupStart && "border-l border-l-[var(--sg-matrix-grid-border)]" + )} + style={{ backgroundColor: palette.background, borderBottomColor: palette.accent, borderBottomWidth: 3 }} + > + <Tooltip tooltipContent={tooltipContent} position="top"> + <span className="relative block h-full w-full overflow-hidden"> + <span className="absolute left-1/2 top-1/2 block w-[148px] origin-center -translate-x-1/2 -translate-y-1/2 -rotate-90 truncate whitespace-nowrap text-center leading-4"> + {column.label} + </span> + </span> + </Tooltip> + </th> + ); + })} + {trailingSpacerWidth > 0 ? ( + <th + aria-hidden="true" + className="h-[180px] border-b border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-panel-secondary)] p-0" + style={{ minWidth: trailingSpacerWidth, width: trailingSpacerWidth }} + /> + ) : null} + <th + aria-colindex={totalColumnCount + 2} + scope="col" + className={cn( + "sticky top-0 z-20 h-[180px] w-[44px] min-w-[44px] border-b border-r border-l border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-panel-secondary)] px-1 pb-3 text-center align-bottom text-[11px] font-medium text-[var(--sg-matrix-text-secondary)]", + stickySummaries && "lg:right-[44px] lg:z-30" + )} + > + Total + </th> + <th + aria-colindex={totalColumnCount + 3} + scope="col" + className={cn( + "sticky top-0 z-20 h-[180px] w-[44px] min-w-[44px] border-b border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-panel-secondary)] px-1 pb-3 text-center align-bottom text-[11px] font-medium text-[var(--sg-matrix-text-secondary)]", + stickySummaries && "lg:right-0 lg:z-30" + )} + > + Average + </th> + </tr> + </thead> +); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-loading-state.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-loading-state.tsx new file mode 100644 index 00000000000..2fab70c0139 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-loading-state.tsx @@ -0,0 +1,49 @@ +import { Loader } from "@plane/ui"; +import { cn } from "@plane/utils"; + +type MatrixLoadingStateProps = { + className?: string; + columnCount?: number; + rowCount?: number; +}; + +export const MatrixLoadingState = ({ className, columnCount = 8, rowCount = 5 }: MatrixLoadingStateProps) => ( + <Loader + className={cn( + "vertical-scrollbar horizontal-scrollbar scrollbar-lg max-h-[520px] min-h-52 w-full overflow-auto", + className + )} + > + <span className="sr-only">Loading matrix data</span> + <div className="min-w-max" aria-hidden="true"> + <div className="sticky top-0 z-10 flex h-24 border-b border-custom-border-200 bg-custom-background-90"> + <div className="sticky left-0 z-20 flex w-56 flex-shrink-0 items-center border-r border-custom-border-200 bg-custom-background-90 px-4"> + <Loader.Item className="h-3 w-24" /> + </div> + {Array.from({ length: columnCount }).map((_, columnIndex) => ( + <div + key={`matrix-loading-header-${columnIndex}`} + className="flex w-20 flex-shrink-0 items-center justify-center border-r border-custom-border-200 px-2" + > + <Loader.Item className="h-10 w-3" /> + </div> + ))} + </div> + {Array.from({ length: rowCount }).map((_, rowIndex) => ( + <div key={`matrix-loading-row-${rowIndex}`} className="flex h-11 border-b border-custom-border-200"> + <div className="sticky left-0 z-[1] flex w-56 flex-shrink-0 items-center border-r border-custom-border-200 bg-custom-background-100 px-4"> + <Loader.Item className="h-3 w-32" /> + </div> + {Array.from({ length: columnCount }).map((__, columnIndex) => ( + <div + key={`matrix-loading-cell-${rowIndex}-${columnIndex}`} + className="flex w-20 flex-shrink-0 items-center justify-center border-r border-custom-border-100 px-2" + > + <Loader.Item className="h-3 w-5" /> + </div> + ))} + </div> + ))} + </div> + </Loader> +); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-playlist-panel.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-playlist-panel.tsx new file mode 100644 index 00000000000..92ad4e070b8 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-playlist-panel.tsx @@ -0,0 +1,968 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { MouseEvent as ReactMouseEvent } from "react"; +import { MoreVertical, Pencil, Share2, Trash2, Video, X } from "lucide-react"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { AlertModalCore } from "@plane/ui"; +import type { TCustomPlaylist, TCustomPlaylistUpdatePayload } from "@/services/media-library.service"; +import { HlsVideo } from "ce/features/media-library/components/hls-video"; +import { PLAYER_FRAME_CLASS } from "../../constants"; +import type { SgTagRow } from "../../types"; +import { buildCustomPlaylistThumbnailUrl, buildCustomPlaylistUrl } from "../../utils"; + +type SgMatrixPlaylistPanelProps = { + customPlaylists: TCustomPlaylist[]; + isCreatingPlaylist?: boolean; + onCreateCard?: () => void; + onCreatePlaylist?: () => void; + onDeletePlaylist: (playlist: TCustomPlaylist) => Promise<void>; + onUpdatePlaylist: (playlist: TCustomPlaylist, payload: TCustomPlaylistUpdatePayload) => Promise<TCustomPlaylist>; + rows?: SgTagRow[]; +}; + +type SgPlaylistVideoModalProps = { + onClose: () => void; + playlist: TCustomPlaylist | null; +}; + +type PlaylistSegment = { + durationSeconds: number; + endSeconds: number; + startSeconds: number; +}; + +type PlaylistClipCard = { + endSeconds: number; + id: string; + index: number; + startSeconds: number; + subtitle: string; + tags: string[]; + thumbnailUrl: string; + timeLabel: string; + timestampLabel: string; + title: string; +}; + +const EMPTY_CARD_VALUES = new Set(["", "-", "--", "n/a", "na", "null", "undefined"]); +const DEFAULT_MULTI_CLIP_SUBTITLE = "All Plays"; +const GENERATED_PLAYLIST_NAME_SUFFIX = /\s*\(\d+\s+clips?\)\s*$/i; + +type PlaylistTextEditField = "name" | "subtitle"; + +type PlaylistTextEditState = { + focusField: PlaylistTextEditField; + name: string; + playlistId: string; + subtitle: string; +}; + +const normalizeCardText = (value: string | null | undefined) => { + const normalizedValue = (value ?? "").trim(); + return EMPTY_CARD_VALUES.has(normalizedValue.toLowerCase()) ? "" : normalizedValue; +}; + +const formatClipTime = (seconds: number) => { + if (!Number.isFinite(seconds) || seconds < 0) return "--:--"; + + const totalSeconds = Math.floor(seconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const remainingSeconds = totalSeconds % 60; + + if (hours > 0) { + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; + } + + return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; +}; + +const formatSourceTimestamp = (value: string | null | undefined) => { + const normalizedValue = normalizeCardText(value); + if (!normalizedValue) return ""; + + const isoTimeMatch = normalizedValue.match(/^\d{4}-\d{2}-\d{2}[T\s](\d{2}:\d{2}(?::\d{2})?)/); + if (isoTimeMatch?.[1]) return isoTimeMatch[1]; + + const clockMatch = normalizedValue.match(/^(\d{1,2}:\d{2}(?::\d{2})?)(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/); + if (clockMatch?.[1]) return clockMatch[1]; + + return normalizedValue; +}; + +const getPlaylistCardClipCount = (playlist: TCustomPlaylist) => { + const savedClips = Array.isArray(playlist.clips) ? playlist.clips : []; + const explicitCount = Number(playlist.clip ?? 0); + return explicitCount > 0 ? explicitCount : savedClips.length; +}; + +const formatPlaylistCardClipCount = (count: number) => + `${String(Math.max(count, 0)).padStart(2, "0")} Clip${count === 1 ? "" : "s"}`; + +const getPlaylistCardTitle = (playlist: TCustomPlaylist) => { + const savedName = normalizeCardText(playlist.name); + if (savedName && !GENERATED_PLAYLIST_NAME_SUFFIX.test(savedName)) return savedName; + + const savedClips = Array.isArray(playlist.clips) ? playlist.clips : []; + const groupValues = Array.from(new Set(savedClips.map((clip) => normalizeCardText(clip.groupValue)).filter(Boolean))); + if (groupValues.length === 1) return groupValues[0]; + + const cleanedName = savedName.replace(GENERATED_PLAYLIST_NAME_SUFFIX, ""); + return normalizeCardText(cleanedName) || normalizeCardText(savedClips[0]?.title) || "Playlist"; +}; + +const getPlaylistCardSubtitle = (playlist: TCustomPlaylist) => { + const savedSubtitle = normalizeCardText(playlist.subtitle); + if (savedSubtitle) return savedSubtitle; + + const savedClips = Array.isArray(playlist.clips) ? playlist.clips : []; + const clipCount = getPlaylistCardClipCount(playlist); + if (clipCount > 1) return DEFAULT_MULTI_CLIP_SUBTITLE; + + return normalizeCardText(savedClips[0]?.subtitle) || "Ready"; +}; + +const parsePlaylistSegments = (playlistText: string): PlaylistSegment[] => { + const segments: PlaylistSegment[] = []; + let elapsedSeconds = 0; + + for (const line of playlistText.split(/\r?\n/)) { + const match = line.trim().match(/^#EXTINF:([\d.]+)/i); + if (!match?.[1]) continue; + + const durationSeconds = Number(match[1]); + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) continue; + + const startSeconds = elapsedSeconds; + elapsedSeconds += durationSeconds; + segments.push({ + durationSeconds, + endSeconds: elapsedSeconds, + startSeconds, + }); + } + + return segments; +}; + +const getClipSegmentRange = ( + segments: PlaylistSegment[], + index: number, + count: number, + fallbackDurationSeconds = 0 +) => { + if (segments.length > 0 && count > 0) { + const startIndex = Math.floor((index * segments.length) / count); + const exclusiveEndIndex = Math.floor(((index + 1) * segments.length) / count); + const endIndex = Math.max(startIndex, exclusiveEndIndex - 1); + const startSegment = segments[startIndex]; + const endSegment = segments[endIndex]; + + if (startSegment && endSegment) { + return { + startSeconds: startSegment.startSeconds, + endSeconds: Math.max(endSegment.endSeconds, startSegment.startSeconds), + }; + } + } + + const startSeconds = index * fallbackDurationSeconds; + return { + startSeconds, + endSeconds: startSeconds + fallbackDurationSeconds, + }; +}; + +const buildClipCards = (playlist: TCustomPlaylist | null, segments: PlaylistSegment[]): PlaylistClipCard[] => { + const savedClips = Array.isArray(playlist?.clips) ? playlist.clips : []; + const explicitCount = Number(playlist?.clip ?? 0); + const count = explicitCount > 0 ? explicitCount : savedClips.length; + if (!playlist || count <= 0) return []; + + const fallbackDurationSeconds = segments.length > 0 ? segments[segments.length - 1].endSeconds / count : 0; + + return Array.from({ length: count }, (_, index) => { + const savedClip = savedClips[index]; + const range = getClipSegmentRange(segments, index, count, fallbackDurationSeconds); + + const timestampLabel = formatSourceTimestamp(savedClip?.timestamp); + const tags = Array.isArray(savedClip?.tags) + ? savedClip.tags.map(normalizeCardText).filter(Boolean).slice(0, 2) + : []; + + return { + endSeconds: range.endSeconds, + id: savedClip?.id || `${playlist.id}-clip-${index + 1}`, + index, + startSeconds: range.startSeconds, + subtitle: + normalizeCardText(savedClip?.subtitle) || + [savedClip?.player, savedClip?.team, savedClip?.groupValue].map(normalizeCardText).filter(Boolean).join(" / "), + tags, + thumbnailUrl: buildCustomPlaylistThumbnailUrl(savedClip?.thumbnail || playlist.thumbnail), + timeLabel: formatClipTime(range.startSeconds) || timestampLabel || `Clip ${index + 1}`, + timestampLabel, + title: normalizeCardText(savedClip?.title) || `Clip ${index + 1}`, + }; + }); +}; + +const SgPlaylistVideoModal = ({ onClose, playlist }: SgPlaylistVideoModalProps) => { + const videoRef = useRef<HTMLVideoElement | null>(null); + const [activeClipIndex, setActiveClipIndex] = useState<number | null>(null); + const [playlistSegments, setPlaylistSegments] = useState<PlaylistSegment[]>([]); + const playlistUrl = buildCustomPlaylistUrl(playlist?.url); + const thumbnailUrl = buildCustomPlaylistThumbnailUrl(playlist?.thumbnail); + const clipCount = playlist?.clip ?? 0; + const playlistTitle = playlist?.name?.trim() || "Playlist"; + const clipCards = useMemo(() => buildClipCards(playlist, playlistSegments), [playlist, playlistSegments]); + const activeClip = activeClipIndex !== null ? clipCards[activeClipIndex] : null; + + useEffect(() => { + setActiveClipIndex(null); + setPlaylistSegments([]); + + if (!playlistUrl) return; + + let isCancelled = false; + void fetch(playlistUrl, { cache: "no-store" }) + .then((response) => (response.ok ? response.text() : "")) + .then((playlistText) => { + if (!isCancelled) setPlaylistSegments(parsePlaylistSegments(playlistText)); + }) + .catch(() => { + if (!isCancelled) setPlaylistSegments([]); + }); + + return () => { + isCancelled = true; + }; + }, [playlistUrl]); + + useEffect(() => { + if (!playlistUrl || !videoRef.current) return; + + const video = videoRef.current; + const playFullPlaylist = () => { + video.currentTime = 0; + void video.play().catch(() => undefined); + }; + + if (video.readyState >= 1) { + playFullPlaylist(); + return; + } + + video.addEventListener("loadedmetadata", playFullPlaylist, { once: true }); + return () => video.removeEventListener("loadedmetadata", playFullPlaylist); + }, [playlistUrl]); + + useEffect(() => { + if (!activeClip || !videoRef.current) return; + + const video = videoRef.current; + const playClip = () => { + video.currentTime = activeClip.startSeconds; + void video.play().catch(() => undefined); + }; + + if (video.readyState >= 1) { + playClip(); + return; + } + + video.addEventListener("loadedmetadata", playClip, { once: true }); + return () => video.removeEventListener("loadedmetadata", playClip); + }, [activeClip]); + + useEffect(() => { + if (!activeClip || !videoRef.current || activeClip.endSeconds <= activeClip.startSeconds) return; + + const video = videoRef.current; + const loopClip = () => { + if (video.currentTime < activeClip.endSeconds - 0.08) return; + + video.currentTime = activeClip.startSeconds; + void video.play().catch(() => undefined); + }; + + video.addEventListener("timeupdate", loopClip); + video.addEventListener("ended", loopClip); + + return () => { + video.removeEventListener("timeupdate", loopClip); + video.removeEventListener("ended", loopClip); + }; + }, [activeClip]); + + useEffect(() => { + if (!playlist) return; + + const originalBodyOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = originalBodyOverflow; + }; + }, [playlist]); + + useEffect(() => { + if (!playlist) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose, playlist]); + + const handlePlayFullPlaylist = useCallback(() => { + setActiveClipIndex(null); + + const video = videoRef.current; + if (!video) return; + + video.currentTime = 0; + void video.play().catch(() => undefined); + }, []); + + if (!playlist) return null; + + return ( + <div + className="fixed inset-0 z-[40] flex items-center justify-center overflow-y-auto bg-black/60 p-3 text-white backdrop-blur-sm sm:p-5" + aria-label={playlistTitle} + aria-modal="true" + role="dialog" + > + <div className="flex h-[min(700px,calc(100dvh-1.5rem))] w-[min(1120px,calc(100vw-1.5rem))] min-h-0 flex-col overflow-hidden rounded-[8px] border border-white/10 bg-[#0d1016] shadow-[0_24px_80px_rgba(0,0,0,0.58)] sm:h-[min(720px,calc(100dvh-2.5rem))] sm:w-[min(1120px,calc(100vw-2.5rem))]"> + <div className="flex h-full min-h-0 flex-col bg-[linear-gradient(180deg,rgba(255,255,255,0.055),rgba(255,255,255,0.015)_42%,rgba(0,0,0,0)_100%)]"> + <div className="flex h-14 shrink-0 items-center justify-between gap-4 border-b border-white/10 px-4 sm:h-16 sm:px-6"> + <div className="flex min-w-0 items-center gap-2.5"> + <h3 className="truncate text-[16px] font-semibold leading-6 tracking-[-0.01em] text-white sm:text-[18px]"> + {playlistTitle} + </h3> + <span className="shrink-0 rounded-[5px] border border-[#338fdc]/30 bg-[#338fdc]/15 px-2 py-1 text-[11px] font-medium leading-none text-[#7cc6ff]"> + {clipCount} clip{clipCount === 1 ? "" : "s"} + </span> + </div> + + <div className="flex shrink-0 items-center gap-1.5 sm:gap-3"> + {/* {playlistUrl ? ( + <a + href={playlistUrl} + download={`${playlistTitle}.m3u8`} + className="inline-flex h-8 items-center gap-2 rounded-[5px] px-2.5 text-[12px] font-medium text-white/88 transition-colors hover:bg-white/8 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#7cc6ff]/70 sm:h-9 sm:px-3" + > + <Download className="h-4 w-4" /> + <span className="hidden sm:inline">Download</span> + </a> + ) : null} */} + + <button + type="button" + onClick={onClose} + className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-white/[0.045] text-white/86 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#8b5cf6]/70 sm:h-10 sm:w-10" + aria-label="Close playlist video" + > + <X className="h-5 w-5" /> + </button> + </div> + </div> + + <div className="min-h-0 flex-1 px-3 pb-3 sm:px-6 sm:pb-6"> + <div className="grid h-full min-h-0 grid-rows-[minmax(220px,1fr)_minmax(150px,220px)] gap-4 lg:grid-cols-[minmax(0,1fr)_280px] lg:grid-rows-1"> + <div className="relative h-full min-h-[240px] overflow-hidden rounded-[5px] border border-white/10 bg-black shadow-[0_18px_54px_rgba(0,0,0,0.44)]"> + {playlist && playlistUrl ? ( + <HlsVideo + key={playlistUrl} + src={playlistUrl} + poster={thumbnailUrl || undefined} + autoPlay + controls + videoRef={videoRef} + className="block h-full w-full bg-black object-contain" + /> + ) : ( + <div className="flex h-full w-full items-center justify-center px-4 text-center text-sm text-white/56"> + Playlist video is unavailable. + </div> + )} + </div> + + <aside className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden rounded-[6px] border border-white/10 bg-white/[0.025]"> + <div className="flex h-11 shrink-0 items-center justify-between gap-3 border-b border-white/10 px-3"> + <div className="text-[14px] font-semibold text-white">Clips ({clipCards.length || clipCount})</div> + <button + type="button" + onClick={handlePlayFullPlaylist} + className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[5px] border border-[#338fdc]/30 bg-[#338fdc]/10 px-2 text-[11px] font-medium text-[#7cc6ff] transition-colors hover:bg-[#338fdc]/18" + > + <Video className="h-3.5 w-3.5" /> + <span>Full playlist</span> + </button> + </div> + + <div className="vertical-scrollbar scrollbar-md min-h-0 flex-1 overflow-y-auto overscroll-contain p-2.5"> + {clipCards.length > 0 ? ( + <ul className="space-y-2"> + {clipCards.map((clipCard, index) => { + const isActive = activeClipIndex === index; + const canSeek = clipCard.endSeconds > clipCard.startSeconds; + + return ( + <li key={clipCard.id}> + <button + type="button" + disabled={!canSeek} + onClick={() => setActiveClipIndex(index)} + className={[ + "group relative flex w-full min-w-0 gap-2 rounded-[6px] border p-2 text-left transition-colors", + isActive + ? "border-[#338fdc]/70 bg-[#338fdc]/12" + : "border-white/10 bg-white/[0.035] hover:bg-white/[0.055]", + !canSeek ? "cursor-not-allowed opacity-55" : "", + ].join(" ")} + aria-pressed={isActive} + > + <span className="absolute right-2 top-2 inline-flex max-w-[92px] items-center rounded-[4px] border border-[#338fdc]/20 bg-[#338fdc]/10 px-1.5 py-0.5 text-[10px] leading-none text-[#9bd4ff]"> + <span className="truncate"> + {clipCard.timestampLabel || `Clip ${clipCard.index + 1}`} + </span> + </span> + + <span className="relative flex h-[50px] w-[76px] shrink-0 items-center justify-center overflow-hidden rounded-[5px] bg-black text-white/46"> + {clipCard.thumbnailUrl ? ( + <img src={clipCard.thumbnailUrl} alt="" className="h-full w-full object-cover" /> + ) : ( + <Video className="h-4 w-4" /> + )} + <span className="absolute bottom-1 right-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] text-white"> + {clipCard.timeLabel} + </span> + </span> + + <span className="min-w-0 flex-1 pr-[96px]"> + <span className="block truncate text-[12px] font-medium leading-4 text-white"> + {clipCard.title} + </span> + {clipCard.subtitle ? ( + <span className="mt-0.5 block truncate text-[11px] leading-4 text-white/52"> + {clipCard.subtitle} + </span> + ) : null} + {clipCard.tags.length > 0 ? ( + <span className="mt-1.5 flex min-w-0 gap-1"> + {clipCard.tags.map((tag) => ( + <span + key={tag} + className="max-w-full truncate rounded-[4px] bg-[#338fdc]/12 mr-1.5 text-[10px] text-[#9bd4ff]" + > + {tag} + </span> + ))} + </span> + ) : null} + </span> + </button> + </li> + ); + })} + </ul> + ) : ( + <div className="flex h-full items-center justify-center px-4 text-center text-xs leading-5 text-white/46"> + Clip cards will appear after the playlist metadata is available. + </div> + )} + </div> + </aside> + </div> + </div> + </div> + </div> + </div> + ); +}; + +export const SgMatrixPlaylistPanel = ({ + customPlaylists, + onDeletePlaylist, + onUpdatePlaylist, +}: SgMatrixPlaylistPanelProps) => { + const [activePlaylist, setActivePlaylist] = useState<TCustomPlaylist | null>(null); + const [menuPlaylistId, setMenuPlaylistId] = useState<string | null>(null); + const [editingPlaylistText, setEditingPlaylistText] = useState<PlaylistTextEditState | null>(null); + const [savingPlaylistText, setSavingPlaylistText] = useState<Pick<PlaylistTextEditState, "playlistId"> | null>(null); + const [playlistPendingDelete, setPlaylistPendingDelete] = useState<TCustomPlaylist | null>(null); + const [isDeletingPlaylist, setIsDeletingPlaylist] = useState(false); + const activeMenuRef = useRef<HTMLDivElement | null>(null); + const titleEditInputRef = useRef<HTMLInputElement | null>(null); + const subtitleEditInputRef = useRef<HTMLInputElement | null>(null); + const isSubmittingTextEditRef = useRef(false); + const playlistOpenTimeoutRef = useRef<number | null>(null); + const skipNextTextEditBlurRef = useRef(false); + const editingPlaylistTextFocusField = editingPlaylistText?.focusField ?? null; + const editingPlaylistTextPlaylistId = editingPlaylistText?.playlistId ?? null; + + const clearPendingPlaylistOpen = useCallback(() => { + if (!playlistOpenTimeoutRef.current) return; + window.clearTimeout(playlistOpenTimeoutRef.current); + playlistOpenTimeoutRef.current = null; + }, []); + + useEffect(() => { + if (!activePlaylist) return; + + const updatedActivePlaylist = customPlaylists.find((playlist) => playlist.id === activePlaylist.id); + if (!updatedActivePlaylist) { + setActivePlaylist(null); + return; + } + + if (updatedActivePlaylist !== activePlaylist) { + setActivePlaylist(updatedActivePlaylist); + } + }, [activePlaylist, customPlaylists]); + + useEffect(() => { + if (!editingPlaylistTextFocusField || !editingPlaylistTextPlaylistId) return; + const input = + editingPlaylistTextFocusField === "subtitle" ? subtitleEditInputRef.current : titleEditInputRef.current; + if (!input) return; + + input.focus(); + if (editingPlaylistTextFocusField === "name") { + input.select(); + return; + } + + const cursorPosition = input.value.length; + input.setSelectionRange(cursorPosition, cursorPosition); + }, [editingPlaylistTextFocusField, editingPlaylistTextPlaylistId]); + + useEffect(() => clearPendingPlaylistOpen, [clearPendingPlaylistOpen]); + + useEffect(() => { + if (!menuPlaylistId) return; + + const handlePointerDown = (event: PointerEvent) => { + if (activeMenuRef.current?.contains(event.target as Node)) return; + setMenuPlaylistId(null); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setMenuPlaylistId(null); + }; + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [menuPlaylistId]); + + const getPlaylistTextUpdatePayload = ( + playlist: TCustomPlaylist, + currentName: string, + currentSubtitle: string, + nextName: string, + nextSubtitle: string + ): TCustomPlaylistUpdatePayload | null => { + const payload: TCustomPlaylistUpdatePayload = {}; + + if (nextName && nextName !== currentName) { + payload.name = nextName; + } + + const savedSubtitle = normalizeCardText(playlist.subtitle); + if (!nextSubtitle) { + if (savedSubtitle) payload.subtitle = null; + } else if (savedSubtitle) { + if (nextSubtitle !== savedSubtitle) payload.subtitle = nextSubtitle; + } else if (nextSubtitle !== currentSubtitle) { + payload.subtitle = nextSubtitle; + } + + return Object.keys(payload).length > 0 ? payload : null; + }; + + const handleStartTextEdit = ( + event: ReactMouseEvent<HTMLElement>, + playlist: TCustomPlaylist, + focusField: PlaylistTextEditField, + currentName: string, + currentSubtitle: string + ) => { + event.preventDefault(); + event.stopPropagation(); + clearPendingPlaylistOpen(); + setMenuPlaylistId(null); + setEditingPlaylistText({ + focusField, + name: currentName, + playlistId: playlist.id, + subtitle: currentSubtitle, + }); + }; + + const handleCancelTextEdit = (skipBlurCommit = false) => { + if (skipBlurCommit) { + skipNextTextEditBlurRef.current = true; + } + setEditingPlaylistText(null); + }; + + const handleSubmitTextEdit = async (playlist: TCustomPlaylist, currentName: string, currentSubtitle: string) => { + if (isSubmittingTextEditRef.current || !editingPlaylistText) return; + + const { playlistId } = editingPlaylistText; + if (playlistId !== playlist.id) return; + + const nextName = editingPlaylistText.name.trim(); + if (!nextName) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Edit custom playlist failed", + message: "Playlist title is required.", + }); + return; + } + + const payload = getPlaylistTextUpdatePayload( + playlist, + currentName, + currentSubtitle, + nextName, + editingPlaylistText.subtitle.trim() + ); + if (!payload) { + handleCancelTextEdit(); + return; + } + + isSubmittingTextEditRef.current = true; + setSavingPlaylistText({ playlistId: playlist.id }); + try { + const updatedPlaylist = await onUpdatePlaylist(playlist, payload); + if (activePlaylist?.id === updatedPlaylist.id) { + setActivePlaylist(updatedPlaylist); + } + setEditingPlaylistText(null); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Custom playlist updated", + message: "The playlist details were updated.", + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Edit custom playlist failed", + message: "Unable to edit this playlist. Please try again.", + }); + } finally { + isSubmittingTextEditRef.current = false; + setSavingPlaylistText(null); + } + }; + + const handleToggleMenu = (event: ReactMouseEvent<HTMLButtonElement>, playlistId: string) => { + event.stopPropagation(); + clearPendingPlaylistOpen(); + setMenuPlaylistId((currentPlaylistId) => (currentPlaylistId === playlistId ? null : playlistId)); + }; + + const handleOpenPlaylist = (playlist: TCustomPlaylist) => { + clearPendingPlaylistOpen(); + playlistOpenTimeoutRef.current = window.setTimeout(() => { + setActivePlaylist(playlist); + playlistOpenTimeoutRef.current = null; + }, 260); + }; + + const handleSharePlaylist = async (playlist: TCustomPlaylist) => { + setMenuPlaylistId(null); + + const playlistUrl = buildCustomPlaylistUrl(playlist.url); + if (!playlistUrl || typeof navigator === "undefined" || !navigator.clipboard) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Share unavailable", + message: "Unable to copy this playlist link.", + }); + return; + } + + try { + await navigator.clipboard.writeText(playlistUrl); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Playlist link copied", + message: "The playlist link is ready to share.", + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Share failed", + message: "Unable to copy this playlist link.", + }); + } + }; + + const handleOpenDeleteModal = (playlist: TCustomPlaylist) => { + setMenuPlaylistId(null); + setPlaylistPendingDelete(playlist); + }; + + const handleCloseDeleteModal = () => { + if (isDeletingPlaylist) return; + setPlaylistPendingDelete(null); + }; + + const handleConfirmDeletePlaylist = async () => { + if (!playlistPendingDelete) return; + + setIsDeletingPlaylist(true); + try { + await onDeletePlaylist(playlistPendingDelete); + if (activePlaylist?.id === playlistPendingDelete.id) { + setActivePlaylist(null); + } + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Playlist deleted", + message: "The playlist was removed from the workspace.", + }); + setPlaylistPendingDelete(null); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Delete failed", + message: "Unable to delete this playlist. Please try again.", + }); + } finally { + setIsDeletingPlaylist(false); + } + }; + + return ( + <> + <aside + className={`${PLAYER_FRAME_CLASS} flex min-h-0 flex-col overflow-hidden rounded-[5px] border border-[var(--sg-matrix-border)] bg-[var(--sg-matrix-panel-secondary)]`} + > + <div className="flex h-[34px] items-center justify-between gap-3 border-b border-[var(--sg-matrix-border)] px-2.5"> + <div className="min-w-0"> + <div className="truncate text-[13px] font-normal text-[var(--sg-matrix-text-secondary)]"> + Playlist Workspace + </div> + </div> + </div> + + <div className="vertical-scrollbar scrollbar-md min-h-0 flex-1 overflow-y-auto p-1.5"> + {customPlaylists.length > 0 ? ( + <ul className="space-y-1.5"> + {customPlaylists.map((playlist) => { + const thumbnailUrl = buildCustomPlaylistThumbnailUrl(playlist.thumbnail); + const clipCount = getPlaylistCardClipCount(playlist); + const cardTitle = getPlaylistCardTitle(playlist); + const cardSubtitle = getPlaylistCardSubtitle(playlist); + const activeTextEdit = editingPlaylistText?.playlistId === playlist.id ? editingPlaylistText : null; + const isEditing = Boolean(activeTextEdit); + const isSavingText = savingPlaylistText?.playlistId === playlist.id; + const thumbnailPreview = ( + <span className="flex h-10 w-14 shrink-0 items-center justify-center overflow-hidden rounded-[4px] bg-[var(--sg-matrix-cell-empty)] text-[var(--sg-matrix-text-muted)]"> + {thumbnailUrl ? ( + <img src={thumbnailUrl} alt="" className="h-full w-full object-cover" /> + ) : ( + <Video className="h-3.5 w-3.5" /> + )} + </span> + ); + + return ( + <li key={playlist.id}> + <div + className="group relative rounded-[5px]" + ref={menuPlaylistId === playlist.id ? activeMenuRef : null} + > + {isEditing ? ( + <form + className="flex w-full min-w-0 items-center gap-2 rounded-[5px] bg-[var(--sg-matrix-selected-nav)] px-2 py-1.5 text-left" + onSubmit={(event) => { + event.preventDefault(); + event.stopPropagation(); + void handleSubmitTextEdit(playlist, cardTitle, cardSubtitle); + }} + onBlur={(event) => { + if (skipNextTextEditBlurRef.current) { + skipNextTextEditBlurRef.current = false; + return; + } + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; + void handleSubmitTextEdit(playlist, cardTitle, cardSubtitle); + }} + > + {thumbnailPreview} + <span className="flex min-w-0 flex-1 flex-col gap-0.5"> + <span className="flex min-w-0 items-center gap-1.5"> + <input + ref={titleEditInputRef} + type="text" + value={activeTextEdit?.name ?? ""} + disabled={isSavingText} + onChange={(event) => + setEditingPlaylistText((currentState) => + currentState ? { ...currentState, name: event.target.value } : currentState + ) + } + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + handleCancelTextEdit(true); + }} + className="h-[19px] w-full min-w-0 border-0 bg-transparent px-0 text-[11px] font-medium leading-none text-[var(--sg-matrix-text)] outline-none placeholder:text-[var(--sg-matrix-text-muted)] focus:ring-0" + aria-label="Playlist title" + /> + <span className="shrink-0 rounded-[4px] border border-[#338fdc]/25 bg-[#338fdc]/10 px-1.5 py-0.5 text-[9px] font-medium leading-none text-[#7cc6ff]"> + {formatPlaylistCardClipCount(clipCount)} + </span> + </span> + <input + ref={subtitleEditInputRef} + type="text" + value={activeTextEdit?.subtitle ?? ""} + disabled={isSavingText} + onChange={(event) => + setEditingPlaylistText((currentState) => + currentState ? { ...currentState, subtitle: event.target.value } : currentState + ) + } + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + handleCancelTextEdit(true); + }} + className="h-[18px] w-full min-w-0 border-0 bg-transparent px-0 text-[10px] leading-none text-[var(--sg-matrix-text)] outline-none placeholder:text-[var(--sg-matrix-text-muted)] focus:ring-0" + aria-label="Playlist subtitle" + /> + </span> + </form> + ) : ( + <button + type="button" + onClick={() => handleOpenPlaylist(playlist)} + className="flex w-full min-w-0 items-center gap-2 rounded-[5px] border border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-selected-nav)] px-2 py-1.5 pr-7 text-left transition-colors hover:bg-[var(--sg-matrix-hover)]" + > + {thumbnailPreview} + <span className="flex min-w-0 flex-1 flex-col gap-0.5"> + <span className="flex min-w-0 items-center gap-1.5"> + <span + className="truncate text-[11px] font-medium text-[var(--sg-matrix-text-secondary)]" + title="Double-click to edit custom playlist" + onDoubleClick={(event) => + handleStartTextEdit(event, playlist, "name", cardTitle, cardSubtitle) + } + > + {cardTitle} + </span> + <span className="shrink-0 rounded-[4px] border border-[#338fdc]/25 bg-[#338fdc]/10 px-1.5 py-0.5 text-[9px] font-medium leading-none text-[#7cc6ff]"> + {formatPlaylistCardClipCount(clipCount)} + </span> + </span> + <span + className="truncate text-[10px] text-[var(--sg-matrix-text-muted)]" + title="Double-click to edit custom playlist" + onDoubleClick={(event) => + handleStartTextEdit(event, playlist, "subtitle", cardTitle, cardSubtitle) + } + > + {cardSubtitle} + </span> + </span> + </button> + )} + + {!isEditing ? ( + <button + type="button" + onClick={(event) => handleToggleMenu(event, playlist.id)} + className="absolute right-1.5 top-1.5 inline-flex h-6 w-6 items-center justify-center rounded text-[var(--sg-matrix-text-muted)] opacity-70 transition-colors hover:bg-[var(--sg-matrix-hover)] hover:text-[var(--sg-matrix-text)] group-hover:opacity-100" + aria-label={`Open ${cardTitle} playlist actions`} + aria-expanded={menuPlaylistId === playlist.id} + > + <MoreVertical className="h-3.5 w-3.5" /> + </button> + ) : null} + + {menuPlaylistId === playlist.id ? ( + <div className="absolute right-1.5 top-8 z-30 w-[148px] overflow-hidden rounded-[5px] border border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-panel-secondary)] py-1 shadow-[0_12px_34px_rgba(0,0,0,0.45)]"> + <button + type="button" + onClick={(event) => handleStartTextEdit(event, playlist, "name", cardTitle, cardSubtitle)} + className="flex h-7 w-full items-center gap-2 px-2 text-left text-[11px] text-[var(--sg-matrix-text-secondary)] transition-colors hover:bg-[var(--sg-matrix-hover)] hover:text-[var(--sg-matrix-text)]" + > + <Pencil className="h-3.5 w-3.5" /> + <span>Edit</span> + </button> + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + void handleSharePlaylist(playlist); + }} + className="flex h-7 w-full items-center gap-2 px-2 text-left text-[11px] text-[var(--sg-matrix-text-secondary)] transition-colors hover:bg-[var(--sg-matrix-hover)] hover:text-[var(--sg-matrix-text)]" + > + <Share2 className="h-3.5 w-3.5" /> + <span>Share</span> + </button> + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + handleOpenDeleteModal(playlist); + }} + className="flex h-7 w-full items-center gap-2 px-2 text-left text-[11px] text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300" + > + <Trash2 className="h-3.5 w-3.5" /> + <span>Delete</span> + </button> + </div> + ) : null} + </div> + </li> + ); + })} + </ul> + ) : ( + <div className="px-2 py-2 text-xs leading-5 text-[var(--sg-matrix-text-muted)]"> + Select tags or populated matrix cells, then click Create Playlist to show it here. + </div> + )} + </div> + </aside> + <AlertModalCore + isOpen={Boolean(playlistPendingDelete)} + title="Delete playlist" + content={ + <> + Delete{" "} + <strong className="font-medium text-custom-text-100"> + {playlistPendingDelete?.name?.trim() || "this playlist"} + </strong> + ? This action cannot be undone. + </> + } + handleClose={handleCloseDeleteModal} + handleSubmit={handleConfirmDeletePlaylist} + isSubmitting={isDeletingPlaylist} + variant="danger" + /> + <SgPlaylistVideoModal playlist={activePlaylist} onClose={() => setActivePlaylist(null)} /> + </> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-row.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-row.tsx new file mode 100644 index 00000000000..fa875518632 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-row.tsx @@ -0,0 +1,145 @@ +import { memo } from "react"; +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; +import type { MatrixCell as MatrixCellData, MatrixColumn, MatrixRow as MatrixRowData } from "../types/matrix.types"; +import { MatrixCell } from "./matrix-cell"; + +type MatrixRowProps = { + activeRowId?: string | null; + ariaRowIndex?: number; + columnStartIndex?: number; + columns: MatrixColumn[]; + isGroupStart?: boolean; + leadingSpacerWidth?: number; + maxVisibleCount: number; + onCellActivate: ( + cell: MatrixCellData, + trigger: HTMLButtonElement, + options?: { additive?: boolean; range?: boolean } + ) => void; + onCellDoubleClick?: (cell: MatrixCellData) => void; + openCellId?: string | null; + previousColumnGroup?: string; + row: MatrixRowData; + selectedCellIds: ReadonlySet<string>; + stickySummaries?: boolean; + totalColumnCount: number; + trailingSpacerWidth?: number; +}; + +const getColumnGroup = (column: MatrixColumn) => column.group ?? column.category ?? column.dimension ?? ""; + +const formatAverage = (average: number) => { + if (!Number.isFinite(average) || average === 0) return "—"; + return Number.isInteger(average) ? String(average) : average.toFixed(1); +}; + +export const MatrixRow = memo(function MatrixRow({ + activeRowId, + ariaRowIndex, + columnStartIndex = 0, + columns, + isGroupStart = false, + leadingSpacerWidth = 0, + maxVisibleCount, + onCellActivate, + onCellDoubleClick, + openCellId, + previousColumnGroup = "", + row, + selectedCellIds, + stickySummaries = true, + totalColumnCount, + trailingSpacerWidth = 0, +}: MatrixRowProps) { + const rowGroup = getColumnGroup(row); + + return ( + <tr aria-rowindex={ariaRowIndex}> + <th + aria-colindex={1} + scope="row" + className={cn( + "sticky left-0 z-10 h-11 w-[140px] min-w-[140px] border-b border-r border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-row-label-bg)] px-2 text-left align-middle", + isGroupStart && "border-t border-t-[var(--sg-matrix-grid-border)]" + )} + > + <Tooltip tooltipContent={rowGroup ? `${row.label} · ${rowGroup}` : row.label} position="right"> + <span className="flex min-w-0 flex-col"> + <span className="truncate text-[13px] font-normal text-[var(--sg-matrix-row-label-text)]">{row.label}</span> + {rowGroup ? ( + <span className="hidden truncate text-[10px] font-normal text-[var(--sg-matrix-text-muted)]"> + {rowGroup} + </span> + ) : null} + </span> + </Tooltip> + </th> + {leadingSpacerWidth > 0 ? ( + <td + aria-hidden="true" + className={cn( + "h-11 border-b border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-cell-empty)] p-0", + isGroupStart && "border-t border-t-[var(--sg-matrix-grid-border)]" + )} + style={{ minWidth: leadingSpacerWidth, width: leadingSpacerWidth }} + /> + ) : null} + {columns.map((column, columnIndex) => { + const cell = row.cells[column.id]; + const columnGroup = getColumnGroup(column); + const previousGroup = columnIndex > 0 ? getColumnGroup(columns[columnIndex - 1]) : previousColumnGroup; + const isColumnGroupStart = (columnStartIndex > 0 || columnIndex > 0) && columnGroup !== previousGroup; + const isActive = Boolean(activeRowId && cell?.sourceRowIds.includes(activeRowId)); + + return ( + <MatrixCell + key={`${row.id}-${column.id}`} + ariaColumnIndex={columnStartIndex + columnIndex + 2} + cell={cell} + columnLabel={column.label} + isActive={isActive} + isGroupStart={isColumnGroupStart} + isPanelOpen={cell?.id === openCellId} + isRowGroupStart={isGroupStart} + isSelected={Boolean(cell && selectedCellIds.has(cell.id))} + maxVisibleCount={maxVisibleCount} + onActivate={onCellActivate} + onDoubleClick={onCellDoubleClick} + rowLabel={row.label} + /> + ); + })} + {trailingSpacerWidth > 0 ? ( + <td + aria-hidden="true" + className={cn( + "h-11 border-b border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-cell-empty)] p-0", + isGroupStart && "border-t border-t-[var(--sg-matrix-grid-border)]" + )} + style={{ minWidth: trailingSpacerWidth, width: trailingSpacerWidth }} + /> + ) : null} + <td + aria-colindex={totalColumnCount + 2} + className={cn( + "h-11 w-[44px] min-w-[44px] border-b border-r border-l border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-panel-secondary)] px-1 text-center text-[12px] font-medium text-[var(--sg-matrix-text-secondary)]", + isGroupStart && "border-t border-t-[var(--sg-matrix-grid-border)]", + stickySummaries && "lg:sticky lg:right-[44px] lg:z-10" + )} + > + {row.total || "—"} + </td> + <td + aria-colindex={totalColumnCount + 3} + className={cn( + "h-11 w-[44px] min-w-[44px] border-b border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-panel-secondary)] px-1 text-center text-[12px] text-[var(--sg-matrix-text-muted)]", + isGroupStart && "border-t border-t-[var(--sg-matrix-grid-border)]", + stickySummaries && "lg:sticky lg:right-0 lg:z-10" + )} + > + {formatAverage(row.average)} + </td> + </tr> + ); +}); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-table.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-table.tsx new file mode 100644 index 00000000000..073262b1a12 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-table.tsx @@ -0,0 +1,242 @@ +"use client"; + +import type { CSSProperties, UIEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { cn } from "@plane/utils"; +import type { MatrixCell, MatrixData, MatrixRow } from "../types/matrix.types"; +import { + getMatrixColumnVirtualRange, + MATRIX_COLUMN_VIRTUALIZATION_THRESHOLD, + MATRIX_COLUMN_WIDTH, + MATRIX_FIRST_COLUMN_WIDTH, + MATRIX_SUMMARY_COLUMNS_WIDTH, +} from "../utils/matrix-virtualization"; +import { MatrixHeader } from "./matrix-header"; +import { MatrixRow as MatrixTableRow } from "./matrix-row"; + +type MatrixTableProps = { + activeRowId?: string | null; + data: MatrixData; + maxHeightClassName?: string; + onCellActivate: ( + cell: MatrixCell, + trigger: HTMLButtonElement, + options?: { additive?: boolean; range?: boolean } + ) => void; + onCellDoubleClick?: (cell: MatrixCell) => void; + openCellId?: string | null; + selectedCellIds: ReadonlySet<string>; + stickySummaries?: boolean; +}; + +const getRowGroup = (row: MatrixRow) => row.group ?? row.category ?? row.dimension ?? ""; + +const HEADER_HEIGHT = 180; +const ROW_HEIGHT = 44; +const ROW_OVERSCAN = 4; +const ROW_VIRTUALIZATION_THRESHOLD = 40; + +const getColumnGroup = (column: MatrixData["columns"][number]) => + column.group ?? column.category ?? column.dimension ?? ""; + +export const MatrixTable = ({ + activeRowId, + data, + maxHeightClassName, + onCellActivate, + onCellDoubleClick, + openCellId, + selectedCellIds, + stickySummaries = true, +}: MatrixTableProps) => { + const scrollContainerRef = useRef<HTMLDivElement>(null); + const visibleColumns = useMemo(() => data.columns.filter((column) => column.visible), [data.columns]); + const visibleRows = useMemo(() => data.rows.filter((row) => row.visible), [data.rows]); + const shouldVirtualizeRows = visibleRows.length > ROW_VIRTUALIZATION_THRESHOLD; + const shouldVirtualizeColumns = visibleColumns.length > MATRIX_COLUMN_VIRTUALIZATION_THRESHOLD; + const [columnWidth, setColumnWidth] = useState(MATRIX_COLUMN_WIDTH); + const [rowVirtualRange, setRowVirtualRange] = useState({ end: 20, start: 0 }); + const [columnVirtualRange, setColumnVirtualRange] = useState({ end: 20, start: 0 }); + const entityAxisLabel = useMemo(() => { + const groups = Array.from( + new Set( + data.entities + .filter((entity) => entity.visible && entity.dimension !== "unassigned") + .map((entity) => entity.group) + .filter((group): group is string => Boolean(group)) + ) + ); + return groups.length === 1 ? groups[0] : "Participants"; + }, [data.entities]); + const firstColumnLabel = data.orientation === "entities-by-actions" ? "Actions" : (entityAxisLabel ?? "Participants"); + + const updateVirtualRanges = useCallback( + (scrollTop: number, scrollLeft: number, viewportHeight: number, viewportWidth: number) => { + const availableColumnWidth = + visibleColumns.length > 0 + ? Math.floor( + (viewportWidth - MATRIX_FIRST_COLUMN_WIDTH - MATRIX_SUMMARY_COLUMNS_WIDTH) / visibleColumns.length + ) + : MATRIX_COLUMN_WIDTH; + const nextColumnWidth = Math.max(MATRIX_COLUMN_WIDTH, availableColumnWidth); + setColumnWidth((currentWidth) => (currentWidth === nextColumnWidth ? currentWidth : nextColumnWidth)); + + const nextRowRange = shouldVirtualizeRows + ? { + start: Math.max(0, Math.floor(Math.max(0, scrollTop - HEADER_HEIGHT) / ROW_HEIGHT) - ROW_OVERSCAN), + end: 0, + } + : { end: visibleRows.length, start: 0 }; + if (shouldVirtualizeRows) { + const visibleRowCount = Math.ceil(viewportHeight / ROW_HEIGHT) + ROW_OVERSCAN * 2; + nextRowRange.end = Math.min(visibleRows.length, nextRowRange.start + visibleRowCount); + } + setRowVirtualRange((currentRange) => + currentRange.start === nextRowRange.start && currentRange.end === nextRowRange.end ? currentRange : nextRowRange + ); + + const nextColumnRange = getMatrixColumnVirtualRange({ + columnWidth: nextColumnWidth, + columnCount: visibleColumns.length, + scrollLeft, + viewportWidth, + virtualize: shouldVirtualizeColumns, + }); + setColumnVirtualRange((currentRange) => + currentRange.start === nextColumnRange.start && currentRange.end === nextColumnRange.end + ? currentRange + : nextColumnRange + ); + }, + [shouldVirtualizeColumns, shouldVirtualizeRows, visibleColumns.length, visibleRows.length] + ); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + const maximumScrollTop = Math.max(0, HEADER_HEIGHT + visibleRows.length * ROW_HEIGHT - container.clientHeight); + if (container.scrollTop > maximumScrollTop) container.scrollTop = maximumScrollTop; + updateVirtualRanges(container.scrollTop, container.scrollLeft, container.clientHeight, container.clientWidth); + + if (typeof ResizeObserver === "undefined") return; + const resizeObserver = new ResizeObserver(() => + updateVirtualRanges(container.scrollTop, container.scrollLeft, container.clientHeight, container.clientWidth) + ); + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, [updateVirtualRanges, visibleRows.length]); + + const handleScroll = useCallback( + (event: UIEvent<HTMLDivElement>) => + updateVirtualRanges( + event.currentTarget.scrollTop, + event.currentTarget.scrollLeft, + event.currentTarget.clientHeight, + event.currentTarget.clientWidth + ), + [updateVirtualRanges] + ); + const rowStart = shouldVirtualizeRows ? Math.min(rowVirtualRange.start, Math.max(0, visibleRows.length - 1)) : 0; + const rowEnd = shouldVirtualizeRows + ? Math.max(rowStart, Math.min(rowVirtualRange.end, visibleRows.length)) + : visibleRows.length; + const columnStart = shouldVirtualizeColumns + ? Math.min(columnVirtualRange.start, Math.max(0, visibleColumns.length - 1)) + : 0; + const columnEnd = shouldVirtualizeColumns + ? Math.max(columnStart, Math.min(columnVirtualRange.end, visibleColumns.length)) + : visibleColumns.length; + const renderedRows = visibleRows.slice(rowStart, rowEnd); + const renderedColumns = visibleColumns.slice(columnStart, columnEnd); + const maxVisibleCount = useMemo( + () => + visibleRows.reduce( + (maxCount, row) => Math.max(maxCount, ...visibleColumns.map((column) => row.cells[column.id]?.count ?? 0)), + 0 + ), + [visibleColumns, visibleRows] + ); + const leadingColumnCount = columnStart; + const trailingColumnCount = visibleColumns.length - columnEnd; + const previousColumnGroup = columnStart > 0 ? getColumnGroup(visibleColumns[columnStart - 1]) : ""; + const physicalColumnCount = + renderedColumns.length + (leadingColumnCount > 0 ? 1 : 0) + (trailingColumnCount > 0 ? 1 : 0) + 3; + const matrixStyle = { "--sg-matrix-column-width": `${columnWidth}px` } as CSSProperties; + + return ( + <div + ref={scrollContainerRef} + aria-label={`${data.sport} tag matrix`} + className={cn( + "vertical-scrollbar horizontal-scrollbar scrollbar-lg min-h-52 w-full overflow-auto border-t border-[var(--sg-matrix-grid-border)] bg-[var(--sg-matrix-cell-empty)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--sg-matrix-active-border)]", + maxHeightClassName ?? "max-h-[520px]" + )} + onScroll={handleScroll} + role="region" + style={matrixStyle} + tabIndex={0} + > + <table + aria-colcount={visibleColumns.length + 3} + aria-rowcount={visibleRows.length + 1} + className="min-w-max border-separate border-spacing-0 bg-[var(--sg-matrix-cell-empty)]" + > + <caption className="sr-only"> + {data.sport} tag matrix. Select populated cells to include tags in a playlist. + </caption> + <MatrixHeader + columnStartIndex={columnStart} + columns={renderedColumns} + firstColumnLabel={firstColumnLabel} + leadingSpacerWidth={leadingColumnCount * columnWidth} + previousColumnGroup={previousColumnGroup} + stickySummaries={stickySummaries} + totalColumnCount={visibleColumns.length} + trailingSpacerWidth={trailingColumnCount * columnWidth} + /> + <tbody> + {rowStart > 0 ? ( + <tr aria-hidden="true"> + <td colSpan={physicalColumnCount} style={{ height: rowStart * ROW_HEIGHT, padding: 0 }} /> + </tr> + ) : null} + {renderedRows.map((row, rowIndex) => { + const absoluteRowIndex = rowStart + rowIndex; + const group = getRowGroup(row); + const previousGroup = absoluteRowIndex > 0 ? getRowGroup(visibleRows[absoluteRowIndex - 1]) : ""; + + return ( + <MatrixTableRow + key={row.id} + activeRowId={activeRowId} + ariaRowIndex={absoluteRowIndex + 2} + columnStartIndex={columnStart} + columns={renderedColumns} + isGroupStart={absoluteRowIndex > 0 && group !== previousGroup} + leadingSpacerWidth={leadingColumnCount * columnWidth} + maxVisibleCount={maxVisibleCount} + onCellActivate={onCellActivate} + onCellDoubleClick={onCellDoubleClick} + openCellId={openCellId} + previousColumnGroup={previousColumnGroup} + row={row} + selectedCellIds={selectedCellIds} + stickySummaries={stickySummaries} + totalColumnCount={visibleColumns.length} + trailingSpacerWidth={trailingColumnCount * columnWidth} + /> + ); + })} + {rowEnd < visibleRows.length ? ( + <tr aria-hidden="true"> + <td + colSpan={physicalColumnCount} + style={{ height: (visibleRows.length - rowEnd) * ROW_HEIGHT, padding: 0 }} + /> + </tr> + ) : null} + </tbody> + </table> + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-toolbar.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-toolbar.tsx new file mode 100644 index 00000000000..73761365d54 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-toolbar.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState } from "react"; +import { Columns3, ListPlus, Plus, X } from "lucide-react"; +import { cn } from "@plane/utils"; +import type { MatrixColumn, MatrixFilterOptions, MatrixFilterState } from "../types/matrix.types"; +import { AxisViewToggle } from "./axis-view-toggle"; +import { MatrixColumnsPanel } from "./matrix-columns-panel"; +import { MatrixFilters } from "./matrix-filters"; + +type MatrixToolbarProps = { + columns: MatrixColumn[]; + canCreateCard?: boolean; + canCreatePlaylist: boolean; + defaultVisibleColumnIds: readonly string[]; + disabled?: boolean; + filters: MatrixFilterState; + filterOptions: MatrixFilterOptions; + hasActiveFilters: boolean; + isCreatingPlaylist?: boolean; + isSwitched: boolean; + onAxisChange: (isSwitched: boolean) => void; + onClearFilters: () => void; + onClearSelection: () => void; + onCreateCard?: () => void; + onCreatePlaylist: () => void; + onFiltersChange: (filters: MatrixFilterState) => void; + onVisibleColumnIdsChange: (visibleColumnIds: string[]) => void; + selectedCellCount: number; + selectedPlayableRowCount: number; + showFilters: boolean; + visibleColumnIds: readonly string[]; +}; + +export const MatrixToolbar = ({ + columns, + canCreateCard = false, + canCreatePlaylist, + defaultVisibleColumnIds, + disabled = false, + filters, + filterOptions, + hasActiveFilters, + isCreatingPlaylist = false, + isSwitched, + onAxisChange, + onClearFilters, + onClearSelection, + onCreateCard, + onCreatePlaylist, + onFiltersChange, + onVisibleColumnIdsChange, + selectedCellCount, + selectedPlayableRowCount, + showFilters, + visibleColumnIds, +}: MatrixToolbarProps) => { + const [isColumnsPanelOpen, setIsColumnsPanelOpen] = useState(false); + const visibleColumnIdSet = new Set(visibleColumnIds); + const visibleColumnCount = columns.filter((column) => visibleColumnIdSet.has(column.id)).length; + + return ( + <div className="flex min-h-11 flex-col justify-center rounded-[5px] border border-[var(--sg-matrix-border)] bg-[var(--sg-matrix-panel-secondary)] px-3 py-1.5"> + <div className="flex min-h-8 flex-wrap items-center justify-between gap-2"> + <AxisViewToggle disabled={disabled} isSwitched={isSwitched} onChange={onAxisChange} /> + <div className="flex items-center gap-1.5"> + {selectedCellCount > 0 ? ( + <> + <span aria-live="polite" className="whitespace-nowrap text-[11px] text-[var(--sg-matrix-text-muted)]"> + {selectedCellCount} {selectedCellCount === 1 ? "cell" : "cells"} selected + </span> + <button + type="button" + aria-label="Clear selected matrix cells" + disabled={disabled || isCreatingPlaylist} + onClick={onClearSelection} + className="inline-flex h-7 items-center gap-1.5 rounded-[5px] px-2 text-[11px] text-[var(--sg-matrix-text-muted)] transition-colors hover:bg-[var(--sg-matrix-hover)] hover:text-[var(--sg-matrix-text)] disabled:cursor-not-allowed disabled:opacity-45" + > + <X className="h-3.5 w-3.5" /> + Clear + </button> + </> + ) : null} + <button + type="button" + onClick={() => setIsColumnsPanelOpen(true)} + disabled={disabled || columns.length === 0} + className={cn( + "inline-flex h-7 items-center gap-2 rounded-[5px] border px-2.5 text-[11px] font-normal transition-colors", + isColumnsPanelOpen + ? "border-[var(--sg-matrix-active-border)] bg-[var(--sg-matrix-selected-nav)] text-[var(--sg-matrix-text)]" + : "border-[var(--sg-matrix-border)] bg-[var(--sg-matrix-selected-nav)] text-[var(--sg-matrix-text-secondary)] hover:bg-[var(--sg-matrix-hover)] hover:text-[var(--sg-matrix-text)]", + (disabled || columns.length === 0) && "cursor-not-allowed opacity-40" + )} + > + <Columns3 className="h-3.5 w-3.5" /> + <span>Columns</span> + <span className="text-[var(--sg-matrix-text-muted)]"> + {visibleColumnCount}/{columns.length} + </span> + </button> + <button + type="button" + disabled={disabled || !canCreateCard} + onClick={onCreateCard} + className="inline-flex h-7 items-center gap-1.5 rounded-[5px] border border-[var(--sg-matrix-border)] bg-[var(--sg-matrix-selected-nav)] px-2.5 text-[11px] font-normal text-[var(--sg-matrix-text-secondary)] transition-colors hover:bg-[var(--sg-matrix-hover)] hover:text-[var(--sg-matrix-text)] disabled:cursor-not-allowed disabled:text-[var(--sg-matrix-text-disabled)] disabled:opacity-45" + > + <Plus className="h-3.5 w-3.5" /> + Create Card + </button> + <button + type="button" + disabled={disabled || !canCreatePlaylist || selectedPlayableRowCount === 0} + onClick={onCreatePlaylist} + title={ + selectedCellCount > 0 && selectedPlayableRowCount === 0 + ? "Selected tags do not contain playable timestamps" + : undefined + } + className="inline-flex h-7 items-center gap-1.5 rounded-[5px] border border-custom-primary-100 bg-custom-primary-100 px-2.5 text-[11px] font-normal text-white transition-colors hover:border-custom-primary-200 hover:bg-custom-primary-200 disabled:cursor-not-allowed disabled:opacity-45" + > + <ListPlus className="h-3.5 w-3.5" /> + {isCreatingPlaylist ? "Creating" : "Create Playlist"} + </button> + </div> + </div> + {showFilters ? ( + <div className="hidden"> + <MatrixFilters + disabled={disabled} + filters={filters} + hasActiveFilters={hasActiveFilters} + onChange={onFiltersChange} + onClear={onClearFilters} + options={filterOptions} + /> + </div> + ) : null} + {isColumnsPanelOpen ? ( + <MatrixColumnsPanel + columns={columns} + defaultVisibleColumnIds={defaultVisibleColumnIds} + onChange={onVisibleColumnIdsChange} + onClose={() => setIsColumnsPanelOpen(false)} + visibleColumnIds={visibleColumnIds} + /> + ) : null} + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-view-toggle.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-view-toggle.tsx new file mode 100644 index 00000000000..b7d3a5a06b5 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-view-toggle.tsx @@ -0,0 +1,63 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import { Grid3x3, List } from "lucide-react"; +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; + +export type MatrixViewMode = "list" | "matrix"; + +type MatrixViewToggleProps = { + className?: string; + disabled?: boolean; + isMobile?: boolean; + onChange: (view: MatrixViewMode) => void; + value: MatrixViewMode; +}; + +const VIEW_OPTIONS: Array<{ icon: LucideIcon; label: string; value: MatrixViewMode }> = [ + { icon: List, label: "List", value: "list" }, + { icon: Grid3x3, label: "Matrix", value: "matrix" }, +]; + +export const MatrixViewToggle = ({ + className, + disabled = false, + isMobile = false, + onChange, + value, +}: MatrixViewToggleProps) => ( + <div + aria-label="Tag view" + className={cn("flex items-center gap-1 rounded bg-custom-background-80 p-1", className)} + role="group" + > + {VIEW_OPTIONS.map((option) => { + const Icon = option.icon; + const isActive = value === option.value; + + return ( + <Tooltip key={option.value} tooltipContent={`${option.label} view`} isMobile={isMobile}> + <button + type="button" + aria-label={`${option.label} view`} + aria-pressed={isActive} + className={cn( + "group grid h-8 w-8 place-items-center rounded text-custom-text-200 transition-colors", + "hover:bg-custom-background-100 hover:text-custom-text-100", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-custom-primary-100", + isActive && "bg-custom-background-100 text-custom-text-100 shadow-custom-shadow-2xs", + disabled && "cursor-not-allowed opacity-50" + )} + disabled={disabled} + onClick={() => { + if (!isActive) onChange(option.value); + }} + > + <Icon aria-hidden="true" className="h-3.5 w-3.5" size={14} strokeWidth={2} /> + </button> + </Tooltip> + ); + })} + </div> +); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-view.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-view.tsx new file mode 100644 index 00000000000..c3806114f12 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/components/matrix-view.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { cn } from "@plane/utils"; +import type { SportTableKind, SgTagRow } from "../../types"; +import { useMatrixData } from "../hooks/use-matrix-data"; +import { useMatrixSelection } from "../hooks/use-matrix-selection"; +import type { MatrixCell, MatrixOrientation } from "../types/matrix.types"; +import { orientMatrixData } from "../utils/build-matrix-data"; +import { getMatrixPlaylistRows } from "../utils/create-matrix-playlist"; +import { MatrixEmptyState } from "./matrix-empty-state"; +import { MatrixLoadingState } from "./matrix-loading-state"; +import { MatrixTable } from "./matrix-table"; +import { MatrixToolbar } from "./matrix-toolbar"; + +export type MatrixViewProps = { + activeRowId?: string | null; + canCreatePlaylist?: boolean; + className?: string; + error?: Error | string | null; + hasEvent?: boolean; + isCreatingPlaylist?: boolean; + isLoading?: boolean; + layout?: "standard" | "workspace"; + onCreateCard?: (rows: SgTagRow[]) => void | Promise<void>; + onCreatePlaylist?: (rows: SgTagRow[]) => void | Promise<void>; + onFocusedRowsChange?: (rows: SgTagRow[]) => void; + onPlayTagRow?: (row: SgTagRow) => void | Promise<void>; + preferenceKey?: string; + sport: SportTableKind | string; + tagRows: readonly SgTagRow[]; +}; + +export const MatrixView = ({ + activeRowId, + canCreatePlaylist, + className, + error = null, + hasEvent = true, + isCreatingPlaylist = false, + isLoading = false, + layout = "standard", + onCreateCard, + onCreatePlaylist, + onFocusedRowsChange, + onPlayTagRow, + preferenceKey, + sport, + tagRows, +}: MatrixViewProps) => { + const [isSwitched, setIsSwitched] = useState(false); + const [activeCellId, setActiveCellId] = useState<string | null>(null); + const orientation: MatrixOrientation = isSwitched ? "entities-by-actions" : "actions-by-entities"; + const { + clearFilters, + filteredSourceTags, + filterOptions, + filters, + hasActiveFilters, + matrix, + sourceTags, + sportResolution, + setFilters, + } = useMatrixData({ orientation, sport, tagRows }); + const [visibleColumnIdsByOrientation, setVisibleColumnIdsByOrientation] = useState< + Partial<Record<MatrixOrientation, string[]>> + >({}); + const displayedColumns = useMemo(() => matrix?.columns ?? [], [matrix?.columns]); + const displayedColumnIds = useMemo(() => displayedColumns.map((column) => column.id), [displayedColumns]); + const defaultVisibleColumnIds = useMemo(() => { + const visibleColumns = displayedColumns.filter((column) => column.visible); + const preferredColumns = visibleColumns.length > 0 ? visibleColumns : displayedColumns; + return preferredColumns.slice(0, 14).map((column) => column.id); + }, [displayedColumns]); + const activeVisibleColumnIds = visibleColumnIdsByOrientation[orientation] ?? defaultVisibleColumnIds; + const displayedMatrix = useMemo(() => { + if (!matrix) return null; + + const visibleColumnIdSet = new Set(activeVisibleColumnIds); + const markVisible = (column: (typeof matrix.actions)[number]) => ({ + ...column, + visible: visibleColumnIdSet.has(column.id), + }); + + return orientMatrixData( + { + ...matrix, + actions: orientation === "entities-by-actions" ? matrix.actions.map(markVisible) : matrix.actions, + entities: orientation === "actions-by-entities" ? matrix.entities.map(markVisible) : matrix.entities, + }, + orientation + ); + }, [activeVisibleColumnIds, matrix, orientation]); + const { clearSelection, selectedCellIds, selectedSourceRowIds, selection, selectCell } = + useMatrixSelection(displayedMatrix); + + const tagRowsById = useMemo(() => new Map(tagRows.map((row) => [row.id, row])), [tagRows]); + const selectedRows = useMemo( + () => selectedSourceRowIds.map((rowId) => tagRowsById.get(rowId)).filter((row): row is SgTagRow => Boolean(row)), + [selectedSourceRowIds, tagRowsById] + ); + const selectedPlayableRows = useMemo(() => getMatrixPlaylistRows(selectedRows), [selectedRows]); + const activeCell = activeCellId && displayedMatrix ? displayedMatrix.cells[activeCellId] : undefined; + const activeCellRows = useMemo( + () => + (activeCell?.sourceRowIds ?? []) + .map((rowId) => tagRowsById.get(rowId)) + .filter((row): row is SgTagRow => Boolean(row)), + [activeCell?.sourceRowIds, tagRowsById] + ); + const focusedRows = activeCellRows.length > 0 ? activeCellRows : selectedRows; + + useEffect(() => { + const columnIdSet = new Set(displayedColumnIds); + + setVisibleColumnIdsByOrientation((currentValue) => { + const currentColumnIds = currentValue[orientation]; + if (!currentColumnIds) return currentValue; + + const nextValue = currentColumnIds.filter((columnId) => columnIdSet.has(columnId)); + if (nextValue.length === currentColumnIds.length) return currentValue; + return { ...currentValue, [orientation]: nextValue }; + }); + }, [displayedColumnIds, orientation]); + + useEffect(() => { + if (!preferenceKey || typeof window === "undefined") return; + try { + const storedValue = window.localStorage.getItem(preferenceKey); + if (!storedValue) return; + const parsedValue = JSON.parse(storedValue) as Partial<Record<MatrixOrientation, string[]>>; + setVisibleColumnIdsByOrientation({ + "actions-by-entities": Array.isArray(parsedValue["actions-by-entities"]) + ? parsedValue["actions-by-entities"] + : undefined, + "entities-by-actions": Array.isArray(parsedValue["entities-by-actions"]) + ? parsedValue["entities-by-actions"] + : undefined, + }); + } catch { + setVisibleColumnIdsByOrientation({}); + } + }, [preferenceKey]); + + const handleCellActivate = useCallback( + (cell: MatrixCell, _trigger: HTMLButtonElement, options?: { additive?: boolean; range?: boolean }) => { + selectCell(cell, options?.range ? "range" : options?.additive ? "toggle" : "replace"); + setActiveCellId(cell.id); + }, + [selectCell] + ); + const handleCreatePlaylist = useCallback(() => { + if (selectedPlayableRows.length > 0) void onCreatePlaylist?.(selectedPlayableRows); + }, [onCreatePlaylist, selectedPlayableRows]); + const handleCreateCard = useCallback(() => { + if (focusedRows.length > 0) void onCreateCard?.(focusedRows); + }, [focusedRows, onCreateCard]); + const handleVisibleColumnIdsChange = useCallback( + (nextVisibleColumnIds: string[]) => { + const nextValue = { + ...visibleColumnIdsByOrientation, + [orientation]: nextVisibleColumnIds, + }; + setVisibleColumnIdsByOrientation(nextValue); + if (preferenceKey && typeof window !== "undefined") { + window.localStorage.setItem(preferenceKey, JSON.stringify(nextValue)); + } + setActiveCellId(null); + clearSelection(); + }, + [clearSelection, orientation, preferenceKey, visibleColumnIdsByOrientation] + ); + + const hasError = error !== null && error !== undefined; + const errorMessage = typeof error === "string" ? error : error?.message; + const showFilters = hasEvent && !hasError && sportResolution.isSupported && sourceTags.length > 0; + const isWorkspaceLayout = layout === "workspace"; + const playlistCapability = canCreatePlaylist ?? Boolean(onCreatePlaylist); + const selectedTagRowsForCard = activeCellRows.length > 0 ? activeCellRows : selectedRows; + + useEffect(() => { + onFocusedRowsChange?.(focusedRows); + }, [focusedRows, onFocusedRowsChange]); + + return ( + <section + aria-busy={isLoading} + aria-label="Event tag matrix" + className={cn( + "flex min-w-0 flex-col gap-2 overflow-hidden rounded-[5px] bg-transparent", + isWorkspaceLayout && "border-0 bg-transparent", + className + )} + > + <MatrixToolbar + columns={displayedColumns} + canCreatePlaylist={playlistCapability} + defaultVisibleColumnIds={defaultVisibleColumnIds} + disabled={isLoading || hasError || !hasEvent || !sportResolution.isSupported} + filterOptions={filterOptions} + filters={filters} + hasActiveFilters={hasActiveFilters} + isCreatingPlaylist={isCreatingPlaylist} + isSwitched={isSwitched} + canCreateCard={Boolean(onCreateCard) && selectedTagRowsForCard.length > 0} + onAxisChange={setIsSwitched} + onCreateCard={handleCreateCard} + onClearFilters={clearFilters} + onClearSelection={clearSelection} + onCreatePlaylist={handleCreatePlaylist} + onFiltersChange={setFilters} + onVisibleColumnIdsChange={handleVisibleColumnIdsChange} + selectedCellCount={selection.length} + selectedPlayableRowCount={selectedPlayableRows.length} + showFilters={showFilters} + visibleColumnIds={activeVisibleColumnIds} + /> + {isLoading ? ( + <MatrixLoadingState /> + ) : hasError ? ( + <MatrixEmptyState description={errorMessage} kind="error" /> + ) : !hasEvent ? ( + <MatrixEmptyState kind="empty-event" /> + ) : !sportResolution.isSupported ? ( + <MatrixEmptyState + description={`Matrix View is not configured for ${sportResolution.input || "this event's sport"}.`} + kind="unsupported-sport" + /> + ) : sourceTags.length === 0 ? ( + <MatrixEmptyState kind="no-tags" /> + ) : hasActiveFilters && filteredSourceTags.length === 0 ? ( + <MatrixEmptyState kind="no-filter-results" /> + ) : displayedMatrix && displayedMatrix.rows.length > 0 ? ( + <> + <div + className="relative isolate min-h-52 overflow-hidden rounded-[5px]" + > + <div className="min-w-0"> + <MatrixTable + activeRowId={activeRowId} + data={displayedMatrix} + maxHeightClassName={isWorkspaceLayout ? "max-h-[calc(100vh-31rem)] min-h-[300px]" : undefined} + onCellActivate={handleCellActivate} + onCellDoubleClick={(cell) => { + const firstRow = cell.sourceRowIds.map((rowId) => tagRowsById.get(rowId)).find(Boolean); + if (firstRow) void onPlayTagRow?.(firstRow); + }} + openCellId={activeCell?.id} + selectedCellIds={selectedCellIds} + stickySummaries={!isWorkspaceLayout} + /> + </div> + </div> + </> + ) : ( + <MatrixEmptyState kind="no-tags" /> + )} + </section> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/config/sport-matrix-config.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/config/sport-matrix-config.ts new file mode 100644 index 00000000000..bc8bb9a2182 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/config/sport-matrix-config.ts @@ -0,0 +1,309 @@ +import type { + MatrixSportResolution, + SportMatrixAction, + SportMatrixCategory, + SportMatrixConfig, + SportMatrixContextRule, + SupportedMatrixSport, +} from "../types/matrix.types"; + +type ActionSeed = readonly [ + label: string, + category: string, + aliases: readonly string[], + contextRules?: readonly SportMatrixContextRule[], +]; + +const contextRule = ( + sourceActions: readonly string[], + key: string, + values: readonly string[] +): SportMatrixContextRule => ({ sourceActions, values: { [key]: values } }); + +const ENTITY_DIMENSIONS = { + team: { dimension: "team", label: "Teams", color: "#93c5fd" }, + period: { dimension: "period", label: "Periods", color: "#d8b4fe" }, + player: { dimension: "player", label: "Participants", color: "#99f6e4" }, +} as const; + +const toActionId = (label: string) => + label + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +const makeCategories = (values: readonly [id: string, label: string, color: string][]): SportMatrixCategory[] => + values.map(([id, label, color], order) => ({ id, label, color, order })); + +const makeActions = (seeds: readonly ActionSeed[], categories: readonly SportMatrixCategory[]): SportMatrixAction[] => { + const categoryColors = new Map(categories.map((category) => [category.id, category.color])); + return seeds.map(([label, category, aliases, contextRules], order) => ({ + id: toActionId(label), + label, + aliases: [label, toActionId(label), ...aliases], + category, + color: categoryColors.get(category) ?? "#a3a3a3", + contextRules, + order, + visible: true, + })); +}; + +const footballCategories = makeCategories([ + ["offense", "Offense", "#93c5fd"], + ["defense", "Defense", "#fca5a5"], + ["special-teams", "Special Teams", "#99f6e4"], + ["scoring", "Scoring", "#f9a8d4"], + ["discipline", "Discipline", "#fde68a"], +]); + +const cricketCategories = makeCategories([ + ["batting", "Batting", "#93c5fd"], + ["boundary", "Boundaries", "#f9a8d4"], + ["extras", "Extras", "#fde68a"], + ["dismissal", "Dismissals", "#fca5a5"], + ["control", "Match Control", "#99f6e4"], +]); + +const basketballCategories = makeCategories([ + ["scoring", "Scoring", "#93c5fd"], + ["possession", "Possession", "#99f6e4"], + ["defense", "Defense", "#d8b4fe"], + ["discipline", "Discipline", "#fca5a5"], +]); + +const baseballCategories = makeCategories([ + ["hitting", "Hitting", "#93c5fd"], + ["plate", "Plate Appearance", "#d8b4fe"], + ["baserunning", "Baserunning", "#99f6e4"], + ["defense", "Defense", "#fca5a5"], + ["scoring", "Scoring", "#f9a8d4"], +]); + +const soccerCategories = makeCategories([ + ["attack", "Attack", "#93c5fd"], + ["possession", "Possession", "#99f6e4"], + ["defense", "Defense", "#d8b4fe"], + ["restart", "Restarts", "#f9a8d4"], + ["discipline", "Discipline", "#fca5a5"], +]); + +const FOOTBALL_ACTIONS = makeActions( + [ + ["Pass Complete", "offense", ["pass_complete", "completed_pass", "complete_pass"]], + ["Pass Incomplete", "offense", ["pass_incomplete", "incomplete_pass"]], + ["Run", "offense", ["run", "rush", "rushing_play"]], + ["Sack", "defense", ["sack", "qb_sack"]], + ["Field Goal", "special-teams", ["field_goal"]], + ["Punt", "special-teams", ["punt"]], + ["Kickoff", "special-teams", ["kickoff", "kick_off"]], + ["Two Point", "scoring", ["two_point", "two_point_conv", "2_point_conv", "2-point conv"]], + ["Penalty", "discipline", ["penalty"]], + ["Turnover", "defense", ["turnover", "possession_change"]], + ["Interception", "defense", ["interception", "intercepted"]], + [ + "First Down", + "offense", + ["first_down"], + [ + contextRule(["pass_complete", "pass_incomplete", "interception", "run", "sack", "turnover"], "first_down", [ + "true", + ]), + ], + ], + [ + "Touchdown", + "scoring", + ["touchdown", "td"], + [ + contextRule(["pass_complete", "pass_incomplete", "interception", "run", "sack", "turnover"], "touchdown", [ + "true", + ]), + ], + ], + ["Fumble", "defense", ["fumble", "fumbled"]], + [ + "Blocked", + "special-teams", + ["blocked", "blocked_kick", "blocked_punt"], + [contextRule(["field_goal", "punt", "kickoff"], "kick_result", ["blocked"])], + ], + ["Offside", "discipline", ["offside", "offsides"]], + ["Holding", "discipline", ["holding"]], + ], + footballCategories +); + +const CRICKET_ACTIONS = makeActions( + [ + ["Dot Ball", "batting", ["dot_ball"]], + ["Single", "batting", ["single", "one_run", "1_run"], [contextRule(["runs_scored"], "exact_runs", ["1"])]], + ["Two Runs", "batting", ["two_runs", "2_runs"], [contextRule(["runs_scored"], "exact_runs", ["2"])]], + ["Three Runs", "batting", ["three_runs", "3_runs"], [contextRule(["runs_scored"], "exact_runs", ["3"])]], + ["Four", "boundary", ["four", "boundary_four"]], + ["Six", "boundary", ["six", "boundary_six"]], + ["Wide", "extras", ["wide"]], + ["No Ball", "extras", ["no_ball", "noball"]], + ["Bye", "extras", ["bye"], [contextRule(["extra"], "extra_type", ["bye"])]], + ["Leg Bye", "extras", ["leg_bye", "legbye"], [contextRule(["extra"], "extra_type", ["leg_bye", "legbye"])]], + ["Wicket", "dismissal", ["wicket", "out"]], + ["Run Out", "dismissal", ["run_out", "runout"]], + ["End Over", "control", ["end_over", "over_end"]], + ["End Innings", "control", ["end_innings", "innings_end"]], + ], + cricketCategories +); + +const BASKETBALL_ACTIONS = makeActions( + [ + ["Two Point Made", "scoring", ["two_point_made", "field_goal_made_2", "made_2", "2pt_made"]], + [ + "Two Point Missed", + "scoring", + ["two_point_missed", "field_goal_missed_2", "field_goal_attempt_2", "missed_2", "2pt_missed"], + ], + ["Three Point Made", "scoring", ["three_point_made", "field_goal_made_3", "made_3", "3pt_made"]], + [ + "Three Point Missed", + "scoring", + ["three_point_missed", "field_goal_missed_3", "field_goal_attempt_3", "missed_3", "3pt_missed"], + ], + ["Free Throw", "scoring", ["free_throw", "free_throw_made", "free_throw_missed"]], + ["Rebound", "possession", ["rebound", "offensive_rebound", "defensive_rebound"]], + [ + "Assist", + "possession", + ["assist"], + [contextRule(["field_goal_made_2", "field_goal_made_3"], "assisted", ["true"])], + ], + ["Steal", "defense", ["steal"]], + ["Block", "defense", ["block", "blocked_shot"]], + ["Foul", "discipline", ["foul", "personal_foul", "technical_foul"]], + ["Turnover", "possession", ["turnover"]], + ], + basketballCategories +); + +const BASEBALL_ACTIONS = makeActions( + [ + ["Single", "hitting", ["single"]], + ["Double", "hitting", ["double"]], + ["Triple", "hitting", ["triple"]], + ["Home Run", "scoring", ["home_run", "homerun"]], + ["Strikeout", "plate", ["strikeout", "strike_out"]], + ["Walk", "plate", ["walk", "base_on_balls"]], + ["Hit by Pitch", "plate", ["hit_by_pitch", "hbp"]], + ["Stolen Base", "baserunning", ["stolen_base", "steal_base"]], + ["Error", "defense", ["error", "fielding_error"]], + ["Run", "scoring", ["run", "run_scored"]], + ["RBI", "scoring", ["rbi", "run_batted_in"]], + ], + baseballCategories +); + +const SOCCER_ACTIONS = makeActions( + [ + ["Goal", "attack", ["goal", "goal_scored"]], + ["Shot", "attack", ["shot", "shot_attempt"]], + ["Shot on Target", "attack", ["shot_on_target", "shot_target"]], + ["Pass", "possession", ["pass", "pass_complete"]], + ["Assist", "attack", ["assist"]], + ["Tackle", "defense", ["tackle", "tackle_won"]], + ["Interception", "defense", ["interception"]], + ["Save", "defense", ["save", "goalkeeper_save"]], + ["Corner", "restart", ["corner", "corner_kick"]], + ["Foul", "discipline", ["foul"]], + ["Yellow Card", "discipline", ["yellow_card", "booking"]], + ["Red Card", "discipline", ["red_card", "sending_off"]], + ["Offside", "discipline", ["offside"]], + ], + soccerCategories +); + +const buildConfig = ( + sport: SupportedMatrixSport, + label: string, + categories: readonly SportMatrixCategory[], + actions: readonly SportMatrixAction[], + rowDimensionPriority: SportMatrixConfig["rowDimensionPriority"] +): SportMatrixConfig => ({ + sport, + label, + categories, + actions, + metricDimensionPriority: ["player", "team", "period"], + rowDimensionPriority, + entityDimensions: ENTITY_DIMENSIONS, +}); + +export const SUPPORTED_MATRIX_SPORTS = [ + "american-football", + "cricket", + "basketball", + "baseball", + "soccer", +] as const satisfies readonly SupportedMatrixSport[]; + +export const SPORT_MATRIX_CONFIGS: Readonly<Record<SupportedMatrixSport, SportMatrixConfig>> = { + "american-football": buildConfig("american-football", "American Football", footballCategories, FOOTBALL_ACTIONS, [ + "team", + "period", + "player", + ]), + cricket: buildConfig("cricket", "Cricket", cricketCategories, CRICKET_ACTIONS, ["team", "period", "player"]), + basketball: buildConfig("basketball", "Basketball", basketballCategories, BASKETBALL_ACTIONS, [ + "team", + "period", + "player", + ]), + baseball: buildConfig("baseball", "Baseball", baseballCategories, BASEBALL_ACTIONS, ["team", "period", "player"]), + soccer: buildConfig("soccer", "Soccer", soccerCategories, SOCCER_ACTIONS, ["team", "period", "player"]), +}; + +const normalizeSportInput = (value: string | null | undefined) => + String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + +export const normalizeMatrixSport = (value: string | null | undefined): SupportedMatrixSport | null => { + const normalized = normalizeSportInput(value); + if (!normalized) return null; + if ( + ["american-football", "americanfootball", "football", "gridiron"].includes(normalized) || + (normalized.includes("american") && normalized.includes("football")) + ) { + return "american-football"; + } + if (normalized.includes("basketball")) return "basketball"; + if (normalized.includes("baseball")) return "baseball"; + if (normalized.includes("cricket")) return "cricket"; + if ( + ["soccer", "association-football", "associationfootball"].includes(normalized) || + normalized.includes("soccer") || + (normalized.includes("association") && normalized.includes("football")) + ) { + return "soccer"; + } + return null; +}; + +export const getSportMatrixConfig = (value: string | null | undefined): SportMatrixConfig | null => { + const sport = normalizeMatrixSport(value); + return sport ? SPORT_MATRIX_CONFIGS[sport] : null; +}; + +export const resolveSportMatrixConfig = (value: string | null | undefined): MatrixSportResolution => { + const input = String(value ?? ""); + const normalizedInput = normalizeSportInput(value); + const sport = normalizeMatrixSport(value); + return { + input, + normalizedInput, + sport, + config: sport ? SPORT_MATRIX_CONFIGS[sport] : null, + isSupported: sport !== null, + }; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/hooks/use-matrix-data.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/hooks/use-matrix-data.ts new file mode 100644 index 00000000000..eb102447f89 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/hooks/use-matrix-data.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useMemo } from "react"; +import type { SportTableKind, SgTagRow } from "../../types"; +import { resolveSportMatrixConfig } from "../config/sport-matrix-config"; +import type { MatrixOrientation, MatrixSourceTag } from "../types/matrix.types"; +import { buildMatrixData, orientMatrixData } from "../utils/build-matrix-data"; +import { useMatrixFilters } from "./use-matrix-filters"; + +type UseMatrixDataArgs = { + orientation: MatrixOrientation; + sport: SportTableKind | string; + tagRows: readonly SgTagRow[]; +}; + +const toMatrixSourceTag = (row: SgTagRow): MatrixSourceTag => ({ + action: row.action, + clipId: row.clipId, + context: row.context, + groupValue: row.matrixPeriod, + id: row.id, + player: row.matrixParticipant, + playlistFallbackTimestamp: row.playlistFallbackTimestamp, + playlistTimestamp: row.playlistTimestamp, + result: row.result, + sourceTagId: row.sourceTagId, + sourceUrl: row.sourceUrl, + team: row.team, + thumbnailUrl: row.thumbnailUrl, +}); + +export const useMatrixData = ({ orientation, sport, tagRows }: UseMatrixDataArgs) => { + const sportResolution = useMemo(() => resolveSportMatrixConfig(sport), [sport]); + const sourceTags = useMemo(() => tagRows.map(toMatrixSourceTag), [tagRows]); + const filterState = useMatrixFilters({ config: sportResolution.config, sourceTags }); + const canonicalMatrix = useMemo( + () => + sportResolution.config + ? buildMatrixData(filterState.filteredSourceTags, sportResolution.config, "entities-by-actions") + : null, + [filterState.filteredSourceTags, sportResolution.config] + ); + const matrix = useMemo( + () => (canonicalMatrix ? orientMatrixData(canonicalMatrix, orientation) : null), + [canonicalMatrix, orientation] + ); + + return { + ...filterState, + matrix, + sourceTags, + sportResolution, + }; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/hooks/use-matrix-filters.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/hooks/use-matrix-filters.ts new file mode 100644 index 00000000000..ec3e33ad24d --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/hooks/use-matrix-filters.ts @@ -0,0 +1,46 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import type { MatrixFilterState, MatrixSourceTag, SportMatrixConfig } from "../types/matrix.types"; +import { + buildMatrixFilterOptions, + clearMatrixFilters, + filterMatrixSourceTags, + hasActiveMatrixFilters, +} from "../utils/matrix-filters"; + +type UseMatrixFiltersArgs = { + config: SportMatrixConfig | null; + sourceTags: readonly MatrixSourceTag[]; +}; + +const EMPTY_FILTER_OPTIONS = { + teams: [], + players: [], + categories: [], + periods: [], +}; + +export const useMatrixFilters = ({ config, sourceTags }: UseMatrixFiltersArgs) => { + const [filters, setFilters] = useState<MatrixFilterState>(() => clearMatrixFilters()); + + const filterOptions = useMemo( + () => (config ? buildMatrixFilterOptions(sourceTags, config) : EMPTY_FILTER_OPTIONS), + [config, sourceTags] + ); + const filteredSourceTags = useMemo( + () => (config ? filterMatrixSourceTags(sourceTags, filters, config) : []), + [config, filters, sourceTags] + ); + const hasActiveFilters = useMemo(() => hasActiveMatrixFilters(filters), [filters]); + const clearFilters = useCallback(() => setFilters(clearMatrixFilters()), []); + + return { + clearFilters, + filteredSourceTags, + filterOptions, + filters, + hasActiveFilters, + setFilters, + }; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/hooks/use-matrix-selection.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/hooks/use-matrix-selection.ts new file mode 100644 index 00000000000..6ff64ee9f70 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/hooks/use-matrix-selection.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { MatrixCell, MatrixCellSelection, MatrixData } from "../types/matrix.types"; +import { + clearMatrixCellSelection, + getSelectedMatrixSourceRowIds, + pruneMatrixCellSelection, + rangeMatrixCellSelection, + replaceMatrixCellSelection, + toggleMatrixCellSelection, +} from "../utils/matrix-selection"; + +type MatrixSelectionMode = "replace" | "toggle" | "range"; + +const selectionsMatch = (left: MatrixCellSelection, right: MatrixCellSelection) => + left.length === right.length && left.every((cellId, index) => cellId === right[index]); + +export const useMatrixSelection = (matrix: MatrixData | null) => { + const [selection, setSelection] = useState<MatrixCellSelection>(() => clearMatrixCellSelection()); + const validSelection = useMemo( + () => (matrix ? pruneMatrixCellSelection(selection, matrix) : clearMatrixCellSelection()), + [matrix, selection] + ); + + useEffect(() => { + if (!selectionsMatch(selection, validSelection)) setSelection(validSelection); + }, [selection, validSelection]); + + const selectCell = useCallback( + (cell: MatrixCell, mode: MatrixSelectionMode = "replace") => + setSelection((current) => { + if (mode === "toggle") return toggleMatrixCellSelection(current, cell); + if (mode === "range" && matrix) return rangeMatrixCellSelection(current, matrix, cell); + return replaceMatrixCellSelection(cell); + }), + [matrix] + ); + const clearSelection = useCallback(() => setSelection(clearMatrixCellSelection()), []); + const selectedCellIds = useMemo(() => new Set(validSelection), [validSelection]); + const selectedSourceRowIds = useMemo( + () => (matrix ? getSelectedMatrixSourceRowIds(validSelection, matrix) : []), + [matrix, validSelection] + ); + + return { + clearSelection, + selectedCellIds, + selectedSourceRowIds, + selection: validSelection, + selectCell, + }; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/index.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/index.ts new file mode 100644 index 00000000000..a28c98ffa2d --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/index.ts @@ -0,0 +1,19 @@ +export { MatrixView } from "./components/matrix-view"; +export type { MatrixViewProps } from "./components/matrix-view"; +export { MatrixViewToggle } from "./components/matrix-view-toggle"; +export type { MatrixViewMode } from "./components/matrix-view-toggle"; +export { + getSportMatrixConfig, + normalizeMatrixSport, + resolveSportMatrixConfig, + SPORT_MATRIX_CONFIGS, + SUPPORTED_MATRIX_SPORTS, +} from "./config/sport-matrix-config"; +export type { + MatrixCell, + MatrixColumn, + MatrixData, + MatrixOrientation, + MatrixRow, + SupportedMatrixSport, +} from "./types/matrix.types"; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/types/matrix.types.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/types/matrix.types.ts new file mode 100644 index 00000000000..22d36345baf --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/types/matrix.types.ts @@ -0,0 +1,141 @@ +export type SupportedMatrixSport = "american-football" | "cricket" | "basketball" | "baseball" | "soccer"; + +export type MatrixOrientation = "entities-by-actions" | "actions-by-entities"; + +export type MatrixEntityDimension = "team" | "period" | "player" | "unassigned"; + +export type MatrixAxisKind = "entity" | "action"; + +export type MatrixSourceTag = { + id: string; + sourceTagId?: string | null; + action: string; + context?: Readonly<Record<string, string>>; + player?: string | null; + result?: string | null; + team?: string | null; + groupValue?: string | null; + sourceUrl?: string; + clipId?: string | null; + thumbnailUrl?: string | null; + playlistTimestamp?: string | null; + playlistFallbackTimestamp?: string | null; +}; + +export type SportMatrixCategory = { + id: string; + label: string; + color: string; + order: number; +}; + +export type SportMatrixAction = { + id: string; + label: string; + aliases: readonly string[]; + category: string; + color: string; + order: number; + visible: boolean; + contextRules?: readonly SportMatrixContextRule[]; +}; + +export type SportMatrixContextRule = { + sourceActions: readonly string[]; + values: Readonly<Record<string, readonly string[]>>; +}; + +export type SportMatrixEntityDimension = { + dimension: Exclude<MatrixEntityDimension, "unassigned">; + label: string; + color: string; +}; + +export type SportMatrixConfig = { + sport: SupportedMatrixSport; + label: string; + categories: readonly SportMatrixCategory[]; + actions: readonly SportMatrixAction[]; + metricDimensionPriority: readonly Exclude<MatrixEntityDimension, "unassigned">[]; + rowDimensionPriority: readonly Exclude<MatrixEntityDimension, "unassigned">[]; + entityDimensions: Readonly<Record<Exclude<MatrixEntityDimension, "unassigned">, SportMatrixEntityDimension>>; +}; + +export type MatrixSportResolution = { + input: string; + normalizedInput: string; + sport: SupportedMatrixSport | null; + config: SportMatrixConfig | null; + isSupported: boolean; +}; + +export type MatrixCell = { + /** Stable across orientations. `rowId` is always the canonical entity id. */ + id: string; + rowId: string; + /** Always the canonical action id, including when actions are displayed as rows. */ + columnId: string; + count: number; + tagIds: string[]; + sourceRowIds: string[]; + sourceUrls: string[]; + /** Present only when the source supplied explicit clip identifiers. */ + clipIds?: string[]; +}; + +export type MatrixColumn = { + id: string; + label: string; + kind: MatrixAxisKind; + category?: string; + dimension?: MatrixEntityDimension; + group?: string; + isMetric?: boolean; + color?: string; + order: number; + visible: boolean; +}; + +export type MatrixRow = MatrixColumn & { + cells: Record<string, MatrixCell>; + total: number; + average: number; +}; + +export type MatrixData = { + sport: SupportedMatrixSport; + orientation: MatrixOrientation; + /** Canonical entity axis. It is invariant when the display orientation changes. */ + entities: MatrixColumn[]; + /** Canonical action axis. It is invariant when the display orientation changes. */ + actions: MatrixColumn[]; + /** Display rows for `orientation`. */ + rows: MatrixRow[]; + /** Display columns for `orientation`. */ + columns: MatrixColumn[]; + /** Canonical cells keyed by stable cell id. */ + cells: Record<string, MatrixCell>; + sourceTagCount: number; +}; + +export type MatrixFilterState = { + search: string; + teams: readonly string[]; + players: readonly string[]; + categories: readonly string[]; + periods: readonly string[]; +}; + +export type MatrixFilterOption = { + value: string; + label: string; +}; + +export type MatrixFilterOptions = { + teams: MatrixFilterOption[]; + players: MatrixFilterOption[]; + categories: MatrixFilterOption[]; + periods: MatrixFilterOption[]; +}; + +export type MatrixCellSelection = readonly string[]; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/build-matrix-data.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/build-matrix-data.ts new file mode 100644 index 00000000000..96ffcf31a9a --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/build-matrix-data.ts @@ -0,0 +1,382 @@ +import type { + MatrixCell, + MatrixColumn, + MatrixData, + MatrixEntityDimension, + MatrixOrientation, + MatrixRow, + MatrixSourceTag, + SportMatrixAction, + SportMatrixConfig, +} from "../types/matrix.types"; + +type ResolvedTag = { + source: MatrixSourceTag; + actionIds: string[]; + entityIds: string[]; +}; + +type ResolvedEntity = { + dimension: MatrixEntityDimension; + id: string; + isMetric: boolean; + label: string; +}; + +const EMPTY_VALUES = new Set(["", "--", "\u2014", "n/a", "na", "none", "null", "undefined"]); + +const hasValue = (value: string | null | undefined) => + !EMPTY_VALUES.has( + String(value ?? "") + .trim() + .toLowerCase() + ); + +const normalizeKey = (value: string | null | undefined) => + String(value ?? "") + .trim() + .toLowerCase() + .replace(/&/g, " and ") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +const formatLabel = (value: string | null | undefined, fallback = "Unknown Action") => { + const normalized = String(value ?? "").trim(); + if (!normalized || !hasValue(normalized)) return fallback; + return normalized + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .split(" ") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +}; + +const actionAxisId = (actionId: string) => `action:${encodeURIComponent(actionId.trim().toLowerCase())}`; +const entityAxisId = (dimension: MatrixEntityDimension, label: string) => + `entity:${dimension}:${encodeURIComponent(label.trim().toLowerCase())}`; +const cellId = (entityId: string, actionId: string) => + `cell:${encodeURIComponent(entityId)}:${encodeURIComponent(actionId)}`; + +const average = (total: number, itemCount: number) => (itemCount > 0 ? Math.round((total / itemCount) * 100) / 100 : 0); + +const uniquePush = (values: string[], value: string | null | undefined) => { + const normalized = String(value ?? "").trim(); + if (normalized && !values.includes(normalized)) values.push(normalized); +}; + +const buildActionLookup = (config: SportMatrixConfig) => { + const lookup = new Map<string, SportMatrixAction>(); + config.actions.forEach((action) => { + [action.id, action.label, ...action.aliases].forEach((alias) => { + const normalized = normalizeKey(alias); + if (normalized && !lookup.has(normalized)) lookup.set(normalized, action); + }); + }); + return lookup; +}; + +const resolveActionKey = (action: string, lookup: Map<string, SportMatrixAction>) => { + const normalized = normalizeKey(action); + const configured = lookup.get(normalized); + return configured + ? { configured, observedId: null } + : { configured: null, observedId: normalized || "unknown_action" }; +}; + +const buildContextLookup = (tag: MatrixSourceTag) => { + const context = new Map<string, string>(); + Object.entries(tag.context ?? {}).forEach(([key, value]) => { + const normalizedKey = normalizeKey(key); + const normalizedValue = normalizeKey(value); + if (normalizedKey && normalizedValue) context.set(normalizedKey, normalizedValue); + }); + if (hasValue(tag.result)) context.set("result", normalizeKey(tag.result)); + return context; +}; + +const matchesContextRule = ( + tag: MatrixSourceTag, + rule: NonNullable<SportMatrixAction["contextRules"]>[number], + context: ReadonlyMap<string, string> +) => { + const sourceAction = normalizeKey(tag.action); + if (!rule.sourceActions.some((action) => normalizeKey(action) === sourceAction)) return false; + return Object.entries(rule.values).every(([key, allowedValues]) => { + const value = context.get(normalizeKey(key)); + return Boolean(value && allowedValues.some((allowedValue) => normalizeKey(allowedValue) === value)); + }); +}; + +const resolveConfiguredActions = ( + tag: MatrixSourceTag, + config: SportMatrixConfig, + lookup: Map<string, SportMatrixAction> +) => { + const resolved = new Map<string, SportMatrixAction>(); + const context = buildContextLookup(tag); + const primaryAction = lookup.get(normalizeKey(tag.action)); + if (primaryAction) resolved.set(primaryAction.id, primaryAction); + config.actions.forEach((action) => { + if (action.contextRules?.some((rule) => matchesContextRule(tag, rule, context))) resolved.set(action.id, action); + }); + return Array.from(resolved.values()); +}; + +const buildActions = (sourceTags: readonly MatrixSourceTag[], config: SportMatrixConfig) => { + const lookup = buildActionLookup(config); + const categoryLabels = new Map(config.categories.map((category) => [category.id, category.label])); + const configuredColumns: MatrixColumn[] = config.actions.map((action) => ({ + id: actionAxisId(action.id), + label: action.label, + kind: "action", + category: action.category, + group: categoryLabels.get(action.category) ?? formatLabel(action.category, "Other"), + color: action.color, + order: action.order, + visible: action.visible, + })); + const unknownById = new Map<string, string>(); + + sourceTags.forEach((tag) => { + if (resolveConfiguredActions(tag, config, lookup).length > 0) return; + const observedId = resolveActionKey(tag.action, lookup).observedId; + if (observedId && !unknownById.has(observedId)) { + unknownById.set(observedId, formatLabel(tag.action)); + } + }); + + const unknownColumns = Array.from(unknownById.entries()) + .sort((left, right) => left[1].localeCompare(right[1]) || left[0].localeCompare(right[0])) + .map<MatrixColumn>(([observedId, label], index) => ({ + id: actionAxisId(`observed:${observedId}`), + label, + kind: "action", + category: "other", + group: "Other", + color: "#a3a3a3", + order: configuredColumns.length + index, + visible: true, + })); + + return { + actions: [...configuredColumns, ...unknownColumns], + lookup, + }; +}; + +const getEntityValues = (tag: MatrixSourceTag) => ({ + team: tag.team, + period: tag.groupValue, + player: tag.player, +}); + +const selectMetricDimension = (sourceTags: readonly MatrixSourceTag[], config: SportMatrixConfig) => + config.metricDimensionPriority.find((dimension) => + sourceTags.some((tag) => hasValue(getEntityValues(tag)[dimension])) + ) ?? null; + +const getTagEntities = ( + tag: MatrixSourceTag, + config: SportMatrixConfig, + metricDimension: Exclude<MatrixEntityDimension, "unassigned"> | null +) => { + const values = getEntityValues(tag); + const entities = config.rowDimensionPriority.flatMap<ResolvedEntity>((dimension) => { + const value = values[dimension]; + if (!hasValue(value)) return []; + const label = String(value).trim(); + return [{ dimension, id: entityAxisId(dimension, label), isMetric: dimension === metricDimension, label }]; + }); + if (!metricDimension || !hasValue(values[metricDimension])) { + entities.push({ + dimension: "unassigned", + id: entityAxisId("unassigned", "Unassigned"), + isMetric: true, + label: "Unassigned", + }); + } + return entities; +}; + +const buildEntities = ( + sourceTags: readonly MatrixSourceTag[], + config: SportMatrixConfig, + metricDimension: Exclude<MatrixEntityDimension, "unassigned"> | null +) => { + const entitiesById = new Map<string, MatrixColumn>(); + + sourceTags.forEach((tag) => { + getTagEntities(tag, config, metricDimension).forEach(({ dimension, id, isMetric, label }) => { + if (entitiesById.has(id)) return; + const dimensionConfig = + dimension === "unassigned" + ? metricDimension + ? config.entityDimensions[metricDimension] + : null + : config.entityDimensions[dimension]; + entitiesById.set(id, { + id, + label, + kind: "entity", + dimension, + group: dimensionConfig?.label ?? "Other", + isMetric, + color: dimensionConfig?.color ?? "#a3a3a3", + order: 0, + visible: true, + }); + }); + }); + + const orderedDimensions: readonly MatrixEntityDimension[] = [...config.rowDimensionPriority, "unassigned"]; + const dimensionOrder = new Map<MatrixEntityDimension, number>( + orderedDimensions.map((dimension, index) => [dimension, index]) + ); + return Array.from(entitiesById.values()) + .sort( + (left, right) => + (dimensionOrder.get(left.dimension ?? "unassigned") ?? Number.MAX_SAFE_INTEGER) - + (dimensionOrder.get(right.dimension ?? "unassigned") ?? Number.MAX_SAFE_INTEGER) || + left.order - right.order || + left.label.localeCompare(right.label, undefined, { numeric: true, sensitivity: "base" }) + ) + .map((entity, order) => ({ ...entity, order })); +}; + +const resolveTagActionIds = ( + tag: MatrixSourceTag, + config: SportMatrixConfig, + lookup: Map<string, SportMatrixAction>, + actionIdsByObservedKey: Map<string, string> +) => { + const configuredActions = resolveConfiguredActions(tag, config, lookup); + if (configuredActions.length > 0) return configuredActions.map((action) => actionAxisId(action.id)); + const resolved = resolveActionKey(tag.action, lookup); + const observedKey = resolved.observedId ?? "unknown_action"; + return [actionIdsByObservedKey.get(observedKey) ?? actionAxisId(`observed:${observedKey}`)]; +}; + +const buildRows = ( + rowAxis: readonly MatrixColumn[], + columnAxis: readonly MatrixColumn[], + cells: Record<string, MatrixCell>, + orientation: MatrixOrientation +): MatrixRow[] => + rowAxis.map((axisItem) => { + const rowCells: Record<string, MatrixCell> = {}; + let total = 0; + let populatedCellCount = 0; + columnAxis.forEach((column) => { + const canonicalEntityId = orientation === "entities-by-actions" ? axisItem.id : column.id; + const canonicalActionId = orientation === "entities-by-actions" ? column.id : axisItem.id; + const currentCell = cells[cellId(canonicalEntityId, canonicalActionId)]; + if (!currentCell) return; + rowCells[column.id] = currentCell; + const contributesToSummary = + column.visible && (orientation === "entities-by-actions" || column.isMetric === true); + if (contributesToSummary) { + total += currentCell.count; + if (currentCell.count > 0) populatedCellCount += 1; + } + }); + + return { + ...axisItem, + cells: rowCells, + total, + average: average(total, populatedCellCount), + }; + }); + +const orient = ( + sport: MatrixData["sport"], + entities: MatrixColumn[], + actions: MatrixColumn[], + cells: Record<string, MatrixCell>, + sourceTagCount: number, + orientation: MatrixOrientation +): MatrixData => { + const rowAxis = orientation === "entities-by-actions" ? entities : actions; + const columnAxis = orientation === "entities-by-actions" ? actions : entities; + return { + sport, + orientation, + entities, + actions, + rows: buildRows(rowAxis, columnAxis, cells, orientation), + columns: columnAxis, + cells, + sourceTagCount, + }; +}; + +export const buildMatrixData = ( + sourceTags: readonly MatrixSourceTag[], + config: SportMatrixConfig, + orientation: MatrixOrientation = "entities-by-actions" +): MatrixData => { + const inputTags = sourceTags.map((tag) => ({ ...tag })); + const { actions, lookup } = buildActions(inputTags, config); + const metricDimension = selectMetricDimension(inputTags, config); + const entities = buildEntities(inputTags, config, metricDimension); + const actionIdsByObservedKey = new Map<string, string>(); + actions.forEach((action) => { + const encodedPrefix = "action:observed%3A"; + if (action.id.startsWith(encodedPrefix)) { + actionIdsByObservedKey.set(decodeURIComponent(action.id.slice(encodedPrefix.length)), action.id); + } + }); + const entityIds = new Set(entities.map((entity) => entity.id)); + const resolvedTags: ResolvedTag[] = inputTags.map((source) => ({ + source, + actionIds: resolveTagActionIds(source, config, lookup, actionIdsByObservedKey), + entityIds: getTagEntities(source, config, metricDimension) + .map((entity) => entity.id) + .filter((entityId) => entityIds.has(entityId)), + })); + const cells: Record<string, MatrixCell> = {}; + + entities.forEach((entity) => { + actions.forEach((action) => { + const id = cellId(entity.id, action.id); + cells[id] = { + id, + rowId: entity.id, + columnId: action.id, + count: 0, + tagIds: [], + sourceRowIds: [], + sourceUrls: [], + }; + }); + }); + + resolvedTags.forEach(({ source, actionIds: matchingActionIds, entityIds: matchingEntityIds }) => { + matchingEntityIds.forEach((currentEntityId) => { + matchingActionIds.forEach((actionId) => { + const currentCell = cells[cellId(currentEntityId, actionId)]; + if (!currentCell) return; + currentCell.count += 1; + uniquePush(currentCell.sourceRowIds, source.id); + uniquePush(currentCell.tagIds, source.sourceTagId || source.id); + uniquePush(currentCell.sourceUrls, source.sourceUrl); + if (hasValue(source.clipId)) { + currentCell.clipIds ??= []; + uniquePush(currentCell.clipIds, source.clipId); + } + }); + }); + }); + + return orient(config.sport, entities, actions, cells, inputTags.length, orientation); +}; + +export const orientMatrixData = (matrix: MatrixData, orientation: MatrixOrientation): MatrixData => + orient(matrix.sport, matrix.entities, matrix.actions, matrix.cells, matrix.sourceTagCount, orientation); + +export const transposeMatrixData = (matrix: MatrixData): MatrixData => + orientMatrixData( + matrix, + matrix.orientation === "entities-by-actions" ? "actions-by-entities" : "entities-by-actions" + ); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/create-matrix-playlist.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/create-matrix-playlist.ts new file mode 100644 index 00000000000..4d2a8a9ab32 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/create-matrix-playlist.ts @@ -0,0 +1,141 @@ +import type { MediaLibraryService } from "@/services/media-library.service"; +import type { TMediaItem } from "ce/features/media-library/types/media-library.types"; +import type { SgTagRow } from "../../types"; +import { buildArchivedPlaylistUrl, getSgTagRowStreamName, playlistHasMediaSegments } from "../../utils"; + +type MatrixPlaylistEntry = { + original_stream_name: string; + timestamp: string; +}; + +type CreateMatrixPlaylistArgs = { + mediaLibraryService: MediaLibraryService; + rows: SgTagRow[]; + streamName?: string | null; +}; + +export type MatrixPlaylistResult = { + fileName: string; + rowIds: string[]; + url: string; +}; + +type BuildMatrixPlaylistItemArgs = { + result: MatrixPlaylistResult; + rows: SgTagRow[]; + workItemId: string | null; +}; + +const getRowTimestamp = (row: SgTagRow, preferFallback: boolean) => { + const primaryTimestamp = row.playlistTimestamp?.trim() || ""; + const fallbackTimestamp = row.playlistFallbackTimestamp?.trim() || ""; + return preferFallback ? fallbackTimestamp || primaryTimestamp : primaryTimestamp || fallbackTimestamp; +}; + +export const getMatrixPlaylistRows = (rows: SgTagRow[]) => { + const uniqueRows = new Map<string, SgTagRow>(); + for (const row of rows) { + if (!getRowTimestamp(row, false) || uniqueRows.has(row.id)) continue; + uniqueRows.set(row.id, row); + } + return Array.from(uniqueRows.values()); +}; + +const buildPlaylistCandidate = (rows: SgTagRow[], fallbackStreamName: string, preferFallback: boolean) => { + const entriesByKey = new Map<string, MatrixPlaylistEntry>(); + const rowIds: string[] = []; + + for (const row of rows) { + const timestamp = getRowTimestamp(row, preferFallback); + const streamName = getSgTagRowStreamName(row, fallbackStreamName); + if (!timestamp) continue; + if (!streamName) continue; + + const entry = { original_stream_name: streamName, timestamp }; + entriesByKey.set(`${streamName}\u0000${timestamp}`, entry); + rowIds.push(row.id); + } + + return { + entries: Array.from(entriesByKey.values()), + rowIds, + }; +}; + +export const createMatrixPlaylist = async ({ + mediaLibraryService, + rows, + streamName, +}: CreateMatrixPlaylistArgs): Promise<MatrixPlaylistResult> => { + const normalizedStreamName = streamName?.trim() ?? ""; + const playlistRows = getMatrixPlaylistRows(rows); + if (playlistRows.length === 0) { + throw new Error("The selected tags do not contain playable stream timestamps."); + } + + const playlistCandidates = [ + buildPlaylistCandidate(playlistRows, normalizedStreamName, false), + buildPlaylistCandidate(playlistRows, normalizedStreamName, true), + ]; + let firstGeneratedResult: MatrixPlaylistResult | null = null; + const seenCandidates = new Set<string>(); + + for (const { entries, rowIds } of playlistCandidates) { + const candidateKey = JSON.stringify(entries); + if (entries.length === 0 || seenCandidates.has(candidateKey)) continue; + seenCandidates.add(candidateKey); + + const fileName = await mediaLibraryService.createPlaylist(entries); + const url = fileName ? buildArchivedPlaylistUrl(fileName) : null; + if (!fileName || !url) continue; + + const result = { + fileName, + rowIds, + url, + }; + firstGeneratedResult ??= result; + + if (await playlistHasMediaSegments(url)) { + return result; + } + } + + if (firstGeneratedResult) { + return firstGeneratedResult; + } + + throw new Error("The selected tags did not produce a playable playlist."); +}; + +export const buildMatrixPlaylistItem = ({ result, rows, workItemId }: BuildMatrixPlaylistItemArgs): TMediaItem => ({ + action: "play_streaming", + author: "", + createdAt: "", + description: "", + docs: [], + downloadSrc: result.url, + duration: "", + fileSrc: result.url, + format: "m3u8", + id: `sg-matrix-playlist-${result.fileName}`, + itemsCount: rows.length, + link: result.url, + linkedFormat: "m3u8", + linkedMediaType: "video", + mediaType: "video", + meta: { + hls: true, + hls_direct: true, + playlistFileName: result.fileName, + sourceTagIds: rows.map((row) => row.sourceTagId).filter((id): id is string => Boolean(id)), + tagRowIds: result.rowIds, + }, + primaryTag: "Matrix playlist", + secondaryTag: "", + thumbnail: "", + title: `Matrix playlist (${rows.length} tag${rows.length === 1 ? "" : "s"})`, + videoSrc: result.url, + views: 0, + workItemId, +}); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-filters.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-filters.ts new file mode 100644 index 00000000000..982b793c680 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-filters.ts @@ -0,0 +1,172 @@ +import type { + MatrixFilterOption, + MatrixFilterOptions, + MatrixFilterState, + MatrixSourceTag, + SportMatrixAction, + SportMatrixConfig, +} from "../types/matrix.types"; + +export const EMPTY_MATRIX_FILTER_STATE: MatrixFilterState = { + search: "", + teams: [], + players: [], + categories: [], + periods: [], +}; + +const normalize = (value: string | null | undefined) => + String(value ?? "") + .trim() + .toLowerCase(); + +const normalizeAction = (value: string | null | undefined) => + normalize(value) + .replace(/&/g, " and ") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +const hasValue = (value: string | null | undefined) => { + const normalized = normalize(value); + return !["", "--", "\u2014", "n/a", "na", "none", "null", "undefined"].includes(normalized); +}; + +const buildActionLookup = (config: SportMatrixConfig) => { + const lookup = new Map<string, SportMatrixAction>(); + config.actions.forEach((action) => { + [action.id, action.label, ...action.aliases].forEach((alias) => { + const key = normalizeAction(alias); + if (key && !lookup.has(key)) lookup.set(key, action); + }); + }); + return lookup; +}; + +const buildContextLookup = (tag: MatrixSourceTag) => { + const context = new Map<string, string>(); + Object.entries(tag.context ?? {}).forEach(([key, value]) => { + const normalizedKey = normalizeAction(key); + const normalizedValue = normalizeAction(value); + if (normalizedKey && normalizedValue) context.set(normalizedKey, normalizedValue); + }); + if (hasValue(tag.result)) context.set("result", normalizeAction(tag.result)); + return context; +}; + +const resolveActions = (tag: MatrixSourceTag, config: SportMatrixConfig, lookup: Map<string, SportMatrixAction>) => { + const resolved = new Map<string, SportMatrixAction>(); + const primaryAction = lookup.get(normalizeAction(tag.action)); + if (primaryAction) resolved.set(primaryAction.id, primaryAction); + const sourceAction = normalizeAction(tag.action); + const context = buildContextLookup(tag); + config.actions.forEach((action) => { + const matches = action.contextRules?.some( + (rule) => + rule.sourceActions.some((candidate) => normalizeAction(candidate) === sourceAction) && + Object.entries(rule.values).every(([key, values]) => { + const contextValue = context.get(normalizeAction(key)); + return Boolean(contextValue && values.some((candidate) => normalizeAction(candidate) === contextValue)); + }) + ); + if (matches) resolved.set(action.id, action); + }); + return Array.from(resolved.values()); +}; + +const toOptions = (values: readonly (string | null | undefined)[]): MatrixFilterOption[] => { + const valuesByKey = new Map<string, string>(); + values.forEach((value) => { + if (!hasValue(value)) return; + const label = String(value).trim(); + const key = normalize(label); + if (!valuesByKey.has(key)) valuesByKey.set(key, label); + }); + return Array.from(valuesByKey.entries()) + .sort((left, right) => left[1].localeCompare(right[1])) + .map(([, label]) => ({ value: label, label })); +}; + +export const createEmptyMatrixFilters = (): MatrixFilterState => ({ + search: "", + teams: [], + players: [], + categories: [], + periods: [], +}); + +export const clearMatrixFilters = createEmptyMatrixFilters; + +export const hasActiveMatrixFilters = (filters: MatrixFilterState) => + Boolean( + filters.search.trim() || + filters.teams.length || + filters.players.length || + filters.categories.length || + filters.periods.length + ); + +export const buildMatrixFilterOptions = ( + sourceTags: readonly MatrixSourceTag[], + config: SportMatrixConfig +): MatrixFilterOptions => { + const lookup = buildActionLookup(config); + const observedCategories = new Set<string>(); + sourceTags.forEach((tag) => { + const actions = resolveActions(tag, config, lookup); + if (actions.length === 0) observedCategories.add("other"); + actions.forEach((action) => observedCategories.add(action.category)); + }); + const categoryOptions = config.categories + .filter((category) => observedCategories.has(category.id)) + .sort((left, right) => left.order - right.order) + .map((category) => ({ value: category.id, label: category.label })); + if (observedCategories.has("other")) categoryOptions.push({ value: "other", label: "Other" }); + + return { + teams: toOptions(sourceTags.map((tag) => tag.team)), + players: toOptions(sourceTags.map((tag) => tag.player)), + categories: categoryOptions, + periods: toOptions(sourceTags.map((tag) => tag.groupValue)), + }; +}; + +const matchesSelectedValues = (value: string | null | undefined, selected: readonly string[]) => { + if (selected.length === 0) return true; + const normalizedValue = normalize(value); + return selected.some((entry) => normalize(entry) === normalizedValue); +}; + +export const filterMatrixSourceTags = ( + sourceTags: readonly MatrixSourceTag[], + filters: MatrixFilterState, + config: SportMatrixConfig +): MatrixSourceTag[] => { + const lookup = buildActionLookup(config); + const search = normalize(filters.search); + const actionSearch = normalizeAction(filters.search); + const selectedCategories = new Set(filters.categories.map(normalize)); + + return sourceTags.filter((tag) => { + const configuredActions = resolveActions(tag, config, lookup); + const categories = configuredActions.length > 0 ? configuredActions.map((action) => action.category) : ["other"]; + if (!matchesSelectedValues(tag.team, filters.teams)) return false; + if (!matchesSelectedValues(tag.player, filters.players)) return false; + if (!matchesSelectedValues(tag.groupValue, filters.periods)) return false; + if (selectedCategories.size > 0 && !categories.some((category) => selectedCategories.has(normalize(category)))) { + return false; + } + if (!search) return true; + + return [ + tag.action, + ...configuredActions.flatMap((action) => [action.label, action.category]), + tag.player, + tag.team, + tag.groupValue, + tag.result, + ...Object.values(tag.context ?? {}), + ] + .filter((value): value is string => typeof value === "string") + .some((value) => normalize(value).includes(search) || normalizeAction(value).includes(actionSearch)); + }); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-formatters.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-formatters.ts new file mode 100644 index 00000000000..8c6181eff2d --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-formatters.ts @@ -0,0 +1,53 @@ +import type { MatrixEntityDimension } from "../types/matrix.types"; + +const EMPTY_MATRIX_VALUES = new Set(["", "--", "\u2014", "n/a", "na", "none", "null", "undefined"]); + +export const normalizeMatrixKey = (value: string | null | undefined) => + String(value ?? "") + .trim() + .toLowerCase() + .replace(/&/g, " and ") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +export const hasUsableMatrixValue = (value: string | null | undefined) => { + const normalized = String(value ?? "") + .trim() + .toLowerCase(); + return !EMPTY_MATRIX_VALUES.has(normalized); +}; + +export const formatMatrixLabel = (value: string | null | undefined, fallback = "Unknown") => { + const normalized = String(value ?? "").trim(); + if (!hasUsableMatrixValue(normalized)) return fallback; + + return normalized + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .split(" ") + .filter(Boolean) + .map((word) => { + if (/^\d+$/.test(word)) return word; + if (word.length <= 3 && /^[a-z]+$/i.test(word) && ["rbi", "hbp", "fga"].includes(word.toLowerCase())) { + return word.toUpperCase(); + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(" "); +}; + +const encodeMatrixIdPart = (value: string) => encodeURIComponent(value.trim().toLowerCase()); + +export const buildMatrixEntityId = (dimension: MatrixEntityDimension, label: string) => + `entity:${dimension}:${encodeMatrixIdPart(label)}`; + +export const buildMatrixActionId = (actionId: string) => `action:${encodeMatrixIdPart(actionId)}`; + +export const buildMatrixCellId = (entityId: string, actionId: string) => + `cell:${encodeURIComponent(entityId)}:${encodeURIComponent(actionId)}`; + +export const calculateMatrixAverage = (total: number, itemCount: number, precision = 2) => { + if (!Number.isFinite(total) || itemCount <= 0) return 0; + const factor = 10 ** Math.max(0, precision); + return Math.round((total / itemCount) * factor) / factor; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-selection.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-selection.ts new file mode 100644 index 00000000000..b21808b7736 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-selection.ts @@ -0,0 +1,84 @@ +import type { MatrixCell, MatrixCellSelection, MatrixData } from "../types/matrix.types"; + +const unique = (values: readonly string[]) => Array.from(new Set(values.filter(Boolean))); + +export const isMatrixCellSelected = (selection: MatrixCellSelection, cellId: string) => selection.includes(cellId); + +export const toggleMatrixCellSelection = (selection: MatrixCellSelection, cell: MatrixCell): MatrixCellSelection => { + const current = unique(selection); + if (current.includes(cell.id)) return current.filter((cellId) => cellId !== cell.id); + if (cell.count <= 0 || cell.sourceRowIds.length === 0) return current; + return [...current, cell.id]; +}; + +export const replaceMatrixCellSelection = (cell: MatrixCell): MatrixCellSelection => + cell.count > 0 && cell.sourceRowIds.length > 0 ? [cell.id] : []; + +export const rangeMatrixCellSelection = ( + selection: MatrixCellSelection, + matrix: MatrixData, + targetCell: MatrixCell +): MatrixCellSelection => { + const current = pruneMatrixCellSelection(selection, matrix); + const anchorCellId = current[current.length - 1]; + const anchorCell = anchorCellId ? matrix.cells[anchorCellId] : null; + if (!anchorCell) return replaceMatrixCellSelection(targetCell); + + const rowIndexById = new Map(matrix.rows.map((row, index) => [row.id, index])); + const columnIndexById = new Map( + matrix.columns.filter((column) => column.visible).map((column, index) => [column.id, index]) + ); + const getDisplayCoordinates = (cell: MatrixCell) => { + const rowId = matrix.orientation === "entities-by-actions" ? cell.rowId : cell.columnId; + const columnId = matrix.orientation === "entities-by-actions" ? cell.columnId : cell.rowId; + return { + columnIndex: columnIndexById.get(columnId) ?? -1, + rowIndex: rowIndexById.get(rowId) ?? -1, + }; + }; + const anchor = getDisplayCoordinates(anchorCell); + const target = getDisplayCoordinates(targetCell); + if (anchor.rowIndex < 0 || anchor.columnIndex < 0 || target.rowIndex < 0 || target.columnIndex < 0) { + return replaceMatrixCellSelection(targetCell); + } + + const minRow = Math.min(anchor.rowIndex, target.rowIndex); + const maxRow = Math.max(anchor.rowIndex, target.rowIndex); + const minColumn = Math.min(anchor.columnIndex, target.columnIndex); + const maxColumn = Math.max(anchor.columnIndex, target.columnIndex); + const selected = new Set(current); + + matrix.rows.slice(minRow, maxRow + 1).forEach((row) => { + matrix.columns + .filter((column) => column.visible) + .slice(minColumn, maxColumn + 1) + .forEach((column) => { + const cell = row.cells[column.id]; + if (cell?.count > 0 && cell.sourceRowIds.length > 0) selected.add(cell.id); + }); + }); + + return Array.from(selected); +}; + +export const clearMatrixCellSelection = (): MatrixCellSelection => []; + +export const pruneMatrixCellSelection = (selection: MatrixCellSelection, matrix: MatrixData): MatrixCellSelection => + unique(selection).filter((cellId) => { + const cell = matrix.cells[cellId]; + return Boolean(cell && cell.count > 0 && cell.sourceRowIds.length > 0); + }); + +export const getSelectedMatrixCells = (selection: MatrixCellSelection, matrix: MatrixData): MatrixCell[] => + pruneMatrixCellSelection(selection, matrix) + .map((cellId) => matrix.cells[cellId]) + .filter((cell): cell is MatrixCell => Boolean(cell)); + +export const getSelectedMatrixSourceRowIds = (selection: MatrixCellSelection, matrix: MatrixData): string[] => + unique(getSelectedMatrixCells(selection, matrix).flatMap((cell) => cell.sourceRowIds)); + +export const getSelectedMatrixTagIds = (selection: MatrixCellSelection, matrix: MatrixData): string[] => + unique(getSelectedMatrixCells(selection, matrix).flatMap((cell) => cell.tagIds)); + +export const getSelectedMatrixClipIds = (selection: MatrixCellSelection, matrix: MatrixData): string[] => + unique(getSelectedMatrixCells(selection, matrix).flatMap((cell) => cell.clipIds ?? [])); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-virtualization.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-virtualization.ts new file mode 100644 index 00000000000..3fd728083f9 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/matrix-view/utils/matrix-virtualization.ts @@ -0,0 +1,48 @@ +export const MATRIX_COLUMN_WIDTH = 44; +export const MATRIX_FIRST_COLUMN_WIDTH = 140; +export const MATRIX_SUMMARY_COLUMNS_WIDTH = MATRIX_COLUMN_WIDTH * 2; +export const MATRIX_COLUMN_OVERSCAN = 3; +export const MATRIX_COLUMN_VIRTUALIZATION_THRESHOLD = 40; + +export type MatrixColumnVirtualRange = { + end: number; + start: number; +}; + +type GetMatrixColumnVirtualRangeOptions = { + columnWidth?: number; + columnCount: number; + firstColumnWidth?: number; + scrollLeft: number; + summaryColumnsWidth?: number; + viewportWidth: number; + virtualize: boolean; +}; + +/** + * Returns an end-exclusive data-column range. Sticky labels and summaries reduce + * the usable viewport but do not change the table's underlying scroll width. + */ +export const getMatrixColumnVirtualRange = ({ + columnWidth = MATRIX_COLUMN_WIDTH, + columnCount, + firstColumnWidth = MATRIX_FIRST_COLUMN_WIDTH, + scrollLeft, + summaryColumnsWidth = MATRIX_SUMMARY_COLUMNS_WIDTH, + viewportWidth, + virtualize, +}: GetMatrixColumnVirtualRangeOptions): MatrixColumnVirtualRange => { + if (!virtualize || columnCount <= 0) return { end: columnCount, start: 0 }; + + const normalizedScrollLeft = Math.max(0, scrollLeft); + const dataViewportWidth = Math.max(columnWidth, viewportWidth - firstColumnWidth - summaryColumnsWidth); + const firstVisibleColumn = Math.floor(normalizedScrollLeft / columnWidth); + const visibleColumnCount = Math.ceil(dataViewportWidth / columnWidth) + 1; + const start = Math.max(0, Math.min(columnCount - 1, firstVisibleColumn) - MATRIX_COLUMN_OVERSCAN); + const end = Math.min( + columnCount, + Math.max(start + 1, firstVisibleColumn + visibleColumnCount + MATRIX_COLUMN_OVERSCAN) + ); + + return { end, start }; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/media-thumbnail-lookup.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/media-thumbnail-lookup.ts new file mode 100644 index 00000000000..6bb6eea7dd6 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/media-thumbnail-lookup.ts @@ -0,0 +1,277 @@ +import { API_BASE_URL } from "@plane/constants"; +import type { IRosterPlayer } from "@plane/types"; +import type { TMediaArtifact } from "@/services/media-library.service"; +import type { TMediaItem } from "ce/features/media-library/types/media-library.types"; +import type { SgTagRow } from "./types"; +import { joinApiPath } from "./page-url"; + +type TThumbnailLookupContext = { + packageId?: string; + projectId: string; + workspaceSlug: string; +}; + +const buildManifestArtifactFileUrl = (context: TThumbnailLookupContext, artifactName: string) => { + const normalizedArtifactName = artifactName.trim(); + + if (!context.workspaceSlug || !context.projectId || !context.packageId || !normalizedArtifactName) { + return ""; + } + + return joinApiPath( + API_BASE_URL, + `/api/workspaces/${context.workspaceSlug}/projects/${context.projectId}/media-library/packages/${context.packageId}/artifacts/${encodeURIComponent( + normalizedArtifactName + )}/file/` + ); +}; + +const resolveFallbackUrl = (value: string | null | undefined) => { + const normalizedValue = (value ?? "").trim(); + if (!normalizedValue) return ""; + if (/^https?:\/\//i.test(normalizedValue)) return normalizedValue; + return `/${normalizedValue.replace(/^\/+/, "")}`; +}; + +const getJerseyNumberKeys = (value: string | null | undefined) => { + const normalizedValue = (value ?? "").trim().replace(/^#/, "").replace(/\s+/g, ""); + if (!normalizedValue) return []; + + const withoutLeadingZeros = normalizedValue.replace(/^0+(?=\d)/, ""); + return Array.from(new Set([normalizedValue.toLowerCase(), withoutLeadingZeros.toLowerCase()].filter(Boolean))); +}; + +export const buildTimelinePlayerLabelMap = (players: IRosterPlayer[] | undefined) => { + const labelMap = new Map<string, string>(); + + (players ?? []).forEach((player) => { + const playerName = player.player_name.trim(); + const jerseyNumber = player.jersey_number?.trim() ?? ""; + const playerLabel = [playerName, jerseyNumber ? `#${jerseyNumber.replace(/^#/, "")}` : ""] + .filter(Boolean) + .join(", "); + + if (!playerLabel) return; + + getJerseyNumberKeys(jerseyNumber).forEach((key) => { + labelMap.set(key, playerLabel); + }); + }); + + return labelMap; +}; + +const getThumbnailLookupKeys = (value: string | null | undefined) => { + const normalizedValue = (value ?? "").trim(); + if (!normalizedValue) return []; + + const keys = new Set<string>(); + const addLookupKeyVariants = (candidateValue: string) => { + const normalizedCandidateValue = candidateValue.trim().toLowerCase(); + if (!normalizedCandidateValue) return; + + keys.add(normalizedCandidateValue); + + if (normalizedCandidateValue.startsWith("/")) { + keys.add(normalizedCandidateValue.replace(/^\/+/, "")); + } else if (!/^https?:\/\//i.test(normalizedCandidateValue)) { + keys.add(`/${normalizedCandidateValue}`); + } + + const fileName = normalizedCandidateValue.split("/").pop() ?? ""; + if (!fileName || fileName === normalizedCandidateValue) return; + + keys.add(fileName); + + const fileStem = fileName.replace(/\.[a-z0-9]+$/i, ""); + if (fileStem && fileStem !== fileName) { + keys.add(fileStem); + } + }; + + const baseValue = normalizedValue.split("?")[0].split("#")[0]; + addLookupKeyVariants(baseValue); + + try { + const url = new URL(normalizedValue, typeof window !== "undefined" ? window.location.origin : "http://localhost"); + url.hash = ""; + url.search = ""; + addLookupKeyVariants(`${url.origin}${url.pathname}`); + addLookupKeyVariants(url.pathname); + } catch { + // Keep the normalized raw value when URL parsing is unavailable for this input. + } + + return Array.from(keys).filter(Boolean); +}; + +const getArtifactIdFromPath = (value: string) => { + const normalizedValue = value.trim(); + if (!normalizedValue) return ""; + + try { + const url = new URL(normalizedValue, typeof window !== "undefined" ? window.location.origin : "http://localhost"); + const match = url.pathname.match(/(?:^|\/)artifacts\/([^/]+)(?:\/|$)/); + return match?.[1] ? decodeURIComponent(match[1]) : ""; + } catch { + const match = normalizedValue.match(/(?:^|\/)artifacts\/([^/]+)(?:\/|$)/); + return match?.[1] ? decodeURIComponent(match[1]) : ""; + } +}; + +const getCoachProxyThumbnailName = (value: string | null | undefined) => { + const normalizedValue = (value ?? "").trim(); + if (!normalizedValue) return ""; + + try { + const url = new URL(normalizedValue, typeof window !== "undefined" ? window.location.origin : "http://localhost"); + const normalizedPath = url.pathname.replace(/\/$/, ""); + if (!normalizedPath.endsWith("/api/coach/media/proxy")) return ""; + + return (url.searchParams.get("thumbnail") ?? "").trim().replace(/\.jpg$/i, ""); + } catch { + return ""; + } +}; + +const resolveCoachTagThumbnailUrl = (value: string | null | undefined, cpServerBaseUrl: string) => { + const normalizedValue = (value ?? "").trim(); + const normalizedCpServerBaseUrl = cpServerBaseUrl.replace(/\/$/, ""); + if (!normalizedValue || !normalizedCpServerBaseUrl) return ""; + + const thumbnailName = getCoachProxyThumbnailName(normalizedValue); + if (thumbnailName) { + return `${normalizedCpServerBaseUrl}/blobs/thumbnails/${encodeURIComponent(thumbnailName)}.jpg`; + } + + try { + const url = new URL(normalizedValue, typeof window !== "undefined" ? window.location.origin : "http://localhost"); + if (/^https?:\/\//i.test(normalizedValue)) { + return ""; + } + if (url.pathname.startsWith("/blobs/thumbnails/")) { + return `${normalizedCpServerBaseUrl}${url.pathname}${url.search}`; + } + } catch { + if (normalizedValue.startsWith("/blobs/thumbnails/")) { + return `${normalizedCpServerBaseUrl}${normalizedValue}`; + } + } + + if (!normalizedValue.includes("/") && !normalizedValue.includes("?") && !normalizedValue.includes("#")) { + const thumbnailName = normalizedValue.replace(/\.jpg$/i, ""); + return `${normalizedCpServerBaseUrl}/blobs/thumbnails/${encodeURIComponent(thumbnailName)}.jpg`; + } + + return ""; +}; + +const isManifestThumbnailArtifact = (artifact: TMediaArtifact) => + (artifact.format ?? "").toLowerCase() === "thumbnail" || (artifact.action ?? "").toLowerCase() === "preview"; + +export const buildMediaThumbnailLookup = ( + items: TMediaItem[] | undefined, + manifestArtifacts: TMediaArtifact[] | undefined, + context: TThumbnailLookupContext +) => { + const lookup = new Map<string, string>(); + const addLookup = (value: string | null | undefined, thumbnail: string) => { + getThumbnailLookupKeys(value).forEach((key) => { + if (!lookup.has(key)) lookup.set(key, thumbnail); + }); + }; + const artifactByKey = new Map<string, TMediaArtifact>(); + const addArtifactLookupKeys = (artifact: TMediaArtifact, thumbnail: string) => { + addLookup(artifact.name, thumbnail); + addLookup(artifact.path, thumbnail); + addLookup(artifact.link, thumbnail); + + const artifactIdFromPath = getArtifactIdFromPath(artifact.path); + addLookup(artifactIdFromPath, thumbnail); + }; + const resolveArtifactByValue = (value: string | null | undefined) => { + for (const key of getThumbnailLookupKeys(value)) { + const artifact = artifactByKey.get(key); + if (artifact) return artifact; + } + + return undefined; + }; + + (manifestArtifacts ?? []).forEach((artifact) => { + getThumbnailLookupKeys(artifact.name).forEach((key) => artifactByKey.set(key, artifact)); + getThumbnailLookupKeys(artifact.path).forEach((key) => { + if (!artifactByKey.has(key)) artifactByKey.set(key, artifact); + }); + }); + + (manifestArtifacts ?? []).forEach((artifact) => { + if (!isManifestThumbnailArtifact(artifact)) return; + + const thumbnailUrl = buildManifestArtifactFileUrl(context, artifact.name) || resolveFallbackUrl(artifact.path); + if (!thumbnailUrl) return; + + addArtifactLookupKeys(artifact, thumbnailUrl); + + const linkedArtifact = resolveArtifactByValue(artifact.link); + if (linkedArtifact) { + addArtifactLookupKeys(linkedArtifact, thumbnailUrl); + } + }); + + (items ?? []).forEach((item) => { + if (!item.thumbnail) return; + + addLookup(item.id, item.thumbnail); + addLookup(item.link, item.thumbnail); + addLookup(item.videoSrc, item.thumbnail); + addLookup(item.imageSrc, item.thumbnail); + addLookup(item.fileSrc, item.thumbnail); + addLookup(item.downloadSrc, item.thumbnail); + addLookup(item.thumbnail, item.thumbnail); + }); + + return lookup; +}; + +const getThumbnailFromLookup = (value: string | null | undefined, thumbnailLookup: Map<string, string>) => { + for (const key of getThumbnailLookupKeys(value)) { + const thumbnail = thumbnailLookup.get(key); + if (thumbnail) return thumbnail; + } + + return ""; +}; + +export const resolveTagRowArtifactThumbnail = ( + row: SgTagRow, + thumbnailLookup: Map<string, string>, + cpServerBaseUrl: string +) => { + if (row.thumbnailUrl) { + const thumbnailMatch = getThumbnailFromLookup(row.thumbnailUrl, thumbnailLookup); + if (thumbnailMatch) return thumbnailMatch; + + const thumbnailArtifactId = getArtifactIdFromPath(row.thumbnailUrl); + if (thumbnailArtifactId) { + const thumbnailArtifactMatch = getThumbnailFromLookup(thumbnailArtifactId, thumbnailLookup); + if (thumbnailArtifactMatch) return thumbnailArtifactMatch; + } + + const coachTagThumbnailUrl = resolveCoachTagThumbnailUrl(row.thumbnailUrl, cpServerBaseUrl); + if (coachTagThumbnailUrl) return coachTagThumbnailUrl; + + return row.thumbnailUrl; + } + + const sourceMatch = getThumbnailFromLookup(row.sourceUrl, thumbnailLookup); + if (sourceMatch) return sourceMatch; + + const artifactId = getArtifactIdFromPath(row.sourceUrl); + if (artifactId) { + const artifactMatch = getThumbnailFromLookup(artifactId, thumbnailLookup); + if (artifactMatch) return artifactMatch; + } + + return ""; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/page-url.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/page-url.ts new file mode 100644 index 00000000000..6bef66802eb --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/page-url.ts @@ -0,0 +1 @@ +export const joinApiPath = (base: string, path: string) => `${base?.replace(/\/$/, "") ?? ""}${path}`; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/page.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/page.tsx new file mode 100644 index 00000000000..9674a992411 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/page.tsx @@ -0,0 +1,995 @@ +"use client"; + +import type { UIEvent } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; +import useSWR from "swr"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssue } from "@plane/types"; +import { + buildSgEventAnnotationVideoItem, + buildSgEventAnnotationViewKey, + getSgEventMediaReferenceAnnotations, +} from "@/components/annotation"; +import type { TCustomPlaylistAnnotation } from "@/components/annotation"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import type { + TCustomPlaylist, + TCustomPlaylistClip, + TCustomPlaylistUpdatePayload, +} from "@/services/media-library.service"; +import { MediaLibraryService } from "@/services/media-library.service"; +import { RosterService } from "@/services/roster.service"; +import type { TMediaItem } from "ce/features/media-library/types/media-library.types"; +import { getEventMediaDetails } from "ce/features/media-library/utils/media-event"; +import { buildEventPayloadDevices, fetchSgEventDevices, loadSgMediaPayload } from "./data"; +import { SgEventDetailsCard } from "./details-card"; +import { SgEventHeader, SgEventTitleBar } from "./header"; +import { useSgEventPlaybackState } from "./hooks/use-sg-event-playback-state"; +import { useSgEventTagState } from "./hooks/use-sg-event-tag-state"; +import { fetchKanavioTagRowsPayload, isNumericEventId, normalizeFetchedTagPayload } from "./kanavio-tag-payload"; +import { MatrixView } from "./matrix-view"; +import { SgMatrixPlaylistPanel } from "./matrix-view/components/matrix-playlist-panel"; +import { + buildMatrixPlaylistItem as buildCustomPlaylistItem, + createMatrixPlaylist as createCustomPlaylist, +} from "./matrix-view/utils/create-matrix-playlist"; +import { buildTimelinePlayerLabelMap } from "./media-thumbnail-lookup"; +import { SgEventVideoPlayer } from "./sg-event-video-player"; +import { SgEventTagsPanel } from "./tags-view"; +import { SgEventTimelinePanel, isTimelineTagPlaybackOverrideId } from "./timeline-view"; +import { TIMELINE_PAGE_CONTENT_CLASS, TIMELINE_PAGE_SCROLL_CLASS } from "./timeline-view/utils/timeline-layout"; +import { getTimelinePlaylistRows } from "./timeline-view/utils/timeline-playlist-selection"; +import type { SgEventDetailPageProps, SgEventTagViewMode, SgIssue, SgTagRow } from "./types"; +import { + asRecord, + buildBaseEventDateTime, + buildEventTitle, + firstNonEmptyRecord, + formatLongDateTime, + getCpServerBaseUrl, + getLastPathSegment, + getSportTableConfig, + getSgTagRowStreamName, + normalizeTagRows, + pickText, + toText, +} from "./utils"; + +const normalizeNumericEventId = (value: unknown) => { + const normalizedValue = toText(value).trim(); + return /^\d+$/.test(normalizedValue) ? normalizedValue : ""; +}; + +const pickNumericSgEventId = (sources: Array<Record<string, unknown> | null | undefined>) => { + const keyGroups = [ + ["sg_event_id", "sgEventId", "sgEventID"], + ["event_id", "eventId", "preview_event_id", "previewEventId"], + ["plane_event_id", "planeEventId"], + ]; + + for (const keys of keyGroups) { + for (const source of sources) { + if (!source) continue; + + for (const key of keys) { + const eventId = normalizeNumericEventId(source[key]); + if (eventId) return eventId; + } + } + } + + return ""; +}; + +const getEventVideoErrorMessage = (error: unknown, fallbackMessage: string): string => { + if (!error) return fallbackMessage; + if (typeof error === "string") return error; + if (error instanceof Error) return error.message || fallbackMessage; + if (Array.isArray(error)) { + const message = error + .map((entry) => getEventVideoErrorMessage(entry, "")) + .filter(Boolean) + .join(" "); + return message || fallbackMessage; + } + if (typeof error !== "object") return fallbackMessage; + + const errorRecord = error as Record<string, unknown>; + for (const key of ["detail", "error", "message", "errorMessage", "error_message"]) { + const value = errorRecord[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + + const fieldMessages = Object.entries(errorRecord) + .map(([field, value]) => { + const message = getEventVideoErrorMessage(value, ""); + return message ? `${field}: ${message}` : ""; + }) + .filter(Boolean); + + return fieldMessages.join(" ") || fallbackMessage; +}; + +const CUSTOM_PLAYLIST_MAX_TEXT_LENGTH = 255; +const CUSTOM_PLAYLIST_MAX_BIGINT = "9223372036854775807"; + +const truncateCustomPlaylistText = (value: string, maxLength = CUSTOM_PLAYLIST_MAX_TEXT_LENGTH) => { + const normalizedValue = value.trim(); + if (normalizedValue.length <= maxLength) return normalizedValue; + + return normalizedValue.slice(0, maxLength).trimEnd(); +}; + +const normalizeCustomPlaylistFileName = (value: string | null | undefined) => { + const fileName = getLastPathSegment(value); + if (!fileName || fileName.length > CUSTOM_PLAYLIST_MAX_TEXT_LENGTH || /[\\/]/.test(fileName)) return ""; + + return fileName; +}; + +const normalizeCustomPlaylistEventId = (value: string) => { + const normalizedValue = value.trim(); + if (!/^\d+$/.test(normalizedValue)) return null; + + const withoutLeadingZeroes = normalizedValue.replace(/^0+/, "") || "0"; + if (withoutLeadingZeroes === "0") return null; + if ( + withoutLeadingZeroes.length > CUSTOM_PLAYLIST_MAX_BIGINT.length || + (withoutLeadingZeroes.length === CUSTOM_PLAYLIST_MAX_BIGINT.length && + withoutLeadingZeroes > CUSTOM_PLAYLIST_MAX_BIGINT) + ) { + return null; + } + + const numericEventId = Number(withoutLeadingZeroes); + return Number.isSafeInteger(numericEventId) ? numericEventId : normalizedValue; +}; + +const buildCustomPlaylistName = (eventTitle: string, clipCount: number) => { + const suffix = ` (${clipCount} clip${clipCount === 1 ? "" : "s"})`; + const fallbackTitle = "Playlist"; + const titleLength = Math.max(1, CUSTOM_PLAYLIST_MAX_TEXT_LENGTH - suffix.length); + const title = truncateCustomPlaylistText(eventTitle || fallbackTitle, titleLength) || fallbackTitle; + + return truncateCustomPlaylistText(`${title}${suffix}`); +}; + +const buildCustomPlaylistClips = (rows: SgTagRow[]): TCustomPlaylistClip[] => + rows.map((row, index) => { + const title = truncateCustomPlaylistText(row.action || row.primaryDetail || `Clip ${index + 1}`); + const subtitle = truncateCustomPlaylistText([row.player, row.team, row.groupValue].filter(Boolean).join(" / ")); + const tags = [row.result, row.primaryDetail, row.secondaryDetail] + .map((tag) => truncateCustomPlaylistText(tag)) + .filter(Boolean); + + return { + groupValue: truncateCustomPlaylistText(row.groupValue), + id: truncateCustomPlaylistText(row.id), + player: truncateCustomPlaylistText(row.player), + primaryDetail: truncateCustomPlaylistText(row.primaryDetail), + result: truncateCustomPlaylistText(row.result), + sourceTagId: row.sourceTagId, + subtitle, + tags, + team: truncateCustomPlaylistText(row.team), + thumbnail: normalizeCustomPlaylistFileName(row.thumbnailUrl) || null, + timestamp: row.playlistTimestamp ?? row.playlistFallbackTimestamp, + title, + }; + }); + +export const SgEventDetailPage = ({ + enableMatrixView = false, + defaultTagViewMode, + showTagListActions = true, + issue, + mediaItem = null, + projectId, + workspaceSlug, + fallbackBackHref, + onBack, +}: SgEventDetailPageProps) => { + const sgIssue = issue as SgIssue | undefined; + const router = useAppRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { getProjectById } = useProject(); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + const rosterService = useMemo(() => new RosterService(), []); + const [tagViewMode, setTagViewMode] = useState<SgEventTagViewMode>(() => { + if (defaultTagViewMode === "matrix" && !enableMatrixView) return "timeline"; + return defaultTagViewMode ?? (enableMatrixView ? "matrix" : "timeline"); + }); + const [isTagListBodyScrolled, setIsTagListBodyScrolled] = useState(false); + const [isListPageScrolled, setIsListPageScrolled] = useState(false); + const [isCreatingCustomPlaylist, setIsCreatingCustomPlaylist] = useState(false); + const [isTimelinePlaylistSelectionMode, setIsTimelinePlaylistSelectionMode] = useState(false); + + const mediaMeta = asRecord(mediaItem?.meta); + const cpServerBaseUrl = useMemo(() => getCpServerBaseUrl(), []); + const project = getProjectById(projectId); + const resolvedWorkItemId = issue?.id || mediaItem?.workItemId || ""; + const { + data: sgMediaPayload, + error: sgMediaError, + isLoading: isMediaLoading, + mutate: mutateSgMediaPayload, + } = useSWR( + workspaceSlug && projectId && (resolvedWorkItemId || mediaItem?.id) + ? `SG_EVENT_MEDIA_${workspaceSlug}_${projectId}_${resolvedWorkItemId || mediaItem?.id}` + : null, + () => loadSgMediaPayload(workspaceSlug, projectId, resolvedWorkItemId, mediaItem, mediaLibraryService), + { revalidateOnFocus: false } + ); + const { data: rosterPlayers } = useSWR( + workspaceSlug && projectId ? `PROJECT_ROSTER_${workspaceSlug}_${projectId}` : null, + () => rosterService.getRoster(workspaceSlug, projectId), + { revalidateOnFocus: false } + ); + const timelinePlayerLabelByNumber = useMemo(() => buildTimelinePlayerLabelMap(rosterPlayers), [rosterPlayers]); + + const eventDetails = useMemo( + () => getEventMediaDetails(mediaItem) ?? sgMediaPayload?.eventDetails ?? null, + [mediaItem, sgMediaPayload?.eventDetails] + ); + const resolvedSport = + eventDetails?.sport || toText(mediaMeta.sport) || toText((project as { sport?: unknown } | undefined)?.sport); + const sportTableConfig = useMemo(() => getSportTableConfig(resolvedSport), [resolvedSport]); + const sgEventMeta = asRecord(sgMediaPayload?.eventItem?.meta); + const eventPayload = firstNonEmptyRecord( + sgMediaPayload?.eventPayload, + sgEventMeta.event, + sgEventMeta.rawEvent, + mediaMeta.event, + mediaMeta.rawEvent, + sgEventMeta, + mediaMeta + ); + const payloadSources = [ + asRecord(eventPayload), + asRecord(asRecord(eventPayload).event), + asRecord(asRecord(eventPayload).rawEvent), + ]; + const sgEventItemRecord = asRecord(sgMediaPayload?.eventItem); + const resolvedSgEventId = + normalizeNumericEventId(sgIssue?.sg_event_id) || + pickNumericSgEventId([...payloadSources, sgEventMeta, sgEventItemRecord, mediaMeta, asRecord(mediaItem)]); + const shouldUseKanavioTagApi = Boolean(resolvedSgEventId && isNumericEventId(resolvedSgEventId)); + const resolvedCustomPlaylistEventId = shouldUseKanavioTagApi ? resolvedSgEventId : null; + const { data: customPlaylists = [], mutate: mutateCustomPlaylists } = useSWR( + resolvedCustomPlaylistEventId + ? `CUSTOM_PLAYLISTS_${workspaceSlug}_${projectId}_${resolvedCustomPlaylistEventId}` + : null, + () => { + if (!resolvedCustomPlaylistEventId) return Promise.resolve([]); + + return mediaLibraryService.getCustomPlaylists(resolvedCustomPlaylistEventId, { + projectId, + workspaceSlug, + }); + }, + { revalidateOnFocus: false } + ); + const { + data: kanavioTagsPayload, + error: kanavioTagsError, + isLoading: isKanavioTagsLoading, + } = useSWR( + shouldUseKanavioTagApi ? `KANAVIO_FETCH_TAGS_${cpServerBaseUrl}_${resolvedSgEventId}` : null, + () => fetchKanavioTagRowsPayload(cpServerBaseUrl, resolvedSgEventId), + { revalidateOnFocus: false } + ); + const { data: sgEventDevices, isLoading: isLoadingViews } = useSWR( + cpServerBaseUrl && resolvedSgEventId ? `SG_EVENT_DEVICES_${cpServerBaseUrl}_${resolvedSgEventId}` : null, + () => fetchSgEventDevices(cpServerBaseUrl, resolvedSgEventId), + { revalidateOnFocus: false } + ); + const dateValue = + pickText(payloadSources, ["dt_event", "eventDateTime", "date", "event_date", "start_date", "eventDate"]) || + eventDetails?.eventDateTime || + eventDetails?.eventDate || + toText(mediaMeta.start_date) || + issue?.start_date || + ""; + const timeValue = + pickText(payloadSources, ["dt_event", "eventDateTime", "time", "event_time", "start_time", "eventTime"]) || + eventDetails?.eventTime || + toText(mediaMeta.start_time) || + issue?.start_time || + ""; + const baseEventDateTime = buildBaseEventDateTime(dateValue, timeValue); + const apiTagSourcePayload = useMemo(() => normalizeFetchedTagPayload(kanavioTagsPayload), [kanavioTagsPayload]); + const fallbackTagSourcePayload = useMemo( + () => firstNonEmptyRecord(eventPayload, sgEventMeta, mediaMeta), + [eventPayload, mediaMeta, sgEventMeta] + ); + const tagSourcePayload = shouldUseKanavioTagApi ? apiTagSourcePayload : fallbackTagSourcePayload; + const tagRows = useMemo( + () => + tagSourcePayload + ? normalizeTagRows(tagSourcePayload, eventDetails, sportTableConfig.sport, baseEventDateTime) + : [], + [baseEventDateTime, eventDetails, sportTableConfig.sport, tagSourcePayload] + ); + const payloadViewDevices = useMemo(() => buildEventPayloadDevices(eventPayload), [eventPayload]); + const viewDevices = sgEventDevices && sgEventDevices.length > 0 ? sgEventDevices : payloadViewDevices; + const primaryStreamName = + pickText(payloadSources, ["primaryStreamName", "primary_stream_name"]) || eventDetails?.primaryStreamName || ""; + const { + activePlaybackOverrideId, + activeTimelineTagId, + activeVideo, + clearActiveTimelineTag, + fullStreamPlaybackItem, + handlePlayTagRow, + handlePlaybackTimeChange, + handleResetTimelinePlayback, + handleSeekTimelineSeconds, + handleSwitchToFullStream, + hasPlayableVideo, + isPlayerPlaying, + isPlaybackOverrideActive, + pendingSeekRequestId, + pendingSeekSeconds, + playbackAnnotationItem, + playbackItem, + playPlaybackOverride, + playerDurationSeconds, + playerPlaybackRate, + selectedViewDevice, + selectedViewId, + selectedViewLabel, + setSelectedViewId, + timelinePanelPlayheadSeconds, + } = useSgEventPlaybackState({ + eventItem: sgMediaPayload?.eventItem, + mediaItem, + mediaLibraryService, + primaryStreamName, + resolvedWorkItemId, + videoItems: sgMediaPayload?.videoItems, + viewDevices, + }); + const { + allVisibleSelected, + availableGroups, + clearSelectedTagIds, + effectiveGroupValue, + favoriteTagIds, + filteredRows, + handleCreateMatrixCard, + handleRemoveTag, + handleSelectAll, + handleToggleFavorite, + handleToggleSearch, + handleToggleTagSelection, + handleUpdateTag, + isSearchOpen, + matrixRows, + playlistPanelRows, + rowFilterMode, + searchQuery, + selectedRows, + selectedTagIds, + setFocusedMatrixRows, + setRowFilterMode, + setSearchQuery, + setSelectedGroupValue, + tagTypeRows, + } = useSgEventTagState({ + cpServerBaseUrl, + manifestArtifacts: sgMediaPayload?.manifestArtifacts, + mediaItems: sgMediaPayload?.mediaItems, + onActiveTagRemoved: clearActiveTimelineTag, + packageId: sgMediaPayload?.packageId, + projectId, + tagRows, + workspaceSlug, + }); + + useEffect(() => { + if (tagViewMode === "list") return; + setIsTagListBodyScrolled(false); + setIsListPageScrolled(false); + }, [tagViewMode]); + + useEffect(() => { + if (tagViewMode !== "timeline" && isTimelinePlaylistSelectionMode) { + setIsTimelinePlaylistSelectionMode(false); + } + }, [isTimelinePlaylistSelectionMode, tagViewMode]); + const projectName = toText((project as { name?: unknown } | undefined)?.name); + const eventTitle = buildEventTitle({ + eventDetails, + issue: + issue ?? + ({ + id: mediaItem?.id ?? "", + name: mediaItem?.title ?? "", + opposition_team: mediaMeta.opposition ?? null, + } as TIssue), + payload: eventPayload, + projectName, + }); + const venueName = + pickText(payloadSources, ["venue", "venue_name", "location", "location_label", "locationLabel"]) || + eventDetails?.locationLabel || + ""; + const venueAddress = pickText(payloadSources, ["address", "venue_address", "location_address", "locationAddress"]); + const eventStatus = + pickText(payloadSources, ["status", "event_status"]) || + eventDetails?.status || + toText(mediaMeta.status) || + (issue?.completed_at ? "Completed" : "Scheduled"); + const levelLabel = + pickText(payloadSources, ["team_level", "level"]) || + eventDetails?.level || + toText(mediaMeta.level) || + issue?.level || + "Freshmen"; + const eventDateTimeLabel = formatLongDateTime(dateValue, timeValue); + + const handleBack = () => { + if (onBack) { + onBack(); + return; + } + + if (typeof window !== "undefined" && window.history.length > 1) { + router.back(); + return; + } + + router.push(fallbackBackHref || `/${workspaceSlug}/projects/${projectId}/issues`); + }; + + const handleCreateCustomPlaylist = useCallback( + async (rows: SgTagRow[]) => { + if (isCreatingCustomPlaylist) return false; + if (rows.length === 0) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "No clips selected", + message: "Select at least one tag before creating a playlist.", + }); + return false; + } + if (!resolvedCustomPlaylistEventId) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Missing event", + message: "A service gateway event id is required before creating a playlist.", + }); + return false; + } + const streamName = (selectedViewDevice?.streamName || primaryStreamName).trim(); + setIsCreatingCustomPlaylist(true); + + try { + const customPlaylistEventId = normalizeCustomPlaylistEventId(resolvedCustomPlaylistEventId); + if (customPlaylistEventId === null) { + throw new Error("A valid service gateway event id is required before creating a playlist."); + } + + const result = await createCustomPlaylist({ mediaLibraryService, rows, streamName }); + const includedRowIds = new Set(result.rowIds); + const includedRows = rows.filter((row) => includedRowIds.has(row.id)); + const thumbnail = + includedRows.find((row) => row.thumbnailUrl)?.thumbnailUrl || + activeVideo?.thumbnail || + mediaItem?.thumbnail || + null; + const playlistFileName = normalizeCustomPlaylistFileName(result.fileName || result.url); + if (!playlistFileName) { + throw new Error("The generated playlist did not include a valid file name."); + } + + const thumbnailFileName = normalizeCustomPlaylistFileName(thumbnail); + const customPlaylistPayload = { + event_id: customPlaylistEventId, + name: buildCustomPlaylistName(eventTitle, includedRows.length), + url: playlistFileName, + ...(thumbnailFileName ? { thumbnail: thumbnailFileName } : {}), + clip: includedRows.length, + clips: buildCustomPlaylistClips(includedRows), + project_id: projectId, + workspace_slug: workspaceSlug, + }; + let customPlaylist: TCustomPlaylist; + try { + customPlaylist = await mediaLibraryService.createCustomPlaylist(customPlaylistPayload); + } catch (error) { + const message = getEventVideoErrorMessage(error, ""); + if (!/payload is not valid/i.test(message)) { + throw error; + } + + customPlaylist = await mediaLibraryService.createCustomPlaylist({ + event_id: customPlaylistPayload.event_id, + name: customPlaylistPayload.name, + url: customPlaylistPayload.url, + clip: customPlaylistPayload.clip, + project_id: customPlaylistPayload.project_id, + workspace_slug: customPlaylistPayload.workspace_slug, + }); + } + + playPlaybackOverride( + buildCustomPlaylistItem({ + result, + rows: includedRows, + workItemId: resolvedWorkItemId || null, + }) + ); + void mutateCustomPlaylists((currentPlaylists = []) => [customPlaylist, ...currentPlaylists], { + revalidate: false, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Playlist created", + message: `${includedRows.length} selected tag${includedRows.length === 1 ? "" : "s"} are ready to play.`, + }); + return true; + } catch (error) { + const message = getEventVideoErrorMessage(error, "Unable to create a playlist from the selected tags."); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Playlist creation failed", + message, + }); + return false; + } finally { + setIsCreatingCustomPlaylist(false); + } + }, + [ + activeVideo?.thumbnail, + eventTitle, + isCreatingCustomPlaylist, + mediaLibraryService, + mediaItem?.thumbnail, + mutateCustomPlaylists, + playPlaybackOverride, + primaryStreamName, + projectId, + resolvedCustomPlaylistEventId, + resolvedWorkItemId, + selectedViewDevice?.streamName, + workspaceSlug, + ] + ); + const timelinePlaylistRows = useMemo( + () => getTimelinePlaylistRows(filteredRows, selectedTagIds), + [filteredRows, selectedTagIds] + ); + + const handleCreateTimelinePlaylist = useCallback(async () => { + const wasCreated = await handleCreateCustomPlaylist(timelinePlaylistRows); + if (!wasCreated) return; + + clearSelectedTagIds(); + setIsTimelinePlaylistSelectionMode(false); + }, [clearSelectedTagIds, handleCreateCustomPlaylist, timelinePlaylistRows]); + + const handleCreateMatrixPlaylist = useCallback( + async (rows: SgTagRow[]) => { + await handleCreateCustomPlaylist(rows); + }, + [handleCreateCustomPlaylist] + ); + + const handleDeleteCustomPlaylist = useCallback( + async (playlist: TCustomPlaylist) => { + await mediaLibraryService.deleteCustomPlaylist(playlist.id); + void mutateCustomPlaylists( + (currentPlaylists = []) => currentPlaylists.filter((currentPlaylist) => currentPlaylist.id !== playlist.id), + { revalidate: false } + ); + }, + [mediaLibraryService, mutateCustomPlaylists] + ); + + const handleUpdateCustomPlaylist = useCallback( + async (playlist: TCustomPlaylist, payload: TCustomPlaylistUpdatePayload) => { + const updatedPlaylist = await mediaLibraryService.updateCustomPlaylist(playlist.id, payload); + void mutateCustomPlaylists( + (currentPlaylists = []) => + currentPlaylists.map((currentPlaylist) => + currentPlaylist.id === updatedPlaylist.id ? updatedPlaylist : currentPlaylist + ), + { revalidate: false } + ); + return updatedPlaylist; + }, + [mediaLibraryService, mutateCustomPlaylists] + ); + const handleUpdateVideoAnnotations = useCallback( + async (videoItem: TMediaItem, annotations: TCustomPlaylistAnnotation[]) => { + if (!videoItem?.packageId || !videoItem.id) { + throw new Error("Video annotations can only be saved on media library artifacts."); + } + + const videoMeta = videoItem.meta ?? {}; + const eventArtifact = sgMediaPayload?.eventItem?.packageId ? sgMediaPayload.eventItem : videoItem; + const eventPackageId = eventArtifact.packageId ?? videoItem.packageId; + if (!eventPackageId) { + throw new Error("Video annotations can only be saved on media library artifacts."); + } + const metaViewDeviceId = toText(videoMeta.annotationViewDeviceId); + const metaViewStreamId = toText(videoMeta.annotationViewStreamId); + const metaViewStreamName = toText(videoMeta.annotationViewStreamName); + const metaViewKey = toText(videoMeta.annotationViewKey); + const metaVideoSource = toText(videoMeta.annotationVideoSource); + const annotationViewKey = buildSgEventAnnotationViewKey({ + deviceId: selectedViewDevice?.id ?? metaViewDeviceId, + streamId: selectedViewDevice?.streamId ?? metaViewStreamId, + streamName: selectedViewDevice?.streamName ?? metaViewStreamName, + viewKey: metaViewKey, + videoSrc: selectedViewDevice?.hlsUrl ?? metaVideoSource, + }); + const updatedEvent = await mediaLibraryService.updateEventVideoAnnotations( + workspaceSlug, + projectId, + eventPackageId, + eventArtifact.id, + { + annotations, + device_id: selectedViewDevice?.id ?? metaViewDeviceId, + stream_id: selectedViewDevice?.streamId ?? metaViewStreamId, + stream_name: selectedViewDevice?.streamName ?? metaViewStreamName, + view_key: annotationViewKey, + } + ); + const updatedAnnotations = getSgEventMediaReferenceAnnotations(videoMeta, { + deviceId: selectedViewDevice?.id ?? metaViewDeviceId, + eventPayload: updatedEvent.eventPayload, + streamId: selectedViewDevice?.streamId ?? metaViewStreamId, + streamName: selectedViewDevice?.streamName ?? metaViewStreamName, + viewKey: annotationViewKey, + videoSrc: selectedViewDevice?.hlsUrl ?? metaVideoSource, + }); + const nextMeta = { + ...videoMeta, + annotations: updatedAnnotations, + }; + void mutateSgMediaPayload( + (currentPayload) => { + if (!currentPayload) return currentPayload; + + const updateItem = (currentItem: TMediaItem): TMediaItem => + currentItem.id === videoItem.id && currentItem.packageId === videoItem.packageId + ? { ...currentItem, meta: nextMeta } + : currentItem; + + return { + ...currentPayload, + eventItem: currentPayload.eventItem ? updateItem(currentPayload.eventItem) : currentPayload.eventItem, + mediaItems: currentPayload.mediaItems.map(updateItem), + videoItems: currentPayload.videoItems.map(updateItem), + eventPayload: updatedEvent.eventPayload ?? currentPayload.eventPayload, + }; + }, + { revalidate: false } + ); + + return { + ...videoItem, + meta: nextMeta, + }; + }, + [mediaLibraryService, mutateSgMediaPayload, projectId, selectedViewDevice, sgMediaPayload?.eventItem, workspaceSlug] + ); + const kanavioTagsErrorMessage = + kanavioTagsError instanceof Error + ? kanavioTagsError.message + : kanavioTagsError + ? "Unable to fetch event tags." + : null; + const matrixError = + matrixRows.length === 0 && shouldUseKanavioTagApi && kanavioTagsErrorMessage + ? kanavioTagsErrorMessage + : matrixRows.length === 0 && sgMediaPayload?.eventPayloadStatus === "error" + ? (sgMediaPayload.eventPayloadErrorMessage ?? "Unable to load the completed event data for Matrix View.") + : matrixRows.length === 0 && sgMediaError instanceof Error + ? sgMediaError + : matrixRows.length === 0 && sgMediaError + ? "Unable to load the event media required for Matrix View." + : null; + const activeMatrixRowId = isTimelineTagPlaybackOverrideId(activePlaybackOverrideId) + ? (activePlaybackOverrideId?.slice("sg-tag-".length) ?? null) + : null; + const matrixStreamName = (selectedViewDevice?.streamName ?? primaryStreamName).trim(); + const hasMatrixRowStreamName = matrixRows.some((row) => Boolean(getSgTagRowStreamName(row))); + const isMatrixWorkspaceMode = enableMatrixView && tagViewMode === "matrix"; + const activePlaylistRows = isMatrixWorkspaceMode + ? playlistPanelRows + : tagViewMode === "timeline" + ? timelinePlaylistRows + : selectedRows; + const isTagRowsLoading = isMediaLoading || (shouldUseKanavioTagApi && isKanavioTagsLoading); + const playbackAnnotationPageItem = useMemo( + () => + buildSgEventAnnotationVideoItem(playbackAnnotationItem, { + deviceId: selectedViewDevice?.id, + eventPayload, + streamId: selectedViewDevice?.streamId, + streamName: selectedViewDevice?.streamName, + title: selectedViewDevice?.name, + videoSrc: selectedViewDevice?.hlsUrl, + }), + [ + eventPayload, + playbackAnnotationItem, + selectedViewDevice?.hlsUrl, + selectedViewDevice?.id, + selectedViewDevice?.name, + selectedViewDevice?.streamId, + selectedViewDevice?.streamName, + ] + ); + const canSavePlaybackAnnotations = Boolean(playbackAnnotationPageItem?.packageId); + const currentHref = useMemo(() => { + const queryString = searchParams.toString(); + return queryString ? `${pathname}?${queryString}` : pathname; + }, [pathname, searchParams]); + const playbackAnnotationHref = useMemo(() => { + if (!playbackAnnotationPageItem?.packageId || !playbackAnnotationPageItem.id) { + return null; + } + + const params = new URLSearchParams(); + params.set("annotation", "open"); + params.set("from", currentHref); + const viewKey = buildSgEventAnnotationViewKey({ + deviceId: selectedViewDevice?.id, + streamId: selectedViewDevice?.streamId, + streamName: selectedViewDevice?.streamName, + videoSrc: selectedViewDevice?.hlsUrl, + }); + if (viewKey) { + params.set("viewKey", viewKey); + } + if (selectedViewDevice?.id) { + params.set("deviceId", String(selectedViewDevice.id)); + } + if (selectedViewDevice?.streamId) { + params.set("streamId", selectedViewDevice.streamId); + } + if (selectedViewDevice?.streamName) { + params.set("stream", selectedViewDevice.streamName); + } + if (selectedViewDevice?.hlsUrl) { + params.set("videoSrc", selectedViewDevice.hlsUrl); + } + if (selectedViewDevice?.name) { + params.set("view", selectedViewDevice.name); + } + + return `/${workspaceSlug}/projects/${projectId}/media-library/${encodeURIComponent(playbackAnnotationPageItem.id)}?${params.toString()}`; + }, [ + currentHref, + playbackAnnotationPageItem?.id, + playbackAnnotationPageItem?.packageId, + projectId, + selectedViewDevice?.hlsUrl, + selectedViewDevice?.id, + selectedViewDevice?.name, + selectedViewDevice?.streamId, + selectedViewDevice?.streamName, + workspaceSlug, + ]); + const handleOpenPlaybackAnnotationPage = useCallback(() => { + if (!playbackAnnotationHref) return; + + router.push(playbackAnnotationHref); + }, [playbackAnnotationHref, router]); + const matrixPreferenceKey = `plane:media-library:matrix-columns:${workspaceSlug}:${projectId}:${ + resolvedSgEventId || mediaItem?.id || resolvedWorkItemId || "event" + }:${sportTableConfig.sport}`; + const isTagListScrolled = isTagListBodyScrolled || isListPageScrolled; + const shouldShowEventSummary = tagViewMode !== "list" || !isTagListScrolled; + const shouldShowMatrixEventSummary = !isListPageScrolled; + const handlePageScroll = useCallback( + (event: UIEvent<HTMLDivElement>) => { + if (tagViewMode !== "list" && tagViewMode !== "matrix") return; + setIsListPageScrolled(event.currentTarget.scrollTop > 8); + }, + [tagViewMode] + ); + + return ( + <div className="sg-matrix-workspace h-full bg-[var(--sg-matrix-page)] text-[var(--sg-matrix-text)]"> + <div className={TIMELINE_PAGE_SCROLL_CLASS} onScroll={handlePageScroll}> + <div className={TIMELINE_PAGE_CONTENT_CLASS}> + <SgEventHeader + eventStatus={eventStatus} + eventTitle={eventTitle} + fullStreamPlaybackItem={fullStreamPlaybackItem} + handleBack={handleBack} + handleSwitchToFullStream={handleSwitchToFullStream} + isMatrixViewEnabled={enableMatrixView} + isLoadingViews={isLoadingViews} + isTagClipActive={isPlaybackOverrideActive} + selectedViewId={selectedViewId} + selectedViewLabel={selectedViewLabel} + setSelectedViewId={setSelectedViewId} + setTagViewMode={setTagViewMode} + tagViewMode={tagViewMode} + viewDevices={viewDevices} + /> + + {isMatrixWorkspaceMode ? ( + <> + <div className="grid min-w-0 gap-[10px] xl:grid-cols-[minmax(0,76fr)_minmax(260px,24fr)]"> + <div className="min-w-0 rounded-[5px] bg-[var(--sg-matrix-video-bg)]"> + <SgEventVideoPlayer + item={playbackItem} + annotationItem={playbackAnnotationPageItem ?? playbackAnnotationItem} + compactEmpty={!hasPlayableVideo} + onOpenAnnotationPage={playbackAnnotationHref ? handleOpenPlaybackAnnotationPage : undefined} + onPlaybackTimeChange={handlePlaybackTimeChange} + onUpdateAnnotations={canSavePlaybackAnnotations ? handleUpdateVideoAnnotations : undefined} + seekRequestId={pendingSeekRequestId} + seekToSeconds={pendingSeekSeconds} + /> + </div> + <SgMatrixPlaylistPanel + customPlaylists={customPlaylists} + onDeletePlaylist={handleDeleteCustomPlaylist} + onUpdatePlaylist={handleUpdateCustomPlaylist} + /> + </div> + + {shouldShowMatrixEventSummary && ( + <> + <SgEventTitleBar + eventStatus={eventStatus} + eventTitle={eventTitle} + handleSwitchToFullStream={handleSwitchToFullStream} + isTagClipActive={isPlaybackOverrideActive} + /> + + <SgEventDetailsCard + eventDateTimeLabel={eventDateTimeLabel} + levelLabel={levelLabel} + venueAddress={venueAddress} + venueName={venueName} + /> + </> + )} + + <div className="flex flex-col gap-2"> + <MatrixView + activeRowId={activeMatrixRowId} + className="min-h-0" + canCreatePlaylist={Boolean(matrixStreamName) || hasMatrixRowStreamName} + error={matrixError} + hasEvent={Boolean(mediaItem || issue || eventDetails || eventPayload)} + isCreatingPlaylist={isCreatingCustomPlaylist} + isLoading={isTagRowsLoading} + layout="workspace" + onCreateCard={handleCreateMatrixCard} + onCreatePlaylist={handleCreateMatrixPlaylist} + onFocusedRowsChange={setFocusedMatrixRows} + onPlayTagRow={handlePlayTagRow} + preferenceKey={matrixPreferenceKey} + sport={resolvedSport || ""} + tagRows={matrixRows} + /> + </div> + </> + ) : ( + <div className="min-w-0"> + <div className="flex min-h-0 flex-col gap-3"> + <div className="flex min-w-0 flex-col gap-3"> + <div className="grid min-w-0 gap-[10px] xl:grid-cols-[minmax(0,76fr)_minmax(260px,24fr)]"> + <div className="min-w-0 rounded-[5px] bg-[var(--sg-matrix-video-bg)]"> + <SgEventVideoPlayer + item={playbackItem} + annotationItem={playbackAnnotationPageItem ?? playbackAnnotationItem} + compactEmpty={!hasPlayableVideo} + onOpenAnnotationPage={playbackAnnotationHref ? handleOpenPlaybackAnnotationPage : undefined} + onPlaybackTimeChange={handlePlaybackTimeChange} + onUpdateAnnotations={canSavePlaybackAnnotations ? handleUpdateVideoAnnotations : undefined} + seekRequestId={pendingSeekRequestId} + seekToSeconds={pendingSeekSeconds} + /> + </div> + <SgMatrixPlaylistPanel + customPlaylists={customPlaylists} + onDeletePlaylist={handleDeleteCustomPlaylist} + onUpdatePlaylist={handleUpdateCustomPlaylist} + /> + </div> + + {shouldShowEventSummary && ( + <> + <SgEventTitleBar + eventStatus={eventStatus} + eventTitle={eventTitle} + handleSwitchToFullStream={handleSwitchToFullStream} + isTagClipActive={isPlaybackOverrideActive} + /> + + <SgEventDetailsCard + eventDateTimeLabel={eventDateTimeLabel} + levelLabel={levelLabel} + venueAddress={venueAddress} + venueName={venueName} + /> + </> + )} + </div> + + {tagViewMode === "timeline" ? ( + <SgEventTimelinePanel + activePlaybackOverrideId={activePlaybackOverrideId} + activeTagRowId={activeTimelineTagId} + isCreatingPlaylist={isCreatingCustomPlaylist} + isPlaylistSelectionMode={isTimelinePlaylistSelectionMode} + isMediaLoading={isTagRowsLoading} + onClearTagSelection={clearSelectedTagIds} + onCreatePlaylist={() => void handleCreateTimelinePlaylist()} + isPlayerPlaying={isPlayerPlaying} + onPlayTagRow={handlePlayTagRow} + onPlaylistSelectionModeChange={setIsTimelinePlaylistSelectionMode} + onResetPlayback={handleResetTimelinePlayback} + onSeekTimelineSeconds={handleSeekTimelineSeconds} + onToggleTagSelection={handleToggleTagSelection} + playerDurationSeconds={playerDurationSeconds} + playerPlaybackRate={playerPlaybackRate} + playheadSeconds={timelinePanelPlayheadSeconds} + rows={filteredRows} + selectedTagIds={selectedTagIds} + sport={sportTableConfig.sport} + tagTypeRows={tagTypeRows} + playerLabelByNumber={timelinePlayerLabelByNumber} + /> + ) : ( + <SgEventTagsPanel + activeFilterLabel={ + rowFilterMode === "all" + ? "All rows" + : rowFilterMode === "favorites" + ? "Favorites only" + : "Selected rows" + } + activePlaybackOverrideId={activePlaybackOverrideId} + allVisibleSelected={allVisibleSelected} + availableGroups={availableGroups} + clipThumbnailUrl={activeVideo?.thumbnail || mediaItem?.thumbnail || playbackItem?.thumbnail || ""} + effectiveGroupValue={effectiveGroupValue} + favoriteTagIds={favoriteTagIds} + isCreatingPlaylist={isCreatingCustomPlaylist} + isMediaLoading={isTagRowsLoading} + isSearchOpen={isSearchOpen} + onListScrollStateChange={setIsTagListBodyScrolled} + onCreatePlaylist={() => void handleCreateCustomPlaylist(activePlaylistRows)} + onPlayTagRow={handlePlayTagRow} + onRemoveTag={handleRemoveTag} + onRowFilterModeChange={setRowFilterMode} + onSearchQueryChange={setSearchQuery} + onSelectAll={handleSelectAll} + onSelectedGroupValueChange={setSelectedGroupValue} + onToggleFavorite={handleToggleFavorite} + onToggleSearch={handleToggleSearch} + onToggleTagSelection={handleToggleTagSelection} + onUpdateTag={handleUpdateTag} + rowFilterMode={rowFilterMode} + rows={filteredRows} + searchQuery={searchQuery} + selectedTagIds={selectedTagIds} + showCreateActions={showTagListActions} + sportTableConfig={sportTableConfig} + /> + )} + </div> + </div> + )} + </div> + </div> + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/raw-tag-fields.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/raw-tag-fields.ts new file mode 100644 index 00000000000..c652c2760ca --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/raw-tag-fields.ts @@ -0,0 +1,48 @@ +const asRecord = (value: unknown): Record<string, unknown> => + value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}; + +const toFieldText = (value: unknown): string => { + if (typeof value === "string") return value.trim(); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (value && typeof value === "object" && !Array.isArray(value)) { + return toFieldText(asRecord(value).name); + } + return ""; +}; + +export const normalizeRawTagFieldKey = (value: unknown) => + toFieldText(value) + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +export const findExactRawTagFieldValue = (tag: Record<string, unknown>, names: readonly string[]) => { + const normalizedNames = new Set(names.map(normalizeRawTagFieldKey).filter(Boolean)); + + for (const [key, value] of Object.entries(tag)) { + if (!normalizedNames.has(normalizeRawTagFieldKey(key))) continue; + const directValue = toFieldText(value); + if (directValue) return directValue; + } + + const dataEntries = Array.isArray(tag.data) ? tag.data : []; + for (const entry of dataEntries) { + const entryRecord = asRecord(entry); + const entryKey = normalizeRawTagFieldKey( + entryRecord.tag ?? + entryRecord.field ?? + entryRecord.field_name ?? + entryRecord.fieldName ?? + entryRecord.name ?? + entryRecord.key + ); + if (!normalizedNames.has(entryKey)) continue; + const entryValue = toFieldText( + entryRecord.value ?? entryRecord.field_value ?? entryRecord.fieldValue ?? entryRecord.val + ); + if (entryValue) return entryValue; + } + + return ""; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/sg-event-video-player.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/sg-event-video-player.tsx new file mode 100644 index 00000000000..844922771c8 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/sg-event-video-player.tsx @@ -0,0 +1,768 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import videojs from "video.js"; +import { Check, Pencil } from "lucide-react"; +import { cn } from "@plane/utils"; +import { VideoAnnotationEditor } from "@/components/annotation"; +import type { TCustomPlaylistAnnotation } from "@/components/annotation"; +import { useResolvedMediaSources } from "ce/features/media-library/hooks/media-detail-hooks"; +import type { TMediaItem } from "ce/features/media-library/types/media-library.types"; +import { getQualitySelection, getVideoRepresentations } from "ce/features/media-library/utils/media-detail-utils"; +import { PLAYER_FRAME_CLASS, PLAYER_STAGE_CLASS, SG_PLAYER_STYLE } from "./constants"; + +type SgEventVideoPlayerProps = { + item: TMediaItem | null; + annotationItem?: TMediaItem | null; + compactEmpty?: boolean; + onPlaybackTimeChange?: ( + seconds: number, + durationSeconds: number | null, + playbackState?: { + isPlaying: boolean; + playbackRate: number; + } + ) => void; + onUpdateAnnotations?: (item: TMediaItem, annotations: TCustomPlaylistAnnotation[]) => Promise<TMediaItem | void>; + onOpenAnnotationPage?: () => void; + seekRequestId?: number; + seekToSeconds?: number | null; +}; + +type TQualityRepresentation = { + bandwidth?: number; + bitrate?: number; + enabled?: (enabled?: boolean) => boolean; + height?: number; + id?: string; +}; + +type TQualityOption = { + disabled?: boolean; + isAuto: boolean; + key: string; + label: string; + rep: TQualityRepresentation | null; + selected: boolean; +}; + +const HLS_MIME_TYPES = ["application/x-mpegURL", "application/vnd.apple.mpegurl"] as const; + +export const SgEventVideoPlayer = ({ + item, + annotationItem = null, + compactEmpty = false, + onPlaybackTimeChange, + onUpdateAnnotations, + onOpenAnnotationPage, + seekRequestId = 0, + seekToSeconds = null, +}: SgEventVideoPlayerProps) => { + const normalizedAction = (item?.action ?? "").toLowerCase(); + const documentFormat = (item?.format ?? "").toLowerCase(); + const meta = (item?.meta ?? {}) as Record<string, unknown>; + const isFiniteTagClip = + typeof item?.id === "string" && + item.id.startsWith("sg-tag-") && + typeof meta.playlistFileName === "string" && + Boolean(meta.playlistFileName.trim()); + const videoRef = useRef<HTMLVideoElement | null>(null); + const playerRef = useRef<ReturnType<typeof videojs> | null>(null); + const settingsPanelRef = useRef<HTMLDivElement | null>(null); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [playerTick, setPlayerTick] = useState(0); + const [qualitySelection, setQualitySelection] = useState<string | null>(null); + const [playerElement, setPlayerElement] = useState<HTMLElement | null>(null); + const [currentVideoSeconds, setCurrentVideoSeconds] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [, setIsVideoAnnotationMode] = useState(false); + const { effectiveVideoSrc, isVideo, resolvedVideoFormat, useCredentials, crossOrigin } = useResolvedMediaSources({ + documentFormat, + item, + meta, + normalizedAction, + }); + const effectiveAnnotationItem = annotationItem ?? item; + const canAnnotateVideo = Boolean( + effectiveAnnotationItem?.packageId && effectiveAnnotationItem.id && onOpenAnnotationPage + ); + + useEffect(() => { + setCurrentVideoSeconds(0); + setIsVideoAnnotationMode(false); + setIsSettingsOpen(false); + setQualitySelection(null); + }, [effectiveAnnotationItem?.id, item?.id]); + + useEffect(() => { + if (!isSettingsOpen) return; + + const handlePointer = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (!target) return; + if (settingsPanelRef.current?.contains(target)) return; + if (target.closest(".vjs-settings-button")) return; + setIsSettingsOpen(false); + }; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setIsSettingsOpen(false); + }; + + document.addEventListener("mousedown", handlePointer); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handlePointer); + document.removeEventListener("keydown", handleKey); + }; + }, [isSettingsOpen]); + + useEffect(() => { + if (!isVideo || !videoRef.current) { + if (playerRef.current) { + playerRef.current.dispose(); + playerRef.current = null; + } + return; + } + + if (!playerRef.current) { + const skipBackButtonName = "SgSkipBackButton"; + const skipForwardButtonName = "SgSkipForwardButton"; + const previousButtonName = "SgPreviousButton"; + const nextButtonName = "SgPlayButton"; + const loopButtonName = "SgLoopButton"; + const settingsButtonName = "SgSettingsButton"; + + if (!videojs.getComponent(previousButtonName)) { + const Button = videojs.getComponent("Button"); + // video.js exposes component classes through an untyped registry API. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const PreviousButton = class extends (Button as any) { + constructor(playerInstance: unknown, options: unknown) { + super(playerInstance, options); + this.controlText("Jump to start"); + this.addClass("vjs-previous-button"); + } + + handleClick() { + this.player()?.currentTime?.(0); + } + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + videojs.registerComponent(previousButtonName, PreviousButton as any); + } + + if (!videojs.getComponent(skipBackButtonName)) { + const Button = videojs.getComponent("Button"); + // video.js exposes component classes through an untyped registry API. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const SkipBackButton = class extends (Button as any) { + constructor(playerInstance: unknown, options: unknown) { + super(playerInstance, options); + this.controlText("Skip backward 10 seconds"); + this.addClass("vjs-skip-backward-button"); + } + + handleClick() { + const player = this.player(); + const currentTime = Number(player?.currentTime?.() ?? 0); + player?.currentTime?.(Math.max(0, currentTime - 10)); + } + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + videojs.registerComponent(skipBackButtonName, SkipBackButton as any); + } + + if (!videojs.getComponent(skipForwardButtonName)) { + const Button = videojs.getComponent("Button"); + // video.js exposes component classes through an untyped registry API. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const SkipForwardButton = class extends (Button as any) { + constructor(playerInstance: unknown, options: unknown) { + super(playerInstance, options); + this.controlText("Skip forward 10 seconds"); + this.addClass("vjs-skip-forward-button"); + } + + handleClick() { + const player = this.player(); + const currentTime = Number(player?.currentTime?.() ?? 0); + const duration = Number(player?.duration?.() ?? 0); + const nextTime = duration > 0 ? Math.min(duration, currentTime + 10) : currentTime + 10; + player?.currentTime?.(nextTime); + } + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + videojs.registerComponent(skipForwardButtonName, SkipForwardButton as any); + } + + if (!videojs.getComponent(nextButtonName)) { + const Button = videojs.getComponent("Button"); + // video.js exposes component classes through an untyped registry API. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const NextButton = class extends (Button as any) { + constructor(playerInstance: unknown, options: unknown) { + super(playerInstance, options); + this.controlText("Play"); + this.addClass("vjs-next-button"); + } + + handleClick() { + const player = this.player(); + const duration = Number(player?.duration?.() ?? 0); + if (Number.isFinite(duration) && duration > 0) { + player?.currentTime?.(Math.max(0, duration - 0.01)); + } + } + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + videojs.registerComponent(nextButtonName, NextButton as any); + } + + if (!videojs.getComponent(loopButtonName)) { + const Button = videojs.getComponent("Button"); + // video.js exposes component classes through an untyped registry API. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const LoopButton = class extends (Button as any) { + constructor(playerInstance: unknown, options: unknown) { + super(playerInstance, options); + this.controlText("Toggle loop"); + this.addClass("vjs-loop-button"); + this.addClass("vjs-control-active"); + } + + handleClick() { + const player = this.player(); + const nextLoopState = !Boolean(player?.loop?.()); + player?.loop?.(nextLoopState); + this.toggleClass("vjs-control-active", nextLoopState); + } + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + videojs.registerComponent(loopButtonName, LoopButton as any); + } + + if (!videojs.getComponent(settingsButtonName)) { + const Button = videojs.getComponent("Button"); + // video.js exposes component classes through an untyped registry API. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const SettingsButton = class extends (Button as any) { + constructor(playerInstance: unknown, options: unknown) { + super(playerInstance, options); + this.controlText("Player settings"); + this.addClass("vjs-settings-button"); + } + + handleClick() { + const player = this.player(); + player?.trigger?.("sgsettingstoggle"); + } + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + videojs.registerComponent(settingsButtonName, SettingsButton as any); + } + + playerRef.current = videojs(videoRef.current, { + autoplay: false, + controls: true, + crossOrigin, + fluid: false, + html5: { + nativeAudioTracks: false, + nativeVideoTracks: false, + nativeTextTracks: false, + vhs: { + overrideNative: true, + withCredentials: useCredentials, + }, + }, + muted: false, + playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2], + playsinline: true, + preload: "auto", + responsive: true, + controlBar: { + children: [ + "currentTimeDisplay", + "progressControl", + "durationDisplay", + "volumePanel", + previousButtonName, + skipBackButtonName, + "playToggle", + skipForwardButtonName, + nextButtonName, + loopButtonName, + "subsCapsButton", + "pictureInPictureToggle", + "fullscreenToggle", + settingsButtonName, + ], + }, + }); + playerRef.current.loop(true); + videoRef.current.removeAttribute("loop"); + setPlayerElement((playerRef.current.el?.() as HTMLElement | null | undefined) ?? null); + + const handleQualityStateChange = () => setPlayerTick((currentValue) => currentValue + 1); + playerRef.current.on("loadedmetadata", handleQualityStateChange); + playerRef.current.on("loadeddata", handleQualityStateChange); + playerRef.current.on("canplay", handleQualityStateChange); + playerRef.current.on("qualitychange", handleQualityStateChange); + playerRef.current.on("sgsettingstoggle", () => { + setIsSettingsOpen((currentValue) => !currentValue); + }); + } + + return () => { + if (playerRef.current) { + playerRef.current.dispose(); + playerRef.current = null; + } + setPlayerElement(null); + }; + }, [crossOrigin, isVideo, useCredentials]); + + useEffect(() => { + if (!playerRef.current || !effectiveVideoSrc) return; + const player = playerRef.current; + const type = + resolvedVideoFormat === "m3u8" + ? "application/x-mpegURL" + : resolvedVideoFormat === "mp4" + ? "video/mp4" + : undefined; + const resolvedSource = + effectiveVideoSrc || + (typeof item?.videoSrc === "string" && item.videoSrc.trim()) || + (typeof item?.fileSrc === "string" && item.fileSrc.trim()) || + ""; + const sourceCandidates = + resolvedVideoFormat === "m3u8" + ? HLS_MIME_TYPES.map((hlsType) => ({ + crossOrigin, + src: resolvedSource, + type: hlsType, + withCredentials: useCredentials, + })) + : [ + { + crossOrigin, + src: effectiveVideoSrc, + type, + withCredentials: useCredentials, + }, + ]; + let candidateIndex = 0; + let startupTimer: ReturnType<typeof setTimeout> | null = null; + + const clearStartupTimer = () => { + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } + }; + + const handleLoadedData = () => { + clearStartupTimer(); + }; + + const applyCandidate = (nextIndex: number) => { + const candidate = sourceCandidates[nextIndex]; + + if (!candidate) { + return; + } + + const techElement = player.el()?.querySelector("video"); + if (techElement instanceof HTMLVideoElement) { + if (candidate.crossOrigin) { + techElement.crossOrigin = candidate.crossOrigin; + } else { + techElement.removeAttribute("crossorigin"); + } + } + + player.src(candidate.type ? { src: candidate.src, type: candidate.type } : { src: candidate.src }); + player.poster(item?.thumbnail ?? ""); + player.load(); + + const playAttempt = player.play(); + if (playAttempt && typeof playAttempt.catch === "function") { + void playAttempt.catch(() => { + // Ignore autoplay failures and let the user start playback manually. + }); + } + + clearStartupTimer(); + startupTimer = setTimeout(() => { + const readyState = Number(player.readyState?.() ?? 0); + const seekable = Number(player.seekable?.().length ?? 0); + const buffered = Number(player.buffered?.().length ?? 0); + const duration = Number(player.duration?.() ?? 0); + const hasStarted = + readyState >= 1 || seekable > 0 || buffered > 0 || (Number.isFinite(duration) && duration > 0); + + if (!hasStarted) { + handlePlayerError(); + } + }, 8000); + }; + + const handlePlayerError = () => { + clearStartupTimer(); + const nextIndex = candidateIndex + 1; + + if (nextIndex >= sourceCandidates.length) { + return; + } + + candidateIndex = nextIndex; + applyCandidate(candidateIndex); + }; + + player.on("error", handlePlayerError); + player.on("loadeddata", handleLoadedData); + applyCandidate(candidateIndex); + + return () => { + clearStartupTimer(); + player.off("error", handlePlayerError); + player.off("loadeddata", handleLoadedData); + }; + }, [ + crossOrigin, + effectiveVideoSrc, + item?.fileSrc, + item?.thumbnail, + item?.videoSrc, + resolvedVideoFormat, + useCredentials, + ]); + + useEffect(() => { + const player = playerRef.current; + if (!player || !onPlaybackTimeChange) return; + + const emitPlaybackTimeChange = (isPlayingOverride?: boolean) => { + const currentTime = Number(player.currentTime?.() ?? 0); + const duration = Number(player.duration?.() ?? 0); + const playbackRate = Number(player.playbackRate?.() ?? 1); + const explicitIsPlaying = typeof isPlayingOverride === "boolean" ? isPlayingOverride : undefined; + const isPlaying = + explicitIsPlaying ?? + (!Boolean(player.paused?.()) && !Boolean(player.ended?.()) && Number(player.readyState?.() ?? 0) > 0); + setIsPlaying(isPlaying); + + onPlaybackTimeChange( + Number.isFinite(currentTime) ? currentTime : 0, + Number.isFinite(duration) && duration > 0 ? duration : null, + { + isPlaying, + playbackRate: Number.isFinite(playbackRate) && playbackRate > 0 ? playbackRate : 1, + } + ); + }; + const handlePlaybackTimeChange = () => emitPlaybackTimeChange(); + const handlePlaybackActive = () => emitPlaybackTimeChange(true); + const handlePlaybackInactive = () => emitPlaybackTimeChange(false); + const handleSeeked = () => emitPlaybackTimeChange(!Boolean(player.paused?.()) && !Boolean(player.ended?.())); + + player.on("durationchange", handlePlaybackTimeChange); + player.on("ended", handlePlaybackInactive); + player.on("loadedmetadata", handlePlaybackTimeChange); + player.on("pause", handlePlaybackInactive); + player.on("play", handlePlaybackActive); + player.on("playing", handlePlaybackActive); + player.on("ratechange", handlePlaybackTimeChange); + player.on("seeked", handleSeeked); + player.on("seeking", handlePlaybackInactive); + player.on("stalled", handlePlaybackInactive); + player.on("timeupdate", handlePlaybackTimeChange); + player.on("waiting", handlePlaybackInactive); + handlePlaybackTimeChange(); + + return () => { + player.off("durationchange", handlePlaybackTimeChange); + player.off("ended", handlePlaybackInactive); + player.off("loadedmetadata", handlePlaybackTimeChange); + player.off("pause", handlePlaybackInactive); + player.off("play", handlePlaybackActive); + player.off("playing", handlePlaybackActive); + player.off("ratechange", handlePlaybackTimeChange); + player.off("seeked", handleSeeked); + player.off("seeking", handlePlaybackInactive); + player.off("stalled", handlePlaybackInactive); + player.off("timeupdate", handlePlaybackTimeChange); + player.off("waiting", handlePlaybackInactive); + }; + }, [effectiveVideoSrc, item?.id, onPlaybackTimeChange]); + + useEffect(() => { + const player = playerRef.current; + if (!player || !isVideo) return; + + const updateVideoTime = () => { + const currentTime = Number(player.currentTime?.() ?? 0); + setCurrentVideoSeconds(Number.isFinite(currentTime) && currentTime > 0 ? currentTime : 0); + }; + const playerEvents = ["durationchange", "loadedmetadata", "seeked", "seeking", "timeupdate"]; + + playerEvents.forEach((eventName) => player.on(eventName, updateVideoTime)); + updateVideoTime(); + + return () => { + playerEvents.forEach((eventName) => player.off(eventName, updateVideoTime)); + }; + }, [effectiveVideoSrc, isVideo, item?.id]); + + useEffect(() => { + const player = playerRef.current; + if (!player || !isFiniteTagClip) return; + + const handleEnded = () => { + const duration = Number(player.duration?.() ?? 0); + player.pause(); + if (Number.isFinite(duration) && duration > 0) { + try { + player.currentTime(Math.max(0, duration - 0.01)); + } catch { + // Leave the player paused at the end if seeking is unavailable. + } + } + }; + + player.on("ended", handleEnded); + return () => { + player.off("ended", handleEnded); + }; + }, [isFiniteTagClip, item?.id]); + + useEffect(() => { + const player = playerRef.current; + if (!player || seekToSeconds == null || seekToSeconds < 0) return; + + const seekAndPlay = () => { + try { + player.currentTime(seekToSeconds); + } catch { + return; + } + + const playAttempt = player.play(); + if (playAttempt && typeof playAttempt.catch === "function") { + void playAttempt.catch(() => { + // Leave the current frame visible if autoplay is blocked. + }); + } + }; + + if (player.readyState() >= 1) { + seekAndPlay(); + return; + } + + player.one("loadeddata", seekAndPlay); + + return () => { + player.off("loadeddata", seekAndPlay); + }; + }, [effectiveVideoSrc, item?.id, seekRequestId, seekToSeconds]); + + const qualityOptions = useMemo(() => { + const qualityRefreshKey = playerTick; + void qualityRefreshKey; + const player = playerRef.current; + if (!player) { + return [{ key: "auto", label: "Auto", isAuto: true, selected: true, rep: null, disabled: true }]; + } + + const reps = getVideoRepresentations(player) as TQualityRepresentation[]; + if (!reps.length) { + return [{ key: "auto", label: "Auto", isAuto: true, selected: true, rep: null, disabled: true }]; + } + + const { isAuto, activeRep } = getQualitySelection(reps); + const sorted = reps + .map((rep, index) => ({ + rep, + height: rep?.height ?? 0, + bandwidth: rep?.bandwidth ?? rep?.bitrate ?? 0, + index, + })) + .sort((left, right) => { + if (left.height !== right.height) return right.height - left.height; + if (left.bandwidth !== right.bandwidth) return right.bandwidth - left.bandwidth; + return left.index - right.index; + }); + const fallbackSelected = qualitySelection === null ? (isAuto ? "auto" : null) : qualitySelection; + const options: TQualityOption[] = []; + + if (sorted.length > 1) { + options.push({ + key: "auto", + label: "Auto", + isAuto: true, + selected: fallbackSelected === "auto" || (qualitySelection === null && isAuto), + rep: null, + }); + } + + sorted.forEach(({ rep, height, bandwidth }) => { + const label = height ? `${height}p` : bandwidth ? `${Math.round(bandwidth / 1000)} kbps` : "Source"; + const key = `${label}-${bandwidth}-${height}-${rep?.id ?? ""}`; + options.push({ + key, + label, + isAuto: false, + selected: qualitySelection === key || (qualitySelection === null && !isAuto && activeRep === rep), + rep, + }); + }); + + if (sorted.length === 1 && !options.some((option) => option.selected)) { + options[0].selected = true; + } + + return options; + }, [playerTick, qualitySelection]); + + const handleQualitySelect = useCallback((option: TQualityOption) => { + if (option.disabled) return; + const player = playerRef.current; + if (!player) return; + const reps = getVideoRepresentations(player) as TQualityRepresentation[]; + if (!reps.length) return; + + if (option.isAuto) { + reps.forEach((rep) => rep?.enabled?.(true)); + setQualitySelection("auto"); + } else { + reps.forEach((rep) => rep?.enabled?.(rep === option.rep)); + if (option.key) setQualitySelection(option.key); + } + + player.trigger("qualitychange"); + setPlayerTick((currentValue) => currentValue + 1); + }, []); + const handleAnnotationPause = useCallback(() => { + const player = playerRef.current; + player?.pause?.(); + }, []); + const handleAnnotationModeChange = useCallback((enabled: boolean) => { + setIsVideoAnnotationMode(enabled); + + const player = playerRef.current; + player?.controls?.(true); + }, []); + const handleSaveVideoAnnotations = useCallback( + async (annotations: TCustomPlaylistAnnotation[]) => { + if (!effectiveAnnotationItem || !onUpdateAnnotations) return annotations; + + const updatedItem = await onUpdateAnnotations(effectiveAnnotationItem, annotations); + return (updatedItem?.meta?.annotations as TCustomPlaylistAnnotation[] | undefined) ?? annotations; + }, + [effectiveAnnotationItem, onUpdateAnnotations] + ); + const settingsPanelContent = isSettingsOpen ? ( + <div + ref={settingsPanelRef} + className="sg-event-settings-panel" + role="dialog" + aria-label="Player settings" + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + <div className="sg-event-settings-title">Quality</div> + <div className="sg-event-settings-options" role="menu" aria-label="Video quality"> + {qualityOptions.map((option) => ( + <button + key={option.key} + type="button" + role="menuitemradio" + aria-checked={option.selected} + disabled={option.disabled} + onClick={() => { + handleQualitySelect(option); + if (!option.disabled) setIsSettingsOpen(false); + }} + className={cn("sg-event-settings-option", option.selected && "is-active", option.disabled && "is-disabled")} + > + <span className="sg-event-settings-check" aria-hidden="true"> + <Check className="h-3.5 w-3.5" /> + </span> + <span className="sg-event-settings-label">{option.label}</span> + </button> + ))} + </div> + </div> + ) : null; + const videoAnnotationContent = effectiveAnnotationItem ? ( + <VideoAnnotationEditor + annotationKey={`${effectiveAnnotationItem.packageId ?? "event"}:${effectiveAnnotationItem.id}`} + annotations={effectiveAnnotationItem.meta?.annotations} + canEdit={false} + currentTime={currentVideoSeconds} + isPlaying={isPlaying} + modeResetKey={`${effectiveAnnotationItem.id}:view`} + onModeChange={handleAnnotationModeChange} + onRequestPause={handleAnnotationPause} + onSave={handleSaveVideoAnnotations} + thumbnailUrl={item?.thumbnail || effectiveAnnotationItem.thumbnail} + /> + ) : null; + const playerLayerContent = ( + <> + {videoAnnotationContent} + {settingsPanelContent} + {canAnnotateVideo ? ( + <button + type="button" + onClick={onOpenAnnotationPage} + className="sg-event-annotation-button" + aria-label="Open annotation editor" + title="Open annotation editor" + > + <Pencil className="h-4 w-4 shrink-0" /> + <span className="whitespace-nowrap leading-none">Annotate</span> + </button> + ) : null} + </> + ); + + if (!item || !isVideo) { + return ( + <div + className={cn( + "flex items-center justify-center rounded-xl border border-custom-border-200 bg-custom-background-90 text-sm text-custom-text-300", + compactEmpty ? "min-h-[180px] px-6 py-8 lg:min-h-[220px]" : `${PLAYER_FRAME_CLASS} px-6 py-8` + )} + > + <div className="flex max-w-md flex-col items-center text-center"> + <div className="text-sm font-medium text-custom-text-200">No SG video available</div> + <div className="mt-1 text-xs text-custom-text-400"> + This event has metadata and tags, but no playable video source is linked yet. + </div> + </div> + </div> + ); + } + + return ( + <div + className={cn( + "flex items-center justify-center overflow-hidden rounded-[5px] bg-[var(--sg-matrix-video-bg)]", + PLAYER_FRAME_CLASS + )} + > + <div className={cn("sg-event-player relative", PLAYER_STAGE_CLASS)}> + <style jsx global> + {SG_PLAYER_STYLE} + </style> + <video ref={videoRef} className="video-js vjs-big-play-centered" playsInline loop /> + {playerElement ? createPortal(playerLayerContent, playerElement) : playerLayerContent} + </div> + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/edit-tag-row-modal.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/edit-tag-row-modal.tsx new file mode 100644 index 00000000000..8af2b681f06 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/edit-tag-row-modal.tsx @@ -0,0 +1,82 @@ +import { X } from "lucide-react"; +import { EModalPosition, EModalWidth, Input, ModalCore } from "@plane/ui"; +import type { SgTagRow, SgTagRowEditPayload } from "../../types"; + +const EDITABLE_TAG_FIELDS: Array<{ key: keyof SgTagRowEditPayload; label: string; placeholder: string }> = [ + { key: "player", label: "Player", placeholder: "Player" }, + { key: "groupValue", label: "Group", placeholder: "Group" }, + { key: "action", label: "Action", placeholder: "Action" }, + { key: "primaryDetail", label: "Primary detail", placeholder: "Primary detail" }, + { key: "secondaryDetail", label: "Secondary detail", placeholder: "Secondary detail" }, + { key: "result", label: "Result", placeholder: "Result" }, + { key: "team", label: "Team", placeholder: "Team" }, + { key: "timecode", label: "Timecode", placeholder: "00:00-00:05" }, +]; + +type EditTagRowModalProps = { + draft: SgTagRowEditPayload; + isOpen: boolean; + onChange: (key: keyof SgTagRowEditPayload, value: string) => void; + onClose: () => void; + onSubmit: () => void; + row: SgTagRow | null; +}; + +export const EditTagRowModal = ({ draft, isOpen, onChange, onClose, onSubmit, row }: EditTagRowModalProps) => ( + <ModalCore isOpen={isOpen} handleClose={onClose} position={EModalPosition.TOP} width={EModalWidth.XXL}> + <div className="border-b border-custom-border-200 px-5 py-4"> + <div className="flex items-start justify-between gap-4"> + <div className="min-w-0"> + <h3 className="text-lg font-semibold text-custom-text-100">Edit tag row</h3> + <p className="mt-1 truncate text-sm text-custom-text-300"> + {row ? `${row.action || "Tag"} · ${row.timecode || "No timecode"}` : "Update row details"} + </p> + </div> + <button + type="button" + onClick={onClose} + className="rounded-md p-1.5 text-custom-text-400 transition-colors hover:bg-custom-background-90 hover:text-custom-text-200" + aria-label="Close edit tag row modal" + > + <X className="h-4 w-4" /> + </button> + </div> + </div> + <form + onSubmit={(event) => { + event.preventDefault(); + onSubmit(); + }} + > + <div className="grid gap-4 p-5 md:grid-cols-2"> + {EDITABLE_TAG_FIELDS.map((field) => ( + <div key={field.key} className="space-y-2"> + <label className="text-xs font-medium uppercase tracking-wide text-custom-text-400">{field.label}</label> + <Input + value={draft[field.key]} + onChange={(event) => onChange(field.key, event.target.value)} + placeholder={field.placeholder} + className="w-full border-custom-border-200 bg-custom-background-100" + autoFocus={field.key === "player"} + /> + </div> + ))} + </div> + <div className="flex items-center justify-end gap-2 border-t border-custom-border-200 px-5 py-4"> + <button + type="button" + onClick={onClose} + className="inline-flex h-8 items-center rounded-md border border-custom-border-200 bg-custom-background-100 px-3 text-sm font-medium text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100" + > + Cancel + </button> + <button + type="submit" + className="inline-flex h-8 items-center rounded-md bg-custom-primary-100 px-3 text-sm font-medium text-white transition-colors hover:bg-custom-primary-200" + > + Save changes + </button> + </div> + </form> + </ModalCore> +); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/tags-columns-panel.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/tags-columns-panel.tsx new file mode 100644 index 00000000000..118a1e592ec --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/tags-columns-panel.tsx @@ -0,0 +1,162 @@ +import type { Dispatch, SetStateAction } from "react"; +import { ChevronDown, Search, X } from "lucide-react"; +import { cn } from "@plane/utils"; +import type { SgTagColumn, SgTagColumnGroup } from "../utils/tags-panel-model"; + +export type TagsColumnGroup = { + columns: SgTagColumn[]; + name: SgTagColumnGroup; +}; + +type TagsColumnsPanelProps = { + collapsedColumnGroups: Record<string, boolean>; + columnDefinitions: SgTagColumn[]; + columnGroups: TagsColumnGroup[]; + columnSearchQuery: string; + isOpen: boolean; + onClose: () => void; + onCollapsedColumnGroupsChange: Dispatch<SetStateAction<Record<string, boolean>>>; + onColumnSearchQueryChange: (value: string) => void; + onVisibleColumnKeysChange: Dispatch<SetStateAction<string[]>>; + selectedAvailableColumnCount: number; + totalColumnCount: number; + visibleColumnKeys: string[]; +}; + +export const TagsColumnsPanel = ({ + collapsedColumnGroups, + columnDefinitions, + columnGroups, + columnSearchQuery, + isOpen, + onClose, + onCollapsedColumnGroupsChange, + onColumnSearchQueryChange, + onVisibleColumnKeysChange, + selectedAvailableColumnCount, + totalColumnCount, + visibleColumnKeys, +}: TagsColumnsPanelProps) => { + if (!isOpen) return null; + + return ( + <div className="fixed inset-0 z-30 flex justify-end bg-black/50" role="presentation" onClick={onClose}> + <aside + aria-label="Columns" + aria-modal="true" + className="flex h-full w-full max-w-[340px] flex-col border-l border-custom-border-200 bg-custom-background-100 shadow-xl" + role="dialog" + onClick={(event) => event.stopPropagation()} + > + <div className="border-b border-custom-border-200 px-4 py-4"> + <div className="mb-3 flex items-center justify-between gap-3"> + <div className="min-w-0"> + <h3 className="text-sm font-semibold text-custom-text-100">Columns</h3> + <p className="mt-0.5 text-xs text-custom-text-400"> + {selectedAvailableColumnCount} of {totalColumnCount} shown + </p> + </div> + <button + type="button" + onClick={onClose} + className="inline-flex h-8 w-8 items-center justify-center rounded-md text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100" + > + <X className="h-4 w-4" /> + </button> + </div> + <label className="flex h-9 items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-3 text-sm text-custom-text-300"> + <Search className="h-4 w-4" /> + <input + value={columnSearchQuery} + onChange={(event) => onColumnSearchQueryChange(event.target.value)} + placeholder="Search columns" + className="min-w-0 flex-1 bg-transparent text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400" + /> + </label> + </div> + + <div className="flex gap-3 border-b border-custom-border-200 px-4 py-2.5"> + <button + type="button" + onClick={() => onVisibleColumnKeysChange(columnDefinitions.map((column) => column.key))} + className="text-xs font-medium text-custom-primary-100 hover:underline" + > + Select all + </button> + <button + type="button" + onClick={() => onVisibleColumnKeysChange([])} + className="text-xs font-medium text-custom-primary-100 hover:underline" + > + Clear all + </button> + <button + type="button" + onClick={() => + onVisibleColumnKeysChange( + columnDefinitions.filter((column) => column.isDefaultVisible).map((column) => column.key) + ) + } + className="text-xs font-medium text-custom-primary-100 hover:underline" + > + Reset + </button> + </div> + + <div className="vertical-scrollbar scrollbar-md min-h-0 flex-1 overflow-y-auto px-2 py-2"> + {columnGroups.length === 0 ? ( + <div className="px-3 py-8 text-center text-sm text-custom-text-400">No matching columns.</div> + ) : ( + columnGroups.map((group) => { + const isCollapsed = Boolean(collapsedColumnGroups[group.name]); + + return ( + <div key={group.name} className="mb-1"> + <button + type="button" + onClick={() => + onCollapsedColumnGroupsChange((currentValue) => ({ + ...currentValue, + [group.name]: !currentValue[group.name], + })) + } + className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-custom-text-400 transition-colors hover:bg-custom-background-90" + > + <ChevronDown className={cn("h-3.5 w-3.5 transition-transform", isCollapsed && "-rotate-90")} /> + <span>{group.name}</span> + </button> + {!isCollapsed && ( + <div className="flex flex-col"> + {group.columns.map((column) => ( + <label + key={column.key} + className="flex cursor-pointer items-center gap-2 rounded-md px-7 py-1.5 text-sm text-custom-text-200 transition-colors hover:bg-custom-background-90" + > + <input + type="checkbox" + checked={visibleColumnKeys.includes(column.key)} + onChange={() => + onVisibleColumnKeysChange((currentValue) => + currentValue.includes(column.key) + ? currentValue.filter((key) => key !== column.key) + : [...currentValue, column.key] + ) + } + className="h-4 w-4 rounded border-custom-border-200 accent-custom-primary-100" + /> + <span className="min-w-0 flex-1 truncate" title={column.label}> + {column.label} + </span> + </label> + ))} + </div> + )} + </div> + ); + }) + )} + </div> + </aside> + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/tags-panel-toolbar.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/tags-panel-toolbar.tsx new file mode 100644 index 00000000000..532a47111d8 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/tags-panel-toolbar.tsx @@ -0,0 +1,176 @@ +import { Check, Columns3, ListPlus, Plus, Search } from "lucide-react"; +import { Tooltip } from "@plane/propel/tooltip"; +import { CustomMenu, CustomSelect } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { ICON_BUTTON_CLASS, ROW_FILTER_LABELS } from "../../constants"; +import type { RowFilterMode } from "../../types"; + +const TEXT_BUTTON_CLASS = + "inline-flex h-9 items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-100 px-3 text-xs font-medium text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100"; +const PRIMARY_TEXT_BUTTON_CLASS = + "inline-flex h-9 items-center gap-2 rounded-md border border-custom-primary-100 bg-custom-primary-100 px-3 text-xs font-medium text-white transition-colors hover:border-custom-primary-200 hover:bg-custom-primary-200"; + +const TagsFilterIcon = ({ className }: { className?: string }) => ( + <svg xmlns="http://www.w3.org/2000/svg" width="12" height="11" viewBox="0 0 12 11" fill="none" className={className}> + <path + d="M11.5 5.49971H4.15378M1.56076 5.49971H0.5M1.56076 5.49971C1.56076 5.17074 1.69732 4.85524 1.94041 4.62262C2.1835 4.39 2.5132 4.25932 2.85697 4.25932C3.20075 4.25932 3.53045 4.39 3.77354 4.62262C4.01662 4.85524 4.15319 5.17074 4.15319 5.49971C4.15319 5.82869 4.01662 6.14419 3.77354 6.37681C3.53045 6.60943 3.20075 6.74011 2.85697 6.74011C2.5132 6.74011 2.1835 6.60943 1.94041 6.37681C1.69732 6.14419 1.56076 5.82869 1.56076 5.49971ZM11.5 9.25903H8.08227M8.08227 9.25903C8.08227 9.58808 7.94538 9.90394 7.70223 10.1366C7.45909 10.3693 7.12932 10.5 6.78546 10.5C6.44168 10.5 6.11198 10.3687 5.8689 10.1361C5.62581 9.90351 5.48924 9.58801 5.48924 9.25903M8.08227 9.25903C8.08227 8.92998 7.94538 8.61469 7.70223 8.38202C7.45909 8.14935 7.12932 8.01863 6.78546 8.01863C6.44168 8.01863 6.11198 8.14932 5.8689 8.38194C5.62581 8.61456 5.48924 8.93006 5.48924 9.25903M5.48924 9.25903H0.5M11.5 1.7404H9.65378M7.06076 1.7404H0.5M7.06076 1.7404C7.06076 1.41142 7.19732 1.09592 7.44041 0.863304C7.6835 0.630684 8.01319 0.5 8.35697 0.5C8.52719 0.5 8.69575 0.532084 8.85301 0.59442C9.01028 0.656756 9.15317 0.748123 9.27354 0.863304C9.3939 0.978486 9.48938 1.11523 9.55452 1.26572C9.61966 1.41621 9.65319 1.57751 9.65319 1.7404C9.65319 1.90329 9.61966 2.06459 9.55452 2.21508C9.48938 2.36557 9.3939 2.50231 9.27354 2.61749C9.15317 2.73267 9.01028 2.82404 8.85301 2.88638C8.69575 2.94871 8.52719 2.9808 8.35697 2.9808C8.01319 2.9808 7.6835 2.85011 7.44041 2.61749C7.19732 2.38487 7.06076 2.06937 7.06076 1.7404Z" + stroke="currentColor" + strokeMiterlimit="10" + strokeLinecap="round" + /> + </svg> +); + +type TagsPanelToolbarProps = { + activeFilterLabel: string; + availableGroups: string[]; + defaultGroupValue: string; + effectiveGroupValue: string; + groupSelectLabel: string; + isColumnsPanelOpen: boolean; + isCreatingPlaylist: boolean; + isSearchOpen: boolean; + onColumnsPanelOpen: () => void; + onCreatePlaylist?: () => void; + onRowFilterModeChange: (mode: RowFilterMode) => void; + onSearchQueryChange: (value: string) => void; + onSelectedGroupValueChange: (value: string) => void; + onToggleSearch: () => void; + rowFilterMode: RowFilterMode; + searchQuery: string; + selectedCount: number; + selectedAvailableColumnCount: number; + showCreateActions: boolean; + totalColumnCount: number; +}; + +export const TagsPanelToolbar = ({ + activeFilterLabel, + availableGroups, + defaultGroupValue, + effectiveGroupValue, + groupSelectLabel, + isColumnsPanelOpen, + isCreatingPlaylist, + isSearchOpen, + onColumnsPanelOpen, + onCreatePlaylist, + onRowFilterModeChange, + onSearchQueryChange, + onSelectedGroupValueChange, + onToggleSearch, + rowFilterMode, + searchQuery, + selectedCount, + selectedAvailableColumnCount, + showCreateActions, + totalColumnCount, +}: TagsPanelToolbarProps) => ( + <div className="flex flex-col gap-3 border-b border-custom-border-200 px-3 py-3 lg:flex-row lg:items-center lg:justify-between"> + <div className="flex flex-wrap items-center gap-3"> + <span className="text-sm font-medium text-custom-text-100">Group by :</span> + <CustomSelect + value={effectiveGroupValue} + onChange={(value: string) => onSelectedGroupValueChange(value)} + label={<span className="truncate">{groupSelectLabel}</span>} + placement="bottom-start" + className="h-8" + buttonClassName="h-8 min-w-[112px] rounded-md border border-custom-border-200 bg-custom-background-100 px-3 py-1.5 text-xs text-custom-text-300 hover:bg-custom-background-90" + optionsClassName="min-w-[140px]" + > + <CustomSelect.Option value="All tags"> + <span className="text-sm">All clips</span> + </CustomSelect.Option> + {(availableGroups.length > 0 ? availableGroups : [defaultGroupValue]).map((groupValue) => ( + <CustomSelect.Option key={groupValue} value={groupValue}> + <span className="text-sm">{groupValue}</span> + </CustomSelect.Option> + ))} + </CustomSelect> + </div> + + <div className="flex flex-wrap items-center justify-end gap-2"> + {showCreateActions && ( + <button type="button" className={TEXT_BUTTON_CLASS}> + <Plus className="h-3.5 w-3.5" /> + <span>Create Card</span> + </button> + )} + {isSearchOpen && ( + <label className="flex h-9 items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-100 px-3 text-sm text-custom-text-300"> + <Search className="h-4 w-4" /> + <input + value={searchQuery} + onChange={(event) => onSearchQueryChange(event.target.value)} + placeholder="Search" + className="w-32 bg-transparent text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400" + /> + </label> + )} + <Tooltip tooltipContent={isSearchOpen ? "Hide search" : "Search"} isMobile={false}> + <button type="button" onClick={onToggleSearch} className={ICON_BUTTON_CLASS}> + <Search className="h-4 w-4" /> + </button> + </Tooltip> + <CustomMenu + placement="bottom-end" + closeOnSelect + customButton={ + <Tooltip tooltipContent={`Filter: ${activeFilterLabel}`} isMobile={false}> + <button + type="button" + className={cn( + "inline-flex h-9 w-9 items-center justify-center rounded-md border transition-colors", + rowFilterMode !== "all" + ? "border-custom-primary-100/30 bg-custom-primary-100/15 text-custom-primary-100" + : "border-custom-border-200 bg-custom-background-100 text-custom-text-300 hover:bg-custom-background-90 hover:text-custom-text-100" + )} + > + <TagsFilterIcon className="h-4 w-4" /> + </button> + </Tooltip> + } + > + {(Object.keys(ROW_FILTER_LABELS) as RowFilterMode[]).map((mode) => ( + <CustomMenu.MenuItem + key={mode} + className="flex items-center justify-between gap-2" + onClick={() => onRowFilterModeChange(mode)} + > + {ROW_FILTER_LABELS[mode]} + {rowFilterMode === mode && <Check className="h-3 w-3" />} + </CustomMenu.MenuItem> + ))} + </CustomMenu> + <Tooltip tooltipContent="Columns" isMobile={false}> + <button + type="button" + onClick={onColumnsPanelOpen} + className={cn( + "inline-flex h-9 items-center gap-2 rounded-md border px-3 text-xs font-medium transition-colors", + isColumnsPanelOpen + ? "border-custom-primary-100/30 bg-custom-primary-100/15 text-custom-primary-100" + : "border-custom-border-200 bg-custom-background-100 text-custom-text-300 hover:bg-custom-background-90 hover:text-custom-text-100" + )} + > + <Columns3 className="h-4 w-4" /> + <span>Columns</span> + <span className="text-custom-text-400"> + {selectedAvailableColumnCount}/{totalColumnCount} + </span> + </button> + </Tooltip> + {onCreatePlaylist && ( + <button + type="button" + disabled={selectedCount === 0 || isCreatingPlaylist} + onClick={onCreatePlaylist} + className={`${PRIMARY_TEXT_BUTTON_CLASS} disabled:cursor-not-allowed disabled:opacity-45`} + > + <ListPlus className="h-3.5 w-3.5" /> + <span>{isCreatingPlaylist ? "Creating" : "Create Playlist"}</span> + </button> + )} + </div> + </div> +); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/tags-panel.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/tags-panel.tsx new file mode 100644 index 00000000000..743acb9065e --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/components/tags-panel.tsx @@ -0,0 +1,520 @@ +import { useEffect, useMemo, useState } from "react"; +import { Check, Pencil, Star, Trash2 } from "lucide-react"; +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; +import { SURFACE_CLASS } from "../../constants"; +import type { RowFilterMode, SgTagRow, SgTagRowEditPayload, SportTableConfig } from "../../types"; +import { + buildEditDraft, + COLUMN_GROUP_ORDER, + DEFAULT_VISIBLE_COLUMN_KEYS, + displayCellValue, + formatColumnLabel, + getClipDuration, + getContextColumnKey, + getContextKeyFromColumnKey, + getDisplayTimecode, + getRawTagColumnValue, + getSportLabel, + STANDARD_RAW_TAG_COLUMNS, + STANDARD_RAW_TAG_CONTEXT_KEYS, +} from "../utils/tags-panel-model"; +import type { SgTagColumn } from "../utils/tags-panel-model"; +import { EditTagRowModal } from "./edit-tag-row-modal"; +import { TagsColumnsPanel } from "./tags-columns-panel"; +import { TagsPanelToolbar } from "./tags-panel-toolbar"; + +type SgEventTagsPanelProps = { + activeFilterLabel: string; + activePlaybackOverrideId: string | null; + allVisibleSelected: boolean; + availableGroups: string[]; + clipThumbnailUrl: string; + effectiveGroupValue: string; + favoriteTagIds: string[]; + isCreatingPlaylist?: boolean; + isMediaLoading: boolean; + isSearchOpen: boolean; + onListScrollStateChange?: (isScrolled: boolean) => void; + onCreatePlaylist?: () => void; + onPlayTagRow: (row: SgTagRow) => Promise<void>; + onRemoveTag: (tagId: string) => void; + onRowFilterModeChange: (mode: RowFilterMode) => void; + onSearchQueryChange: (value: string) => void; + onSelectAll: () => void; + onSelectedGroupValueChange: (value: string) => void; + onToggleFavorite: (tagId: string) => void; + onToggleSearch: () => void; + onToggleTagSelection: (tagId: string) => void; + onUpdateTag: (tagId: string, updates: SgTagRowEditPayload) => void; + rowFilterMode: RowFilterMode; + rows: SgTagRow[]; + searchQuery: string; + selectedTagIds: string[]; + showCreateActions?: boolean; + sportTableConfig: SportTableConfig; +}; + +export const SgEventTagsPanel = ({ + activeFilterLabel, + activePlaybackOverrideId, + allVisibleSelected, + availableGroups, + clipThumbnailUrl, + effectiveGroupValue, + favoriteTagIds, + isCreatingPlaylist = false, + isMediaLoading, + isSearchOpen, + onListScrollStateChange, + onCreatePlaylist, + onPlayTagRow, + onRemoveTag, + onRowFilterModeChange, + onSearchQueryChange, + onSelectAll, + onSelectedGroupValueChange, + onToggleFavorite, + onToggleSearch, + onToggleTagSelection, + onUpdateTag, + rowFilterMode, + rows, + searchQuery, + selectedTagIds, + showCreateActions = true, + sportTableConfig, +}: SgEventTagsPanelProps) => { + const isCompactFootballTable = Boolean(sportTableConfig.isCompactFootballTable); + const groupSelectLabel = effectiveGroupValue === "All tags" ? "Select group" : effectiveGroupValue; + const detailColumnLabel = isCompactFootballTable ? "Down & Dist" : sportTableConfig.primaryDetailLabel; + const [isColumnsPanelOpen, setIsColumnsPanelOpen] = useState(false); + const [visibleColumnKeys, setVisibleColumnKeys] = useState<string[]>(DEFAULT_VISIBLE_COLUMN_KEYS); + const [columnSearchQuery, setColumnSearchQuery] = useState(""); + const [collapsedColumnGroups, setCollapsedColumnGroups] = useState<Record<string, boolean>>({}); + const [editingRow, setEditingRow] = useState<SgTagRow | null>(null); + const [editDraft, setEditDraft] = useState<SgTagRowEditPayload>(() => ({ + action: "", + groupValue: "", + player: "", + primaryDetail: "", + result: "", + secondaryDetail: "", + team: "", + timecode: "", + })); + + const baseColumnDefinitions = useMemo<SgTagColumn[]>( + () => [ + { + getValue: (row) => getClipDuration(row, sportTableConfig.sport), + group: "Core", + isDefaultVisible: true, + key: "duration", + label: "Duration (s)", + width: "minmax(104px, 0.7fr)", + }, + { + getValue: (row) => row.player, + group: "Core", + isDefaultVisible: true, + key: "player", + label: sportTableConfig.playerLabel ?? "Player", + width: "minmax(150px, 1.15fr)", + }, + { + getValue: (row) => row.groupValue, + group: "Sport", + isDefaultVisible: true, + key: "groupValue", + label: sportTableConfig.groupByLabel, + width: "minmax(110px, 0.8fr)", + }, + { + getValue: (row) => row.action, + group: "Sport", + isDefaultVisible: true, + key: "action", + label: sportTableConfig.actionLabel, + width: "minmax(150px, 1fr)", + }, + { + getValue: (row) => row.primaryDetail, + group: "Sport", + isDefaultVisible: true, + key: "primaryDetail", + label: detailColumnLabel, + width: "minmax(130px, 0.9fr)", + }, + { + getValue: (row) => { + if (!isCompactFootballTable) return row.result; + return row.result && row.result !== "--" ? row.result : row.secondaryDetail; + }, + group: "Sport", + isDefaultVisible: true, + key: "result", + label: "Result", + width: "minmax(120px, 0.8fr)", + }, + { + getValue: (row) => row.team, + group: "Source", + isDefaultVisible: true, + key: "team", + label: "Team", + width: "minmax(120px, 0.8fr)", + }, + { + getValue: (row) => getDisplayTimecode(row, sportTableConfig.sport), + group: "Source", + isDefaultVisible: true, + key: "timecode", + label: "Timecode", + width: "minmax(140px, 0.9fr)", + }, + { + getValue: (row) => row.clipId ?? "--", + group: "Source", + isDefaultVisible: true, + key: "clipId", + label: "Clip ID", + width: "minmax(160px, 1fr)", + }, + { + getValue: (row) => row.sourceTagId ?? "--", + group: "Source", + isDefaultVisible: true, + key: "sourceTagId", + label: "Source tag ID", + width: "minmax(160px, 1fr)", + }, + { + getValue: (row) => row.playlistTimestamp ?? "--", + group: "Source", + isDefaultVisible: true, + key: "playlistTimestamp", + label: "Playlist timestamp", + width: "minmax(190px, 1.2fr)", + }, + ], + [ + detailColumnLabel, + isCompactFootballTable, + sportTableConfig.actionLabel, + sportTableConfig.groupByLabel, + sportTableConfig.playerLabel, + sportTableConfig.sport, + ] + ); + const standardRawTagColumnDefinitions = useMemo<SgTagColumn[]>(() => { + const sportLabel = getSportLabel(sportTableConfig.sport); + + return STANDARD_RAW_TAG_COLUMNS.map((column) => ({ + getValue: (row: SgTagRow) => getRawTagColumnValue(row, column.key, column.label, sportLabel), + group: "Raw tag data", + isDefaultVisible: true, + key: getContextColumnKey(column.key), + label: column.label, + width: column.width, + })); + }, [sportTableConfig.sport]); + const contextColumnDefinitions = useMemo<SgTagColumn[]>(() => { + const contextKeys = new Set<string>(); + + rows.forEach((row) => { + Object.entries(row.context).forEach(([key, value]) => { + if (STANDARD_RAW_TAG_CONTEXT_KEYS.has(key)) return; + if (value && value !== "--") contextKeys.add(key); + }); + }); + + return Array.from(contextKeys) + .sort((a, b) => formatColumnLabel(a).localeCompare(formatColumnLabel(b))) + .map((key) => { + const columnKey = getContextColumnKey(key); + + return { + getValue: (row: SgTagRow) => row.context[getContextKeyFromColumnKey(columnKey)] ?? "--", + group: "Raw tag data", + key: columnKey, + label: formatColumnLabel(key), + width: "minmax(150px, 1fr)", + }; + }); + }, [rows]); + const columnDefinitions = useMemo( + () => [...baseColumnDefinitions, ...standardRawTagColumnDefinitions, ...contextColumnDefinitions], + [baseColumnDefinitions, contextColumnDefinitions, standardRawTagColumnDefinitions] + ); + const visibleColumns = useMemo(() => { + const visibleColumnKeySet = new Set(visibleColumnKeys); + return columnDefinitions.filter((column) => visibleColumnKeySet.has(column.key)); + }, [columnDefinitions, visibleColumnKeys]); + const tableGridTemplateColumns = `56px minmax(120px, 150px) ${visibleColumns + .map((column) => column.width) + .join(" ")} 96px`; + const normalizedColumnSearchQuery = columnSearchQuery.trim().toLowerCase(); + const columnGroups = useMemo( + () => + COLUMN_GROUP_ORDER.map((groupName) => ({ + columns: columnDefinitions.filter((column) => { + if (column.group !== groupName) return false; + if (!normalizedColumnSearchQuery) return true; + + return `${column.label} ${column.key}`.toLowerCase().includes(normalizedColumnSearchQuery); + }), + name: groupName, + })).filter((group) => group.columns.length > 0), + [columnDefinitions, normalizedColumnSearchQuery] + ); + const selectedAvailableColumnCount = visibleColumns.length; + const totalColumnCount = columnDefinitions.length; + const isEditModalOpen = Boolean(editingRow); + const editingRowId = editingRow?.id; + + useEffect(() => { + if (!editingRowId) return; + const latestRow = rows.find((row) => row.id === editingRowId); + if (latestRow) { + setEditingRow(latestRow); + setEditDraft(buildEditDraft(latestRow)); + } + }, [editingRowId, rows]); + + const openEditModal = (row: SgTagRow) => { + setEditingRow(row); + setEditDraft(buildEditDraft(row)); + }; + + const closeEditModal = () => { + setEditingRow(null); + }; + + const updateEditDraft = (key: keyof SgTagRowEditPayload, value: string) => { + setEditDraft((currentValue) => ({ ...currentValue, [key]: value })); + }; + + const submitEditDraft = () => { + if (!editingRow) return; + onUpdateTag(editingRow.id, editDraft); + closeEditModal(); + }; + + return ( + <section className={cn(SURFACE_CLASS, "overflow-hidden")}> + <EditTagRowModal + draft={editDraft} + isOpen={isEditModalOpen} + onChange={updateEditDraft} + onClose={closeEditModal} + onSubmit={submitEditDraft} + row={editingRow} + /> + + <TagsPanelToolbar + activeFilterLabel={activeFilterLabel} + availableGroups={availableGroups} + defaultGroupValue={sportTableConfig.defaultGroupValue} + effectiveGroupValue={effectiveGroupValue} + groupSelectLabel={groupSelectLabel} + isColumnsPanelOpen={isColumnsPanelOpen} + isCreatingPlaylist={isCreatingPlaylist} + isSearchOpen={isSearchOpen} + onColumnsPanelOpen={() => setIsColumnsPanelOpen(true)} + onCreatePlaylist={onCreatePlaylist} + onRowFilterModeChange={onRowFilterModeChange} + onSearchQueryChange={onSearchQueryChange} + onSelectedGroupValueChange={onSelectedGroupValueChange} + onToggleSearch={onToggleSearch} + rowFilterMode={rowFilterMode} + searchQuery={searchQuery} + selectedCount={selectedTagIds.length} + selectedAvailableColumnCount={selectedAvailableColumnCount} + showCreateActions={showCreateActions} + totalColumnCount={totalColumnCount} + /> + + <div + className="sg-event-tags-list-scrollbar vertical-scrollbar horizontal-scrollbar scrollbar-lg min-h-52 max-h-[640px] overflow-auto" + onScroll={(event) => onListScrollStateChange?.(event.currentTarget.scrollTop > 8)} + > + <div className="min-w-full"> + <div + className="sticky top-0 z-[2] grid w-max min-w-full items-center gap-3 border-b border-custom-border-200 bg-custom-sidebar-background-100 px-3 py-3 text-xs font-medium text-custom-text-300" + style={{ gridTemplateColumns: tableGridTemplateColumns }} + > + <button type="button" onClick={onSelectAll} className="flex items-center gap-2 text-left"> + <span + className={cn( + "flex h-4 w-4 items-center justify-center rounded border", + allVisibleSelected + ? "border-custom-primary-100 bg-custom-primary-100 text-white" + : "border-custom-border-200 text-transparent" + )} + > + <Check className="h-3 w-3" /> + </span> + <span>No.</span> + </button> + <div>Clip</div> + {visibleColumns.map((column) => ( + <div key={column.key} className="truncate" title={column.label}> + {column.label} + </div> + ))} + <div>Action</div> + </div> + + {rows.length === 0 ? ( + <div className="px-5 py-12 text-center text-sm text-custom-text-400"> + No SG tags matched the current filter set. + </div> + ) : ( + rows.map((row, index) => { + const isSelected = selectedTagIds.includes(row.id); + const isFavorited = favoriteTagIds.includes(row.id); + const rowThumbnailUrl = row.thumbnailUrl || clipThumbnailUrl; + + return ( + <div + key={row.id} + className={cn( + "grid w-max min-w-full cursor-pointer items-center gap-3 border-t border-custom-border-200 px-3 py-2 text-xs text-custom-text-200 transition-colors", + isSelected + ? "bg-[#0f2638] text-custom-text-100 shadow-[inset_3px_0_0_#1780d5] hover:bg-[#123047]" + : "hover:bg-custom-background-90", + activePlaybackOverrideId === `sg-tag-${row.id}` && !isSelected && "bg-custom-background-90" + )} + style={{ gridTemplateColumns: tableGridTemplateColumns }} + role="button" + tabIndex={0} + onClick={() => { + void onPlayTagRow(row); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + void onPlayTagRow(row); + } + }} + > + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + onToggleTagSelection(row.id); + }} + className="flex items-center gap-2 text-left" + > + <span + className={cn( + "flex h-4 w-4 items-center justify-center rounded border", + isSelected + ? "border-[#1780d5] bg-[#1780d5] text-white" + : "border-custom-border-200 text-transparent" + )} + > + <Check className="h-3 w-3" /> + </span> + <span className={cn("text-custom-text-400", isSelected && "text-custom-text-100")}> + {index + 1} + </span> + </button> + <div className="h-10 w-[74px] overflow-hidden rounded bg-custom-background-80"> + {rowThumbnailUrl ? ( + <img src={rowThumbnailUrl} alt="" className="h-full w-full object-cover" draggable={false} /> + ) : ( + <div className="h-full w-full bg-custom-background-90" /> + )} + </div> + {visibleColumns.map((column) => { + const cellValue = column.getValue(row); + + return ( + <div key={column.key} className="truncate" title={cellValue}> + {displayCellValue(cellValue)} + </div> + ); + })} + <div className="flex items-center gap-1.5"> + <Tooltip tooltipContent="Edit row" isMobile={false}> + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + openEditModal(row); + }} + className="rounded-md p-1.5 text-custom-text-300 transition-colors hover:bg-custom-background-100 hover:text-custom-text-100" + > + <Pencil className="h-4 w-4" /> + </button> + </Tooltip> + <Tooltip tooltipContent={isFavorited ? "Remove favorite" : "Favorite"} isMobile={false}> + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + onToggleFavorite(row.id); + }} + className="rounded-md p-1.5 text-[#d0a64a] transition-colors hover:bg-custom-background-100" + > + <Star + className={cn("h-4 w-4", { + "fill-[#d0a64a]": isFavorited, + })} + /> + </button> + </Tooltip> + <Tooltip tooltipContent="Remove row" isMobile={false}> + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + onRemoveTag(row.id); + }} + className="rounded-md p-1.5 text-red-500 transition-colors hover:bg-red-500/10" + > + <Trash2 className="h-4 w-4" /> + </button> + </Tooltip> + </div> + </div> + ); + }) + )} + </div> + </div> + + <div className="flex flex-col gap-1 border-t border-custom-border-200 px-4 py-2.5 text-xs text-custom-text-400 sm:flex-row sm:items-center sm:justify-between"> + <span> + {rows.length} clips · {selectedAvailableColumnCount} of {totalColumnCount} columns shown + </span> + {totalColumnCount > selectedAvailableColumnCount && ( + <span className="hidden sm:inline">Use Columns to show more fields</span> + )} + </div> + + {isMediaLoading && ( + <div className="border-t border-custom-border-200 px-4 py-2.5 text-xs text-custom-text-400"> + Syncing SG media package and playlist references for this event. + </div> + )} + + <TagsColumnsPanel + collapsedColumnGroups={collapsedColumnGroups} + columnDefinitions={columnDefinitions} + columnGroups={columnGroups} + columnSearchQuery={columnSearchQuery} + isOpen={isColumnsPanelOpen} + onClose={() => setIsColumnsPanelOpen(false)} + onCollapsedColumnGroupsChange={setCollapsedColumnGroups} + onColumnSearchQueryChange={setColumnSearchQuery} + onVisibleColumnKeysChange={setVisibleColumnKeys} + selectedAvailableColumnCount={selectedAvailableColumnCount} + totalColumnCount={totalColumnCount} + visibleColumnKeys={visibleColumnKeys} + /> + </section> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/index.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/index.ts new file mode 100644 index 00000000000..e1388a79816 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/index.ts @@ -0,0 +1 @@ +export { SgEventTagsPanel } from "./components/tags-panel"; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/utils/tags-panel-model.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/utils/tags-panel-model.ts new file mode 100644 index 00000000000..ef0b690a366 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/tags-view/utils/tags-panel-model.ts @@ -0,0 +1,226 @@ +import type { SgTagRow, SgTagRowEditPayload, SportTableConfig, SportTableKind } from "../../types"; +import { formatLooseLabel, parseTimecodeToSeconds } from "../../utils"; + +const CONTEXT_COLUMN_PREFIX = "context:"; + +export const getContextColumnKey = (key: string) => `${CONTEXT_COLUMN_PREFIX}${key}`; + +export const STANDARD_RAW_TAG_COLUMNS = [ + { key: "sport", label: "Sport", width: "minmax(130px, 0.85fr)" }, + { key: "quarter", label: "Quarter", width: "minmax(120px, 0.8fr)" }, + { key: "distance", label: "Distance", width: "minmax(110px, 0.75fr)" }, + { key: "down", label: "Down", width: "minmax(96px, 0.65fr)" }, + { key: "drive_number", label: "Drive Number", width: "minmax(140px, 0.9fr)" }, + { key: "game_clock_seconds", label: "Game Clock Seconds", width: "minmax(170px, 1.05fr)" }, + { key: "period", label: "Period", width: "minmax(110px, 0.75fr)" }, + { key: "play_number", label: "Play Number", width: "minmax(130px, 0.85fr)" }, + { key: "possession_team", label: "Possession Team", width: "minmax(165px, 1fr)" }, + { key: "primary_actor_number", label: "Primary Actor Number", width: "minmax(185px, 1.1fr)" }, + { key: "qb", label: "Qb", width: "minmax(120px, 0.8fr)" }, + { key: "rosters", label: "Rosters", width: "minmax(130px, 0.85fr)" }, + { key: "score_away", label: "Score Away", width: "minmax(130px, 0.85fr)" }, + { key: "score_home", label: "Score Home", width: "minmax(130px, 0.85fr)" }, + { key: "yard_line", label: "Yard Line", width: "minmax(125px, 0.8fr)" }, + { key: "yards_gained", label: "Yards Gained", width: "minmax(140px, 0.9fr)" }, + { key: "home_team", label: "Home Team", width: "minmax(140px, 0.9fr)" }, + { key: "away_team", label: "Away Team", width: "minmax(140px, 0.9fr)" }, + { key: "field_position", label: "Field Position", width: "minmax(150px, 0.95fr)" }, + { key: "play_type", label: "Play Type", width: "minmax(135px, 0.9fr)" }, + { key: "formation", label: "Formation", width: "minmax(130px, 0.85fr)" }, + { key: "personnel", label: "Personnel", width: "minmax(130px, 0.85fr)" }, + { key: "coverage", label: "Coverage", width: "minmax(130px, 0.85fr)" }, + { key: "blitz", label: "Blitz", width: "minmax(96px, 0.65fr)" }, + { key: "penalty", label: "Penalty", width: "minmax(130px, 0.85fr)" }, + { key: "penalty_yards", label: "Penalty Yards", width: "minmax(145px, 0.9fr)" }, +] as const; + +export const STANDARD_RAW_TAG_CONTEXT_KEYS: ReadonlySet<string> = new Set( + STANDARD_RAW_TAG_COLUMNS.map((column) => column.key) +); + +export const DEFAULT_VISIBLE_COLUMN_KEYS = [ + "duration", + "player", + "groupValue", + "action", + "primaryDetail", + "result", + "team", + "timecode", + "clipId", + "sourceTagId", + "playlistTimestamp", + ...STANDARD_RAW_TAG_COLUMNS.map((column) => getContextColumnKey(column.key)), +]; + +export const COLUMN_GROUP_ORDER = ["Core", "Sport", "Source", "Raw tag data"] as const; + +export type SgTagColumnGroup = (typeof COLUMN_GROUP_ORDER)[number]; + +export type SgTagColumn = { + getValue: (row: SgTagRow) => string; + group: SgTagColumnGroup; + isDefaultVisible?: boolean; + key: string; + label: string; + width: string; +}; + +const BASKETBALL_FALLBACK_DURATION_SECONDS = 5; + +const formatDuration = (seconds: number) => { + const safeSeconds = Math.max(0, Math.round(seconds)); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const remainingSeconds = safeSeconds % 60; + + if (hours > 0) { + return [hours, minutes, remainingSeconds].map((value) => String(value).padStart(2, "0")).join(":"); + } + + return [minutes, remainingSeconds].map((value) => String(value).padStart(2, "0")).join(":"); +}; + +export const getClipDuration = (row: SgTagRow, sport: SportTableKind) => { + if ( + typeof row.clipDurationSeconds === "number" && + Number.isFinite(row.clipDurationSeconds) && + row.clipDurationSeconds > 0 + ) { + return formatDuration(row.clipDurationSeconds); + } + + if ( + row.clipRangeSource !== "timecode" && + row.clipStartSeconds !== null && + row.clipEndSeconds !== null && + row.clipEndSeconds > row.clipStartSeconds + ) { + return formatDuration(row.clipEndSeconds - row.clipStartSeconds); + } + + const rangeParts = row.timecode.split(/\s*[-\u2013\u2014]\s*/).filter(Boolean); + if (rangeParts.length >= 2) { + const start = parseTimecodeToSeconds(rangeParts[0]); + const end = parseTimecodeToSeconds(rangeParts[1]); + + if (start !== null && end !== null && end > start) { + return formatDuration(end - start); + } + } + + if (sport === "basketball") { + return formatDuration(BASKETBALL_FALLBACK_DURATION_SECONDS); + } + + return "--"; +}; + +export const getDisplayTimecode = (row: SgTagRow, sport: SportTableKind) => { + if (row.timecode && row.timecode !== "--") return row.timecode; + if (sport === "basketball" && row.primaryDetail && row.primaryDetail !== "--") { + return `Game ${row.primaryDetail}`; + } + + return "--"; +}; + +export const displayCellValue = (value: string) => (value && value !== "--" ? value : "--"); + +export const getContextKeyFromColumnKey = (key: string) => key.slice(CONTEXT_COLUMN_PREFIX.length); + +export const formatColumnLabel = (key: string) => formatLooseLabel(key.replace(/_/g, " ")); + +const getStableRawColumnNumber = (row: SgTagRow, key: string) => { + const seed = [row.sourceTagId, row.clipId, row.id, row.timecode, row.action, key].filter(Boolean).join("|"); + let hash = 0; + + for (let index = 0; index < seed.length; index += 1) { + hash = (hash << 5) - hash + seed.charCodeAt(index); + hash |= 0; + } + + return Math.abs(hash); +}; + +const getRealCellValue = (value: string | null | undefined) => (value && value !== "--" ? value : ""); + +const getFallbackTeamValue = (row: SgTagRow, hash: number) => getRealCellValue(row.team) || `Team ${(hash % 2) + 1}`; + +export const getSportLabel = (sport: SportTableConfig["sport"]) => + sport === "american-football" ? "American Football" : formatColumnLabel(sport); + +const buildFakeRawTagValue = (row: SgTagRow, key: string, label: string, sportLabel: string) => { + const hash = getStableRawColumnNumber(row, key); + const teamValue = getFallbackTeamValue(row, hash); + + switch (key) { + case "sport": + return sportLabel; + case "quarter": + return getRealCellValue(row.groupValue) || `Quarter ${(hash % 4) + 1}`; + case "distance": + return String((hash % 20) + 1); + case "down": + return String((hash % 4) + 1); + case "drive_number": + return String((hash % 16) + 1); + case "game_clock_seconds": + return String((hash % 900) + 1); + case "period": + return getRealCellValue(row.matrixPeriod) || getRealCellValue(row.groupValue) || String((hash % 4) + 1); + case "play_number": + return String((hash % 160) + 1); + case "possession_team": + return teamValue; + case "primary_actor_number": + return String((hash % 99) + 1); + case "qb": + return getRealCellValue(row.player) || `QB ${(hash % 99) + 1}`; + case "rosters": + return `Roster ${(hash % 6) + 1}`; + case "score_away": + case "score_home": + return String(hash % 45); + case "yard_line": + return String((hash % 50) + 1); + case "yards_gained": + return String((hash % 31) - 10); + case "home_team": + return `Home ${teamValue}`; + case "away_team": + return `Away Team ${(hash % 2) + 1}`; + case "field_position": + return `${teamValue} ${(hash % 50) + 1}`; + case "play_type": + return getRealCellValue(row.action) || `Play Type ${(hash % 12) + 1}`; + case "formation": + return `Formation ${(hash % 8) + 1}`; + case "personnel": + return `${(hash % 3) + 1}${(hash % 4) + 1} personnel`; + case "coverage": + return `Coverage ${(hash % 6) + 1}`; + case "blitz": + return hash % 2 === 0 ? "Yes" : "No"; + case "penalty": + return hash % 3 === 0 ? "Holding" : "None"; + case "penalty_yards": + return hash % 3 === 0 ? "5" : "0"; + default: + return `${label} ${(hash % 100) + 1}`; + } +}; + +export const getRawTagColumnValue = (row: SgTagRow, key: string, label: string, sportLabel: string) => + getRealCellValue(row.context[key]) || buildFakeRawTagValue(row, key, label, sportLabel); + +export const buildEditDraft = (row: SgTagRow): SgTagRowEditPayload => ({ + action: row.action, + groupValue: row.groupValue, + player: row.player, + primaryDetail: row.primaryDetail, + result: row.result, + secondaryDetail: row.secondaryDetail, + team: row.team, + timecode: row.timecode, +}); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-layout.test.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-layout.test.ts new file mode 100644 index 00000000000..8f3a1f0b015 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-layout.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Node's type-stripping test runner requires explicit TypeScript extensions. +// @ts-expect-error See comment above. +import * as timelineLayout from "../utils/timeline-layout.ts"; + +const { + TIMELINE_CANVAS_CONTENT_CLASS, + TIMELINE_HORIZONTAL_SCROLL_CLASS, + TIMELINE_PAGE_CONTENT_CLASS, + TIMELINE_PAGE_SCROLL_CLASS, + TIMELINE_PANEL_MIN_HEIGHT_PX, + TIMELINE_PANEL_ROOT_CLASS, + TIMELINE_RULER_CONTENT_CLASS, + TIMELINE_RULER_SCROLL_CLASS, + TIMELINE_STICKY_FOOTER_CLASS, + TIMELINE_TRACKS_SCROLL_CLASS, + getTimelineHorizontalWheelDeltaPx, + getTimelineZoomWheelDirection, +} = timelineLayout; + +test("timeline page scrollport exposes a flush sticky bottom edge", () => { + assert.match(TIMELINE_PAGE_SCROLL_CLASS, /\boverflow-y-auto\b/); + assert.match(TIMELINE_PAGE_SCROLL_CLASS, /\bpt-3\b/); + assert.doesNotMatch(TIMELINE_PAGE_SCROLL_CLASS, /\bpy-3\b/); + assert.doesNotMatch(TIMELINE_PAGE_SCROLL_CLASS, /\bpb-3\b/); + + assert.match(TIMELINE_PAGE_CONTENT_CLASS, /\bpb-3\b/); +}); + +test("timeline section participates in natural vertical document flow", () => { + assert.match(TIMELINE_PANEL_ROOT_CLASS, /\bflex\b/); + assert.ok(TIMELINE_PANEL_ROOT_CLASS.includes(`min-h-[${TIMELINE_PANEL_MIN_HEIGHT_PX}px]`)); + assert.doesNotMatch(TIMELINE_PANEL_ROOT_CLASS, /\boverflow-hidden\b/); + assert.doesNotMatch(TIMELINE_PANEL_ROOT_CLASS, /\boverscroll-contain\b/); + + assert.doesNotMatch(TIMELINE_TRACKS_SCROLL_CLASS, /\bpb-\d+\b/); + assert.doesNotMatch(TIMELINE_TRACKS_SCROLL_CLASS, /\bvertical-scrollbar\b/); + assert.doesNotMatch(TIMELINE_TRACKS_SCROLL_CLASS, /\boverflow-y-auto\b/); + assert.doesNotMatch(TIMELINE_TRACKS_SCROLL_CLASS, /\bflex-1\b/); + assert.doesNotMatch(TIMELINE_TRACKS_SCROLL_CLASS, /\boverscroll-contain\b/); +}); + +test("timeline footer is sticky inside the timeline section", () => { + assert.match(TIMELINE_STICKY_FOOTER_CLASS, /\bsticky\b/); + assert.match(TIMELINE_STICKY_FOOTER_CLASS, /\bbottom-0\b/); + assert.ok(TIMELINE_STICKY_FOOTER_CLASS.includes("z-[5]")); + assert.match(TIMELINE_STICKY_FOOTER_CLASS, /\bbg-custom-background-100\b/); + assert.doesNotMatch(TIMELINE_STICKY_FOOTER_CLASS, /\bfixed\b/); + + assert.match(TIMELINE_RULER_SCROLL_CLASS, /\bh-10\b/); + assert.match(TIMELINE_RULER_SCROLL_CLASS, /\boverflow-x-auto\b/); + assert.match(TIMELINE_RULER_SCROLL_CLASS, /\boverflow-y-hidden\b/); + assert.doesNotMatch(TIMELINE_RULER_SCROLL_CLASS, /\boverflow-y-auto\b/); + assert.match(TIMELINE_RULER_SCROLL_CLASS, /\bsg-event-timeline-scrollbar\b/); +}); + +test("timeline has a single visible horizontal scrollbar", () => { + const horizontalOwnerCount = [TIMELINE_HORIZONTAL_SCROLL_CLASS, TIMELINE_RULER_SCROLL_CLASS].filter( + (className) => /\bhorizontal-scrollbar\b/.test(className) && /\boverflow-x-auto\b/.test(className) + ).length; + + assert.equal(horizontalOwnerCount, 1); + assert.doesNotMatch(TIMELINE_HORIZONTAL_SCROLL_CLASS, /\bhorizontal-scrollbar\b/); + assert.doesNotMatch(TIMELINE_HORIZONTAL_SCROLL_CLASS, /\boverflow-x-auto\b/); +}); + +test("timeline content width changes are not animated between zoom levels", () => { + assert.equal(typeof TIMELINE_CANVAS_CONTENT_CLASS, "string"); + assert.equal(typeof TIMELINE_RULER_CONTENT_CLASS, "string"); + assert.doesNotMatch(TIMELINE_CANVAS_CONTENT_CLASS, /transition-\[width\]/); + assert.doesNotMatch(TIMELINE_RULER_CONTENT_CLASS, /transition-\[width\]/); +}); + +test("ordinary vertical wheel input does not move the horizontal timeline", () => { + assert.equal(getTimelineHorizontalWheelDeltaPx({ deltaX: 0, deltaY: 120 }), 0); + assert.equal(getTimelineHorizontalWheelDeltaPx({ deltaX: 8, deltaY: 120 }), 0); + assert.equal(getTimelineHorizontalWheelDeltaPx({ deltaX: 40, deltaY: 40 }), 0); +}); + +test("intentional horizontal wheel input can move the horizontal timeline", () => { + assert.equal(getTimelineHorizontalWheelDeltaPx({ deltaX: 120, deltaY: 8 }), 120); + assert.equal(getTimelineHorizontalWheelDeltaPx({ deltaX: -80, deltaY: 12 }), -80); + assert.equal(getTimelineHorizontalWheelDeltaPx({ deltaX: 0, deltaY: 90, shiftKey: true }), 90); +}); + +test("alt wheel input intentionally controls timeline zoom", () => { + assert.equal(getTimelineZoomWheelDirection({ altKey: true, deltaY: -120 }), "in"); + assert.equal(getTimelineZoomWheelDirection({ altKey: true, deltaY: 120 }), "out"); + assert.equal(getTimelineZoomWheelDirection({ altKey: false, deltaY: -120 }), null); + assert.equal(getTimelineZoomWheelDirection({ altKey: true, deltaY: 0 }), null); +}); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-playlist-selection.test.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-playlist-selection.test.ts new file mode 100644 index 00000000000..782506f65c4 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-playlist-selection.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { SgTagRow } from "../../types"; + +// Node's type-stripping test runner requires explicit TypeScript extensions. +// @ts-expect-error See comment above. +import { getTimelinePlaylistRows } from "../utils/timeline-playlist-selection.ts"; + +const buildRow = (overrides: Partial<SgTagRow>): SgTagRow => ({ + action: "Run", + clipDurationSeconds: null, + clipEndSeconds: null, + clipId: null, + clipRangeSource: null, + clipStartSeconds: null, + context: {}, + groupValue: "Quarter 1", + id: "tag-1", + matrixParticipant: null, + matrixPeriod: null, + player: "--", + playlistFallbackTimestamp: null, + playlistTimestamp: null, + primaryDetail: "", + result: "--", + secondaryDetail: "", + sourceTagId: null, + sourceUrl: "", + team: "home", + thumbnailUrl: "", + timecode: "00:00", + ...overrides, +}); + +test("timeline playlist rows include selected playable rows in chronological order", () => { + const rows = [ + buildRow({ clipStartSeconds: 18, id: "late", playlistTimestamp: "00:18-00:24" }), + buildRow({ clipStartSeconds: 5, id: "early", playlistTimestamp: "00:05-00:12" }), + buildRow({ clipStartSeconds: 12, id: "middle", playlistTimestamp: "00:12-00:20" }), + ]; + + assert.deepEqual( + getTimelinePlaylistRows(rows, ["middle", "late", "early"]).map((row) => row.id), + ["early", "middle", "late"] + ); +}); + +test("timeline playlist rows ignore unselected, duplicate, and unplayable rows", () => { + const rows = [ + buildRow({ id: "selected", playlistTimestamp: "00:10-00:16" }), + buildRow({ id: "selected", playlistTimestamp: "00:10-00:16" }), + buildRow({ id: "unselected", playlistTimestamp: "00:20-00:26" }), + buildRow({ id: "unplayable", playlistTimestamp: null, playlistFallbackTimestamp: null }), + ]; + + assert.deepEqual( + getTimelinePlaylistRows(rows, ["selected", "unplayable"]).map((row) => row.id), + ["selected"] + ); +}); + +test("timeline playlist rows can sort by fallback timestamp when clip start is missing", () => { + const rows = [ + buildRow({ id: "second", playlistFallbackTimestamp: "00:09-00:14" }), + buildRow({ id: "first", playlistTimestamp: "00:04-00:10" }), + ]; + + assert.deepEqual( + getTimelinePlaylistRows(rows, ["second", "first"]).map((row) => row.id), + ["first", "second"] + ); +}); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-scale.test.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-scale.test.ts new file mode 100644 index 00000000000..97095f991f6 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-scale.test.ts @@ -0,0 +1,428 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Node's type-stripping test runner requires explicit TypeScript extensions. +// @ts-expect-error See comment above. +import * as timelineScale from "../utils/timeline-scale.ts"; + +const { + DEFAULT_TIMELINE_SCALE_INDEX, + MIN_TIMELINE_MAJOR_TICK_SPACING_PX, + MIN_SECOND_TICK_SPACING_PX, + TIMELINE_SCALE_LEVELS, + buildTimelineZoomStops, + buildScaledTimelineTicks, + getTimelineVisibleDurationLabel, + getTimelineTagEndSeconds, + getTimelinePanelInputPlayheadSeconds, + getTimelinePlaybackSeconds, + getTimelineScaleIndexFromSliderValue, + getNextTimelineScaleIndex, + getTimelineRangePixels, + getTimelineSecondsFromClientX, + getTimelinePositionPercent, + getTimelineContentWidth, + getTimelineEffectiveContentWidth, + getTimelineTimePixel, + getTimelineScaleLabel, + getTimelineZoomLabel, + getTimelineZoomPercentLabel, + getTimelineZoomStopIndex, + getTimelineZoomStopIndexFromSliderValue, + isTimelineTagPlaybackOverrideId, +} = timelineScale; + +test("timeline scale controls clamp at supported zoom bounds", () => { + assert.equal(getNextTimelineScaleIndex(DEFAULT_TIMELINE_SCALE_INDEX, "in"), DEFAULT_TIMELINE_SCALE_INDEX + 1); + assert.equal(getNextTimelineScaleIndex(DEFAULT_TIMELINE_SCALE_INDEX, "out"), DEFAULT_TIMELINE_SCALE_INDEX - 1); + assert.equal(getNextTimelineScaleIndex(0, "out"), 0); + assert.equal(getNextTimelineScaleIndex(TIMELINE_SCALE_LEVELS.length - 1, "in"), TIMELINE_SCALE_LEVELS.length - 1); +}); + +test("timeline zoom slider values map to clamped scale indexes", () => { + assert.equal(getTimelineScaleIndexFromSliderValue("4", DEFAULT_TIMELINE_SCALE_INDEX), 4); + assert.equal(getTimelineScaleIndexFromSliderValue("-4", DEFAULT_TIMELINE_SCALE_INDEX), 0); + assert.equal( + getTimelineScaleIndexFromSliderValue("999", DEFAULT_TIMELINE_SCALE_INDEX), + TIMELINE_SCALE_LEVELS.length - 1 + ); + assert.equal(getTimelineScaleIndexFromSliderValue("not-a-number", DEFAULT_TIMELINE_SCALE_INDEX), DEFAULT_TIMELINE_SCALE_INDEX); +}); + +test("timeline content width expands and contracts from the default scale", () => { + const defaultWidth = getTimelineContentWidth(TIMELINE_SCALE_LEVELS[DEFAULT_TIMELINE_SCALE_INDEX]); + const zoomedInWidth = getTimelineContentWidth(TIMELINE_SCALE_LEVELS[DEFAULT_TIMELINE_SCALE_INDEX + 2]); + const zoomedOutWidth = getTimelineContentWidth(TIMELINE_SCALE_LEVELS[DEFAULT_TIMELINE_SCALE_INDEX - 1]); + + assert.ok(zoomedInWidth > defaultWidth); + assert.ok(zoomedOutWidth < defaultWidth); + assert.match(getTimelineScaleLabel(TIMELINE_SCALE_LEVELS[DEFAULT_TIMELINE_SCALE_INDEX]), /^\d+%$/); +}); + +test("timeline zoom percent label always displays the current zoom percentage", () => { + assert.equal(getTimelineZoomPercentLabel(0.5), "50%"); + assert.equal(getTimelineZoomPercentLabel(1), "100%"); + assert.equal(getTimelineZoomPercentLabel(TIMELINE_SCALE_LEVELS.at(-1) ?? 1), "6400%"); +}); + +test("timeline zoom label shows fit when selected zoom is below the viewport width", () => { + assert.deepEqual( + getTimelineZoomLabel({ + scale: 0.75, + selectedContentWidthPx: 1050, + viewportWidthPx: 1800, + }), + { + detailLabel: "Fit to viewport (75%)", + displayLabel: "Fit", + isFitToViewport: true, + percentLabel: "75%", + } + ); +}); + +test("timeline zoom label shows percentage before measurement and once detail exceeds viewport", () => { + assert.deepEqual( + getTimelineZoomLabel({ + scale: 1, + selectedContentWidthPx: 1400, + viewportWidthPx: 0, + }), + { + detailLabel: "100%", + displayLabel: "100%", + isFitToViewport: false, + percentLabel: "100%", + } + ); + assert.deepEqual( + getTimelineZoomLabel({ + scale: 1.25, + selectedContentWidthPx: 1750, + viewportWidthPx: 1200, + }), + { + detailLabel: "125%", + displayLabel: "125%", + isFitToViewport: false, + percentLabel: "125%", + } + ); +}); + +test("timeline zoom stops collapse redundant fit-to-viewport levels", () => { + const zoomStops = buildTimelineZoomStops({ + totalSeconds: 49 * 60 + 2, + viewportWidthPx: 1800, + }); + + assert.equal(zoomStops[0]?.kind, "fit"); + assert.equal(zoomStops[0]?.scaleIndex, 3); + assert.deepEqual( + zoomStops.slice(0, 4).map((stop) => stop.scaleIndex), + [3, 4, 5, 6] + ); + assert.equal(zoomStops.some((stop) => stop.scaleIndex === 0), false); + assert.equal(zoomStops.some((stop) => stop.scaleIndex === 1), false); + assert.equal(zoomStops.some((stop) => stop.scaleIndex === 2), false); +}); + +test("timeline zoom stops preserve raw zoom levels before viewport measurement", () => { + const zoomStops = buildTimelineZoomStops({ + totalSeconds: 49 * 60 + 2, + viewportWidthPx: 0, + }); + + assert.equal(zoomStops[0]?.kind, "detail"); + assert.deepEqual( + zoomStops.slice(0, 4).map((stop) => stop.scaleIndex), + [0, 1, 2, 3] + ); +}); + +test("timeline zoom stop index maps collapsed raw indexes to the fit stop", () => { + const zoomStops = buildTimelineZoomStops({ + totalSeconds: 49 * 60 + 2, + viewportWidthPx: 1800, + }); + + assert.equal(getTimelineZoomStopIndex({ scaleIndex: 0, zoomStops }), 0); + assert.equal(getTimelineZoomStopIndex({ scaleIndex: 2, zoomStops }), 0); + assert.equal(getTimelineZoomStopIndex({ scaleIndex: 3, zoomStops }), 0); + assert.equal(getTimelineZoomStopIndex({ scaleIndex: 4, zoomStops }), 1); + assert.equal(getTimelineZoomStopIndexFromSliderValue("999", 0, zoomStops.length), zoomStops.length - 1); +}); + +test("timeline visible duration label summarizes how much time is currently on screen", () => { + assert.deepEqual( + getTimelineVisibleDurationLabel({ + contentWidthPx: 1800, + totalSeconds: 49 * 60 + 2, + viewportWidthPx: 1800, + }), + { + compactLabel: "~49m", + detailLabel: "~49m visible", + } + ); + assert.deepEqual( + getTimelineVisibleDurationLabel({ + contentWidthPx: 7000, + totalSeconds: 50 * 60, + viewportWidthPx: 1400, + }), + { + compactLabel: "~10m", + detailLabel: "~10m visible", + } + ); +}); + +test("timeline content can exceed the viewport width when users zoom in", () => { + const compactViewportWidth = 900; + const zoomedInWidth = getTimelineContentWidth(2); + + assert.ok(zoomedInWidth > compactViewportWidth); +}); + +test("timeline uses the rendered fit-to-viewport width for both ruler and item pixels", () => { + const totalSeconds = 49 * 60 + 2; + const finalTagSeconds = 37.5 * 60; + const viewportWidth = 2200; + const selectedZoomWidth = getTimelineContentWidth(0.5, totalSeconds); + const renderedContentWidth = getTimelineEffectiveContentWidth({ + selectedContentWidthPx: selectedZoomWidth, + viewportWidthPx: viewportWidth, + }); + const itemLeftPx = getTimelineTimePixel(finalTagSeconds, totalSeconds, renderedContentWidth); + const rulerTickPx = (getTimelinePositionPercent(finalTagSeconds, totalSeconds) / 100) * renderedContentWidth; + + assert.equal(renderedContentWidth, viewportWidth); + assert.ok(Math.abs(itemLeftPx - rulerTickPx) < 0.000001); +}); + +test("timeline keeps selected zoom width once it exceeds the viewport", () => { + const selectedZoomWidth = getTimelineContentWidth(2, 49 * 60 + 2); + + assert.equal( + getTimelineEffectiveContentWidth({ + selectedContentWidthPx: selectedZoomWidth, + viewportWidthPx: 1200, + }), + selectedZoomWidth + ); +}); + +test("timeline positions are clamped to the shared percent coordinate system", () => { + assert.equal(getTimelinePositionPercent(0, 120), 0); + assert.equal(getTimelinePositionPercent(30, 120), 25); + assert.equal(getTimelinePositionPercent(120, 120), 100); + assert.equal(getTimelinePositionPercent(150, 120), 100); + assert.equal(getTimelinePositionPercent(-5, 120), 0); +}); + +test("timeline pointer seeking accounts for horizontal scroll and zoomed content width", () => { + assert.ok( + Math.abs( + getTimelineSecondsFromClientX({ + clientX: 450, + contentWidthPx: 2000, + scrollLeftPx: 300, + totalSeconds: 100, + viewportLeftPx: 200, + }) - 27.5 + ) < 0.000001 + ); +}); + +test("timeline pointer seeking clamps to the seekable media duration", () => { + assert.equal( + getTimelineSecondsFromClientX({ + clientX: 2600, + contentWidthPx: 2000, + scrollLeftPx: 0, + seekableSeconds: 90, + totalSeconds: 120, + viewportLeftPx: 0, + }), + 90 + ); + assert.equal( + getTimelineSecondsFromClientX({ + clientX: -40, + contentWidthPx: 2000, + scrollLeftPx: 0, + seekableSeconds: 90, + totalSeconds: 120, + viewportLeftPx: 0, + }), + 0 + ); +}); + +test("timeline ticks become more precise when zooming in and coarser when zooming out", () => { + const zoomedOutTicks = buildScaledTimelineTicks(3600, 0.5); + const defaultTicks = buildScaledTimelineTicks(3600, 1); + const zoomedInTicks = buildScaledTimelineTicks(3600, 4); + const zoomedOutMajorTicks = zoomedOutTicks.filter((tick) => tick.kind === "major"); + const defaultMajorTicks = defaultTicks.filter((tick) => tick.kind === "major"); + const zoomedInMajorTicks = zoomedInTicks.filter((tick) => tick.kind === "major"); + + assert.ok(defaultMajorTicks.length > zoomedOutMajorTicks.length); + assert.ok(zoomedInMajorTicks.length > defaultMajorTicks.length); + assert.equal(zoomedInTicks[0]?.position, 0); + assert.equal(zoomedInTicks.at(-1)?.position, 100); +}); + +test("timeline ruler includes labeled major ticks and unlabeled minor subdivisions", () => { + const ticks = buildScaledTimelineTicks(600, 2, getTimelineContentWidth(2, 600)); + const majorTicks = ticks.filter((tick) => tick.kind === "major"); + const minorTicks = ticks.filter((tick) => tick.kind === "minor"); + + assert.ok(majorTicks.length > 0); + assert.ok(minorTicks.length > 0); + assert.ok(majorTicks.every((tick) => tick.label.length > 0)); + assert.ok(minorTicks.every((tick) => tick.label === "")); + assert.ok(minorTicks.some((tick) => tick.seconds > (majorTicks[0]?.seconds ?? 0))); +}); + +test("timeline ruler keeps major tick labels far enough apart at compact zoom", () => { + const totalSeconds = 3600; + const contentWidth = getTimelineContentWidth(0.5, totalSeconds); + const majorTicks = buildScaledTimelineTicks(totalSeconds, 0.5, contentWidth).filter((tick) => tick.kind === "major"); + const majorTickGaps = majorTicks.slice(1).map((tick, index) => { + const previousTick = majorTicks[index]; + + return ((tick.seconds - previousTick.seconds) * contentWidth) / totalSeconds; + }); + + assert.ok(majorTickGaps.every((gapPx) => gapPx >= MIN_TIMELINE_MAJOR_TICK_SPACING_PX)); +}); + +test("maximum timeline zoom supports readable one-second precision", () => { + const eventDurationSeconds = 20 * 60; + const maxScale = TIMELINE_SCALE_LEVELS.at(-1) ?? 1; + const contentWidth = getTimelineContentWidth(maxScale, eventDurationSeconds); + const ticks = buildScaledTimelineTicks(eventDurationSeconds, maxScale, contentWidth); + const majorTicks = ticks.filter((tick) => tick.kind === "major"); + + assert.ok(contentWidth / eventDurationSeconds >= MIN_SECOND_TICK_SPACING_PX); + assert.equal(majorTicks[1]?.seconds, 1); + assert.equal(majorTicks[1]?.label, "00:01"); + assert.equal(majorTicks[1]?.position, (1 * 100) / eventDurationSeconds); + assert.equal(ticks.at(-1)?.position, 100); + assert.equal(getTimelineScaleLabel(maxScale), "1 sec"); +}); + +test("timeline tag ranges use duration-based pixel geometry at one-second zoom", () => { + const totalSeconds = 20; + const contentWidth = getTimelineContentWidth(TIMELINE_SCALE_LEVELS.at(-1) ?? 1, totalSeconds); + const range = getTimelineRangePixels({ + contentWidthPx: contentWidth, + endSeconds: 12, + startSeconds: 4, + totalSeconds, + }); + const pixelsPerSecond = contentWidth / totalSeconds; + + assert.ok(pixelsPerSecond >= MIN_SECOND_TICK_SPACING_PX); + assert.equal(range.leftPx, 4 * pixelsPerSecond); + assert.equal(range.widthPx, 8 * pixelsPerSecond); +}); + +test("timeline tag end prefers actual clip duration over a wider display timecode range", () => { + assert.equal( + getTimelineTagEndSeconds({ + clipDurationSeconds: 8, + explicitEndSeconds: 24, + startSeconds: 12, + }), + 20 + ); +}); + +test("timeline tag end does not default unknown tags to twelve seconds", () => { + assert.equal( + getTimelineTagEndSeconds({ + clipDurationSeconds: null, + explicitEndSeconds: null, + startSeconds: 12, + }), + 20 + ); +}); + +test("timeline tag ranges recalculate positions and widths across zoom levels", () => { + const totalSeconds = 100; + const zoomedOutWidth = getTimelineContentWidth(0.5, totalSeconds); + const zoomedInWidth = getTimelineContentWidth(1.25, totalSeconds); + const zoomedOutRange = getTimelineRangePixels({ + contentWidthPx: zoomedOutWidth, + endSeconds: 18, + startSeconds: 10, + totalSeconds, + }); + const zoomedInRange = getTimelineRangePixels({ + contentWidthPx: zoomedInWidth, + endSeconds: 18, + startSeconds: 10, + totalSeconds, + }); + + assert.ok(zoomedInRange.leftPx > zoomedOutRange.leftPx); + assert.ok(zoomedInRange.widthPx > zoomedOutRange.widthPx); + assert.equal(zoomedOutRange.leftPx, (10 / totalSeconds) * zoomedOutWidth); + assert.ok(Math.abs(zoomedOutRange.widthPx - (8 / totalSeconds) * zoomedOutWidth) < 0.000001); + assert.equal(zoomedInRange.leftPx, (10 / totalSeconds) * zoomedInWidth); + assert.ok(Math.abs(zoomedInRange.widthPx - (8 / totalSeconds) * zoomedInWidth) < 0.000001); +}); + +test("clip-relative playback maps back onto the full stream timeline", () => { + assert.equal( + getTimelinePlaybackSeconds({ + activeClipStartSeconds: 100, + isClipPlaybackActive: true, + playheadSeconds: 3, + }), + 103 + ); + assert.equal( + getTimelinePlaybackSeconds({ + activeClipStartSeconds: 100, + isClipPlaybackActive: false, + playheadSeconds: 3, + }), + 3 + ); +}); + +test("timeline panel receives clip-local playhead seconds only for tag playback overrides", () => { + assert.equal(isTimelineTagPlaybackOverrideId("sg-tag-row-1"), true); + assert.equal(isTimelineTagPlaybackOverrideId("sg-matrix-playlist-generated"), false); + assert.equal(isTimelineTagPlaybackOverrideId(null), false); + + assert.equal( + getTimelinePanelInputPlayheadSeconds({ + playbackOverrideId: "sg-tag-row-1", + playheadBaseSeconds: 100, + playerLocalSeconds: 3, + }), + 3 + ); + assert.equal( + getTimelinePanelInputPlayheadSeconds({ + playbackOverrideId: "sg-matrix-playlist-generated", + playheadBaseSeconds: 100, + playerLocalSeconds: 3, + }), + 3 + ); + assert.equal( + getTimelinePanelInputPlayheadSeconds({ + playbackOverrideId: null, + playheadBaseSeconds: 100, + playerLocalSeconds: 3, + }), + 103 + ); +}); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-tag-types.test.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-tag-types.test.ts new file mode 100644 index 00000000000..0842b61317c --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-tag-types.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { SgTagRow } from "../../types"; + +// Node's type-stripping test runner requires explicit TypeScript extensions. +// @ts-expect-error See comment above. +import { buildTimelineTagTypeOptions, getTimelineRowTagTypeKeys } from "../utils/timeline-tag-types.ts"; + +const buildRow = (overrides: Partial<SgTagRow>): SgTagRow => ({ + action: "Run", + clipDurationSeconds: null, + clipEndSeconds: null, + clipId: null, + clipRangeSource: null, + clipStartSeconds: null, + context: {}, + groupValue: "Quarter 1", + id: "tag-1", + matrixParticipant: null, + matrixPeriod: null, + player: "--", + playlistFallbackTimestamp: null, + playlistTimestamp: null, + primaryDetail: "", + result: "--", + secondaryDetail: "", + sourceTagId: null, + sourceUrl: "", + team: "home", + thumbnailUrl: "", + timecode: "00:00", + ...overrides, +}); + +test("american football timeline exposes the full catalog of tag filters", () => { + const options = buildTimelineTagTypeOptions([], "american-football"); + + assert.equal(options.length, 31); + assert.deepEqual( + options.map((option) => option.group), + [ + ...Array(7).fill("Play call"), + ...Array(5).fill("Special teams"), + ...Array(6).fill("Outcome"), + ...Array(5).fill("Defense"), + ...Array(4).fill("Down & distance"), + ...Array(4).fill("Player notes"), + ] + ); + assert.deepEqual( + options.slice(0, 7).map((option) => option.label), + ["Pass complete", "Pass incomplete", "Run", "Sack", "Play action", "Bootleg", "Draw"] + ); +}); + +test("american football catalog uses the provided tag color codes", () => { + const colorsByLabel = new Map( + buildTimelineTagTypeOptions([], "american-football").map((option) => [option.label, option.color]) + ); + + assert.deepEqual(Object.fromEntries(colorsByLabel), { + "Pass complete": "#7AACD0", + "Pass incomplete": "#E07B4E", + Run: "#86CF95", + Sack: "#E7A0B8", + "Play action": "#4EB5DE", + Bootleg: "#7BCCE0", + Draw: "#CADF72", + Kickoff: "#F5B400", + Punt: "#F07C4A", + "Field goal": "#F0E24A", + "Two point": "#F0904A", + "Onside kick": "#E0C07B", + Touchdown: "#05E5AD", + Turnover: "#DC2626", + "Explosive play": "#FD9038", + Penalty: "#DE4EA8", + "Big loss": "#A84EDE", + "Red zone entry": "#DE4E6B", + Blitz: "#C4A0F0", + Interception: "#DE4EB0", + "Sack (defense)": "#9C7BD4", + "Coverage breakdown": "#DE7BA8", + "Missed tackle": "#D47B9C", + "3rd down": "#4A9EDE", + "4th down": "#4A9EDE", + "Goal line": "#4ADEC4", + "2-minute drill": "#E85A4F", + "Highlight play": "#F0D74A", + "Coach flag": "#F07A4A", + Injury: "#F05A5A", + Substitution: "#A0B0C0", + }); +}); + +test("football rows can match multiple independent catalog filters", () => { + const keys = getTimelineRowTagTypeKeys( + buildRow({ + action: "pass_complete", + context: { down: "3", highlight: "true" }, + primaryDetail: "3rd & 6", + result: "Touchdown", + }), + "american-football" + ); + + assert.ok(keys.includes("passComplete")); + assert.ok(keys.includes("touchdown")); + assert.ok(keys.includes("thirdDown")); + assert.ok(keys.includes("highlight")); +}); + +test("unknown football tag values remain available as observed filters", () => { + const [option] = buildTimelineTagTypeOptions( + [ + buildRow({ + action: "custom_trick_play", + result: "--", + }), + ], + "american-football" + ).filter((currentOption) => currentOption.key === "observed:custom trick play"); + + assert.equal(option?.label, "Custom Trick Play"); + assert.equal(option?.group, "Observed tags"); +}); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-track-assignment.test.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-track-assignment.test.ts new file mode 100644 index 00000000000..4cb585b7482 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/__tests__/timeline-track-assignment.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Node's type-stripping test runner requires explicit TypeScript extensions. +// @ts-expect-error See comment above. +import { getTimelineCategoryLaneId, getTimelineRowLaneIds } from "../utils/timeline-track-assignment.ts"; + +const footballCategoryLanes = [ + { + id: "offense", + keywords: ["pass", "run", "touchdown"], + }, + { + id: "defense", + keywords: ["interception", "sack", "turnover"], + }, + { + id: "special", + keywords: ["field goal", "kickoff", "punt"], + }, +]; + +const buildRow = (overrides: Partial<Parameters<typeof getTimelineRowLaneIds>[0]> = {}) => ({ + action: "", + context: {}, + groupValue: "", + player: "", + primaryDetail: "", + result: "", + secondaryDetail: "", + team: "", + ...overrides, +}); + +test("player-tagged rows still render in their matching category lane", () => { + const row = buildRow({ + action: "pass_complete", + player: "#09", + }); + + assert.deepEqual(getTimelineRowLaneIds(row, footballCategoryLanes), ["offense", "player-9"]); +}); + +test("category assignment also uses tag context metadata", () => { + const row = buildRow({ + action: "return", + context: { phase: "special teams kickoff" }, + player: "--", + }); + + assert.equal(getTimelineCategoryLaneId(row, footballCategoryLanes), "special"); + assert.deepEqual(getTimelineRowLaneIds(row, footballCategoryLanes), ["special"]); +}); diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/components/timeline-panel.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/components/timeline-panel.tsx new file mode 100644 index 00000000000..9c9d2a01bd4 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/components/timeline-panel.tsx @@ -0,0 +1,1145 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { MouseEvent, PointerEvent, UIEvent, WheelEvent } from "react"; +import { + Check, + Eye, + ListPlus, + Minus, + MousePointer2, + Plus, + RotateCcw, + SkipBack, + SkipForward, + Tags, + X, +} from "lucide-react"; +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; +import { SURFACE_CLASS } from "../../constants"; +import type { SgTagRow, SportTableKind } from "../../types"; +import { + TIMELINE_CANVAS_CONTENT_CLASS, + TIMELINE_HORIZONTAL_SCROLL_CLASS, + TIMELINE_LANE_LABEL_COLUMN_CLASS, + TIMELINE_PANEL_ROOT_CLASS, + TIMELINE_RULER_CONTENT_CLASS, + TIMELINE_RULER_SCROLL_CLASS, + TIMELINE_STICKY_FOOTER_CLASS, + TIMELINE_TRACKS_ROW_CLASS, + TIMELINE_TRACKS_SCROLL_CLASS, + getTimelineHorizontalWheelDeltaPx, + getTimelineZoomWheelDirection, +} from "../utils/timeline-layout"; +import { + buildLaneMarkerOffsets, + buildSortedTimelineRows, + buildTagPlaybackOverrideId, + buildTimelineLanes, + buildTimelinePlacements, + buildTimelineTagTypeOptions, + getPlaybackOverrideRowId, + getPositiveDurationSeconds, + getTimelinePrimaryTagTypeKey, + getTimelineRowTagTypeKeys, + hashString, + LANE_TONE_CLASS, + MARKER_COLORS, + PLAYHEAD_OVERFLOW_BUCKET_SECONDS, +} from "../utils/timeline-model"; +import type { TimelineRowPlacement, TimelineTagTypeOption } from "../utils/timeline-model"; +import { + DEFAULT_TIMELINE_TAG_DURATION_SECONDS, + DEFAULT_TIMELINE_SCALE_INDEX, + TIMELINE_SCALE_LEVELS, + buildTimelineZoomStops, + buildScaledTimelineTicks, + getTimelineContentWidth, + getTimelineEffectiveContentWidth, + getTimelinePlaybackSeconds, + getTimelineRangePixels, + getTimelineSecondsFromClientX, + getTimelineTimePixel, + getTimelineVisibleDurationLabel, + getTimelineZoomLabel, + getTimelineZoomStopIndex, + getTimelineZoomStopIndexFromSliderValue, + isTimelineTagPlaybackOverrideId, +} from "../utils/timeline-scale"; +import { formatTooltipText, TimelineTagTooltip } from "./timeline-tag-tooltip"; +import { TimelineTagTypesPanel } from "./timeline-tag-types-panel"; + +type SgEventTimelinePanelProps = { + activePlaybackOverrideId: string | null; + activeTagRowId: string | null; + isCreatingPlaylist?: boolean; + isMediaLoading: boolean; + isPlaylistSelectionMode?: boolean; + onClearTagSelection?: () => void; + onCreatePlaylist?: () => void; + isPlayerPlaying: boolean; + onPlayTagRow: (row: SgTagRow) => Promise<void>; + onPlaylistSelectionModeChange?: (nextValue: boolean) => void; + onResetPlayback: () => void; + onSeekTimelineSeconds?: (seconds: number) => void; + onToggleTagSelection: (tagId: string) => void; + playerDurationSeconds: number | null; + playheadSeconds: number; + playerPlaybackRate: number; + playerLabelByNumber: Map<string, string>; + rows: SgTagRow[]; + selectedTagIds: string[]; + sport: SportTableKind; + tagTypeRows?: SgTagRow[]; +}; + +type TimelineZoomAnchor = { + seconds: number; + viewportOffsetPx: number; +}; + +const TOOL_BUTTON_CLASS = + "inline-flex h-8 w-8 items-center justify-center rounded-md text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100"; +const TEXT_TOOL_BUTTON_CLASS = + "inline-flex h-8 items-center gap-2 rounded-md border px-3 text-xs font-medium transition-colors"; + +const getPlayheadTransform = (positionPx: number) => `translate3d(${positionPx}px, 0, 0) translateX(-50%)`; + +const getTimelineTickLabelClassName = (position: number) => + cn( + "absolute top-4 whitespace-nowrap text-[10px] font-medium tabular-nums leading-none text-custom-text-400", + position <= 0 ? "translate-x-0" : position >= 100 ? "-translate-x-full" : "-translate-x-1/2" + ); + +export const SgEventTimelinePanel = ({ + activePlaybackOverrideId, + activeTagRowId, + isCreatingPlaylist = false, + isMediaLoading, + isPlaylistSelectionMode = false, + onClearTagSelection, + onCreatePlaylist, + isPlayerPlaying, + onPlayTagRow, + onPlaylistSelectionModeChange, + onResetPlayback, + onSeekTimelineSeconds, + onToggleTagSelection, + playerDurationSeconds, + playheadSeconds, + playerPlaybackRate, + playerLabelByNumber, + rows, + selectedTagIds, + sport, + tagTypeRows, +}: SgEventTimelinePanelProps) => { + const [isTagTypesPanelOpen, setIsTagTypesPanelOpen] = useState(false); + const [visibleTagTypeKeys, setVisibleTagTypeKeys] = useState<string[] | null>(null); + const [tagTypeSearchQuery, setTagTypeSearchQuery] = useState(""); + const [collapsedTagTypeGroups, setCollapsedTagTypeGroups] = useState<Record<string, boolean>>({}); + const [timelineScaleIndex, setTimelineScaleIndex] = useState(DEFAULT_TIMELINE_SCALE_INDEX); + const [timelineViewportWidth, setTimelineViewportWidth] = useState(0); + const timelineScrollRef = useRef<HTMLDivElement | null>(null); + const timelineRulerScrollRef = useRef<HTMLDivElement | null>(null); + const playheadTrackElementRef = useRef<HTMLDivElement | null>(null); + const playheadRulerElementRef = useRef<HTMLDivElement | null>(null); + const skimmerTrackElementRef = useRef<HTMLDivElement | null>(null); + const skimmerRulerElementRef = useRef<HTMLDivElement | null>(null); + const skimmerSecondsRef = useRef<number | null>(null); + const lastTimelinePointerClientXRef = useRef<number | null>(null); + const lastTimelinePointerViewportRef = useRef<HTMLDivElement | null>(null); + const fullStreamDurationSecondsRef = useRef<number | null>(null); + const pendingTimelineZoomAnchorRef = useRef<TimelineZoomAnchor | null>(null); + const isTagClipActive = isTimelineTagPlaybackOverrideId(activePlaybackOverrideId); + const activePlaybackRowId = getPlaybackOverrideRowId(activePlaybackOverrideId); + const timelineDurationSeconds = isTagClipActive ? fullStreamDurationSecondsRef.current : playerDurationSeconds; + const tagTypeSourceRows = tagTypeRows ?? rows; + const tagTypeOptions = useMemo( + () => buildTimelineTagTypeOptions(tagTypeSourceRows, sport), + [sport, tagTypeSourceRows] + ); + const defaultVisibleTagTypeKeys = useMemo( + () => tagTypeOptions.filter((option) => option.defaultVisible).map((option) => option.key), + [tagTypeOptions] + ); + const tagTypeOptionKeySet = useMemo(() => new Set(tagTypeOptions.map((option) => option.key)), [tagTypeOptions]); + const activeVisibleTagTypeKeys = useMemo( + () => (visibleTagTypeKeys ?? defaultVisibleTagTypeKeys).filter((key) => tagTypeOptionKeySet.has(key)), + [defaultVisibleTagTypeKeys, tagTypeOptionKeySet, visibleTagTypeKeys] + ); + const activeVisibleTagTypeKeySet = useMemo(() => new Set(activeVisibleTagTypeKeys), [activeVisibleTagTypeKeys]); + const tagTypeOptionsByKey = useMemo( + () => new Map(tagTypeOptions.map((option) => [option.key, option])), + [tagTypeOptions] + ); + const visibleTimelineRows = useMemo( + () => + rows.filter((row) => getTimelineRowTagTypeKeys(row, sport).some((key) => activeVisibleTagTypeKeySet.has(key))), + [activeVisibleTagTypeKeySet, rows, sport] + ); + const timelineLanes = useMemo( + () => buildTimelineLanes(visibleTimelineRows, sport, playerLabelByNumber), + [playerLabelByNumber, sport, visibleTimelineRows] + ); + const activeClipDurationByRowId = useMemo(() => { + const activeClipDurationSeconds = + isTagClipActive && activePlaybackRowId ? getPositiveDurationSeconds(playerDurationSeconds) : null; + + return activeClipDurationSeconds !== null && activePlaybackRowId + ? new Map([[activePlaybackRowId, activeClipDurationSeconds]]) + : new Map<string, number>(); + }, [activePlaybackRowId, isTagClipActive, playerDurationSeconds]); + const rowPlacements = useMemo( + () => buildTimelinePlacements(visibleTimelineRows, timelineDurationSeconds, activeClipDurationByRowId), + [activeClipDurationByRowId, timelineDurationSeconds, visibleTimelineRows] + ); + const sortedTimelineRows = useMemo( + () => buildSortedTimelineRows(visibleTimelineRows, rowPlacements), + [rowPlacements, visibleTimelineRows] + ); + const normalizedTagTypeSearchQuery = tagTypeSearchQuery.trim().toLowerCase(); + const tagTypeGroups = useMemo(() => { + const groupsByName = new Map<string, TimelineTagTypeOption[]>(); + + tagTypeOptions.forEach((option) => { + if ( + normalizedTagTypeSearchQuery && + !`${option.label} ${option.group} ${option.key}`.toLowerCase().includes(normalizedTagTypeSearchQuery) + ) { + return; + } + + const currentOptions = groupsByName.get(option.group) ?? []; + currentOptions.push(option); + groupsByName.set(option.group, currentOptions); + }); + + return Array.from(groupsByName.entries()) + .map(([name, options]) => ({ + name, + options: options.sort((left, right) => left.label.localeCompare(right.label)), + order: Math.min(...options.map((option) => option.order)), + })) + .sort((left, right) => left.order - right.order || left.name.localeCompare(right.name)); + }, [normalizedTagTypeSearchQuery, tagTypeOptions]); + const visibleTagTypeCount = tagTypeOptions.filter((option) => activeVisibleTagTypeKeySet.has(option.key)).length; + const totalTagTypeCount = tagTypeOptions.length; + const activeTimelineRowIndex = sortedTimelineRows.findIndex( + (row) => row.id === activeTagRowId || row.id === activePlaybackRowId + ); + const activeTimelineRow = activeTimelineRowIndex >= 0 ? sortedTimelineRows[activeTimelineRowIndex] : null; + const activeTimelinePlacement = activeTimelineRow ? rowPlacements[activeTimelineRow.id] : null; + const timelinePlayheadSecondsRaw = getTimelinePlaybackSeconds({ + activeClipStartSeconds: isTagClipActive ? (activeTimelinePlacement?.startSeconds ?? null) : null, + isClipPlaybackActive: isTagClipActive, + playheadSeconds, + }); + const maxRowSeconds = Object.values(rowPlacements).reduce( + (maxSeconds, placement) => Math.max(maxSeconds, placement.endSeconds ?? placement.startSeconds), + 0 + ); + const knownTimelineExtentSeconds = Math.max(maxRowSeconds, timelineDurationSeconds ?? 0, 60); + const overflowTimelineExtentSeconds = + timelinePlayheadSecondsRaw > knownTimelineExtentSeconds + ? Math.ceil(timelinePlayheadSecondsRaw / PLAYHEAD_OVERFLOW_BUCKET_SECONDS) * PLAYHEAD_OVERFLOW_BUCKET_SECONDS + : knownTimelineExtentSeconds; + const timelineExtentSeconds = Math.max(knownTimelineExtentSeconds, overflowTimelineExtentSeconds); + const totalSeconds = Math.max(1, Math.ceil(timelineExtentSeconds || 0)); + const timelineZoomStops = useMemo( + () => + buildTimelineZoomStops({ + totalSeconds, + viewportWidthPx: timelineViewportWidth, + }), + [timelineViewportWidth, totalSeconds] + ); + const activeTimelineZoomStopIndex = getTimelineZoomStopIndex({ + scaleIndex: timelineScaleIndex, + zoomStops: timelineZoomStops, + }); + const effectiveTimelineScaleIndex = timelineZoomStops[activeTimelineZoomStopIndex]?.scaleIndex ?? timelineScaleIndex; + const timelineScale = + TIMELINE_SCALE_LEVELS[effectiveTimelineScaleIndex] ?? TIMELINE_SCALE_LEVELS[DEFAULT_TIMELINE_SCALE_INDEX]; + const selectedTimelineContentWidth = getTimelineContentWidth(timelineScale, totalSeconds); + const timelineContentWidth = getTimelineEffectiveContentWidth({ + selectedContentWidthPx: selectedTimelineContentWidth, + viewportWidthPx: timelineViewportWidth, + }); + const timelinePlayheadSeconds = Math.min(timelinePlayheadSecondsRaw, totalSeconds); + const playheadPositionPx = getTimelineTimePixel(timelinePlayheadSeconds, totalSeconds, timelineContentWidth); + const visibleTicks = buildScaledTimelineTicks(totalSeconds, timelineScale, timelineContentWidth); + const canZoomOut = activeTimelineZoomStopIndex > 0; + const canZoomIn = activeTimelineZoomStopIndex < timelineZoomStops.length - 1; + const hasTimelineRows = sortedTimelineRows.length > 0; + const selectedTagCount = selectedTagIds.length; + const canCreatePlaylist = Boolean(onCreatePlaylist) && selectedTagCount > 0 && !isCreatingPlaylist; + const timelineZoomLabel = getTimelineZoomLabel({ + scale: timelineScale, + selectedContentWidthPx: selectedTimelineContentWidth, + viewportWidthPx: timelineViewportWidth, + }); + const timelineVisibleDurationLabel = getTimelineVisibleDurationLabel({ + contentWidthPx: timelineContentWidth, + totalSeconds, + viewportWidthPx: timelineViewportWidth, + }); + const timelineZoomDetailLabel = timelineVisibleDurationLabel + ? `${timelineZoomLabel.detailLabel} · ${timelineVisibleDurationLabel.detailLabel}` + : timelineZoomLabel.detailLabel; + const seekableDurationSeconds = Math.max( + 0, + timelineDurationSeconds ?? fullStreamDurationSecondsRef.current ?? totalSeconds + ); + + const setTimelineIndicatorPosition = useCallback( + (element: HTMLDivElement | null, seconds: number) => { + if (!element) return; + + element.style.transform = getPlayheadTransform(getTimelineTimePixel(seconds, totalSeconds, timelineContentWidth)); + }, + [timelineContentWidth, totalSeconds] + ); + + const setPlaybackPlayheadPosition = useCallback( + (seconds: number) => { + setTimelineIndicatorPosition(playheadTrackElementRef.current, seconds); + setTimelineIndicatorPosition(playheadRulerElementRef.current, seconds); + }, + [setTimelineIndicatorPosition] + ); + + const setTimelineSkimmerVisible = useCallback((isVisible: boolean) => { + const opacity = isVisible ? "1" : "0"; + + if (skimmerTrackElementRef.current) { + skimmerTrackElementRef.current.style.opacity = opacity; + } + if (skimmerRulerElementRef.current) { + skimmerRulerElementRef.current.style.opacity = opacity; + } + }, []); + + const setTimelineSkimmerPosition = useCallback( + (seconds: number) => { + skimmerSecondsRef.current = seconds; + setTimelineIndicatorPosition(skimmerTrackElementRef.current, seconds); + setTimelineIndicatorPosition(skimmerRulerElementRef.current, seconds); + setTimelineSkimmerVisible(true); + }, + [setTimelineIndicatorPosition, setTimelineSkimmerVisible] + ); + + const getTimelinePointerSeconds = useCallback( + (clientX: number, viewportElement: HTMLDivElement) => { + const viewportRect = viewportElement.getBoundingClientRect(); + const scrollLeftPx = timelineRulerScrollRef.current?.scrollLeft ?? timelineScrollRef.current?.scrollLeft ?? 0; + + return getTimelineSecondsFromClientX({ + clientX, + contentWidthPx: timelineContentWidth, + scrollLeftPx, + seekableSeconds: seekableDurationSeconds, + totalSeconds, + viewportLeftPx: viewportRect.left, + }); + }, + [seekableDurationSeconds, timelineContentWidth, totalSeconds] + ); + + const isTimelineHorizontalScrollbarPointer = useCallback( + (event: { clientY: number }, element: HTMLDivElement) => { + const scrollbarHeight = Math.max(0, element.offsetHeight - element.clientHeight); + if (scrollbarHeight <= 0) return false; + + return event.clientY >= element.getBoundingClientRect().bottom - scrollbarHeight; + }, + [] + ); + + const refreshTimelineSkimmerFromLastPointer = useCallback(() => { + const lastClientX = lastTimelinePointerClientXRef.current; + const lastViewportElement = lastTimelinePointerViewportRef.current; + if (lastClientX === null || !lastViewportElement || skimmerSecondsRef.current === null) return; + + setTimelineSkimmerPosition(getTimelinePointerSeconds(lastClientX, lastViewportElement)); + }, [getTimelinePointerSeconds, setTimelineSkimmerPosition]); + + const handleTimelinePointerMove = useCallback( + (event: PointerEvent<HTMLDivElement>) => { + if (isTimelineHorizontalScrollbarPointer(event, event.currentTarget)) return; + + lastTimelinePointerClientXRef.current = event.clientX; + lastTimelinePointerViewportRef.current = event.currentTarget; + setTimelineSkimmerPosition(getTimelinePointerSeconds(event.clientX, event.currentTarget)); + }, + [getTimelinePointerSeconds, isTimelineHorizontalScrollbarPointer, setTimelineSkimmerPosition] + ); + + const handleTimelinePointerLeave = useCallback(() => { + skimmerSecondsRef.current = null; + lastTimelinePointerClientXRef.current = null; + lastTimelinePointerViewportRef.current = null; + setTimelineSkimmerVisible(false); + }, [setTimelineSkimmerVisible]); + + const handleTimelineSeekClick = useCallback( + (event: MouseEvent<HTMLDivElement>) => { + if (event.button !== 0 || isTimelineHorizontalScrollbarPointer(event, event.currentTarget)) return; + + const seekSeconds = getTimelinePointerSeconds(event.clientX, event.currentTarget); + setPlaybackPlayheadPosition(seekSeconds); + onSeekTimelineSeconds?.(seekSeconds); + }, + [ + getTimelinePointerSeconds, + isTimelineHorizontalScrollbarPointer, + onSeekTimelineSeconds, + setPlaybackPlayheadPosition, + ] + ); + + const setTimelineScrollLeft = useCallback((nextScrollLeft: number) => { + const rulerScrollElement = timelineRulerScrollRef.current; + const trackScrollElement = timelineScrollRef.current; + const scrollElement = rulerScrollElement ?? trackScrollElement; + if (!scrollElement) return 0; + + const maxScrollLeft = Math.max(0, scrollElement.scrollWidth - scrollElement.clientWidth); + const clampedScrollLeft = Math.min(Math.max(nextScrollLeft, 0), maxScrollLeft); + + if (rulerScrollElement && Math.abs(rulerScrollElement.scrollLeft - clampedScrollLeft) >= 1) { + rulerScrollElement.scrollLeft = clampedScrollLeft; + } + if (trackScrollElement && Math.abs(trackScrollElement.scrollLeft - clampedScrollLeft) >= 1) { + trackScrollElement.scrollLeft = clampedScrollLeft; + } + + return clampedScrollLeft; + }, []); + + const getTimelineZoomAnchorFromClientX = useCallback( + (clientX: number, viewportElement: HTMLDivElement): TimelineZoomAnchor => { + const viewportRect = viewportElement.getBoundingClientRect(); + const viewportOffsetPx = Math.min(Math.max(clientX - viewportRect.left, 0), viewportElement.clientWidth); + const scrollLeftPx = timelineRulerScrollRef.current?.scrollLeft ?? timelineScrollRef.current?.scrollLeft ?? 0; + + return { + seconds: getTimelineSecondsFromClientX({ + clientX, + contentWidthPx: timelineContentWidth, + scrollLeftPx, + totalSeconds, + viewportLeftPx: viewportRect.left, + }), + viewportOffsetPx, + }; + }, + [timelineContentWidth, totalSeconds] + ); + + const getTimelineZoomAnchor = useCallback((): TimelineZoomAnchor | null => { + const scrollElement = timelineRulerScrollRef.current ?? timelineScrollRef.current; + if (!scrollElement) return null; + + const pointerViewportElement = lastTimelinePointerViewportRef.current; + const pointerClientX = lastTimelinePointerClientXRef.current; + const pointerIsOverTimeline = + pointerViewportElement !== null && + pointerClientX !== null && + pointerViewportElement.isConnected && + pointerViewportElement.matches(":hover"); + + if (pointerIsOverTimeline) { + return getTimelineZoomAnchorFromClientX(pointerClientX, pointerViewportElement); + } + + const playheadPosition = getTimelineTimePixel(timelinePlayheadSeconds, totalSeconds, timelineContentWidth); + const viewportLeft = scrollElement.scrollLeft; + const viewportRight = viewportLeft + scrollElement.clientWidth; + + if (playheadPosition >= viewportLeft && playheadPosition <= viewportRight) { + return { + seconds: timelinePlayheadSeconds, + viewportOffsetPx: playheadPosition - viewportLeft, + }; + } + + const viewportRect = scrollElement.getBoundingClientRect(); + const viewportOffsetPx = scrollElement.clientWidth / 2; + + return { + seconds: getTimelineSecondsFromClientX({ + clientX: viewportRect.left + viewportOffsetPx, + contentWidthPx: timelineContentWidth, + scrollLeftPx: scrollElement.scrollLeft, + totalSeconds, + viewportLeftPx: viewportRect.left, + }), + viewportOffsetPx, + }; + }, [getTimelineZoomAnchorFromClientX, timelineContentWidth, timelinePlayheadSeconds, totalSeconds]); + + const restorePendingTimelineZoomAnchor = useCallback(() => { + const zoomAnchor = pendingTimelineZoomAnchorRef.current; + if (!zoomAnchor) return; + + const nextAnchorPositionPx = getTimelineTimePixel(zoomAnchor.seconds, totalSeconds, timelineContentWidth); + setTimelineScrollLeft(nextAnchorPositionPx - zoomAnchor.viewportOffsetPx); + pendingTimelineZoomAnchorRef.current = null; + refreshTimelineSkimmerFromLastPointer(); + }, [refreshTimelineSkimmerFromLastPointer, setTimelineScrollLeft, timelineContentWidth, totalSeconds]); + + const applyTimelineZoomStopIndex = useCallback( + (nextZoomStopIndex: number, zoomAnchor: TimelineZoomAnchor | null = getTimelineZoomAnchor()) => { + const nextZoomStop = timelineZoomStops[nextZoomStopIndex]; + if (!nextZoomStop) return; + + pendingTimelineZoomAnchorRef.current = zoomAnchor; + setTimelineScaleIndex(nextZoomStop.scaleIndex); + }, + [getTimelineZoomAnchor, timelineZoomStops] + ); + + useEffect(() => { + if (isTagClipActive || playerDurationSeconds === null || playerDurationSeconds <= 0) return; + + fullStreamDurationSecondsRef.current = playerDurationSeconds; + }, [isTagClipActive, playerDurationSeconds]); + + useEffect(() => { + const anchorSeconds = timelinePlayheadSeconds; + const anchorTimeMs = window.performance.now(); + const activePlaybackRate = Number.isFinite(playerPlaybackRate) && playerPlaybackRate > 0 ? playerPlaybackRate : 1; + + setPlaybackPlayheadPosition(anchorSeconds); + + if (!isPlayerPlaying || anchorSeconds >= totalSeconds) return; + + let animationFrameId = 0; + const animatePlayhead = (currentTimeMs: number) => { + const elapsedSeconds = Math.max(0, (currentTimeMs - anchorTimeMs) / 1000) * activePlaybackRate; + const interpolatedSeconds = Math.min(anchorSeconds + elapsedSeconds, totalSeconds); + + setPlaybackPlayheadPosition(interpolatedSeconds); + + if (interpolatedSeconds < totalSeconds) { + animationFrameId = window.requestAnimationFrame(animatePlayhead); + } + }; + + animationFrameId = window.requestAnimationFrame(animatePlayhead); + + return () => window.cancelAnimationFrame(animationFrameId); + }, [isPlayerPlaying, playerPlaybackRate, setPlaybackPlayheadPosition, timelinePlayheadSeconds, totalSeconds]); + + useEffect(() => { + refreshTimelineSkimmerFromLastPointer(); + }, [refreshTimelineSkimmerFromLastPointer]); + + useEffect(() => { + const viewportElements = [timelineScrollRef.current, timelineRulerScrollRef.current].filter( + (element): element is HTMLDivElement => Boolean(element) + ); + if (viewportElements.length === 0) return; + + const updateTimelineViewportWidth = () => { + const nextViewportWidth = Math.max(...viewportElements.map((element) => element.clientWidth), 0); + + setTimelineViewportWidth((currentWidth) => + Math.abs(currentWidth - nextViewportWidth) < 1 ? currentWidth : nextViewportWidth + ); + }; + + updateTimelineViewportWidth(); + + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", updateTimelineViewportWidth); + return () => window.removeEventListener("resize", updateTimelineViewportWidth); + } + + const resizeObserver = new ResizeObserver(updateTimelineViewportWidth); + viewportElements.forEach((element) => resizeObserver.observe(element)); + + return () => resizeObserver.disconnect(); + }, []); + + useEffect(() => { + const trackScrollElement = timelineScrollRef.current; + const rulerScrollElement = timelineRulerScrollRef.current; + if (!trackScrollElement || !rulerScrollElement) return; + + trackScrollElement.scrollLeft = rulerScrollElement.scrollLeft; + }, [timelineContentWidth]); + + const syncTimelineTrackScroll = (nextScrollLeft: number) => { + const trackScrollElement = timelineScrollRef.current; + if (!trackScrollElement || Math.abs(trackScrollElement.scrollLeft - nextScrollLeft) < 1) return; + + trackScrollElement.scrollLeft = nextScrollLeft; + }; + + const handleTimelineRulerScroll = (event: UIEvent<HTMLDivElement>) => { + syncTimelineTrackScroll(event.currentTarget.scrollLeft); + refreshTimelineSkimmerFromLastPointer(); + }; + + const scrollTimelineTo = (nextScrollLeft: number, behavior: "auto" | "smooth" = "auto") => { + const rulerScrollElement = timelineRulerScrollRef.current; + const trackScrollElement = timelineScrollRef.current; + const scrollElement = rulerScrollElement ?? trackScrollElement; + if (!scrollElement) return; + + scrollElement.scrollTo({ behavior, left: nextScrollLeft }); + if (behavior === "auto") { + syncTimelineTrackScroll(nextScrollLeft); + refreshTimelineSkimmerFromLastPointer(); + } + }; + + const scrollTimelineBy = (deltaX: number) => { + const rulerScrollElement = timelineRulerScrollRef.current; + if (!rulerScrollElement) return false; + + const maxScrollLeft = Math.max(0, rulerScrollElement.scrollWidth - rulerScrollElement.clientWidth); + const nextScrollLeft = Math.min(Math.max(rulerScrollElement.scrollLeft + deltaX, 0), maxScrollLeft); + if (Math.abs(rulerScrollElement.scrollLeft - nextScrollLeft) < 1) return false; + + rulerScrollElement.scrollLeft = nextScrollLeft; + syncTimelineTrackScroll(nextScrollLeft); + refreshTimelineSkimmerFromLastPointer(); + return true; + }; + + const handleTimelineHorizontalWheel = (event: WheelEvent<HTMLDivElement>) => { + const zoomDirection = getTimelineZoomWheelDirection({ + altKey: event.altKey, + deltaY: event.deltaY, + }); + if (zoomDirection) { + applyTimelineZoomStopIndex( + activeTimelineZoomStopIndex + (zoomDirection === "in" ? 1 : -1), + getTimelineZoomAnchorFromClientX(event.clientX, event.currentTarget) + ); + event.preventDefault(); + event.stopPropagation(); + return; + } + + const deltaX = getTimelineHorizontalWheelDeltaPx({ + deltaX: event.deltaX, + deltaY: event.deltaY, + shiftKey: event.shiftKey, + }); + if (deltaX === 0 || !scrollTimelineBy(deltaX)) return; + + event.preventDefault(); + event.stopPropagation(); + }; + + const scrollTimelineRangeIntoView = ( + range: { leftPx: number; widthPx: number }, + behavior: "auto" | "smooth" = "smooth" + ) => { + const scrollElement = timelineRulerScrollRef.current ?? timelineScrollRef.current; + if (!scrollElement) return; + + const rangeLeft = range.leftPx; + const rangeRight = range.leftPx + range.widthPx; + const viewportLeft = scrollElement.scrollLeft; + const viewportRight = viewportLeft + scrollElement.clientWidth; + const padding = 80; + + if (rangeLeft < viewportLeft + padding) { + const nextScrollLeft = Math.max(0, rangeLeft - padding); + scrollTimelineTo(nextScrollLeft, behavior); + return; + } + + if (rangeRight > viewportRight - padding) { + const nextScrollLeft = Math.max(0, rangeRight - scrollElement.clientWidth + padding); + scrollTimelineTo(nextScrollLeft, behavior); + } + }; + + const getPlacementRange = (placement: TimelineRowPlacement) => + getTimelineRangePixels({ + contentWidthPx: timelineContentWidth, + endSeconds: placement.endSeconds, + startSeconds: placement.startSeconds, + totalSeconds, + }); + + const handlePlayTimelineRow = (row: SgTagRow) => { + const placement = rowPlacements[row.id]; + if (placement) { + scrollTimelineRangeIntoView(getPlacementRange(placement)); + } + + void onPlayTagRow(row); + }; + + useEffect(() => { + if (!activeTimelinePlacement) return; + if (pendingTimelineZoomAnchorRef.current) return; + + scrollTimelineRangeIntoView(getPlacementRange(activeTimelinePlacement), "auto"); + // The active placement identity intentionally drives scroll restoration on tag selection and zoom changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activePlaybackOverrideId, activeTagRowId, activeTimelinePlacement, timelineContentWidth, totalSeconds]); + + useEffect(() => { + restorePendingTimelineZoomAnchor(); + }, [restorePendingTimelineZoomAnchor]); + + const jumpToPreviousTag = () => { + if (!hasTimelineRows) return; + + const previousRow = + activeTimelineRowIndex >= 0 + ? sortedTimelineRows[(activeTimelineRowIndex - 1 + sortedTimelineRows.length) % sortedTimelineRows.length] + : ([...sortedTimelineRows] + .reverse() + .find((row) => (rowPlacements[row.id]?.startSeconds ?? 0) < Math.max(0, timelinePlayheadSeconds - 0.5)) ?? + sortedTimelineRows.at(-1)); + + if (previousRow) { + handlePlayTimelineRow(previousRow); + } + }; + const jumpToNextTag = () => { + if (!hasTimelineRows) return; + + const nextRow = + activeTimelineRowIndex >= 0 + ? sortedTimelineRows[(activeTimelineRowIndex + 1) % sortedTimelineRows.length] + : (sortedTimelineRows.find( + (row) => (rowPlacements[row.id]?.startSeconds ?? 0) > timelinePlayheadSeconds + 0.5 + ) ?? sortedTimelineRows[0]); + + if (nextRow) { + handlePlayTimelineRow(nextRow); + } + }; + const handleToggleTagType = (tagTypeKey: string) => { + setVisibleTagTypeKeys((currentValue) => { + const nextKeys = new Set(currentValue ?? defaultVisibleTagTypeKeys); + + if (nextKeys.has(tagTypeKey)) { + nextKeys.delete(tagTypeKey); + } else { + nextKeys.add(tagTypeKey); + } + + return Array.from(nextKeys); + }); + }; + const handleTimelineScaleChange = (direction: "in" | "out") => { + applyTimelineZoomStopIndex(activeTimelineZoomStopIndex + (direction === "in" ? 1 : -1)); + }; + const handleTimelineScaleSliderChange = (value: string) => { + applyTimelineZoomStopIndex( + getTimelineZoomStopIndexFromSliderValue(value, activeTimelineZoomStopIndex, timelineZoomStops.length) + ); + }; + + return ( + <section className={cn(SURFACE_CLASS, TIMELINE_PANEL_ROOT_CLASS)} onPointerLeave={handleTimelinePointerLeave}> + <div className="flex flex-col gap-3 border-b border-custom-border-200 px-3 py-2.5 lg:flex-row lg:items-center lg:justify-between"> + <div className="flex items-center gap-1"> + <Tooltip tooltipContent="Jump to previous tag" isMobile={false}> + <button + type="button" + onClick={jumpToPreviousTag} + disabled={!hasTimelineRows} + className={cn(TOOL_BUTTON_CLASS, !hasTimelineRows && "cursor-not-allowed opacity-40")} + > + <SkipBack className="h-4 w-4" /> + </button> + </Tooltip> + <Tooltip tooltipContent="Jump to next tag" isMobile={false}> + <button + type="button" + onClick={jumpToNextTag} + disabled={!hasTimelineRows} + className={cn(TOOL_BUTTON_CLASS, !hasTimelineRows && "cursor-not-allowed opacity-40")} + > + <SkipForward className="h-4 w-4" /> + </button> + </Tooltip> + <div className="mx-2 h-6 w-px bg-custom-border-200" /> + <Tooltip tooltipContent="Reset playhead" isMobile={false}> + <button type="button" onClick={onResetPlayback} className={TOOL_BUTTON_CLASS}> + <RotateCcw className="h-4 w-4" /> + </button> + </Tooltip> + </div> + + <div className="flex flex-wrap items-center justify-end gap-2"> + <button + type="button" + disabled={!onPlaylistSelectionModeChange || !hasTimelineRows} + onClick={() => onPlaylistSelectionModeChange?.(!isPlaylistSelectionMode)} + className={cn( + TEXT_TOOL_BUTTON_CLASS, + isPlaylistSelectionMode + ? "border-custom-primary-100/30 bg-custom-primary-100/15 text-custom-primary-100" + : "border-custom-border-200 bg-custom-background-100 text-custom-text-300 hover:bg-custom-background-90 hover:text-custom-text-100", + (!onPlaylistSelectionModeChange || !hasTimelineRows) && "cursor-not-allowed opacity-40" + )} + > + <MousePointer2 className="h-3.5 w-3.5" /> + <span>{isPlaylistSelectionMode ? "Selecting Clips" : "Select Clips"}</span> + </button> + {selectedTagCount > 0 && ( + <> + <span className="inline-flex h-8 items-center rounded-md border border-custom-border-200 bg-custom-background-100 px-2 text-xs text-custom-text-300"> + {selectedTagCount} selected + </span> + <button + type="button" + disabled={!onClearTagSelection} + onClick={onClearTagSelection} + className={cn( + TEXT_TOOL_BUTTON_CLASS, + "border-custom-border-200 bg-custom-background-100 text-custom-text-300 hover:bg-custom-background-90 hover:text-custom-text-100 disabled:cursor-not-allowed disabled:opacity-40" + )} + > + <X className="h-3.5 w-3.5" /> + <span>Clear</span> + </button> + </> + )} + <button + type="button" + disabled={!canCreatePlaylist} + onClick={onCreatePlaylist} + className={cn( + TEXT_TOOL_BUTTON_CLASS, + "border-custom-primary-100 bg-custom-primary-100 text-white hover:border-custom-primary-200 hover:bg-custom-primary-200 disabled:cursor-not-allowed disabled:opacity-40" + )} + > + <ListPlus className="h-3.5 w-3.5" /> + <span>{isCreatingPlaylist ? "Creating" : "Create Playlist"}</span> + </button> + <Tooltip tooltipContent="Tag types" isMobile={false}> + <button + type="button" + onClick={() => setIsTagTypesPanelOpen(true)} + disabled={totalTagTypeCount === 0} + className={cn( + TEXT_TOOL_BUTTON_CLASS, + isTagTypesPanelOpen + ? "border-custom-primary-100/30 bg-custom-primary-100/15 text-custom-primary-100" + : "border-custom-border-200 bg-custom-background-100 text-custom-text-300 hover:bg-custom-background-90 hover:text-custom-text-100", + totalTagTypeCount === 0 && "cursor-not-allowed opacity-40" + )} + > + <Tags className="h-3.5 w-3.5" /> + <span>Tags</span> + <span className="text-custom-text-400"> + {visibleTagTypeCount}/{totalTagTypeCount} + </span> + </button> + </Tooltip> + {/* <div className="inline-flex h-8 overflow-hidden rounded-md border border-custom-border-200 bg-custom-background-100"> + <Tooltip tooltipContent="Timeline view" isMobile={false}> + <button + type="button" + className="inline-flex h-8 w-8 items-center justify-center bg-custom-background-80 text-custom-text-100" + > + <Maximize2 className="h-3.5 w-3.5" /> + </button> + </Tooltip> + <Tooltip tooltipContent="Time labels" isMobile={false}> + <button + type="button" + className="inline-flex h-8 w-8 items-center justify-center border-l border-custom-border-200 text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100" + > + <Clock3 className="h-3.5 w-3.5" /> + </button> + </Tooltip> + <Tooltip tooltipContent="Expand lanes" isMobile={false}> + <button + type="button" + className="inline-flex h-8 w-8 items-center justify-center border-l border-custom-border-200 text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100" + > + <Copy className="h-3.5 w-3.5" /> + </button> + </Tooltip> + </div> */} + </div> + </div> + + <div className={TIMELINE_TRACKS_SCROLL_CLASS}> + <div className={TIMELINE_TRACKS_ROW_CLASS}> + <div className={TIMELINE_LANE_LABEL_COLUMN_CLASS}> + {timelineLanes.map((lane) => ( + <div + key={lane.id} + className={cn( + "flex h-10 items-center justify-between border-l-2 px-2 text-xs", + LANE_TONE_CLASS[lane.tone] + )} + > + <span className="min-w-0 truncate">{lane.label}</span> + <Eye className="h-3 w-3 shrink-0 opacity-70" /> + </div> + ))} + </div> + + <div + ref={timelineScrollRef} + onClick={handleTimelineSeekClick} + onPointerMove={handleTimelinePointerMove} + onWheel={handleTimelineHorizontalWheel} + className={TIMELINE_HORIZONTAL_SCROLL_CLASS} + > + <div + className={TIMELINE_CANVAS_CONTENT_CLASS} + style={{ width: timelineContentWidth }} + > + <div + ref={playheadTrackElementRef} + className="pointer-events-none absolute left-0 top-0 z-[4] h-full w-0 border-l-2 border-red-500 will-change-transform" + style={{ transform: getPlayheadTransform(playheadPositionPx) }} + /> + <div + ref={skimmerTrackElementRef} + className="pointer-events-none absolute left-0 top-0 z-[3] h-full w-0 border-l-2 border-sky-500 opacity-0 will-change-transform" + style={{ transform: getPlayheadTransform(0) }} + /> + {timelineLanes.map((lane) => { + const laneMarkerOffsets = buildLaneMarkerOffsets(lane.rows, rowPlacements); + + return ( + <div + key={lane.id} + className="relative h-10 border-b border-custom-border-200 bg-custom-background-90" + > + {lane.rows.map((row) => { + const placement = rowPlacements[row.id] ?? { + endSeconds: DEFAULT_TIMELINE_TAG_DURATION_SECONDS, + startSeconds: 0, + }; + const range = getPlacementRange(placement); + const markerTagTypeKey = getTimelinePrimaryTagTypeKey(row, sport, activeVisibleTagTypeKeySet); + const markerColor = + tagTypeOptionsByKey.get(markerTagTypeKey)?.color ?? + MARKER_COLORS[hashString(`${row.action}-${row.player}-${row.timecode}`) % MARKER_COLORS.length]; + const isActive = + activeTagRowId === row.id || activePlaybackOverrideId === buildTagPlaybackOverrideId(row); + const isSelected = selectedTagIds.includes(row.id); + const markerOffset = (laneMarkerOffsets[row.id] ?? 0) % 3; + + return ( + <Tooltip + key={`${lane.id}-${row.id}`} + tooltipContent={<TimelineTagTooltip placement={placement} row={row} />} + className="rounded-md border border-[#b8dcb5] bg-[#dff6dc] px-2 py-1 shadow-lg" + openDelay={80} + isMobile={false} + position="top" + > + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + + if (isPlaylistSelectionMode) { + event.preventDefault(); + onToggleTagSelection(row.id); + return; + } + + handlePlayTimelineRow(row); + }} + onDoubleClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + + if (!isPlaylistSelectionMode) { + onPlaylistSelectionModeChange?.(true); + onToggleTagSelection(row.id); + } + }} + className={cn( + "absolute h-7 min-w-1.5 overflow-hidden rounded-md border border-transparent text-left text-[10px] font-medium leading-7 text-white/95 shadow-sm transition-[box-shadow,filter] hover:brightness-110", + isPlaylistSelectionMode && "cursor-pointer hover:ring-2 hover:ring-white/40", + isActive && "ring-2 ring-custom-primary-100", + isSelected && "border-white/90 ring-2 ring-white/80" + )} + style={{ + backgroundColor: markerColor, + left: range.leftPx, + top: 6 + markerOffset * 3, + width: range.widthPx, + }} + aria-pressed={isActive || isSelected} + > + {isSelected && ( + <span className="pointer-events-none absolute right-0.5 top-0.5 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full bg-white text-custom-background-100 shadow"> + <Check className="h-2.5 w-2.5" /> + </span> + )} + <span className="pointer-events-none block truncate px-1.5"> + {formatTooltipText(row.action, "title") || "Tag"} + </span> + </button> + </Tooltip> + ); + })} + </div> + ); + })} + </div> + </div> + </div> + </div> + + <div className={cn(TIMELINE_STICKY_FOOTER_CLASS, "flex border-t border-custom-border-200")}> + <div + className={cn( + TIMELINE_LANE_LABEL_COLUMN_CLASS, + "flex h-10 items-center gap-2 px-3 text-[11px] text-custom-text-400" + )} + > + <span className="shrink-0">Zoom</span> + <span className="inline-flex min-w-0 flex-1 items-center justify-end gap-1.5"> + <Tooltip tooltipContent="Zoom out timeline" isMobile={false}> + <button + type="button" + aria-label="Zoom out timeline" + onClick={() => handleTimelineScaleChange("out")} + disabled={!canZoomOut} + className={cn( + "inline-flex h-6 w-6 items-center justify-center rounded text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100", + !canZoomOut && "cursor-not-allowed opacity-40" + )} + > + <Minus className="h-3.5 w-3.5" /> + </button> + </Tooltip> + <input + type="range" + min={0} + max={Math.max(0, timelineZoomStops.length - 1)} + step={1} + value={activeTimelineZoomStopIndex} + onChange={(event) => handleTimelineScaleSliderChange(event.currentTarget.value)} + aria-label="Timeline zoom" + aria-valuetext={timelineZoomDetailLabel} + className="h-6 w-16 accent-custom-primary-100" + /> + <Tooltip tooltipContent="Zoom in timeline" isMobile={false}> + <button + type="button" + aria-label="Zoom in timeline" + onClick={() => handleTimelineScaleChange("in")} + disabled={!canZoomIn} + className={cn( + "inline-flex h-6 w-6 items-center justify-center rounded text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100", + !canZoomIn && "cursor-not-allowed opacity-40" + )} + > + <Plus className="h-3.5 w-3.5" /> + </button> + </Tooltip> + <span + className="flex min-w-11 flex-col items-end text-right tabular-nums leading-none text-custom-text-300" + title={timelineZoomDetailLabel} + > + <span>{timelineZoomLabel.displayLabel}</span> + {timelineVisibleDurationLabel && ( + <span className="mt-0.5 text-[9px] text-custom-text-400">{timelineVisibleDurationLabel.compactLabel}</span> + )} + </span> + </span> + </div> + + <div + ref={timelineRulerScrollRef} + onClick={handleTimelineSeekClick} + onPointerMove={handleTimelinePointerMove} + onScroll={handleTimelineRulerScroll} + onWheel={handleTimelineHorizontalWheel} + className={TIMELINE_RULER_SCROLL_CLASS} + > + <div + className={TIMELINE_RULER_CONTENT_CLASS} + style={{ width: timelineContentWidth }} + > + <div + ref={playheadRulerElementRef} + className="pointer-events-none absolute left-0 top-0 z-[5] h-full w-0 border-l-2 border-red-500 will-change-transform" + style={{ transform: getPlayheadTransform(playheadPositionPx) }} + > + <span className="absolute left-1/2 top-0 h-3.5 w-2.5 -translate-x-1/2 rounded-b-sm bg-red-500" /> + </div> + <div + ref={skimmerRulerElementRef} + className="pointer-events-none absolute left-0 top-0 z-[4] h-full w-0 border-l-2 border-sky-500 opacity-0 will-change-transform" + style={{ transform: getPlayheadTransform(0) }} + > + <span className="absolute left-1/2 top-0 h-3.5 w-2.5 -translate-x-1/2 rounded-b-sm bg-sky-500" /> + </div> + {visibleTicks.map((tick) => { + const isMajorTick = tick.kind === "major"; + + return ( + <div + key={`tick-${tick.kind}-${tick.seconds}`} + className="pointer-events-none absolute top-0 h-8 -translate-x-px" + style={{ left: `${tick.position}%` }} + aria-hidden={!isMajorTick} + > + <span + className={cn( + "block w-px bg-custom-border-300", + isMajorTick ? "h-4 bg-custom-text-300" : "h-2.5 opacity-70" + )} + /> + {isMajorTick && <span className={getTimelineTickLabelClassName(tick.position)}>{tick.label}</span>} + </div> + ); + })} + <Plus className="absolute bottom-0 right-1 h-4 w-4 text-custom-text-400" /> + </div> + </div> + </div> + + {rows.length === 0 ? ( + <div className="border-t border-custom-border-200 px-5 py-10 text-center text-sm text-custom-text-400"> + No SG tags matched the current filter set. + </div> + ) : visibleTimelineRows.length === 0 ? ( + <div className="border-t border-custom-border-200 px-5 py-10 text-center text-sm text-custom-text-400"> + No SG tags match the visible tag types. + </div> + ) : null} + + {isMediaLoading && ( + <div className="border-t border-custom-border-200 px-4 py-2.5 text-xs text-custom-text-400"> + Syncing SG media package and playlist references for this event. + </div> + )} + + <TimelineTagTypesPanel + activeVisibleTagTypeKeySet={activeVisibleTagTypeKeySet} + collapsedTagTypeGroups={collapsedTagTypeGroups} + defaultVisibleTagTypeKeys={defaultVisibleTagTypeKeys} + isOpen={isTagTypesPanelOpen} + onClose={() => setIsTagTypesPanelOpen(false)} + onCollapsedTagTypeGroupsChange={setCollapsedTagTypeGroups} + onSearchQueryChange={setTagTypeSearchQuery} + onToggleTagType={handleToggleTagType} + onVisibleTagTypeKeysChange={setVisibleTagTypeKeys} + tagTypeGroups={tagTypeGroups} + tagTypeSearchQuery={tagTypeSearchQuery} + totalTagTypeCount={totalTagTypeCount} + visibleTagTypeCount={visibleTagTypeCount} + /> + </section> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/components/timeline-tag-tooltip.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/components/timeline-tag-tooltip.tsx new file mode 100644 index 00000000000..db25f204e31 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/components/timeline-tag-tooltip.tsx @@ -0,0 +1,35 @@ +import type { SgTagRow } from "../../types"; +import type { TimelineRowPlacement } from "../utils/timeline-model"; +import { formatTimelineTickLabel } from "../utils/timeline-scale"; + +export const formatTooltipText = (value: string, transform: "title" | "upper") => { + const normalizedValue = value.trim().replace(/[_-]+/g, " "); + if (!normalizedValue || normalizedValue === "--") return ""; + + if (transform === "upper") return normalizedValue.toUpperCase(); + + return normalizedValue + .split(" ") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(" "); +}; + +export const TimelineTagTooltip = ({ placement, row }: { placement: TimelineRowPlacement; row: SgTagRow }) => { + const startLabel = formatTimelineTickLabel(placement.startSeconds); + const endLabel = + placement.endSeconds !== null && placement.endSeconds > placement.startSeconds + ? formatTimelineTickLabel(placement.endSeconds) + : ""; + const timeLabel = endLabel ? `${startLabel}-${endLabel}` : startLabel; + const actionLabel = formatTooltipText(row.action, "upper"); + const resultLabel = formatTooltipText(row.result, "title"); + const detailLabel = [actionLabel, resultLabel].filter(Boolean).join(" - ") || formatTooltipText(row.player, "title"); + + return ( + <div className="min-w-[92px] leading-tight"> + <div className="text-[9px] font-medium text-[#3b6f50]">{timeLabel}</div> + <div className="mt-0.5 whitespace-nowrap text-[10px] font-semibold text-[#123f24]">{detailLabel || "Tag"}</div> + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/components/timeline-tag-types-panel.tsx b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/components/timeline-tag-types-panel.tsx new file mode 100644 index 00000000000..895e243e098 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/components/timeline-tag-types-panel.tsx @@ -0,0 +1,166 @@ +import type { Dispatch, SetStateAction } from "react"; +import { ChevronDown, Search, X } from "lucide-react"; +import { cn } from "@plane/utils"; +import type { TimelineTagTypeOption } from "../utils/timeline-model"; + +export type TimelineTagTypeGroup = { + name: string; + options: TimelineTagTypeOption[]; + order: number; +}; + +type TimelineTagTypesPanelProps = { + activeVisibleTagTypeKeySet: ReadonlySet<string>; + collapsedTagTypeGroups: Record<string, boolean>; + defaultVisibleTagTypeKeys: string[]; + isOpen: boolean; + onClose: () => void; + onCollapsedTagTypeGroupsChange: Dispatch<SetStateAction<Record<string, boolean>>>; + onSearchQueryChange: (value: string) => void; + onToggleTagType: (tagTypeKey: string) => void; + onVisibleTagTypeKeysChange: Dispatch<SetStateAction<string[] | null>>; + tagTypeGroups: TimelineTagTypeGroup[]; + tagTypeSearchQuery: string; + totalTagTypeCount: number; + visibleTagTypeCount: number; +}; + +export const TimelineTagTypesPanel = ({ + activeVisibleTagTypeKeySet, + collapsedTagTypeGroups, + defaultVisibleTagTypeKeys, + isOpen, + onClose, + onCollapsedTagTypeGroupsChange, + onSearchQueryChange, + onToggleTagType, + onVisibleTagTypeKeysChange, + tagTypeGroups, + tagTypeSearchQuery, + totalTagTypeCount, + visibleTagTypeCount, +}: TimelineTagTypesPanelProps) => { + if (!isOpen) return null; + + return ( + <div className="fixed inset-0 z-30 flex justify-end bg-black/50" role="presentation" onClick={onClose}> + <aside + aria-label="Tag types" + aria-modal="true" + className="flex h-full w-full max-w-[340px] flex-col border-l border-custom-border-200 bg-custom-background-100 shadow-xl" + role="dialog" + onClick={(event) => event.stopPropagation()} + > + <div className="border-b border-custom-border-200 px-4 py-4"> + <div className="mb-3 flex items-center justify-between gap-3"> + <div className="min-w-0"> + <h3 className="text-sm font-semibold text-custom-text-100">Tag types</h3> + <p className="mt-0.5 text-xs text-custom-text-400"> + {visibleTagTypeCount} of {totalTagTypeCount} shown + </p> + </div> + <button + type="button" + onClick={onClose} + className="inline-flex h-8 w-8 items-center justify-center rounded-md text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100" + > + <X className="h-4 w-4" /> + </button> + </div> + <label className="flex h-9 items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-3 text-sm text-custom-text-300"> + <Search className="h-4 w-4" /> + <input + value={tagTypeSearchQuery} + onChange={(event) => onSearchQueryChange(event.target.value)} + placeholder="Search tag types" + className="min-w-0 flex-1 bg-transparent text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400" + /> + </label> + </div> + + <div className="flex gap-3 border-b border-custom-border-200 px-4 py-2.5"> + <button + type="button" + onClick={() => onVisibleTagTypeKeysChange(defaultVisibleTagTypeKeys)} + className="text-xs font-medium text-custom-primary-100 hover:underline" + > + Show all + </button> + <button + type="button" + onClick={() => onVisibleTagTypeKeysChange([])} + className="text-xs font-medium text-custom-primary-100 hover:underline" + > + Hide all + </button> + <button + type="button" + onClick={() => onVisibleTagTypeKeysChange(null)} + className="text-xs font-medium text-custom-primary-100 hover:underline" + > + Reset to default + </button> + </div> + + <div className="vertical-scrollbar scrollbar-md min-h-0 flex-1 overflow-y-auto px-2 py-2"> + {tagTypeGroups.length === 0 ? ( + <div className="px-3 py-8 text-center text-sm text-custom-text-400">No matching tag types.</div> + ) : ( + tagTypeGroups.map((group) => { + const isCollapsed = Boolean(collapsedTagTypeGroups[group.name]); + + return ( + <div key={group.name} className="mb-1"> + <button + type="button" + onClick={() => + onCollapsedTagTypeGroupsChange((currentValue) => ({ + ...currentValue, + [group.name]: !currentValue[group.name], + })) + } + className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-custom-text-400 transition-colors hover:bg-custom-background-90" + > + <ChevronDown className={cn("h-3.5 w-3.5 transition-transform", isCollapsed && "-rotate-90")} /> + <span>{group.name}</span> + </button> + {!isCollapsed && ( + <div className="flex flex-col"> + {group.options.map((option) => ( + <label + key={option.key} + className={cn( + "flex cursor-pointer items-center gap-2 rounded-md px-7 py-1.5 text-sm text-custom-text-200 transition-colors hover:bg-custom-background-90", + option.matchCount === 0 && "text-custom-text-400" + )} + > + <input + type="checkbox" + checked={activeVisibleTagTypeKeySet.has(option.key)} + onChange={() => onToggleTagType(option.key)} + className="h-4 w-4 rounded border-custom-border-200 accent-custom-primary-100" + /> + <span + aria-hidden="true" + className="h-2.5 w-2.5 shrink-0 rounded-full" + style={{ backgroundColor: option.color }} + /> + <span className="min-w-0 flex-1 truncate" title={option.label}> + {option.label} + </span> + <span className="shrink-0 text-xs tabular-nums text-custom-text-400"> + {option.matchCount} + </span> + </label> + ))} + </div> + )} + </div> + ); + }) + )} + </div> + </aside> + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/index.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/index.ts new file mode 100644 index 00000000000..2775525a257 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/index.ts @@ -0,0 +1,5 @@ +export { SgEventTimelinePanel } from "./components/timeline-panel"; +export { + getTimelinePanelInputPlayheadSeconds, + isTimelineTagPlaybackOverrideId, +} from "./utils/timeline-scale"; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-layout.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-layout.ts new file mode 100644 index 00000000000..5631af929a6 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-layout.ts @@ -0,0 +1,55 @@ +export const TIMELINE_PANEL_MIN_HEIGHT_PX = 240; + +export const TIMELINE_PAGE_SCROLL_CLASS = "h-full overflow-y-auto px-3 pt-3"; + +export const TIMELINE_PAGE_CONTENT_CLASS = "flex w-full flex-col gap-3 pb-3"; + +export const TIMELINE_PANEL_ROOT_CLASS = `flex min-h-[${TIMELINE_PANEL_MIN_HEIGHT_PX}px] flex-col overflow-visible`; + +export const TIMELINE_TRACKS_SCROLL_CLASS = "min-h-0"; + +export const TIMELINE_TRACKS_ROW_CLASS = "flex min-w-0"; + +export const TIMELINE_LANE_LABEL_COLUMN_CLASS = "w-[220px] shrink-0 border-r border-custom-border-200"; + +export const TIMELINE_HORIZONTAL_SCROLL_CLASS = "min-w-0 flex-1 overflow-x-hidden overflow-y-hidden"; + +export const TIMELINE_CANVAS_CONTENT_CLASS = "relative"; + +export const TIMELINE_STICKY_FOOTER_CLASS = "sticky bottom-0 z-[5] shrink-0 overflow-hidden bg-custom-background-100"; + +export const TIMELINE_RULER_SCROLL_CLASS = + "sg-event-timeline-scrollbar horizontal-scrollbar scrollbar-md h-10 min-w-0 flex-1 overflow-x-auto overflow-y-hidden [scrollbar-gutter:stable]"; + +export const TIMELINE_RULER_CONTENT_CLASS = "relative h-10"; + +type TimelineHorizontalWheelDeltaArgs = { + deltaX: number; + deltaY: number; + shiftKey?: boolean; +}; + +type TimelineZoomWheelArgs = { + altKey?: boolean; + deltaY: number; +}; + +export const getTimelineHorizontalWheelDeltaPx = ({ + deltaX, + deltaY, + shiftKey = false, +}: TimelineHorizontalWheelDeltaArgs) => { + const normalizedDeltaX = Number.isFinite(deltaX) ? deltaX : 0; + const normalizedDeltaY = Number.isFinite(deltaY) ? deltaY : 0; + + if (shiftKey && normalizedDeltaY !== 0) return normalizedDeltaY; + + return Math.abs(normalizedDeltaX) > Math.abs(normalizedDeltaY) ? normalizedDeltaX : 0; +}; + +export const getTimelineZoomWheelDirection = ({ altKey = false, deltaY }: TimelineZoomWheelArgs) => { + const normalizedDeltaY = Number.isFinite(deltaY) ? deltaY : 0; + if (!altKey || normalizedDeltaY === 0) return null; + + return normalizedDeltaY < 0 ? "in" : "out"; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-model.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-model.ts new file mode 100644 index 00000000000..813a981debf --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-model.ts @@ -0,0 +1,425 @@ +import type { SgTagRow, SportTableKind } from "../../types"; +import { parseTimecodeToSeconds } from "../../utils"; +import { getTimelineTagEndSeconds, isTimelineTagPlaybackOverrideId } from "./timeline-scale"; +import type { TimelineTagTypeOption } from "./timeline-tag-types"; +import { + MARKER_COLORS, + buildTimelineTagTypeOptions as buildTimelineTagTypeOptionsFromRows, + getObservedTimelineTagTypeKey, + getTimelinePrimaryTagTypeKey, + getTimelineRowTagTypeKeys, + hashString, +} from "./timeline-tag-types"; +import { + buildTimelinePlayerLaneId, + getTimelineCategoryLaneId, + getTimelineJerseyNumberKeys, + getTimelinePlayerLaneKey, + getTimelineRowLaneIds, +} from "./timeline-track-assignment"; + +export { + MARKER_COLORS, + getObservedTimelineTagTypeKey, + getTimelinePrimaryTagTypeKey, + getTimelineRowTagTypeKeys, + hashString, +}; +export type { TimelineTagTypeOption }; + +export type TimelineLaneTone = "offense" | "defense" | "special" | "playerA" | "playerB"; + +export type TimelineLane = { + id: string; + label: string; + rows: SgTagRow[]; + tone: TimelineLaneTone; +}; + +export type TimelineRowPlacement = { + endSeconds: number | null; + startSeconds: number; +}; + +export type CategoryLaneDefinition = { + id: string; + keywords: string[]; + label: string; + tone: TimelineLaneTone; +}; + +export const PLAYHEAD_OVERFLOW_BUCKET_SECONDS = 300; + +export const LANE_TONE_CLASS: Record<TimelineLaneTone, string> = { + offense: "border-l-[#2998d8] bg-[#b9defa] text-[#102d3f]", + defense: "border-l-[#ff4f55] bg-[#ffc2c5] text-[#461316]", + special: "border-l-[#49c7a2] bg-[#baf4e4] text-[#14382f]", + playerA: "border-l-[#2998d8] bg-[#afd6f4] text-[#142c3f]", + playerB: "border-l-[#49c7a2] bg-[#baf4e4] text-[#14382f]", +}; + +const normalizeLabel = (value: string) => value.trim(); + +export const getTimelineTagTypeKey = getObservedTimelineTagTypeKey; + +const formatPlayerLaneLabel = (player: string, playerLabelByNumber: Map<string, string>) => { + const rosterLabel = getTimelineJerseyNumberKeys(player) + .map((key) => playerLabelByNumber.get(key)) + .find((label): label is string => Boolean(label)); + + if (rosterLabel) return rosterLabel; + + const normalizedPlayer = normalizeLabel(player); + if (/^\d+$/.test(normalizedPlayer)) return `#${normalizedPlayer}`; + + return normalizedPlayer; +}; + +const CATEGORY_LANES_BY_SPORT: Record<SportTableKind, CategoryLaneDefinition[]> = { + "american-football": [ + { + id: "offense", + keywords: [ + "catch", + "completion", + "conversion", + "first down", + "gain", + "pass", + "reception", + "run", + "rush", + "touchdown", + "two point", + ], + label: "Offense", + tone: "offense", + }, + { + id: "defense", + keywords: ["defense", "fumble", "interception", "sack", "safety", "tackle", "turnover"], + label: "Defense", + tone: "defense", + }, + { + id: "special", + keywords: ["extra point", "field goal", "kick", "kickoff", "punt", "return", "special"], + label: "Special", + tone: "special", + }, + ], + baseball: [ + { + id: "batting", + keywords: ["batter", "bunt", "double", "hit", "home run", "rbi", "run", "single", "steal", "triple"], + label: "Batting", + tone: "offense", + }, + { + id: "pitching", + keywords: ["ball", "balk", "pitch", "pitcher", "strike", "strikeout", "walk", "wild pitch"], + label: "Pitching", + tone: "defense", + }, + { + id: "fielding", + keywords: ["catch", "double play", "error", "field", "fielder", "out", "tag", "throw"], + label: "Fielding", + tone: "special", + }, + ], + basketball: [ + { + id: "offense", + keywords: ["assist", "dunk", "free throw", "layup", "made", "offense", "score", "shot", "three", "two point"], + label: "Offense", + tone: "offense", + }, + { + id: "defense", + keywords: ["block", "charge", "defense", "foul", "rebound", "steal", "turnover"], + label: "Defense", + tone: "defense", + }, + { + id: "transition", + keywords: ["fast break", "substitution", "timeout", "transition"], + label: "Transition", + tone: "special", + }, + ], + cricket: [ + { + id: "batting", + keywords: ["batter", "batting", "boundary", "four", "run", "six", "strike"], + label: "Batting", + tone: "offense", + }, + { + id: "bowling", + keywords: ["ball", "bowled", "bowler", "bowling", "delivery", "dot", "lbw", "over", "wicket"], + label: "Bowling", + tone: "defense", + }, + { + id: "fielding", + keywords: ["catch", "drop", "field", "run out", "stumping"], + label: "Fielding", + tone: "special", + }, + ], + default: [ + { + id: "actions", + keywords: ["action", "play"], + label: "Actions", + tone: "offense", + }, + { + id: "results", + keywords: ["outcome", "result", "score"], + label: "Results", + tone: "defense", + }, + { + id: "other", + keywords: ["event", "tag"], + label: "Other", + tone: "special", + }, + ], + soccer: [ + { + id: "attack", + keywords: ["assist", "attack", "cross", "dribble", "goal", "pass", "shot"], + label: "Attack", + tone: "offense", + }, + { + id: "defense", + keywords: ["block", "clearance", "defense", "foul", "interception", "save", "tackle"], + label: "Defense", + tone: "defense", + }, + { + id: "set-pieces", + keywords: ["corner", "free kick", "goal kick", "penalty", "set piece", "throw in"], + label: "Set Pieces", + tone: "special", + }, + ], +}; + +const getCategoryLaneDefinitions = (sport: SportTableKind) => + CATEGORY_LANES_BY_SPORT[sport] ?? CATEGORY_LANES_BY_SPORT.default; + +export const buildTimelineTagTypeOptions = (rows: SgTagRow[], sport: SportTableKind) => { + const categoryLanes = getCategoryLaneDefinitions(sport); + const categoryLaneById = new Map(categoryLanes.map((lane, index) => [lane.id, { ...lane, order: index }])); + + return buildTimelineTagTypeOptionsFromRows(rows, sport, { + getObservedGroup: (row) => { + const laneId = getTimelineCategoryLaneId(row, categoryLanes); + const categoryLane = categoryLaneById.get(laneId) ?? categoryLaneById.get(categoryLanes[0]?.id ?? ""); + + return { + group: categoryLane?.label ?? "Other", + order: categoryLane?.order ?? Number.MAX_SAFE_INTEGER, + }; + }, + }); +}; + +const getTimecodeStart = (timecode: string) => timecode.split(/\s*[-\u2013\u2014]\s*/)[0] ?? timecode; + +const isPlausibleTimelineSecond = (seconds: number, timelineDurationSeconds: number | null) => + timelineDurationSeconds === null || timelineDurationSeconds <= 0 || seconds <= timelineDurationSeconds + 60; + +export const getPositiveDurationSeconds = (value: number | null | undefined) => + typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; + +const getRowTimecodeStartSeconds = (row: SgTagRow, timelineDurationSeconds: number | null) => { + const seconds = row.clipStartSeconds ?? parseTimecodeToSeconds(getTimecodeStart(row.timecode)); + + if (seconds === null || !isPlausibleTimelineSecond(seconds, timelineDurationSeconds)) return null; + + return seconds; +}; + +const getRowExplicitEndSeconds = (row: SgTagRow, timelineDurationSeconds: number | null) => { + if ( + row.clipEndSeconds !== null && + row.clipRangeSource !== "timecode" && + isPlausibleTimelineSecond(row.clipEndSeconds, timelineDurationSeconds) + ) { + return row.clipEndSeconds; + } + + return null; +}; + +const getTimestampMs = (value: string | null) => { + if (!value) return null; + + const parsedValue = Date.parse(value); + return Number.isNaN(parsedValue) ? null : parsedValue; +}; + +const getRowTimestampMs = (row: SgTagRow) => + getTimestampMs(row.playlistTimestamp) ?? getTimestampMs(row.playlistFallbackTimestamp); + +const getRowDurationSeconds = ( + row: SgTagRow, + timelineDurationSeconds: number | null, + startSeconds: number, + activeClipDurationSeconds: number | null +) => + getTimelineTagEndSeconds({ + clipDurationSeconds: activeClipDurationSeconds ?? getPositiveDurationSeconds(row.clipDurationSeconds), + explicitEndSeconds: getRowExplicitEndSeconds(row, timelineDurationSeconds), + startSeconds, + }) - startSeconds; + +export const buildTimelinePlacements = ( + rows: SgTagRow[], + timelineDurationSeconds: number | null, + activeClipDurationByRowId: ReadonlyMap<string, number> = new Map() +) => { + const timestampValues = rows.map(getRowTimestampMs).filter((value): value is number => value !== null); + const firstTimestampMs = timestampValues.length > 0 ? Math.min(...timestampValues) : null; + const timelineStartMs = firstTimestampMs; + const directPlacements = rows.map((row) => { + const timestampMs = getRowTimestampMs(row); + const timestampStartSeconds = + timelineStartMs !== null && timestampMs !== null ? Math.max(0, (timestampMs - timelineStartMs) / 1000) : null; + const timecodeStartSeconds = getRowTimecodeStartSeconds(row, timelineDurationSeconds); + const startSeconds = timecodeStartSeconds ?? timestampStartSeconds; + + return { + row, + startSeconds, + }; + }); + const maxKnownSeconds = directPlacements.reduce((maxSeconds, placement) => { + if (placement.startSeconds === null) return maxSeconds; + + return Math.max( + maxSeconds, + placement.startSeconds + + getRowDurationSeconds( + placement.row, + timelineDurationSeconds, + placement.startSeconds, + activeClipDurationByRowId.get(placement.row.id) ?? null + ) + ); + }, 0); + const fallbackRows = directPlacements.filter((placement) => placement.startSeconds === null); + const fallbackWindowSeconds = + timelineDurationSeconds !== null && timelineDurationSeconds > 0 + ? timelineDurationSeconds + : Math.max(60, Math.ceil(maxKnownSeconds / 300) * 300); + let fallbackIndex = 0; + + return directPlacements.reduce<Record<string, TimelineRowPlacement>>((accumulator, placement) => { + const fallbackStartSeconds = + fallbackRows.length > 0 ? ((fallbackIndex + 1) * fallbackWindowSeconds) / (fallbackRows.length + 1) : 0; + const startSeconds = placement.startSeconds ?? fallbackStartSeconds; + const endSeconds = getTimelineTagEndSeconds({ + clipDurationSeconds: + activeClipDurationByRowId.get(placement.row.id) ?? + getPositiveDurationSeconds(placement.row.clipDurationSeconds), + explicitEndSeconds: getRowExplicitEndSeconds(placement.row, timelineDurationSeconds), + startSeconds, + }); + + if (placement.startSeconds === null) { + fallbackIndex += 1; + } + + accumulator[placement.row.id] = { + endSeconds, + startSeconds, + }; + return accumulator; + }, {}); +}; + +export const buildLaneMarkerOffsets = (rows: SgTagRow[], rowPlacements: Record<string, TimelineRowPlacement>) => { + const collisionCounts = new Map<number, number>(); + + return [...rows] + .sort((left, right) => { + const leftPlacement = rowPlacements[left.id]; + const rightPlacement = rowPlacements[right.id]; + + return (leftPlacement?.startSeconds ?? 0) - (rightPlacement?.startSeconds ?? 0); + }) + .reduce<Record<string, number>>((accumulator, row) => { + const placement = rowPlacements[row.id]; + const secondBucket = Math.round(placement?.startSeconds ?? 0); + const currentCount = collisionCounts.get(secondBucket) ?? 0; + + accumulator[row.id] = currentCount; + collisionCounts.set(secondBucket, currentCount + 1); + return accumulator; + }, {}); +}; + +export const buildSortedTimelineRows = (rows: SgTagRow[], rowPlacements: Record<string, TimelineRowPlacement>) => + [...rows].sort((left, right) => { + const leftPlacement = rowPlacements[left.id]; + const rightPlacement = rowPlacements[right.id]; + + return (leftPlacement?.startSeconds ?? 0) - (rightPlacement?.startSeconds ?? 0); + }); + +export const buildTagPlaybackOverrideId = (row: SgTagRow) => `sg-tag-${row.id}`; + +export const getPlaybackOverrideRowId = (playbackOverrideId: string | null) => + isTimelineTagPlaybackOverrideId(playbackOverrideId) ? (playbackOverrideId?.slice("sg-tag-".length) ?? null) : null; + +const buildPlayerLanes = (rows: SgTagRow[], playerLabelByNumber: Map<string, string>) => { + const playerCounts = rows.reduce<Map<string, number>>((accumulator, row) => { + const player = normalizeLabel(row.player); + if (!player || player === "--") return accumulator; + + const playerKey = getTimelinePlayerLaneKey(player); + accumulator.set(playerKey, (accumulator.get(playerKey) ?? 0) + 1); + return accumulator; + }, new Map<string, number>()); + + return Array.from(playerCounts.entries()) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .map(([playerKey], index) => ({ + id: buildTimelinePlayerLaneId(playerKey), + label: formatPlayerLaneLabel(playerKey, playerLabelByNumber), + rows: [], + tone: index % 2 === 0 ? "playerA" : "playerB", + })) satisfies TimelineLane[]; +}; + +export const buildTimelineLanes = ( + rows: SgTagRow[], + sport: SportTableKind, + playerLabelByNumber: Map<string, string> +) => { + const categoryLaneDefinitions = CATEGORY_LANES_BY_SPORT[sport] ?? CATEGORY_LANES_BY_SPORT.default; + const coreLanes: TimelineLane[] = categoryLaneDefinitions.map((lane) => ({ + id: lane.id, + label: lane.label, + rows: [], + tone: lane.tone, + })); + const playerLanes = buildPlayerLanes(rows, playerLabelByNumber); + const lanesById = new Map([...coreLanes, ...playerLanes].map((lane) => [lane.id, lane])); + + rows.forEach((row) => { + getTimelineRowLaneIds(row, categoryLaneDefinitions).forEach((laneId) => { + const lane = lanesById.get(laneId) ?? coreLanes[0]; + lane?.rows.push(row); + }); + }); + + return [...coreLanes, ...playerLanes]; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-playlist-selection.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-playlist-selection.ts new file mode 100644 index 00000000000..8ac5aaf620a --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-playlist-selection.ts @@ -0,0 +1,49 @@ +import type { SgTagRow } from "../../types"; + +const parseTimePartSeconds = (value: string) => { + const parts = value + .trim() + .split(":") + .map((part) => Number(part)); + + if (parts.length === 0 || parts.some((part) => !Number.isFinite(part) || part < 0)) return null; + if (parts.length === 1) return parts[0]; + if (parts.length === 2) return parts[0] * 60 + parts[1]; + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + + return null; +}; + +const getPlaylistRangeStartSeconds = (value: string | null | undefined) => { + if (!value?.trim()) return null; + + const [start] = value.split("-"); + return start ? parseTimePartSeconds(start) : null; +}; + +const hasPlayableTimelineRow = (row: SgTagRow) => + Boolean(row.playlistTimestamp?.trim() || row.playlistFallbackTimestamp?.trim()); + +const getTimelinePlaylistSortSeconds = (row: SgTagRow) => { + if (typeof row.clipStartSeconds === "number" && Number.isFinite(row.clipStartSeconds)) return row.clipStartSeconds; + + return ( + getPlaylistRangeStartSeconds(row.playlistTimestamp) ?? + getPlaylistRangeStartSeconds(row.playlistFallbackTimestamp) ?? + Number.POSITIVE_INFINITY + ); +}; + +export const getTimelinePlaylistRows = (rows: SgTagRow[], selectedTagIds: string[]) => { + const selectedIdSet = new Set(selectedTagIds); + const seenIds = new Set<string>(); + + return rows + .filter((row) => { + if (!selectedIdSet.has(row.id) || seenIds.has(row.id) || !hasPlayableTimelineRow(row)) return false; + + seenIds.add(row.id); + return true; + }) + .sort((left, right) => getTimelinePlaylistSortSeconds(left) - getTimelinePlaylistSortSeconds(right)); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-scale.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-scale.ts new file mode 100644 index 00000000000..fb48f4f9c3d --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-scale.ts @@ -0,0 +1,423 @@ +export const BASE_TIMELINE_WIDTH_PX = 1400; +export const MIN_TIMELINE_WIDTH_PX = 760; +export const MIN_TIMELINE_MAJOR_TICK_SPACING_PX = 72; +export const MIN_TIMELINE_MINOR_TICK_SPACING_PX = 12; +export const MIN_SECOND_TICK_SPACING_PX = 56; +export const SECOND_LEVEL_TIMELINE_SCALE = 64; +export const DEFAULT_TIMELINE_TAG_DURATION_SECONDS = 8; +export const TIMELINE_SCALE_LEVELS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4, 8, 16, 32, 64] as const; +export const DEFAULT_TIMELINE_SCALE_INDEX = 2; +const TIMELINE_NICE_INTERVAL_SECONDS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 14400] as const; + +export type TimelineScaleDirection = "in" | "out"; +export type TimelineTickKind = "major" | "minor"; +export type TimelineZoomStopKind = "fit" | "detail"; + +export type TimelineTick = { + kind: TimelineTickKind; + label: string; + position: number; + seconds: number; +}; + +export type TimelineZoomStop = { + kind: TimelineZoomStopKind; + scale: number; + scaleIndex: number; + selectedContentWidthPx: number; +}; + +export const getClampedTimelineScaleIndex = (index: number) => + Math.min(Math.max(index, 0), TIMELINE_SCALE_LEVELS.length - 1); + +export const getNextTimelineScaleIndex = (currentIndex: number, direction: TimelineScaleDirection) => + getClampedTimelineScaleIndex(currentIndex + (direction === "in" ? 1 : -1)); + +export const getTimelineScaleIndexFromSliderValue = (value: number | string, fallbackIndex: number) => { + const parsedIndex = typeof value === "number" ? value : Number(value); + + return Number.isFinite(parsedIndex) ? getClampedTimelineScaleIndex(Math.round(parsedIndex)) : fallbackIndex; +}; + +export const getTimelineContentWidth = (scale: number, totalSeconds = 0) => { + const scaledBaseWidth = Math.round(BASE_TIMELINE_WIDTH_PX * scale); + const secondLevelWidth = + scale >= SECOND_LEVEL_TIMELINE_SCALE ? Math.ceil(Math.max(1, totalSeconds) * MIN_SECOND_TICK_SPACING_PX) : 0; + + return Math.max(MIN_TIMELINE_WIDTH_PX, scaledBaseWidth, secondLevelWidth); +}; + +export const getTimelineEffectiveContentWidth = ({ + selectedContentWidthPx, + viewportWidthPx, +}: { + selectedContentWidthPx: number; + viewportWidthPx: number; +}) => Math.max(1, selectedContentWidthPx || 0, viewportWidthPx || 0); + +export const getTimelineScaleLabel = (scale: number) => + scale >= SECOND_LEVEL_TIMELINE_SCALE ? "1 sec" : `${Math.round(scale * 100)}%`; + +export const getTimelineZoomPercentLabel = (scale: number) => `${Math.round(Math.max(0, scale || 0) * 100)}%`; + +export const getTimelineZoomLabel = ({ + scale, + selectedContentWidthPx, + viewportWidthPx, +}: { + scale: number; + selectedContentWidthPx: number; + viewportWidthPx: number; +}) => { + const percentLabel = getTimelineZoomPercentLabel(scale); + const isFitToViewport = viewportWidthPx > 0 && selectedContentWidthPx <= viewportWidthPx; + + return { + detailLabel: isFitToViewport ? `Fit to viewport (${percentLabel})` : percentLabel, + displayLabel: isFitToViewport ? "Fit" : percentLabel, + isFitToViewport, + percentLabel, + }; +}; + +export const buildTimelineZoomStops = ({ + totalSeconds, + viewportWidthPx, +}: { + totalSeconds: number; + viewportWidthPx: number; +}): TimelineZoomStop[] => { + const allStops = TIMELINE_SCALE_LEVELS.map((scale, scaleIndex) => ({ + kind: "detail" as const, + scale, + scaleIndex, + selectedContentWidthPx: getTimelineContentWidth(scale, totalSeconds), + })); + if (viewportWidthPx <= 0) return allStops; + + let lastFitStopIndex = -1; + for (let stopIndex = allStops.length - 1; stopIndex >= 0; stopIndex -= 1) { + if (allStops[stopIndex].selectedContentWidthPx <= viewportWidthPx) { + lastFitStopIndex = stopIndex; + break; + } + } + if (lastFitStopIndex < 0) return allStops; + + return [ + { + ...allStops[lastFitStopIndex], + kind: "fit" as const, + }, + ...allStops.slice(lastFitStopIndex + 1), + ]; +}; + +export const getTimelineZoomStopIndex = ({ + scaleIndex, + zoomStops, +}: { + scaleIndex: number; + zoomStops: TimelineZoomStop[]; +}) => { + if (zoomStops.length === 0) return 0; + + const exactStopIndex = zoomStops.findIndex((stop) => stop.scaleIndex === scaleIndex); + if (exactStopIndex >= 0) return exactStopIndex; + + const firstStop = zoomStops[0]; + if (firstStop?.kind === "fit" && scaleIndex <= firstStop.scaleIndex) return 0; + + const nextStopIndex = zoomStops.findIndex((stop) => stop.scaleIndex > scaleIndex); + return nextStopIndex >= 0 ? Math.max(0, nextStopIndex - 1) : zoomStops.length - 1; +}; + +export const getTimelineZoomStopIndexFromSliderValue = ( + value: number | string, + fallbackStopIndex: number, + zoomStopCount: number +) => { + const parsedStopIndex = typeof value === "number" ? value : Number(value); + const maxStopIndex = Math.max(0, zoomStopCount - 1); + + return Number.isFinite(parsedStopIndex) + ? Math.min(Math.max(Math.round(parsedStopIndex), 0), maxStopIndex) + : fallbackStopIndex; +}; + +const getCompactDurationLabel = (seconds: number) => { + const roundedSeconds = Math.max(1, Math.round(seconds)); + + if (roundedSeconds >= 3600) { + const hours = Math.floor(roundedSeconds / 3600); + const minutes = Math.round((roundedSeconds % 3600) / 60); + + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + + if (roundedSeconds >= 60) { + return `${Math.max(1, Math.round(roundedSeconds / 60))}m`; + } + + return `${roundedSeconds}s`; +}; + +export const getTimelineVisibleDurationLabel = ({ + contentWidthPx, + totalSeconds, + viewportWidthPx, +}: { + contentWidthPx: number; + totalSeconds: number; + viewportWidthPx: number; +}) => { + if (contentWidthPx <= 0 || totalSeconds <= 0 || viewportWidthPx <= 0) return null; + + const visibleSeconds = Math.min(totalSeconds, (viewportWidthPx / contentWidthPx) * totalSeconds); + const compactDurationLabel = `~${getCompactDurationLabel(visibleSeconds)}`; + + return { + compactLabel: compactDurationLabel, + detailLabel: `${compactDurationLabel} visible`, + }; +}; + +export const getTimelinePositionPercent = (seconds: number, totalSeconds: number) => { + const safeTotalSeconds = Math.max(1, totalSeconds || 0); + const rawPosition = (seconds * 100) / safeTotalSeconds; + + return Math.min(Math.max(rawPosition, 0), 100); +}; + +export const getTimelinePixelsPerSecond = (contentWidthPx: number, totalSeconds: number) => + Math.max(0, contentWidthPx) / Math.max(1, totalSeconds || 0); + +export const getTimelineTimePixel = (seconds: number, totalSeconds: number, contentWidthPx: number) => { + const safeTotalSeconds = Math.max(1, totalSeconds || 0); + const clampedSeconds = Math.min(Math.max(seconds || 0, 0), safeTotalSeconds); + + return clampedSeconds * getTimelinePixelsPerSecond(contentWidthPx, safeTotalSeconds); +}; + +export const getTimelineSecondsFromClientX = ({ + clientX, + contentWidthPx, + scrollLeftPx, + seekableSeconds, + totalSeconds, + viewportLeftPx, +}: { + clientX: number; + contentWidthPx: number; + scrollLeftPx: number; + seekableSeconds?: number | null; + totalSeconds: number; + viewportLeftPx: number; +}) => { + const safeContentWidthPx = Math.max(1, contentWidthPx || 0); + const safeTotalSeconds = Math.max(1, totalSeconds || 0); + const safeScrollLeftPx = Math.max(0, scrollLeftPx || 0); + const safeSeekableSeconds = + typeof seekableSeconds === "number" && Number.isFinite(seekableSeconds) && seekableSeconds >= 0 + ? seekableSeconds + : safeTotalSeconds; + const timelinePositionPx = clientX - viewportLeftPx + safeScrollLeftPx; + const clampedPositionPx = Math.min(Math.max(timelinePositionPx, 0), safeContentWidthPx); + const timelineSeconds = (clampedPositionPx / safeContentWidthPx) * safeTotalSeconds; + + return Math.min(Math.max(timelineSeconds, 0), safeSeekableSeconds); +}; + +export const getTimelineRangePixels = ({ + contentWidthPx, + endSeconds, + minWidthPx = 4, + startSeconds, + totalSeconds, +}: { + contentWidthPx: number; + endSeconds: number | null; + minWidthPx?: number; + startSeconds: number; + totalSeconds: number; +}) => { + const leftPx = getTimelineTimePixel(startSeconds, totalSeconds, contentWidthPx); + const rightPx = + endSeconds !== null && endSeconds > startSeconds + ? getTimelineTimePixel(endSeconds, totalSeconds, contentWidthPx) + : leftPx + minWidthPx; + + return { + leftPx, + widthPx: Math.max(minWidthPx, rightPx - leftPx), + }; +}; + +const getPositiveFiniteSeconds = (value: number | null | undefined) => + typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; + +export const getTimelineTagDurationSeconds = ({ + clipDurationSeconds, + explicitEndSeconds, + fallbackDurationSeconds = DEFAULT_TIMELINE_TAG_DURATION_SECONDS, + startSeconds, +}: { + clipDurationSeconds: number | null; + explicitEndSeconds: number | null; + fallbackDurationSeconds?: number; + startSeconds: number; +}) => { + const actualClipDurationSeconds = getPositiveFiniteSeconds(clipDurationSeconds); + if (actualClipDurationSeconds !== null) return actualClipDurationSeconds; + + if (explicitEndSeconds !== null && explicitEndSeconds > startSeconds) { + return explicitEndSeconds - startSeconds; + } + + return getPositiveFiniteSeconds(fallbackDurationSeconds) ?? DEFAULT_TIMELINE_TAG_DURATION_SECONDS; +}; + +export const getTimelineTagEndSeconds = ({ + clipDurationSeconds, + explicitEndSeconds, + fallbackDurationSeconds, + startSeconds, +}: { + clipDurationSeconds: number | null; + explicitEndSeconds: number | null; + fallbackDurationSeconds?: number; + startSeconds: number; +}) => + startSeconds + + getTimelineTagDurationSeconds({ + clipDurationSeconds, + explicitEndSeconds, + fallbackDurationSeconds, + startSeconds, + }); + +export const getTimelinePlaybackSeconds = ({ + activeClipStartSeconds, + isClipPlaybackActive, + playheadSeconds, +}: { + activeClipStartSeconds: number | null; + isClipPlaybackActive: boolean; + playheadSeconds: number; +}) => + Math.max(0, (isClipPlaybackActive && activeClipStartSeconds !== null ? activeClipStartSeconds : 0) + playheadSeconds); + +export const isTimelineTagPlaybackOverrideId = (playbackOverrideId: string | null) => + playbackOverrideId?.startsWith("sg-tag-") ?? false; + +export const getTimelinePanelInputPlayheadSeconds = ({ + playbackOverrideId, + playheadBaseSeconds, + playerLocalSeconds, +}: { + playbackOverrideId: string | null; + playheadBaseSeconds: number; + playerLocalSeconds: number; +}) => Math.max(0, (playbackOverrideId === null ? playheadBaseSeconds : 0) + playerLocalSeconds); + +const getNiceTimelineIntervalSeconds = (minimumSeconds: number) => { + const safeMinimumSeconds = Math.max(1, Math.ceil(minimumSeconds || 0)); + const matchingInterval = TIMELINE_NICE_INTERVAL_SECONDS.find( + (intervalSeconds) => intervalSeconds >= safeMinimumSeconds + ); + + if (matchingInterval) return matchingInterval; + + const largestInterval = TIMELINE_NICE_INTERVAL_SECONDS.at(-1) ?? 14400; + return Math.ceil(safeMinimumSeconds / largestInterval) * largestInterval; +}; + +const getTimelineMajorTickStepSeconds = (totalSeconds: number, scale: number, contentWidthPx: number) => { + const safeScale = Math.max(scale, TIMELINE_SCALE_LEVELS[0]); + const safeTotalSeconds = Math.max(1, Math.ceil(totalSeconds || 0)); + const safeContentWidthPx = Math.max(1, contentWidthPx || getTimelineContentWidth(safeScale, safeTotalSeconds)); + const pixelsPerSecond = getTimelinePixelsPerSecond(safeContentWidthPx, safeTotalSeconds); + const minimumMajorStepSeconds = MIN_TIMELINE_MAJOR_TICK_SPACING_PX / Math.max(pixelsPerSecond, 0.0001); + + return getNiceTimelineIntervalSeconds(minimumMajorStepSeconds); +}; + +export const buildScaledTickStepSeconds = (totalSeconds: number, scale: number, contentWidthPx?: number) => + getTimelineMajorTickStepSeconds(totalSeconds, scale, contentWidthPx ?? getTimelineContentWidth(scale, totalSeconds)); + +export const formatTimelineTickLabel = (seconds: number) => { + const safeSeconds = Math.max(0, Math.round(seconds)); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const remainingSeconds = safeSeconds % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; + } + + return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; +}; + +const getTimelineMinorTickStepSeconds = (majorStepSeconds: number, pixelsPerSecond: number) => { + const minorStepCandidates = TIMELINE_NICE_INTERVAL_SECONDS.filter( + (intervalSeconds) => intervalSeconds < majorStepSeconds && majorStepSeconds % intervalSeconds === 0 + ); + + return ( + minorStepCandidates.find( + (intervalSeconds) => intervalSeconds * pixelsPerSecond >= MIN_TIMELINE_MINOR_TICK_SPACING_PX + ) ?? null + ); +}; + +export const buildScaledTimelineTicks = ( + totalSeconds: number, + scale: number, + contentWidthPx?: number +): TimelineTick[] => { + const safeTotalSeconds = Math.max(1, Math.ceil(totalSeconds || 0)); + const safeContentWidthPx = Math.max(1, contentWidthPx ?? getTimelineContentWidth(scale, safeTotalSeconds)); + const pixelsPerSecond = getTimelinePixelsPerSecond(safeContentWidthPx, safeTotalSeconds); + const majorStepSeconds = buildScaledTickStepSeconds(safeTotalSeconds, scale, safeContentWidthPx); + const minorStepSeconds = getTimelineMinorTickStepSeconds(majorStepSeconds, pixelsPerSecond); + const majorSeconds = new Set<number>(); + const minorSeconds = new Set<number>(); + + for (let tickSeconds = 0; tickSeconds <= safeTotalSeconds; tickSeconds += majorStepSeconds) { + majorSeconds.add(tickSeconds); + } + + if (minorStepSeconds !== null) { + for (let tickSeconds = minorStepSeconds; tickSeconds <= safeTotalSeconds; tickSeconds += minorStepSeconds) { + if (!majorSeconds.has(tickSeconds)) { + minorSeconds.add(tickSeconds); + } + } + } + + if (!majorSeconds.has(safeTotalSeconds)) { + const previousMajorSeconds = Math.floor(safeTotalSeconds / majorStepSeconds) * majorStepSeconds; + const endLabelSpacingPx = (safeTotalSeconds - previousMajorSeconds) * pixelsPerSecond; + + if (endLabelSpacingPx >= MIN_TIMELINE_MAJOR_TICK_SPACING_PX) { + majorSeconds.add(safeTotalSeconds); + } else { + minorSeconds.add(safeTotalSeconds); + } + } + + return [ + ...Array.from(majorSeconds, (seconds) => ({ + kind: "major" as const, + label: formatTimelineTickLabel(seconds), + position: (seconds * 100) / safeTotalSeconds, + seconds, + })), + ...Array.from(minorSeconds, (seconds) => ({ + kind: "minor" as const, + label: "", + position: (seconds * 100) / safeTotalSeconds, + seconds, + })), + ].sort((leftTick, rightTick) => leftTick.seconds - rightTick.seconds); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-tag-types.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-tag-types.ts new file mode 100644 index 00000000000..502d75dd168 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-tag-types.ts @@ -0,0 +1,497 @@ +import type { SgTagRow, SportTableKind } from "../../types"; + +export type TimelineTagTypeSource = "catalog" | "observed"; + +export type TimelineTagTypeOption = { + color: string; + defaultVisible: boolean; + group: string; + key: string; + label: string; + matchCount: number; + order: number; + source: TimelineTagTypeSource; +}; + +type TimelineTagTypeDefinition = { + color: string; + defaultVisible: boolean; + group: string; + key: string; + keywords: string[]; + label: string; + order: number; +}; + +type ObservedTagTypeGroup = { + group: string; + order: number; +}; + +type BuildTimelineTagTypeOptionsArgs = { + getObservedGroup?: (row: SgTagRow) => ObservedTagTypeGroup; +}; + +export const MARKER_COLORS = ["#ef4444", "#22c55e", "#c084fc", "#fbbf24", "#f472b6", "#60a5fa", "#f59e0b", "#a3e635"]; + +const EMPTY_TIMELINE_VALUES = new Set(["", "--", "\u2014", "n/a", "na", "none", "null", "undefined"]); +const OBSERVED_TAG_GROUP = "Observed tags"; +const OBSERVED_TAG_ORDER = 1000; + +const FOOTBALL_TAG_TYPE_DEFINITIONS: TimelineTagTypeDefinition[] = [ + { + color: "#7AACD0", + defaultVisible: true, + group: "Play call", + key: "passComplete", + keywords: ["pass complete", "completed pass", "completion"], + label: "Pass complete", + order: 0, + }, + { + color: "#E07B4E", + defaultVisible: true, + group: "Play call", + key: "passIncomplete", + keywords: ["pass incomplete", "incomplete pass", "incompletion"], + label: "Pass incomplete", + order: 1, + }, + { + color: "#86CF95", + defaultVisible: true, + group: "Play call", + key: "run", + keywords: ["run", "rush"], + label: "Run", + order: 2, + }, + { + color: "#E7A0B8", + defaultVisible: true, + group: "Play call", + key: "sack", + keywords: ["sack"], + label: "Sack", + order: 3, + }, + { + color: "#4EB5DE", + defaultVisible: false, + group: "Play call", + key: "playAction", + keywords: ["play action"], + label: "Play action", + order: 4, + }, + { + color: "#7BCCE0", + defaultVisible: false, + group: "Play call", + key: "bootleg", + keywords: ["bootleg"], + label: "Bootleg", + order: 5, + }, + { + color: "#CADF72", + defaultVisible: false, + group: "Play call", + key: "draw", + keywords: ["draw"], + label: "Draw", + order: 6, + }, + { + color: "#F5B400", + defaultVisible: true, + group: "Special teams", + key: "kickoff", + keywords: ["kickoff", "kick off"], + label: "Kickoff", + order: 7, + }, + { + color: "#F07C4A", + defaultVisible: true, + group: "Special teams", + key: "punt", + keywords: ["punt"], + label: "Punt", + order: 8, + }, + { + color: "#F0E24A", + defaultVisible: true, + group: "Special teams", + key: "fieldGoal", + keywords: ["field goal"], + label: "Field goal", + order: 9, + }, + { + color: "#F0904A", + defaultVisible: false, + group: "Special teams", + key: "twoPoint", + keywords: ["two point", "2 point", "2pt", "two point conversion"], + label: "Two point", + order: 10, + }, + { + color: "#E0C07B", + defaultVisible: false, + group: "Special teams", + key: "onside", + keywords: ["onside", "onside kick"], + label: "Onside kick", + order: 11, + }, + { + color: "#05E5AD", + defaultVisible: true, + group: "Outcome", + key: "touchdown", + keywords: ["touchdown", "td"], + label: "Touchdown", + order: 12, + }, + { + color: "#DC2626", + defaultVisible: true, + group: "Outcome", + key: "turnover", + keywords: ["turnover"], + label: "Turnover", + order: 13, + }, + { + color: "#FD9038", + defaultVisible: true, + group: "Outcome", + key: "explosive", + keywords: ["explosive", "explosive play"], + label: "Explosive play", + order: 14, + }, + { + color: "#DE4EA8", + defaultVisible: true, + group: "Outcome", + key: "penalty", + keywords: ["penalty", "flag"], + label: "Penalty", + order: 15, + }, + { + color: "#A84EDE", + defaultVisible: false, + group: "Outcome", + key: "bigLoss", + keywords: ["big loss", "loss", "negative play"], + label: "Big loss", + order: 16, + }, + { + color: "#DE4E6B", + defaultVisible: false, + group: "Outcome", + key: "redZone", + keywords: ["red zone", "redzone"], + label: "Red zone entry", + order: 17, + }, + { + color: "#C4A0F0", + defaultVisible: true, + group: "Defense", + key: "blitz", + keywords: ["blitz"], + label: "Blitz", + order: 18, + }, + { + color: "#DE4EB0", + defaultVisible: true, + group: "Defense", + key: "interception", + keywords: ["interception", "intercepted", "pick"], + label: "Interception", + order: 19, + }, + { + color: "#9C7BD4", + defaultVisible: false, + group: "Defense", + key: "sackDef", + keywords: ["sack"], + label: "Sack (defense)", + order: 20, + }, + { + color: "#DE7BA8", + defaultVisible: false, + group: "Defense", + key: "coverageBreak", + keywords: ["coverage breakdown", "coverage bust", "blown coverage"], + label: "Coverage breakdown", + order: 21, + }, + { + color: "#D47B9C", + defaultVisible: false, + group: "Defense", + key: "missedTackle", + keywords: ["missed tackle"], + label: "Missed tackle", + order: 22, + }, + { + color: "#4A9EDE", + defaultVisible: true, + group: "Down & distance", + key: "thirdDown", + keywords: ["3rd", "third down", "down 3"], + label: "3rd down", + order: 23, + }, + { + color: "#4A9EDE", + defaultVisible: false, + group: "Down & distance", + key: "fourthDown", + keywords: ["4th", "fourth down", "down 4"], + label: "4th down", + order: 24, + }, + { + color: "#4ADEC4", + defaultVisible: false, + group: "Down & distance", + key: "goalLine", + keywords: ["goal line", "goalline"], + label: "Goal line", + order: 25, + }, + { + color: "#E85A4F", + defaultVisible: false, + group: "Down & distance", + key: "twoMinute", + keywords: ["2 minute", "two minute"], + label: "2-minute drill", + order: 26, + }, + { + color: "#F0D74A", + defaultVisible: false, + group: "Player notes", + key: "highlight", + keywords: ["highlight", "highlight play"], + label: "Highlight play", + order: 27, + }, + { + color: "#F07A4A", + defaultVisible: false, + group: "Player notes", + key: "coachFlag", + keywords: ["coach flag"], + label: "Coach flag", + order: 28, + }, + { + color: "#F05A5A", + defaultVisible: false, + group: "Player notes", + key: "injury", + keywords: ["injury", "injured"], + label: "Injury", + order: 29, + }, + { + color: "#A0B0C0", + defaultVisible: false, + group: "Player notes", + key: "substitution", + keywords: ["substitution", "sub"], + label: "Substitution", + order: 30, + }, +]; + +const TAG_TYPE_CATALOG_BY_SPORT: Partial<Record<SportTableKind, TimelineTagTypeDefinition[]>> = { + "american-football": FOOTBALL_TAG_TYPE_DEFINITIONS, +}; + +const hasTimelineValue = (value: string | null | undefined) => + !EMPTY_TIMELINE_VALUES.has( + String(value ?? "") + .trim() + .toLowerCase() + ); + +const normalizeTagTypeText = (value: string | null | undefined) => + String(value ?? "") + .trim() + .toLowerCase() + .replace(/&/g, " and ") + .replace(/[_-]+/g, " ") + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + +const getComparableTimelineTagText = (row: SgTagRow) => + normalizeTagTypeText( + [ + row.action, + row.result, + row.primaryDetail, + row.secondaryDetail, + row.team, + row.groupValue, + ...Object.entries(row.context).flatMap(([key, value]) => [key, value]), + ].join(" ") + ); + +const matchesPhrase = (text: string, phrase: string) => { + const normalizedPhrase = normalizeTagTypeText(phrase); + if (!normalizedPhrase) return false; + + return new RegExp(`(?:^|\\s)${normalizedPhrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s|$)`).test(text); +}; + +const getTimelineTagTypeCatalog = (sport: SportTableKind) => TAG_TYPE_CATALOG_BY_SPORT[sport] ?? []; + +export const hashString = (value: string) => { + let hash = 0; + + for (let index = 0; index < value.length; index += 1) { + hash = (hash << 5) - hash + value.charCodeAt(index); + hash |= 0; + } + + return Math.abs(hash); +}; + +const formatTagTypeLabelPart = (value: string) => + value + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .split(" ") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(" "); + +const getUniqueObservedTagTypeParts = (values: readonly string[]) => { + const seen = new Set<string>(); + const parts: string[] = []; + + values.forEach((value) => { + const normalizedValue = normalizeTagTypeText(value); + if (!normalizedValue || seen.has(normalizedValue) || !hasTimelineValue(normalizedValue)) return; + + seen.add(normalizedValue); + parts.push(normalizedValue); + }); + + return parts; +}; + +const getObservedTimelineTagTypeParts = (row: SgTagRow) => { + const actionParts = getUniqueObservedTagTypeParts([row.action, row.result]); + if (actionParts.length > 0) return actionParts; + + const detailParts = getUniqueObservedTagTypeParts([row.primaryDetail, row.secondaryDetail]); + if (detailParts.length > 0) return detailParts; + + return getUniqueObservedTagTypeParts([row.groupValue, row.team]); +}; + +export const getObservedTimelineTagTypeKey = (row: SgTagRow) => { + const key = getObservedTimelineTagTypeParts(row).join("|"); + return key ? `observed:${key}` : `observed:${row.id}`; +}; + +const getObservedTimelineTagTypeLabel = (row: SgTagRow) => { + const label = getObservedTimelineTagTypeParts(row).map(formatTagTypeLabelPart).join(" - "); + return label || "Tag"; +}; + +const getCatalogRowTagTypeKeys = (row: SgTagRow, sport: SportTableKind) => { + const text = getComparableTimelineTagText(row); + + return getTimelineTagTypeCatalog(sport) + .filter((definition) => definition.keywords.some((keyword) => matchesPhrase(text, keyword))) + .map((definition) => definition.key); +}; + +export const getTimelineRowTagTypeKeys = (row: SgTagRow, sport: SportTableKind) => { + const catalogKeys = getCatalogRowTagTypeKeys(row, sport); + return catalogKeys.length > 0 ? catalogKeys : [getObservedTimelineTagTypeKey(row)]; +}; + +export const getTimelinePrimaryTagTypeKey = ( + row: SgTagRow, + sport: SportTableKind, + visibleTagTypeKeys?: ReadonlySet<string> +) => { + const keys = getTimelineRowTagTypeKeys(row, sport); + return keys.find((key) => visibleTagTypeKeys?.has(key) ?? true) ?? keys[0] ?? getObservedTimelineTagTypeKey(row); +}; + +export const buildTimelineTagTypeOptions = ( + rows: SgTagRow[], + sport: SportTableKind, + { getObservedGroup }: BuildTimelineTagTypeOptionsArgs = {} +) => { + const catalog = getTimelineTagTypeCatalog(sport); + const catalogKeySet = new Set(catalog.map((definition) => definition.key)); + const catalogMatchCounts = new Map<string, number>(); + const observedOptionsByKey = new Map<string, TimelineTagTypeOption>(); + + rows.forEach((row) => { + const rowKeys = getTimelineRowTagTypeKeys(row, sport); + rowKeys.forEach((key) => { + if (catalogKeySet.has(key)) { + catalogMatchCounts.set(key, (catalogMatchCounts.get(key) ?? 0) + 1); + } + }); + + if (rowKeys.some((key) => catalogKeySet.has(key))) return; + + const key = getObservedTimelineTagTypeKey(row); + const currentOption = observedOptionsByKey.get(key); + if (currentOption) { + observedOptionsByKey.set(key, { ...currentOption, matchCount: currentOption.matchCount + 1 }); + return; + } + + const observedGroup = catalog.length === 0 && getObservedGroup ? getObservedGroup(row) : null; + observedOptionsByKey.set(key, { + color: MARKER_COLORS[hashString(key) % MARKER_COLORS.length], + defaultVisible: true, + group: observedGroup?.group ?? OBSERVED_TAG_GROUP, + key, + label: getObservedTimelineTagTypeLabel(row), + matchCount: 1, + order: observedGroup?.order ?? OBSERVED_TAG_ORDER, + source: "observed", + }); + }); + + return [ + ...catalog.map<TimelineTagTypeOption>((definition) => ({ + color: definition.color, + defaultVisible: definition.defaultVisible, + group: definition.group, + key: definition.key, + label: definition.label, + matchCount: catalogMatchCounts.get(definition.key) ?? 0, + order: definition.order, + source: "catalog", + })), + ...observedOptionsByKey.values(), + ].sort((left, right) => left.order - right.order || left.label.localeCompare(right.label)); +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-track-assignment.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-track-assignment.ts new file mode 100644 index 00000000000..a15109d4c33 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/timeline-view/utils/timeline-track-assignment.ts @@ -0,0 +1,73 @@ +import type { SgTagRow } from "../../types"; + +type TimelineCategoryDefinition = { + id: string; + keywords: string[]; +}; + +const EMPTY_TIMELINE_VALUES = new Set(["", "--", "\u2014", "n/a", "na", "none", "null", "undefined"]); + +const hasTimelineValue = (value: string | null | undefined) => + !EMPTY_TIMELINE_VALUES.has( + String(value ?? "") + .trim() + .toLowerCase() + ); + +export const getTimelineJerseyNumberKeys = (value: string) => { + const normalizedValue = value.trim().replace(/^#/, "").replace(/\s+/g, ""); + const numberMatch = normalizedValue.match(/^\d+$/) + ? normalizedValue + : (value.match(/#\s*([A-Za-z0-9-]+)/)?.[1] ?? value.match(/\b(\d{1,3})\b/)?.[1] ?? ""); + + if (!numberMatch) return []; + + const normalizedNumber = numberMatch.replace(/^#/, "").replace(/\s+/g, ""); + const withoutLeadingZeros = normalizedNumber.replace(/^0+(?=\d)/, ""); + + return Array.from(new Set([withoutLeadingZeros.toLowerCase(), normalizedNumber.toLowerCase()].filter(Boolean))); +}; + +export const getTimelinePlayerLaneKey = (player: string) => getTimelineJerseyNumberKeys(player)[0] ?? player.trim(); + +export const buildTimelinePlayerLaneId = (player: string) => `player-${player}`; + +const getComparableTimelineText = ( + row: Pick<SgTagRow, "action" | "context" | "groupValue" | "primaryDetail" | "result" | "secondaryDetail" | "team"> +) => + [ + row.action, + row.result, + row.primaryDetail, + row.secondaryDetail, + row.team, + row.groupValue, + ...Object.values(row.context), + ] + .join(" ") + .toLowerCase() + .replace(/[_-]+/g, " "); + +export const getTimelineCategoryLaneId = ( + row: Pick<SgTagRow, "action" | "context" | "groupValue" | "primaryDetail" | "result" | "secondaryDetail" | "team">, + categoryLanes: TimelineCategoryDefinition[] +) => { + const text = getComparableTimelineText(row); + const matchedLane = categoryLanes.find((lane) => lane.keywords.some((keyword) => text.includes(keyword))); + + return matchedLane?.id ?? categoryLanes[0]?.id ?? "actions"; +}; + +export const getTimelineRowLaneIds = ( + row: Pick< + SgTagRow, + "action" | "context" | "groupValue" | "player" | "primaryDetail" | "result" | "secondaryDetail" | "team" + >, + categoryLanes: TimelineCategoryDefinition[] +) => { + const categoryLaneId = getTimelineCategoryLaneId(row, categoryLanes); + const player = row.player.trim(); + if (!hasTimelineValue(player)) return [categoryLaneId]; + + return [categoryLaneId, buildTimelinePlayerLaneId(getTimelinePlayerLaneKey(player))]; +}; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/types.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/types.ts new file mode 100644 index 00000000000..1ff861ad57b --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/types.ts @@ -0,0 +1,91 @@ +import type { TIssue } from "@plane/types"; +import type { TMediaArtifact } from "@/services/media-library.service"; +import type { TMediaItem } from "ce/features/media-library/types/media-library.types"; +import type { TEventMediaDetails } from "ce/features/media-library/utils/media-event"; + +export type SportTableKind = "american-football" | "baseball" | "soccer" | "basketball" | "cricket" | "default"; + +export type SportTableConfig = { + actionLabel: string; + defaultGroupValue: string; + groupByLabel: string; + isCompactFootballTable?: boolean; + playerLabel?: string; + primaryDetailLabel: string; + secondaryDetailLabel: string; + sport: SportTableKind; +}; + +export type SgIssue = TIssue & { sg_event_id?: string | number | null }; + +export type SgEventDetailPageProps = { + enableMatrixView?: boolean; + defaultTagViewMode?: SgEventTagViewMode; + showTagListActions?: boolean; + projectId: string; + workspaceSlug: string; + issue?: TIssue; + mediaItem?: TMediaItem | null; + fallbackBackHref?: string; + onBack?: () => void; +}; + +export type SgTagRow = { + action: string; + clipDurationSeconds?: number | null; + clipId: string | null; + clipEndSeconds: number | null; + clipRangeSource?: "explicit" | "timecode" | null; + clipStartSeconds: number | null; + context: Readonly<Record<string, string>>; + groupValue: string; + id: string; + matrixParticipant: string | null; + matrixPeriod: string | null; + player: string; + playlistFallbackTimestamp: string | null; + playlistTimestamp: string | null; + primaryDetail: string; + result: string; + secondaryDetail: string; + sourceTagId: string | null; + sourceUrl: string; + streamName?: string | null; + team: string; + thumbnailUrl: string; + timecode: string; +}; + +export type SgTagRowEditPayload = Pick< + SgTagRow, + "action" | "groupValue" | "player" | "primaryDetail" | "result" | "secondaryDetail" | "team" | "timecode" +>; + +export type SgEventDevice = { + hlsUrl: string | null; + id: number; + name: string; + streamId: string | null; + streamName: string; +}; + +export type SgEventPayloadLoadStatus = "loaded" | "unavailable" | "error"; + +export type SgEventPayloadLoadResult = { + eventPayload: Record<string, unknown> | null; + eventPayloadErrorMessage: string | null; + eventPayloadStatus: SgEventPayloadLoadStatus; +}; + +export type SgMediaPayload = SgEventPayloadLoadResult & { + eventDetails: TEventMediaDetails | null; + eventItem: TMediaItem | null; + mediaItems: TMediaItem[]; + manifestArtifacts: TMediaArtifact[]; + packageId: string; + videoItems: TMediaItem[]; +}; + +export type RowFilterMode = "all" | "selected" | "favorites"; + +export type SgEventTagViewMode = "list" | "timeline" | "matrix"; diff --git a/apps/web/core/components/issues/issue-detail/sg-event-detail-page/utils.ts b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/utils.ts new file mode 100644 index 00000000000..079165ff173 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/sg-event-detail-page/utils.ts @@ -0,0 +1,1673 @@ +import type { TIssue } from "@plane/types"; +import { parseOppositionTeam } from "@/helpers/opposition-team"; +import type { TMediaItem } from "ce/features/media-library/types/media-library.types"; +import { formatDateValue, formatTimeValue } from "ce/features/media-library/utils/media-detail-utils"; +import type { TEventMediaDetails } from "ce/features/media-library/utils/media-event"; +import { SPORT_TABLE_CONFIGS } from "./constants"; +import { findExactRawTagFieldValue } from "./raw-tag-fields"; +import type { SgTagRow, SportTableKind } from "./types"; + +export const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); + +export const asRecord = (value: unknown): Record<string, unknown> => + value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}; + +export const firstNonEmptyRecord = (...values: unknown[]): Record<string, unknown> | null => { + for (const value of values) { + const record = asRecord(value); + if (Object.keys(record).length > 0) { + return record; + } + } + + return null; +}; + +export const getCpServerBaseUrl = () => process.env.NEXT_PUBLIC_CP_SERVER_URL?.replace(/\/$/, "") ?? ""; + +const DEFAULT_ARCHIVED_HLS_BASE_URL = "/hls"; + +export const getArchivedHlsBaseUrl = () => { + const configuredBaseUrl = process.env.NEXT_PUBLIC_HLS_SERVER_URL?.trim(); + const baseUrl = configuredBaseUrl || DEFAULT_ARCHIVED_HLS_BASE_URL; + const normalizedBaseUrl = baseUrl.replace(/\/+$/, ""); + + return normalizedBaseUrl || DEFAULT_ARCHIVED_HLS_BASE_URL; +}; + +export const toText = (value: unknown): string => { + if (typeof value === "string") { + const normalizedValue = value.trim(); + return normalizedValue || ""; + } + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) { + return value + .map((entry): string => toText(entry)) + .filter(Boolean) + .join(", "); + } + if (value && typeof value === "object" && "name" in (value as Record<string, unknown>)) { + return toText((value as Record<string, unknown>).name); + } + return ""; +}; + +export const toNumber = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsedValue = Number(value); + return Number.isFinite(parsedValue) ? parsedValue : null; + } + return null; +}; + +type GatewayField = { + field?: unknown; + type?: unknown; + value?: unknown; +}; + +const isGatewayField = (value: unknown): value is GatewayField => + Boolean(value) && + typeof value === "object" && + ("field" in (value as GatewayField) || "value" in (value as GatewayField)); + +const demodulateGatewayValue = (value: unknown, type: unknown): unknown => { + if (Number(type) === 6 && Array.isArray(value)) { + return value.map((entry) => demodulateGatewayEntry(entry)); + } + + return value; +}; + +const demodulateGatewayEntry = (entry: unknown): Record<string, unknown> => { + if (Array.isArray(entry) && entry.every(isGatewayField)) { + return entry.reduce<Record<string, unknown>>((accumulator, field) => { + const fieldName = typeof field.field === "string" ? field.field : ""; + if (!fieldName) return accumulator; + + accumulator[fieldName] = demodulateGatewayValue(field.value, field.type); + return accumulator; + }, {}); + } + + return asRecord(entry); +}; + +export const parseGatewayRows = (payload: unknown): Record<string, unknown>[] => { + if (Array.isArray(payload)) { + return payload.map((entry) => demodulateGatewayEntry(entry)).filter((entry) => Object.keys(entry).length > 0); + } + + const gatewayResponse = asRecord(payload)["Gateway Response"]; + const result = asRecord(gatewayResponse).result; + const rows = Array.isArray(result) ? result : []; + + return rows.map((entry) => demodulateGatewayEntry(entry)).filter((entry) => Object.keys(entry).length > 0); +}; + +export const buildArchivedStreamUrl = (streamName: string) => { + const normalizedStreamName = streamName.trim().replace(/^\/+|\/+$/g, ""); + if (!normalizedStreamName) return null; + + return `${getArchivedHlsBaseUrl()}/${normalizedStreamName}/llhls.m3u8`; +}; + +export const buildArchivedPlaylistUrl = (playlistFileName: string) => { + const normalizedFileName = playlistFileName.trim().replace(/^\/+/, ""); + if (!normalizedFileName) return null; + + return `${getArchivedHlsBaseUrl()}/${normalizedFileName}`; +}; + +export const getLastPathSegment = (value: string | null | undefined) => { + const normalizedValue = (value ?? "").trim(); + if (!normalizedValue) return ""; + + try { + const url = new URL(normalizedValue, typeof window !== "undefined" ? window.location.origin : "http://localhost"); + return decodeURIComponent(url.pathname.replace(/\/+$/, "").split("/").pop() ?? "").trim(); + } catch { + return decodeURIComponent(normalizedValue.replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop() ?? "").trim(); + } +}; + +export const buildCustomPlaylistUrl = (value: string | null | undefined) => { + const normalizedValue = (value ?? "").trim(); + if (!normalizedValue) return ""; + if (/^https?:\/\//i.test(normalizedValue)) return normalizedValue; + + return buildArchivedPlaylistUrl(normalizedValue) ?? ""; +}; + +export const buildCustomPlaylistThumbnailUrl = (value: string | null | undefined) => { + const normalizedValue = (value ?? "").trim(); + if (!normalizedValue) return ""; + if (/^https?:\/\//i.test(normalizedValue)) return normalizedValue; + + const cpServerBaseUrl = getCpServerBaseUrl(); + return cpServerBaseUrl + ? `${cpServerBaseUrl}/blobs/thumbnails/${encodeURIComponent(normalizedValue)}` + : normalizedValue; +}; + +export const formatLooseLabel = (value: string) => + value + .replace(/[_-]+/g, " ") + .split(" ") + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); + +export const normalizeSportKey = (value: string | null | undefined): SportTableKind => { + const normalizedValue = (value ?? "").trim().toLowerCase(); + if (!normalizedValue) return "default"; + if (normalizedValue.includes("american") && normalizedValue.includes("football")) return "american-football"; + if (normalizedValue === "football") return "american-football"; + if (normalizedValue.includes("baseball")) return "baseball"; + if (normalizedValue.includes("basketball")) return "basketball"; + if (normalizedValue.includes("cricket")) return "cricket"; + if ( + normalizedValue.includes("soccer") || + normalizedValue.includes("association football") || + normalizedValue.includes("association-football") + ) { + return "soccer"; + } + return "default"; +}; + +export const getSportTableConfig = (sport: string | null | undefined) => + SPORT_TABLE_CONFIGS[normalizeSportKey(sport)] ?? SPORT_TABLE_CONFIGS.default; + +const toOrdinal = (value: string) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) return value; + const absoluteValue = Math.abs(numericValue); + const remainder100 = absoluteValue % 100; + if (remainder100 >= 11 && remainder100 <= 13) return `${numericValue}th`; + const remainder10 = absoluteValue % 10; + if (remainder10 === 1) return `${numericValue}st`; + if (remainder10 === 2) return `${numericValue}nd`; + if (remainder10 === 3) return `${numericValue}rd`; + return `${numericValue}th`; +}; + +const formatClockFromSeconds = (value: string) => { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds < 0) return ""; + const totalSeconds = Math.floor(seconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const remainingSeconds = totalSeconds % 60; + if (hours > 0) { + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; + } + return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; +}; + +const formatClockValue = (value: string) => { + if (!value) return ""; + if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(value)) return value; + return formatClockFromSeconds(value); +}; + +export const pickText = (sources: Array<Record<string, unknown> | null | undefined>, keys: string[]) => { + for (const source of sources) { + if (!source) continue; + for (const key of keys) { + const value = toText(source[key]); + if (value) return value; + } + } + return ""; +}; + +const pickArray = (sources: Array<Record<string, unknown> | null | undefined>, keys: string[]) => { + for (const source of sources) { + if (!source) continue; + for (const key of keys) { + const value = source[key]; + if (Array.isArray(value)) return value; + } + } + return []; +}; + +const normalizeQuarter = (value: string) => { + const normalizedValue = value.trim().toLowerCase(); + const match = normalizedValue.match(/(\d+)/); + if (match?.[1]) return `Quarter ${match[1]}`; + if (normalizedValue.startsWith("q")) return `Quarter ${normalizedValue.slice(1)}`; + return formatLooseLabel(value) || "Quarter 1"; +}; + +const normalizeBasketballQuarter = (value: string) => { + const normalizedValue = value.trim().toLowerCase(); + const match = normalizedValue.match(/(\d+)/); + if (match?.[1]) { + return normalizedValue.startsWith("ot") ? `OT${match[1]}` : `Q${match[1]}`; + } + if (normalizedValue.startsWith("q")) return value.trim().toUpperCase(); + if (normalizedValue.startsWith("ot")) return value.trim().toUpperCase(); + return formatLooseLabel(value) || "Q1"; +}; + +const buildTimecode = (tag: Record<string, unknown>) => { + const directTimecode = toText( + tag.timecode ?? tag.time_code ?? tag.timeRange ?? tag.time_range ?? tag.video_timecode ?? tag.videoTimecode + ); + if (directTimecode) return directTimecode; + + const start = toText(tag.start ?? tag.clip_start ?? tag.video_timecode_clip_start ?? tag.start_timecode); + const end = toText(tag.end ?? tag.clip_end ?? tag.video_timecode_clip_end ?? tag.end_timecode); + if (start && end) return `${start}-${end}`; + return start || end || "--"; +}; + +const normalizeTagLookupKey = (value: unknown) => + toText(value) + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +const isTagDataKeyMatch = (normalizedKey: string, normalizedNames: ReadonlySet<string>) => { + if (!normalizedKey || normalizedNames.has(normalizedKey)) return normalizedNames.has(normalizedKey); + + return Array.from(normalizedNames).some( + (name) => name.length >= 8 && (normalizedKey.startsWith(`${name}_`) || normalizedKey.endsWith(`_${name}`)) + ); +}; + +const findTagDataMatch = (tag: Record<string, unknown>, names: string[]) => { + const normalizedNames = new Set(names.map((name) => normalizeTagLookupKey(name)).filter(Boolean)); + + for (const name of names) { + const directValue = toText(tag[name]); + if (directValue) return { key: name, value: directValue }; + } + + for (const [key, value] of Object.entries(tag)) { + const normalizedKey = normalizeTagLookupKey(key); + if (!isTagDataKeyMatch(normalizedKey, normalizedNames)) continue; + + const directValue = toText(value); + if (directValue) return { key, value: directValue }; + } + + const dataEntries = asArray(tag.data); + for (const entry of dataEntries) { + const entryRecord = asRecord(entry); + const tagName = normalizeTagLookupKey( + entryRecord.tag ?? + entryRecord.field ?? + entryRecord.field_name ?? + entryRecord.fieldName ?? + entryRecord.name ?? + entryRecord.key + ); + if (!isTagDataKeyMatch(tagName, normalizedNames)) continue; + + const tagValue = toText(entryRecord.value ?? entryRecord.field_value ?? entryRecord.fieldValue ?? entryRecord.val); + if (tagValue) return { key: tagName, value: tagValue }; + } + + return null; +}; + +const findTagDataValue = (tag: Record<string, unknown>, names: string[]) => findTagDataMatch(tag, names)?.value ?? ""; + +const normalizeTagContextKey = normalizeTagLookupKey; + +const TAG_CONTEXT_IGNORED_KEYS = new Set([ + "action", + "data", + "id", + "quarter", + "result", + "tag_id", + "tagid", + "team", + "thumbnail_url", + "thumbnailurl", + "time_range", + "timerange", + "timestamp", +]); + +const buildTagContext = (tag: Record<string, unknown>): Readonly<Record<string, string>> => { + const context: Record<string, string> = {}; + + Object.entries(tag).forEach(([key, value]) => { + const normalizedKey = normalizeTagContextKey(key); + if ( + TAG_CONTEXT_IGNORED_KEYS.has(normalizedKey) || + (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") + ) { + return; + } + const normalizedValue = toText(value); + if (normalizedKey && normalizedValue) context[normalizedKey] = normalizedValue; + }); + + asArray(tag.data).forEach((entry) => { + const record = asRecord(entry); + const key = normalizeTagContextKey( + record.tag ?? record.field ?? record.field_name ?? record.fieldName ?? record.name ?? record.key + ); + const value = toText(record.value ?? record.field_value ?? record.fieldValue ?? record.val); + if (key && value) context[key] = value; + }); + + return context; +}; + +const formatYardValue = (value: string) => { + if (!value) return "--"; + const numericValue = Number(value); + if (Number.isFinite(numericValue)) { + const sign = numericValue > 0 ? "+" : ""; + return `${sign}${numericValue} yd`; + } + return formatLooseLabel(value); +}; + +const formatFootballDownDistance = (down: string, distance: string) => { + const normalizedDown = down ? toOrdinal(down) : ""; + const normalizedDistance = distance ? String(Number.isFinite(Number(distance)) ? Number(distance) : distance) : ""; + if (normalizedDown && normalizedDistance) return `${normalizedDown} & ${normalizedDistance}`; + return normalizedDown || normalizedDistance || "--"; +}; + +const DEFAULT_SG_TAG_DURATION_SECONDS = 8; + +const isDisplayValue = (value: string | null | undefined) => { + const normalizedValue = (value ?? "").trim().toLowerCase(); + return Boolean(normalizedValue && normalizedValue !== "--" && normalizedValue !== "-" && normalizedValue !== "n/a"); +}; + +const formatFootballActionResult = (action: string) => { + const normalizedAction = action.trim().toLowerCase(); + if (!normalizedAction || normalizedAction === "--") return "--"; + + if (/pass[_\s-]?complete/.test(normalizedAction)) return "Complete"; + if (/pass[_\s-]?incomplete/.test(normalizedAction)) return "Incomplete"; + if (/touchdown|td/.test(normalizedAction)) return "Touchdown"; + if (/interception|intercepted/.test(normalizedAction)) return "Interception"; + if (/fumble/.test(normalizedAction)) return "Fumble"; + if (/sack/.test(normalizedAction)) return "Sack"; + if (/turnover/.test(normalizedAction)) return "Turnover"; + if (/penalty/.test(normalizedAction)) return "Penalty"; + if (/punt/.test(normalizedAction)) return "Punt"; + if (/kickoff/.test(normalizedAction)) return "Kickoff"; + if (/field[_\s-]?goal/.test(normalizedAction)) return "Field Goal"; + if (/extra[_\s-]?point/.test(normalizedAction)) return "Extra Point"; + if (/end[_\s-]?period|period[_\s-]?end/.test(normalizedAction)) return "End Period"; + if (/run|rush/.test(normalizedAction)) return "Run"; + + return formatLooseLabel(action); +}; + +const formatBaseballInning = (half: string, inning: string) => { + const normalizedHalf = half ? formatLooseLabel(half) : ""; + const normalizedInning = inning ? toOrdinal(inning) : ""; + if (normalizedHalf && normalizedInning) return `${normalizedHalf} ${normalizedInning}`; + return normalizedHalf || normalizedInning || "--"; +}; + +const formatBaseballCount = (balls: string, strikes: string, directCount: string) => { + if (balls || strikes) { + const normalizedBalls = balls || "0"; + const normalizedStrikes = strikes || "0"; + return `${normalizedBalls}-${normalizedStrikes}`; + } + return directCount || "--"; +}; + +const formatBasketballValue = (value: string, result: string) => { + if (value) { + const numericValue = Number(value); + if (Number.isFinite(numericValue)) { + return `${numericValue} point${numericValue === 1 ? "" : "s"}`; + } + return formatLooseLabel(value); + } + return result || "--"; +}; + +const formatBasketballActionResult = (action: string) => { + const normalizedAction = action.trim().toLowerCase(); + if (!normalizedAction || normalizedAction === "--") return "--"; + + if (/(?:field_goal|three_point|3pt).*made.*3|(?:made_3|3pt_made|three_point_made)/.test(normalizedAction)) { + return "3 points"; + } + if (/(?:field_goal|two_point|2pt).*made.*2|(?:made_2|2pt_made|two_point_made)/.test(normalizedAction)) { + return "2 points"; + } + if (/(?:free_throw).*made|(?:made_free_throw|free_throw_made)/.test(normalizedAction)) { + return "1 point"; + } + if (/miss/.test(normalizedAction)) return "Missed"; + + return formatLooseLabel(action); +}; + +const formatCricketRuns = (value: string) => { + if (!value) return "--"; + const numericValue = Number(value); + if (Number.isFinite(numericValue)) { + return String(numericValue); + } + return formatLooseLabel(value); +}; + +const formatCricketActionResult = (action: string) => { + const normalizedAction = normalizeTagLookupKey(action); + if (!normalizedAction) return "--"; + + if (/boundary_six|six|six_runs|6_run/.test(normalizedAction)) return "6"; + if (/boundary_four|four|four_runs|4_run/.test(normalizedAction)) return "4"; + if (/three_runs|3_runs/.test(normalizedAction)) return "3"; + if (/two_runs|2_runs/.test(normalizedAction)) return "2"; + if (/single|one_run|1_run/.test(normalizedAction)) return "1"; + if (/dot_ball|dot/.test(normalizedAction)) return "0"; + if (/run_out|runout/.test(normalizedAction)) return "Run Out"; + if (/wicket|dismissal|bowled|caught|stumped|lbw|out/.test(normalizedAction)) return "Wicket"; + if (/no_ball|noball/.test(normalizedAction)) return "No Ball"; + if (/wide/.test(normalizedAction)) return "Wide"; + if (/leg_bye|legbye/.test(normalizedAction)) return "Leg Bye"; + if (/bye/.test(normalizedAction)) return "Bye"; + if (/end_over|over_end/.test(normalizedAction)) return "End Over"; + if (/end_innings|innings_end/.test(normalizedAction)) return "End Innings"; + + return formatLooseLabel(action); +}; + +const formatCricketOver = (displayValue: string, overNumber: string, ballInOver: string) => { + if (displayValue) return displayValue; + if (overNumber && ballInOver) return `${overNumber}.${ballInOver}`; + if (overNumber) return `Over ${overNumber}`; + return ""; +}; + +const formatCricketOverGroup = (overNumber: string, overDisplay: string) => { + if (overNumber) return `Over ${overNumber}`; + + const displayOverNumber = overDisplay.match(/\b(\d+)(?:\.\d+)?\b/)?.[1] || ""; + if (displayOverNumber) return `Over ${displayOverNumber}`; + + return ""; +}; + +export const parseTimecodeToSeconds = (value: string) => { + const normalizedValue = value.trim(); + if (!normalizedValue || normalizedValue === "--") return null; + const directSeconds = Number(normalizedValue); + if (Number.isFinite(directSeconds) && directSeconds >= 0) { + return directSeconds; + } + + const firstPart = normalizedValue.split(/\s*[-\u2013\u2014]\s*/)[0].trim(); + const parts = firstPart.split(":").map((part) => part.trim()); + if (parts.length < 2 || parts.length > 3) return null; + + const numericParts = parts.map((part) => Number(part)); + if (numericParts.some((part) => !Number.isFinite(part) || part < 0)) return null; + + if (numericParts.length === 2) { + const [minutes, seconds] = numericParts; + return minutes * 60 + seconds; + } + + const [hours, minutes, seconds] = numericParts; + return hours * 3600 + minutes * 60 + seconds; +}; + +const PLAYLIST_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(?:[+-]\d{2}:\d{2}|Z)$/; +const CLOCK_ONLY_TIMESTAMP_REGEX = /^\d{2}:\d{2}:\d{2}$/; +const TIMECODE_RANGE_SEPARATOR_REGEX = /\s*[-\u2013\u2014]\s*/; + +export const normalizePlaylistTimestamp = (value: string, baseEventDateTime?: string | null) => { + const normalizedValue = value.trim(); + if (!normalizedValue) return null; + + if (PLAYLIST_TIMESTAMP_REGEX.test(normalizedValue)) { + return normalizedValue.endsWith("Z") ? normalizedValue.replace(/Z$/, "+00:00") : normalizedValue; + } + + if (CLOCK_ONLY_TIMESTAMP_REGEX.test(normalizedValue) && baseEventDateTime) { + const parsedBaseDate = Date.parse(baseEventDateTime); + if (!Number.isNaN(parsedBaseDate)) { + const [hours, minutes, seconds] = normalizedValue.split(":").map((part) => Number(part)); + const nextDate = new Date(parsedBaseDate); + nextDate.setUTCHours(hours, minutes, seconds, 0); + return nextDate.toISOString().replace(/Z$/, "+00:00"); + } + } + + const parsedValue = Date.parse(normalizedValue); + if (Number.isNaN(parsedValue)) return null; + + return new Date(parsedValue).toISOString().replace(/Z$/, "+00:00"); +}; + +export const buildClockOnlyPlaylistTimestampFallback = (value: string, baseEventDateTime?: string | null) => { + const normalizedValue = value.trim(); + if (!CLOCK_ONLY_TIMESTAMP_REGEX.test(normalizedValue) || !baseEventDateTime) { + return null; + } + + const parsedBaseDate = Date.parse(baseEventDateTime); + if (Number.isNaN(parsedBaseDate)) { + return null; + } + + const [hours, minutes, seconds] = normalizedValue.split(":").map((part) => Number(part)); + const utcCandidate = new Date(parsedBaseDate); + utcCandidate.setUTCHours(hours, minutes, seconds, 0); + + const diffMs = utcCandidate.getTime() - parsedBaseDate; + if (Math.abs(diffMs) < 2 * 60 * 60 * 1000) { + return null; + } + + const inferredOffsetMs = Math.round(diffMs / (30 * 60 * 1000)) * 30 * 60 * 1000; + const adjustedCandidate = new Date(utcCandidate.getTime() - inferredOffsetMs); + + return adjustedCandidate.toISOString().replace(/Z$/, "+00:00"); +}; + +const getTimestampOffsetSeconds = (value: string | null, baseEventDateTime?: string | null) => { + if (!value || !baseEventDateTime) return null; + + const parsedValue = Date.parse(value); + const parsedBaseDate = Date.parse(baseEventDateTime); + if (Number.isNaN(parsedValue) || Number.isNaN(parsedBaseDate)) return null; + + const offsetSeconds = (parsedValue - parsedBaseDate) / 1000; + if (!Number.isFinite(offsetSeconds) || offsetSeconds < 0 || offsetSeconds > 24 * 60 * 60) return null; + + return offsetSeconds; +}; + +const getClockOnlyOffsetSeconds = (value: string, baseEventDateTime?: string | null) => { + const firstPart = value.trim().split(TIMECODE_RANGE_SEPARATOR_REGEX)[0]?.trim() ?? ""; + if (!CLOCK_ONLY_TIMESTAMP_REGEX.test(firstPart)) return null; + + return getTimestampOffsetSeconds( + buildClockOnlyPlaylistTimestampFallback(firstPart, baseEventDateTime) ?? + normalizePlaylistTimestamp(firstPart, baseEventDateTime), + baseEventDateTime + ); +}; + +const isMillisecondTagKey = (value: string | null | undefined) => { + const normalizedKey = normalizeTagLookupKey(value); + return normalizedKey.endsWith("_ms") || normalizedKey.includes("millisecond"); +}; + +const getExplicitTagOffsetSeconds = (value: string, sourceKey?: string | null) => { + const normalizedValue = value.trim(); + if (!normalizedValue) return null; + + const millisecondUnitValue = normalizedValue.match( + /^(\d+(?:\.\d+)?)\s*(?:ms|msec|msecs|millisecond|milliseconds)$/i + )?.[1]; + if (millisecondUnitValue) { + const parsedMilliseconds = Number(millisecondUnitValue); + return Number.isFinite(parsedMilliseconds) && parsedMilliseconds >= 0 ? parsedMilliseconds / 1000 : null; + } + + const numericOffset = Number(normalizedValue); + if (Number.isFinite(numericOffset) && isMillisecondTagKey(sourceKey)) return numericOffset / 1000; + + return parseTimecodeToSeconds(normalizedValue); +}; + +const getExplicitTagDurationSeconds = (value: string, sourceKey?: string | null) => { + const normalizedValue = value.trim(); + if (!normalizedValue) return null; + + const millisecondUnitValue = normalizedValue.match( + /^(\d+(?:\.\d+)?)\s*(?:ms|msec|msecs|millisecond|milliseconds)$/i + )?.[1]; + if (millisecondUnitValue) { + const parsedMilliseconds = Number(millisecondUnitValue); + return Number.isFinite(parsedMilliseconds) && parsedMilliseconds > 0 ? parsedMilliseconds / 1000 : null; + } + + const unitlessValue = + normalizedValue.match(/^(\d+(?:\.\d+)?)\s*(?:s|sec|secs|second|seconds)$/i)?.[1] ?? normalizedValue; + const numericDuration = Number(unitlessValue); + const durationSeconds = + Number.isFinite(numericDuration) && isMillisecondTagKey(sourceKey) + ? numericDuration / 1000 + : parseTimecodeToSeconds(unitlessValue); + + return durationSeconds !== null && durationSeconds > 0 ? durationSeconds : null; +}; + +const formatTagOffsetTimecode = (value: string, sourceKey?: string | null) => { + const normalizedValue = value.trim(); + if (!normalizedValue) return ""; + + const offsetSeconds = getExplicitTagOffsetSeconds(normalizedValue, sourceKey); + return offsetSeconds !== null ? formatClockFromSeconds(String(offsetSeconds)) : normalizedValue; +}; + +const buildOffsetTimecode = ( + start: string, + end: string, + duration: string, + durationSourceKey?: string | null, + startSourceKey?: string | null, + endSourceKey?: string | null +) => { + const formattedStart = formatTagOffsetTimecode(start, startSourceKey); + const formattedEnd = formatTagOffsetTimecode(end, endSourceKey); + + if (formattedStart && formattedEnd) return `${formattedStart}-${formattedEnd}`; + if (formattedStart && duration) { + const startSeconds = getExplicitTagOffsetSeconds(start, startSourceKey); + const durationSeconds = getExplicitTagDurationSeconds(duration, durationSourceKey); + + if (startSeconds !== null && durationSeconds !== null) { + return `${formatClockFromSeconds(String(startSeconds))}-${formatClockFromSeconds(String(startSeconds + durationSeconds))}`; + } + } + + return formattedStart || formattedEnd; +}; + +const formatClipRangeTimecode = (startSeconds: number | null, endSeconds: number | null) => { + if (startSeconds === null) return ""; + + const formattedStart = formatClockFromSeconds(String(startSeconds)); + if (!formattedStart) return ""; + + if (endSeconds !== null && endSeconds > startSeconds) { + const formattedEnd = formatClockFromSeconds(String(endSeconds)); + if (formattedEnd) return `${formattedStart}-${formattedEnd}`; + } + + return formattedStart; +}; + +const formatRawTimestampTimecode = (value: string) => { + const normalizedValue = value.trim(); + if (!normalizedValue) return ""; + + const offsetTimecode = formatTagOffsetTimecode(normalizedValue); + if (offsetTimecode && offsetTimecode !== normalizedValue) return offsetTimecode; + + const isoTimeMatch = normalizedValue.match(/^\d{4}-\d{2}-\d{2}[T\s](\d{1,2}:\d{2}(?::\d{2})?)/); + if (isoTimeMatch?.[1]) return isoTimeMatch[1]; + + const clockMatch = normalizedValue.match(/^(\d{1,2}:\d{2}(?::\d{2})?)(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/); + if (clockMatch?.[1]) return clockMatch[1]; + + return normalizedValue; +}; + +const getTimeRangeOffsetSeconds = (value: string, baseEventDateTime?: string | null) => + getClockOnlyOffsetSeconds(value, baseEventDateTime) ?? parseTimecodeToSeconds(value); + +export const playlistHasMediaSegments = async (playlistUrl: string) => { + try { + const response = await fetch(playlistUrl, { cache: "no-store" }); + if (!response.ok) { + return true; + } + + const playlistText = await response.text(); + return playlistText.split(/\r?\n/).some((line) => { + const trimmedLine = line.trim(); + return Boolean(trimmedLine) && !trimmedLine.startsWith("#"); + }); + } catch { + return true; + } +}; + +export const getSgTagRowStreamName = ( + row: Pick<SgTagRow, "context" | "streamName">, + fallbackStreamName?: string | null +) => { + const normalizedFallbackStreamName = toText(fallbackStreamName); + + return ( + normalizedFallbackStreamName || + row.streamName?.trim() || + row.context.original_stream_name?.trim() || + row.context.stream_name?.trim() || + "" + ); +}; + +const getTagSourceUrl = (tag: Record<string, unknown>) => + findTagDataValue(tag, [ + "playlist_url", + "playlistUrl", + "video_url", + "videoUrl", + "source_url", + "sourceUrl", + "media_url", + "mediaUrl", + "clip_url", + "clipUrl", + "url", + "link", + "path", + ]); + +const getTagStreamName = (tag: Record<string, unknown>) => + findTagDataValue(tag, [ + "original_stream_name", + "originalStreamName", + "stream_name", + "streamName", + "primary_stream_name", + "primaryStreamName", + ]) || + toText( + tag.original_stream_name ?? + tag.originalStreamName ?? + tag.stream_name ?? + tag.streamName ?? + tag.primary_stream_name ?? + tag.primaryStreamName + ); + +const getTagThumbnailUrl = (tag: Record<string, unknown>) => + findTagDataValue(tag, [ + "thumbnail", + "thumbnail_name", + "thumbnailName", + "thumbnail_file", + "thumbnailFile", + "thumbnail_url", + "thumbnailUrl", + "poster", + "poster_url", + "posterUrl", + "preview_url", + "previewUrl", + "image_url", + "imageUrl", + "frame_url", + "frameUrl", + "clip_thumbnail", + "clipThumbnail", + "clip_thumbnail_url", + "clipThumbnailUrl", + ]); + +const getSourceTagId = (tag: Record<string, unknown>) => + findExactRawTagFieldValue(tag, [ + "id", + "tag_id", + "tagId", + "event_tag_id", + "eventTagId", + "source_tag_id", + "sourceTagId", + "source_id", + "sourceId", + "uuid", + "guid", + "_id", + ]); + +const getClipId = (tag: Record<string, unknown>) => + findExactRawTagFieldValue(tag, [ + "clip_id", + "clipId", + "source_clip_id", + "sourceClipId", + "video_clip_id", + "videoClipId", + "media_id", + "mediaId", + "artifact_id", + "artifactId", + "video_id", + "videoId", + ]); + +const compactHash = (value: string) => { + let hash = 0; + + for (let index = 0; index < value.length; index += 1) { + hash = (hash << 5) - hash + value.charCodeAt(index); + hash |= 0; + } + + return Math.abs(hash).toString(36); +}; + +const normalizeComparableTagValue = (value: string) => value.trim().toLowerCase().replace(/\s+/g, " "); + +const splitTimecodeRange = (value: string) => { + const [start = "", end = ""] = value.split("-").map((part) => part.trim()); + + return { + end: end.replace(/\s+/g, ""), + start: start.replace(/\s+/g, ""), + }; +}; + +const buildSgTagRowDedupeKey = ( + row: Pick< + SgTagRow, + | "action" + | "clipId" + | "context" + | "groupValue" + | "player" + | "primaryDetail" + | "result" + | "secondaryDetail" + | "team" + | "timecode" + > +) => { + const { start, end } = splitTimecodeRange(row.timecode); + + return JSON.stringify({ + action: normalizeComparableTagValue(row.action), + clipId: normalizeComparableTagValue(row.clipId ?? ""), + context: Object.entries(row.context) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, normalizeComparableTagValue(value)]), + end, + groupValue: normalizeComparableTagValue(row.groupValue), + player: normalizeComparableTagValue(row.player), + primaryDetail: normalizeComparableTagValue(row.primaryDetail), + result: normalizeComparableTagValue(row.result), + secondaryDetail: normalizeComparableTagValue(row.secondaryDetail), + start, + team: normalizeComparableTagValue(row.team), + }); +}; + +const buildFallbackTagFieldId = (prefix: string, row: Parameters<typeof buildSgTagRowDedupeKey>[0]) => + `${prefix}-${compactHash(buildSgTagRowDedupeKey(row))}`; + +const buildStableSgTagRowId = ( + row: Pick< + SgTagRow, + | "action" + | "clipId" + | "context" + | "groupValue" + | "player" + | "primaryDetail" + | "result" + | "secondaryDetail" + | "team" + | "timecode" + >, + sourceTagId: string | null +) => sourceTagId || `sg-tag-${buildSgTagRowDedupeKey(row)}`; + +const getTagRowCompletenessScore = (row: SgTagRow) => + [ + row.sourceTagId, + row.clipId, + row.sourceUrl, + row.streamName, + row.playlistTimestamp, + row.playlistFallbackTimestamp, + row.clipDurationSeconds !== null && row.clipDurationSeconds !== undefined ? "clip-duration" : "", + row.clipStartSeconds !== null ? "clip-start" : "", + row.clipEndSeconds !== null ? "clip-end" : "", + row.matrixParticipant, + row.matrixPeriod, + row.player !== "--" ? row.player : "", + row.result !== "--" ? row.result : "", + row.primaryDetail !== "--" ? row.primaryDetail : "", + row.secondaryDetail !== "--" ? row.secondaryDetail : "", + row.thumbnailUrl, + Object.keys(row.context).length > 0 ? "context" : "", + ].filter(Boolean).length; + +const mergeDuplicateTagRows = (currentRow: SgTagRow, nextRow: SgTagRow) => { + const preferredRow = + getTagRowCompletenessScore(nextRow) > getTagRowCompletenessScore(currentRow) ? nextRow : currentRow; + const fallbackRow = preferredRow === nextRow ? currentRow : nextRow; + const mergedRow = { + ...preferredRow, + clipId: preferredRow.clipId ?? fallbackRow.clipId, + clipDurationSeconds: preferredRow.clipDurationSeconds ?? fallbackRow.clipDurationSeconds ?? null, + clipEndSeconds: preferredRow.clipEndSeconds ?? fallbackRow.clipEndSeconds, + clipRangeSource: preferredRow.clipRangeSource ?? fallbackRow.clipRangeSource ?? null, + clipStartSeconds: preferredRow.clipStartSeconds ?? fallbackRow.clipStartSeconds, + context: { ...fallbackRow.context, ...preferredRow.context }, + matrixParticipant: preferredRow.matrixParticipant ?? fallbackRow.matrixParticipant, + matrixPeriod: preferredRow.matrixPeriod ?? fallbackRow.matrixPeriod, + player: preferredRow.player !== "--" ? preferredRow.player : fallbackRow.player, + playlistFallbackTimestamp: preferredRow.playlistFallbackTimestamp ?? fallbackRow.playlistFallbackTimestamp, + playlistTimestamp: preferredRow.playlistTimestamp ?? fallbackRow.playlistTimestamp, + primaryDetail: preferredRow.primaryDetail !== "--" ? preferredRow.primaryDetail : fallbackRow.primaryDetail, + result: preferredRow.result !== "--" ? preferredRow.result : fallbackRow.result, + secondaryDetail: preferredRow.secondaryDetail !== "--" ? preferredRow.secondaryDetail : fallbackRow.secondaryDetail, + sourceTagId: preferredRow.sourceTagId ?? fallbackRow.sourceTagId, + sourceUrl: preferredRow.sourceUrl || fallbackRow.sourceUrl, + streamName: preferredRow.streamName || fallbackRow.streamName, + team: preferredRow.team !== "--" ? preferredRow.team : fallbackRow.team, + thumbnailUrl: preferredRow.thumbnailUrl || fallbackRow.thumbnailUrl, + timecode: preferredRow.timecode !== "--" ? preferredRow.timecode : fallbackRow.timecode, + } satisfies SgTagRow; + + return { + ...mergedRow, + id: buildStableSgTagRowId(mergedRow, mergedRow.sourceTagId), + } satisfies SgTagRow; +}; + +export const dedupeTagRows = (rows: SgTagRow[]) => { + const rowsByKey = new Map<string, SgTagRow>(); + const sourceIdToKey = new Map<string, string>(); + let duplicateCount = 0; + + rows.forEach((row) => { + const contentKey = buildSgTagRowDedupeKey(row); + const mappedSourceKey = row.sourceTagId ? sourceIdToKey.get(row.sourceTagId) : undefined; + const existingKey = mappedSourceKey ?? (rowsByKey.has(contentKey) ? contentKey : undefined); + + if (!existingKey) { + rowsByKey.set(contentKey, row); + if (row.sourceTagId) { + sourceIdToKey.set(row.sourceTagId, contentKey); + } + return; + } + + duplicateCount += 1; + const currentRow = rowsByKey.get(existingKey); + if (currentRow) { + rowsByKey.set(existingKey, mergeDuplicateTagRows(currentRow, row)); + } + + if (row.sourceTagId) { + sourceIdToKey.set(row.sourceTagId, existingKey); + } + }); + + if (duplicateCount > 0 && process.env.NODE_ENV !== "production") { + console.info(`[sg-event-detail] Removed ${duplicateCount} duplicate tag row${duplicateCount === 1 ? "" : "s"}.`); + } + + return Array.from(rowsByKey.values()); +}; + +const preserveTagRowsWithUniqueIds = (rows: SgTagRow[]) => { + const idCounts = new Map<string, number>(); + + return rows.map((row) => { + const currentCount = idCounts.get(row.id) ?? 0; + + idCounts.set(row.id, currentCount + 1); + if (currentCount === 0) return row; + + return { + ...row, + id: `${row.id}__${currentCount + 1}`, + }; + }); +}; + +const buildExactTagRowKey = (row: SgTagRow) => + JSON.stringify({ + action: row.action, + clipId: row.clipId, + clipDurationSeconds: row.clipDurationSeconds, + clipEndSeconds: row.clipEndSeconds, + clipRangeSource: row.clipRangeSource, + clipStartSeconds: row.clipStartSeconds, + context: Object.entries(row.context) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, normalizeComparableTagValue(value)]), + groupValue: row.groupValue, + matrixParticipant: row.matrixParticipant, + matrixPeriod: row.matrixPeriod, + player: row.player, + playlistFallbackTimestamp: row.playlistFallbackTimestamp, + playlistTimestamp: row.playlistTimestamp, + primaryDetail: row.primaryDetail, + result: row.result, + secondaryDetail: row.secondaryDetail, + sourceUrl: row.sourceUrl, + streamName: row.streamName, + team: row.team, + timecode: row.timecode, + }); + +const removeExactDuplicateTagRows = (rows: SgTagRow[]) => { + const rowsByKey = new Map<string, SgTagRow>(); + + rows.forEach((row) => { + const rowKey = buildExactTagRowKey(row); + const existingRow = rowsByKey.get(rowKey); + + if (!existingRow) { + rowsByKey.set(rowKey, row); + return; + } + + if (!existingRow.thumbnailUrl && row.thumbnailUrl) { + rowsByKey.set(rowKey, { ...existingRow, thumbnailUrl: row.thumbnailUrl }); + } + }); + + return Array.from(rowsByKey.values()); +}; + +const normalizeTagRowsForDisplay = (rows: SgTagRow[]) => + preserveTagRowsWithUniqueIds(removeExactDuplicateTagRows(rows)); + +const buildTagRowBySport = ( + tag: Record<string, unknown>, + sport: SportTableKind, + baseEventDateTime?: string | null +): SgTagRow | null => { + const context = buildTagContext(tag); + const matrixParticipant = + findExactRawTagFieldValue(tag, ["player", "player_name", "athlete", "athlete_name", "primary_actor"]) || null; + const player = + findTagDataValue(tag, [ + "player", + "player_name", + "athlete", + "athlete_name", + "primary_actor", + "ball_handler", + "striker", + "batter", + "pitcher", + "scorer", + "shooter", + "jersey", + ]) || "--"; + const action = + findTagDataValue(tag, ["primary_action", "action", "event_code", "event", "play", "tag"]) || + formatLooseLabel(toText(tag.action || tag.event_code || tag.play || tag.tag)); + const result = + findTagDataValue(tag, [ + "result", + "outcome", + "play_outcome", + "playOutcome", + "action_result", + "actionResult", + "ball_result", + "ballResult", + "batting_result", + "battingResult", + "bowling_result", + "bowlingResult", + "delivery_outcome", + "deliveryOutcome", + "primary_result", + "primaryResult", + "tag_result", + "tagResult", + "gain", + "yards_gained", + "yardsGained", + "play_result", + "playResult", + "event_result", + "eventResult", + "shot_result", + "delivery_result", + ]) || formatLooseLabel(toText(tag.result || tag.outcome)); + const team = + findTagDataValue(tag, ["team", "unit", "side", "batting_team", "fielding_team", "possession_team"]) || + formatLooseLabel(toText(tag.team || tag.unit)); + + const rawQuarterValue = + findTagDataValue(tag, ["quarter", "period", "phase", "segment", "group"]) || + toText(tag.quarter || tag.period || tag.phase || tag.segment || tag.group); + const groupQuarter = normalizeQuarter(rawQuarterValue || "Quarter 1"); + let timecode = buildTimecode(tag); + const rawPlaylistTimestamp = + findTagDataValue(tag, [ + "timestamp", + "tag_timestamp", + "tagTimestamp", + "event_timestamp", + "absolute_timestamp", + "video_timestamp", + "clip_timestamp", + "clipTimestamp", + "clip_time", + "clipTime", + "program_date_time", + "program_datetime", + "prog_date_time", + ]) || toText(tag.timestamp); + const playlistTimestamp = normalizePlaylistTimestamp(rawPlaylistTimestamp, baseEventDateTime); + const playlistFallbackTimestamp = buildClockOnlyPlaylistTimestampFallback(rawPlaylistTimestamp, baseEventDateTime); + const sourceUrl = getTagSourceUrl(tag); + const streamName = getTagStreamName(tag); + const thumbnailUrl = getTagThumbnailUrl(tag); + const explicitSourceTagId = getSourceTagId(tag); + const explicitClipId = getClipId(tag); + const rawClipStartMatch = findTagDataMatch(tag, [ + "clipStart", + "clip_start", + "clip_start_seconds", + "clip_start_second", + "start", + "start_time", + "startTime", + "start_timestamp", + "startTimestamp", + "start_offset", + "startOffset", + "offset", + "offset_seconds", + "offsetSeconds", + "start_seconds", + "start_second", + "start_ms", + "startMs", + "start_timecode", + "video_offset", + "video_offset_seconds", + "video_offset_ms", + "videoOffsetMs", + "videoStart", + "video_start", + "video_start_ms", + "videoStartMs", + "video_start_seconds", + "videoTime", + "video_time", + "video_time_ms", + "videoTimeMs", + "video_time_seconds", + "video_timestamp_seconds", + "video_timestamp_ms", + "video_timecode_clip_start", + ]); + const rawClipStart = rawClipStartMatch?.value ?? ""; + const rawClipEndMatch = findTagDataMatch(tag, [ + "clipEnd", + "clip_end", + "clip_end_ms", + "clipEndMs", + "clip_end_seconds", + "clip_end_second", + "end", + "end_time", + "endTime", + "end_timestamp", + "endTimestamp", + "end_offset", + "endOffset", + "end_seconds", + "end_second", + "end_ms", + "endMs", + "end_timecode", + "videoEnd", + "video_end", + "video_end_ms", + "videoEndMs", + "video_end_seconds", + "video_timecode_clip_end", + ]); + const rawClipEnd = rawClipEndMatch?.value ?? ""; + const rawClipDurationMatch = findTagDataMatch(tag, [ + "clipDuration", + "clipDurationSeconds", + "clip_duration", + "clip_duration_ms", + "clipDurationMs", + "clip_duration_milliseconds", + "clipDurationMilliseconds", + "clip_duration_seconds", + "clip_duration_second", + "clip_length", + "clipLength", + "clip_length_ms", + "clipLengthMs", + "clip_length_seconds", + "clipLengthSeconds", + "duration", + "duration_ms", + "durationMs", + "duration_milliseconds", + "durationMilliseconds", + "durationSec", + "durationSeconds", + "duration_sec", + "duration_seconds", + "duration_second", + "length", + "length_ms", + "length_seconds", + "playlist_duration", + "playlist_duration_ms", + "playlist_duration_seconds", + "videoDuration", + "videoDurationSeconds", + "video_duration", + "video_duration_ms", + "video_duration_seconds", + ]); + const rawClipDuration = rawClipDurationMatch?.value ?? ""; + const rawDataTimecode = findTagDataValue(tag, [ + "timecode", + "time_code", + "time_range", + "timerange", + "time", + "time_marker", + "timeMarker", + "tag_time", + "tagTime", + "clip_time", + "clipTime", + "clip_timestamp", + "clipTimestamp", + "video_timecode", + "video_timecode_display", + "video_timestamp", + "video_time", + "videoTime", + ]); + if (timecode === "--" && rawDataTimecode) { + timecode = rawDataTimecode; + } + const offsetTimecode = buildOffsetTimecode( + rawClipStart, + rawClipEnd, + rawClipDuration, + rawClipDurationMatch?.key, + rawClipStartMatch?.key, + rawClipEndMatch?.key + ); + if (timecode === "--" && offsetTimecode) { + timecode = offsetTimecode; + } + + let groupValue = SPORT_TABLE_CONFIGS.default.defaultGroupValue; + let matrixPeriod: string | null = null; + let primaryDetail = "--"; + let resultDisplay = result || "--"; + let secondaryDetail = "--"; + + switch (sport) { + case "american-football": { + const down = findTagDataValue(tag, ["down", "down_number"]); + const distance = findTagDataValue(tag, ["distance", "distance_yards", "yards_to_go", "to_go"]); + const yards = + findTagDataValue(tag, ["yard", "yards", "yards_gained", "gain_yards", "distance_gained"]) || + toText(tag.yard || tag.yards); + groupValue = groupQuarter; + matrixPeriod = rawQuarterValue ? groupQuarter : null; + primaryDetail = formatFootballDownDistance(down, distance); + secondaryDetail = formatYardValue(yards); + resultDisplay = + result || (isDisplayValue(secondaryDetail) ? secondaryDetail : formatFootballActionResult(action)); + break; + } + case "baseball": { + const inningNumber = findTagDataValue(tag, ["inning_number", "inning"]); + const halfInning = findTagDataValue(tag, ["half_inning", "half", "inning_half"]); + const balls = findTagDataValue(tag, ["balls"]); + const strikes = findTagDataValue(tag, ["strikes"]); + const count = findTagDataValue(tag, ["count", "pitch_count"]); + const inningDisplay = formatBaseballInning(halfInning, inningNumber); + groupValue = inningDisplay !== "--" ? inningDisplay : SPORT_TABLE_CONFIGS.baseball.defaultGroupValue; + matrixPeriod = inningDisplay !== "--" ? inningDisplay : null; + primaryDetail = inningDisplay; + secondaryDetail = formatBaseballCount(balls, strikes, count); + break; + } + case "soccer": { + const phase = + findTagDataValue(tag, ["half", "period", "phase"]) || formatLooseLabel(toText(tag.period || tag.phase)); + const matchTime = + findTagDataValue(tag, ["match_time", "match_clock", "game_clock", "clock", "minute"]) || + formatClockValue(findTagDataValue(tag, ["game_clock_seconds"])) || + "--"; + const zone = findTagDataValue(tag, ["zone", "shot_zone", "field_zone", "area", "field_position"]) || "--"; + groupValue = phase || SPORT_TABLE_CONFIGS.soccer.defaultGroupValue; + matrixPeriod = phase || null; + primaryDetail = matchTime; + secondaryDetail = zone !== "--" ? formatLooseLabel(zone) : zone; + break; + } + case "basketball": { + const periodValue = findTagDataValue(tag, ["period", "quarter"]); + const rawClockValue = findTagDataValue(tag, ["game_clock", "clock", "game_clock_display"]); + const clockValue = + formatClockValue(rawClockValue) || + rawClockValue || + formatClockValue(findTagDataValue(tag, ["game_clock_seconds"])); + const points = + findTagDataValue(tag, ["points_or_runs_scored", "points", "shot_value", "point_value"]) || + findTagDataValue(tag, ["score_value"]); + const quarterLabel = normalizeBasketballQuarter(periodValue || "Q1"); + groupValue = quarterLabel; + matrixPeriod = periodValue ? quarterLabel : null; + primaryDetail = clockValue || "--"; + secondaryDetail = formatBasketballValue(points, result); + resultDisplay = result || (secondaryDetail !== "--" ? secondaryDetail : formatBasketballActionResult(action)); + break; + } + case "cricket": { + const inningsNumber = findTagDataValue(tag, ["innings_number", "inning"]); + const overDisplay = findTagDataValue(tag, ["over_display"]); + const overNumber = findTagDataValue(tag, ["over_number"]); + const ballInOver = findTagDataValue(tag, ["ball_in_over"]); + const overValue = formatCricketOver(overDisplay, overNumber, ballInOver); + const overGroupValue = formatCricketOverGroup(overNumber, overDisplay); + const runs = findTagDataValue(tag, [ + "exact_runs", + "runs_scored", + "points_or_runs_scored", + "runs", + "run", + "run_value", + "runs_value", + "score_value", + "score_home", + "total_runs", + ]); + groupValue = overGroupValue || SPORT_TABLE_CONFIGS.cricket.defaultGroupValue; + matrixPeriod = overGroupValue || (inningsNumber ? `Innings ${inningsNumber}` : null); + primaryDetail = overValue || "--"; + secondaryDetail = formatCricketRuns(runs); + resultDisplay = result || (isDisplayValue(secondaryDetail) ? secondaryDetail : formatCricketActionResult(action)); + break; + } + default: { + const phase = formatLooseLabel(toText(tag.quarter || tag.period || tag.phase || tag.segment)); + const value = + findTagDataValue(tag, ["value", "score_value", "points_or_runs_scored"]) || + findTagDataValue(tag, ["count", "zone", "yard", "yards"]); + groupValue = phase || SPORT_TABLE_CONFIGS.default.defaultGroupValue; + matrixPeriod = phase || null; + primaryDetail = phase || "--"; + secondaryDetail = value ? formatLooseLabel(value) : "--"; + break; + } + } + + const timestampOffsetSeconds = getTimestampOffsetSeconds( + playlistTimestamp ?? playlistFallbackTimestamp, + baseEventDateTime + ); + const explicitClipStartSeconds = getExplicitTagOffsetSeconds(rawClipStart, rawClipStartMatch?.key); + const explicitClipEndSeconds = getExplicitTagOffsetSeconds(rawClipEnd, rawClipEndMatch?.key); + const explicitClipDurationSeconds = getExplicitTagDurationSeconds(rawClipDuration, rawClipDurationMatch?.key); + const timecodeStartSeconds = getTimeRangeOffsetSeconds(timecode, baseEventDateTime); + const timecodeEndSeconds = getTimeRangeOffsetSeconds( + timecode.split(TIMECODE_RANGE_SEPARATOR_REGEX)[1] ?? "", + baseEventDateTime + ); + let clipDurationSeconds = + explicitClipDurationSeconds ?? + (explicitClipStartSeconds !== null && + explicitClipEndSeconds !== null && + explicitClipEndSeconds > explicitClipStartSeconds + ? explicitClipEndSeconds - explicitClipStartSeconds + : null); + const clipStartSeconds = explicitClipStartSeconds ?? timecodeStartSeconds ?? timestampOffsetSeconds; + const hasPlaylistTimestamp = Boolean(playlistTimestamp || playlistFallbackTimestamp || rawPlaylistTimestamp); + const hasClipReference = Boolean(explicitClipId || sourceUrl || thumbnailUrl); + const shouldUseDefaultSgClipDuration = sport === "american-football" || sport === "cricket"; + if ( + clipDurationSeconds === null && + shouldUseDefaultSgClipDuration && + (clipStartSeconds !== null || hasPlaylistTimestamp || hasClipReference) + ) { + clipDurationSeconds = DEFAULT_SG_TAG_DURATION_SECONDS; + } + const clipEndSeconds = + explicitClipEndSeconds ?? + timecodeEndSeconds ?? + (clipStartSeconds !== null && clipDurationSeconds !== null ? clipStartSeconds + clipDurationSeconds : null); + + if (timecode === "--") { + const derivedTimecode = + formatClipRangeTimecode(clipStartSeconds, clipEndSeconds) || formatRawTimestampTimecode(rawPlaylistTimestamp); + if (derivedTimecode) { + timecode = derivedTimecode; + } + } + const clipRangeSource = + explicitClipStartSeconds !== null || explicitClipEndSeconds !== null || explicitClipDurationSeconds !== null + ? "explicit" + : timecodeStartSeconds !== null || timecodeEndSeconds !== null + ? "timecode" + : null; + + const normalizedAction = action || "--"; + const normalizedPlayer = player || "--"; + + if (normalizedAction === "--" && normalizedPlayer === "--" && timecode === "--") { + return null; + } + + const rowIdentity = { + action: normalizedAction, + clipId: explicitClipId || null, + context, + groupValue, + player: normalizedPlayer, + primaryDetail, + result: resultDisplay, + secondaryDetail, + team: team || "--", + timecode, + }; + const clipId = explicitClipId || buildFallbackTagFieldId("clip", rowIdentity); + const rowIdentityWithClip = { ...rowIdentity, clipId }; + const sourceTagId = explicitSourceTagId || buildFallbackTagFieldId("tag", rowIdentityWithClip); + const stableId = buildStableSgTagRowId(rowIdentityWithClip, sourceTagId); + + return { + action: normalizedAction, + clipId, + clipDurationSeconds, + clipEndSeconds, + clipRangeSource, + clipStartSeconds, + context, + groupValue, + id: stableId, + matrixParticipant, + matrixPeriod, + player: normalizedPlayer, + playlistFallbackTimestamp, + playlistTimestamp, + primaryDetail, + result: resultDisplay, + secondaryDetail, + sourceTagId, + sourceUrl, + streamName: streamName || null, + team: team || "--", + thumbnailUrl, + timecode, + }; +}; + +export const normalizeTagRows = ( + payload: Record<string, unknown> | null, + eventDetails: TEventMediaDetails | null, + sport: SportTableKind, + baseEventDateTime: string | null +) => { + const root = payload ? asRecord(payload) : null; + const nestedEvent = root ? asRecord(root.event) : null; + const nestedRawEvent = root ? asRecord(root.rawEvent) : null; + const rawTags = pickArray([root, nestedEvent, nestedRawEvent], ["tags", "event_tags", "eventTags"]); + + if (rawTags.length > 0) { + return normalizeTagRowsForDisplay( + rawTags + .map((entry) => buildTagRowBySport(asRecord(entry), sport, baseEventDateTime)) + .filter((row): row is SgTagRow => Boolean(row)) + ); + } + + return normalizeTagRowsForDisplay( + (eventDetails?.structuredTags ?? []).map((tag) => { + const defaultConfig = SPORT_TABLE_CONFIGS[sport] ?? SPORT_TABLE_CONFIGS.default; + const action = tag.action ? formatLooseLabel(tag.action) : tag.label; + const quarterValue = + sport === "basketball" + ? normalizeBasketballQuarter(tag.quarter || defaultConfig.defaultGroupValue) + : normalizeQuarter(tag.quarter || defaultConfig.defaultGroupValue); + const groupValue = + sport === "american-football" + ? quarterValue + : sport === "basketball" + ? quarterValue + : defaultConfig.defaultGroupValue; + const primaryDetail = + sport === "american-football" + ? quarterValue + : sport === "basketball" + ? "--" + : defaultConfig.primaryDetailLabel === "Match Time" + ? tag.timeRange || tag.timestamp || "--" + : "--"; + const result = tag.result ? formatLooseLabel(tag.result) : "--"; + const team = tag.team ? formatLooseLabel(tag.team) : "--"; + const timecode = tag.timeRange || tag.timestamp || "--"; + const rowIdentity = { + action, + clipId: null, + context: {}, + groupValue, + player: "--", + primaryDetail, + result, + secondaryDetail: "--", + team, + timecode, + }; + const clipId = buildFallbackTagFieldId("clip", rowIdentity); + const rowIdentityWithClip = { ...rowIdentity, clipId }; + const sourceTagId = buildFallbackTagFieldId("tag", rowIdentityWithClip); + + return { + action, + clipId, + clipDurationSeconds: null, + clipEndSeconds: null, + clipRangeSource: tag.timeRange || tag.timestamp ? "timecode" : null, + clipStartSeconds: tag.timeRange + ? getTimeRangeOffsetSeconds(tag.timeRange, baseEventDateTime) + : tag.timestamp + ? getTimeRangeOffsetSeconds(tag.timestamp, baseEventDateTime) + : null, + context: {}, + groupValue, + id: buildStableSgTagRowId(rowIdentityWithClip, sourceTagId), + matrixParticipant: null, + matrixPeriod: tag.quarter ? quarterValue : null, + player: "--", + playlistFallbackTimestamp: buildClockOnlyPlaylistTimestampFallback(tag.timestamp || "", baseEventDateTime), + playlistTimestamp: normalizePlaylistTimestamp(tag.timestamp || "", baseEventDateTime), + primaryDetail, + result, + secondaryDetail: "--", + sourceTagId, + sourceUrl: "", + streamName: null, + team, + thumbnailUrl: "", + timecode, + } satisfies SgTagRow; + }) + ); +}; + +export const buildBaseEventDateTime = (dateValue: string, timeValue: string) => { + const normalizedDateValue = dateValue.trim(); + const normalizedTimeValue = timeValue.trim(); + const candidates = [ + normalizedTimeValue.includes("T") ? normalizedTimeValue : "", + normalizedDateValue.includes("T") ? normalizedDateValue : "", + normalizedDateValue && normalizedTimeValue ? `${normalizedDateValue} ${normalizedTimeValue}` : "", + normalizedDateValue, + ].filter(Boolean); + + for (const candidate of candidates) { + const parsedValue = Date.parse(candidate); + if (!Number.isNaN(parsedValue)) { + return new Date(parsedValue).toISOString().replace(/Z$/, "+00:00"); + } + } + + return null; +}; + +export const isCoachCompletedEventJsonItem = (item: TMediaItem | null) => { + if (!item) return false; + + const meta = asRecord(item.meta); + const format = item.format.toLowerCase(); + const source = toText(meta.source).toLowerCase(); + + return ( + format === "json" && + item.mediaType === "document" && + (source === "plane-coach" || item.id.startsWith("coach-event-")) + ); +}; + +export const formatLongDateTime = (dateValue: string, timeValue: string) => { + const combinedValue = [dateValue, timeValue].filter(Boolean).join(" ").trim(); + const parsedValue = combinedValue || dateValue || timeValue; + if (!parsedValue) return "--"; + + const parsed = Date.parse(parsedValue); + if (Number.isNaN(parsed)) { + if (dateValue && timeValue) return `${formatDateValue(dateValue)}, ${formatTimeValue(timeValue)}`; + return formatDateValue(parsedValue); + } + + return new Date(parsed).toLocaleString(undefined, { + weekday: "long", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +}; + +export const buildEventTitle = ({ + eventDetails, + issue, + payload, + projectName, +}: { + eventDetails: TEventMediaDetails | null; + issue: TIssue; + payload: Record<string, unknown> | null; + projectName: string; +}) => { + const titleSources = [asRecord(payload), asRecord(asRecord(payload).event)]; + const directTitle = pickText(titleSources, ["title", "name", "event_name"]); + if (directTitle) return directTitle; + if (eventDetails?.title) return eventDetails.title; + + const oppositionName = parseOppositionTeam(issue.opposition_team)?.name || ""; + if (projectName && oppositionName) return `${projectName} vs ${oppositionName}`; + + return issue.name || "SG Event"; +}; diff --git a/apps/web/core/components/issues/issue-detail/sidebar.tsx b/apps/web/core/components/issues/issue-detail/sidebar.tsx index f93fb67e160..5b39dbe1bb4 100644 --- a/apps/web/core/components/issues/issue-detail/sidebar.tsx +++ b/apps/web/core/components/issues/issue-detail/sidebar.tsx @@ -2,34 +2,58 @@ import React from "react"; import { observer } from "mobx-react"; -import { CalendarCheck2, CalendarClock, LayoutPanelTop, Signal, Tag, Triangle, UserCircle2, Users } from "lucide-react"; +import { + Calendar, + CalendarCheck2, + CalendarClock, + Clock, + Handshake, + SignalIcon, + Tag, + Triangle, + UserCircle2, + Users, + User, + Volleyball, +} from "lucide-react"; // i18n import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssue } from "@plane/types"; // ui import { CycleIcon, DoubleCircleIcon, ModuleIcon } from "@plane/propel/icons"; -import { cn, getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils"; +import { getDate, isDateTimePast, isDateTimePastWithOverrides, renderFormattedPayloadDate } from "@plane/utils"; // components +import { CategoryDropdown } from "@/components/dropdowns/category-property"; import { DateDropdown } from "@/components/dropdowns/date"; import { EstimateDropdown } from "@/components/dropdowns/estimate"; +import LevelDropdown from "@/components/dropdowns/level-property"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; -import { PriorityDropdown } from "@/components/dropdowns/priority"; +// import { PriorityDropdown } from "@/components/dropdowns/priority"; +import { ProgramDropdown } from "@/components/dropdowns/program-property"; +import SportDropdown from "@/components/dropdowns/sport-property"; import { StateDropdown } from "@/components/dropdowns/state/dropdown"; + +import { TimeDropdown } from "@/components/dropdowns/time-picker"; // hooks +import { YearRangeDropdown } from "@/components/dropdowns/year-property"; import { useProjectEstimates } from "@/hooks/store/estimates"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useMember } from "@/hooks/store/use-member"; import { useProject } from "@/hooks/store/use-project"; import { useProjectState } from "@/hooks/store/use-project-state"; +import { parseOppositionTeam, serializeOppositionTeam } from "@/helpers/opposition-team"; // plane web components // components import { WorkItemAdditionalSidebarProperties } from "@/plane-web/components/issues/issue-details/additional-properties"; -import { IssueParentSelectRoot } from "@/plane-web/components/issues/issue-details/parent-select-root"; +// import { IssueParentSelectRoot } from "@/plane-web/components/issues/issue-details/parent-select-root"; import { IssueWorklogProperty } from "@/plane-web/components/issues/worklog/property"; import { IssueCycleSelect } from "./cycle-select"; -import { IssueLabel } from "./label"; +// import { IssueLabel } from "./label"; import { IssueModuleSelect } from "./module-select"; import type { TIssueOperations } from "./root"; +import OppositionTeamProperty from "@/plane-web/components/issues/issue-details/opposition-team-property"; type Props = { workspaceSlug: string; @@ -64,12 +88,38 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { const maxDate = issue.target_date ? getDate(issue.target_date) : null; maxDate?.setDate(maxDate.getDate()); + const isDateTimeLocked = !isEditable || isDateTimePast(issue.start_date, issue.start_time); + const projectSport = projectDetails?.sport?.trim() || null; + const issueSport = issue.sport?.trim() || null; + const shouldShowSportField = !!projectSport || !!issueSport; + const isSportLocked = !isEditable || !!projectSport; + + const handleDateTimeUpdate = (data: Partial<TIssue>) => { + if ( + isDateTimePastWithOverrides({ + currentDateValue: issue.start_date, + currentTimeValue: issue.start_time, + nextDateValue: data.start_date, + nextTimeValue: data.start_time, + }) + ) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: "Event date and time cannot be earlier than the current time.", + }); + return; + } + + issueOperations.update(workspaceSlug, projectId, issueId, data); + }; return ( <> <div className="flex items-center h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden"> <div className="h-full w-full overflow-y-auto px-6"> - <h5 className="mt-6 text-sm font-medium">{t("common.properties")}</h5> + {/* <h5 className="mt-6 text-sm font-medium">{t("common.properties")}</h5> */} + <h5 className="mt-6 text-sm font-medium">Event Details</h5> {/* TODO: render properties using a common component */} <div className={`mb-2 mt-3 space-y-2.5 ${!isEditable ? "opacity-60" : ""}`}> <div className="flex h-8 items-center gap-2"> @@ -91,6 +141,49 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { /> </div> + {/* Season Field */} + + <div className="flex h-8 items-center gap-2"> + <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <Calendar className="h-4 w-4 flex-shrink-0" /> + <span>{t("year_field")}</span> + </div> + <YearRangeDropdown + value={issue?.year} + onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { year: val })} + disabled={!isEditable} + placeholder={t("add_year")} + buttonVariant="transparent-with-text" + className="group w-3/5 flex-grow" + buttonContainerClassName="w-full text-left" + buttonClassName="text-sm" + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + {/* category field */} + <div className="flex h-8 items-center gap-2"> + <div className=" flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <Tag className="h-4 w-4 flex-shrink-0" /> + <span>{t("category_field")}</span> + </div> + <CategoryDropdown + value={issue?.category} + onChange={(val) => { + issueOperations.update(workspaceSlug, projectId, issueId, { category: val }); + }} + disabled={!isEditable} + placeholder={t("add_category")} + buttonVariant="transparent-with-text" + className="group w-3/5 flex-grow" + buttonContainerClassName="w-full text-left" + buttonClassName="text-sm" + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + <div className="flex h-8 items-center gap-2"> <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> <Users className="h-4 w-4 flex-shrink-0" /> @@ -115,7 +208,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { /> </div> - <div className="flex h-8 items-center gap-2"> + {/* <div className="flex h-8 items-center gap-2"> <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> <Signal className="h-4 w-4 flex-shrink-0" /> <span>{t("common.priority")}</span> @@ -129,9 +222,9 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { buttonContainerClassName="w-full text-left" buttonClassName="w-min h-auto whitespace-nowrap" /> - </div> + </div> */} - {createdByDetails && ( + {/* {createdByDetails && ( <div className="flex h-8 items-center gap-2"> <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> <UserCircle2 className="h-4 w-4 flex-shrink-0" /> @@ -142,7 +235,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { <span className="flex-grow truncate text-xs leading-5">{createdByDetails?.display_name}</span> </div> </div> - )} + )} */} <div className="flex h-8 items-center gap-2"> <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> @@ -153,12 +246,12 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { placeholder={t("issue.add.start_date")} value={issue.start_date} onChange={(val) => - issueOperations.update(workspaceSlug, projectId, issueId, { + handleDateTimeUpdate({ start_date: val ? renderFormattedPayloadDate(val) : null, }) } maxDate={maxDate ?? undefined} - disabled={!isEditable} + disabled={isDateTimeLocked} buttonVariant="transparent-with-text" className="group w-3/5 flex-grow" buttonContainerClassName="w-full text-left" @@ -171,6 +264,29 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { </div> <div className="flex h-8 items-center gap-2"> + <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <Clock className="h-4 w-4 flex-shrink-0" /> + <span>{t("starting_time")}</span> + </div> + <TimeDropdown + value={issue.start_time} + onChange={(val) => + handleDateTimeUpdate({ + start_time: val, + }) + } + disabled={isDateTimeLocked} + placeholder={t("add_start_time")} + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${issue?.start_time ? "" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + {/* <div className="flex h-8 items-center gap-2"> <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> <CalendarCheck2 className="h-4 w-4 flex-shrink-0" /> <span>{t("common.order_by.due_date")}</span> @@ -197,6 +313,91 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { // TODO: add this logic // showPlaceholderIcon /> + </div> */} + + {shouldShowSportField ? ( + <div className="flex h-8 items-center gap-2"> + <div className="flex w-2/5 flex-shrink-0 items-center gapa-1 text-sm text-custom-text-300"> + <Volleyball className="h-4 w-4 flex-shrink-0" /> + <span>{t("sport_field")}</span> + </div> + <SportDropdown + value={issue.sport} + onChange={(val: string | null) => { + issueOperations.update(workspaceSlug, projectId, issueId, { sport: val }); + }} + disabled={isSportLocked} + placeholder={t("add_sport")} + hideIcon + buttonVariant="transparent-with-text" + className="group w-3/5 flex-grow" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${issue?.sport ? "" : "text-custom-text-400"}`} + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + ) : null} + + {/* opposition field */} + <div className="flex h-8 items-center gap-2"> + <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <Handshake className="h-4 w-4 flex-shrink-0" /> + <span>Opposition</span> + </div> + <OppositionTeamProperty + storageKey={`opp-team-${issueId}`} + value={parseOppositionTeam(issue?.opposition_team)} + onChange={(team) => + issueOperations.update(workspaceSlug, projectId, issueId, { + opposition_team: serializeOppositionTeam(team), + }) + } + disabled={!isEditable} + /> + </div> + + {/* program field */} + <div className="flex h-8 items-center gap-2"> + <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <User className="h-4 w-4 flex-shrink-0" /> + <span>{t("program_field")}</span> + </div> + <ProgramDropdown + value={issue.program} + onChange={(val: string | null) => { + issueOperations.update(workspaceSlug, projectId, issueId, { program: val }); + }} + disabled={!isEditable} + placeholder={t("add_program")} + hideIcon + buttonVariant="transparent-with-text" + className="group w-3/5 flex-grow" + buttonContainerClassName="w-full text-left" + buttonClassName="text-sm" + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + {/* level field */} + <div className="flex h-8 items-center gap-2"> + <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> + <SignalIcon className="h-4 w-4 flex-shrink-0" /> + <span>{t("level_field")}</span> + </div> + <LevelDropdown + value={issue.level} + onChange={(val: string | null) => { + issueOperations.update(workspaceSlug, projectId, issueId, { level: val }); + }} + disabled={!isEditable} + placeholder={t("add_level")} + hideIcon + buttonVariant="transparent-with-text" + className="group w-3/5 flex-grow" + buttonContainerClassName="w-full text-left" + buttonClassName="text-sm" + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> </div> {projectId && areEstimateEnabledByProjectId(projectId) && ( @@ -258,7 +459,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { </div> )} - <div className="flex h-8 items-center gap-2"> + {/* <div className="flex h-8 items-center gap-2"> <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> <LayoutPanelTop className="h-4 w-4 flex-shrink-0" /> <span>{t("common.parent")}</span> @@ -271,9 +472,9 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { issueOperations={issueOperations} disabled={!isEditable} /> - </div> + </div> */} - <div className="flex min-h-8 gap-2"> + {/* <div className="flex min-h-8 gap-2"> <div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300"> <Tag className="h-4 w-4 flex-shrink-0" /> <span>{t("common.labels")}</span> @@ -286,7 +487,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { disabled={!isEditable} /> </div> - </div> + </div> */} <IssueWorklogProperty workspaceSlug={workspaceSlug} diff --git a/apps/web/core/components/issues/issue-layouts/calendar/base-calendar-root.tsx b/apps/web/core/components/issues/issue-layouts/calendar/base-calendar-root.tsx index 575d0124146..f747528c902 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { EIssueGroupByToServerOptions, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { TGroupedIssues } from "@plane/types"; import { EIssuesStoreType } from "@plane/types"; @@ -81,7 +81,10 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { const groupedIssueIds = (issues.groupedIssueIds ?? {}) as TGroupedIssues; const layout = displayFilters?.calendar?.layout ?? "month"; - const { startDate, endDate } = issueCalendarView.getStartAndEndDate(layout) ?? {}; + // Current persisted calendar layouts are month/day, and both fetch against + // the month range so day view retains data after reload. + const rangeLayout = "month" as const; + const { startDate, endDate } = issueCalendarView.getStartAndEndDate(rangeLayout) ?? {}; useEffect(() => { if (startDate && endDate && layout) { @@ -92,7 +95,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { perPageCount: layout === "month" ? 4 : 30, before: endDate, after: startDate, - groupedBy: EIssueGroupByToServerOptions["target_date"], + groupedBy: "start_date", }, viewId ); @@ -158,7 +161,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { issues={issueMap} groupedIssueIds={groupedIssueIds} layout={displayFilters?.calendar?.layout} - showWeekends={displayFilters?.calendar?.show_weekends ?? false} + showWeekends={true} issueCalendarView={issueCalendarView} quickActions={({ issue, parentRef, customActionButton, placement }) => ( <QuickActions diff --git a/apps/web/core/components/issues/issue-layouts/calendar/calendar-time.ts b/apps/web/core/components/issues/issue-layouts/calendar/calendar-time.ts new file mode 100644 index 00000000000..70179db2e89 --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/calendar/calendar-time.ts @@ -0,0 +1,29 @@ +export const isoToLocalDate = (iso?: string | null): Date | null => { + if (!iso) return null; + const d = new Date(iso); + return isNaN(d.getTime()) ? null : d; +}; + +export const isoToLocalHour = (iso?: string | null): number | null => { + const d = isoToLocalDate(iso); + return d ? d.getHours() : null; +}; + +export const isoToLocalDateString = (iso?: string | null): string | null => { + const d = isoToLocalDate(iso); + if (!d) return null; + + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate() + ).padStart(2, "0")}`; +}; + +export const getMinutesFromHourStart = (iso?: string | null): number => { + const d = isoToLocalDate(iso); + return d ? d.getMinutes() : 0; +}; + +export const hourLabel = (hour: number) => { + const h = hour % 12 === 0 ? 12 : hour % 12; + return `${h.toString().padStart(2, "0")}:00 ${hour < 12 ? "AM" : "PM"}`; +}; diff --git a/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx b/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx index 347d2252b70..5c259376593 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx @@ -21,7 +21,6 @@ import { Spinner } from "@plane/ui"; import { renderFormattedPayloadDate, cn } from "@plane/utils"; // constants import { MONTHS_LIST } from "@/constants/calendar"; -// helpers // hooks import { useIssues } from "@/hooks/store/use-issues"; import useSize from "@/hooks/use-window-size"; @@ -32,9 +31,11 @@ import type { ICalendarStore } from "@/store/issue/issue_calendar_view.store"; import type { IModuleIssuesFilter } from "@/store/issue/module"; import type { IProjectIssuesFilter } from "@/store/issue/project"; import type { IProjectViewIssuesFilter } from "@/store/issue/project-views"; -// local imports +// local import { IssueLayoutHOC } from "../issue-layout-HOC"; import type { TRenderQuickActions } from "../list/list-view-types"; +import { CalendarDayHeader } from "./day-header"; +import { DayView } from "./day-view"; import { CalendarHeader } from "./header"; import { CalendarIssueBlocks } from "./issue-blocks"; import { CalendarWeekDays } from "./week-days"; @@ -42,14 +43,14 @@ import { CalendarWeekHeader } from "./week-header"; type Props = { issuesFilterStore: - | IProjectIssuesFilter - | IModuleIssuesFilter - | ICycleIssuesFilter - | IProjectViewIssuesFilter - | IProjectEpicsFilter; + | IProjectIssuesFilter + | IModuleIssuesFilter + | ICycleIssuesFilter + | IProjectViewIssuesFilter + | IProjectEpicsFilter; issues: TIssueMap | undefined; groupedIssueIds: TGroupedIssues; - layout: "month" | "week" | undefined; + layout: "month" | "week" | "day" | undefined; showWeekends: boolean; issueCalendarView: ICalendarStore; loadMoreIssues: (dateString: string) => void; @@ -94,10 +95,13 @@ export const CalendarChart: React.FC<Props> = observer((props) => { readOnly = false, isEpic = false, } = props; + // states const [selectedDate, setSelectedDate] = useState<Date>(new Date()); + //refs const scrollableContainerRef = useRef<HTMLDivElement | null>(null); + // store hooks const { issues: { viewFlags }, @@ -108,12 +112,9 @@ export const CalendarChart: React.FC<Props> = observer((props) => { const { enableIssueCreation, enableQuickAdd } = viewFlags || {}; const calendarPayload = issueCalendarView.calendarPayload; - const allWeeksOfActiveMonth = issueCalendarView.allWeeksOfActiveMonth; - const formattedDatePayload = renderFormattedPayloadDate(selectedDate) ?? undefined; - // Enable Auto Scroll for calendar useEffect(() => { const element = scrollableContainerRef.current; @@ -135,6 +136,13 @@ export const CalendarChart: React.FC<Props> = observer((props) => { const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : []; + // Handler for day view date changes + const handleChangeDate = (newDate: Date) => { + setSelectedDate(newDate); + // Keep calendar payload in sync so week/month navigation continues to work + issueCalendarView.updateCalendarPayload(newDate); + }; + return ( <> <div className="flex h-full w-full flex-col overflow-hidden"> @@ -151,8 +159,14 @@ export const CalendarChart: React.FC<Props> = observer((props) => { })} ref={scrollableContainerRef} > - <CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} /> + {/* SHOW WEEK HEADER ONLY ON MONTH/WEEK */} + {layout !== "day" && <CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} />} + + {/* SHOW DAY HEADER ONLY ON DAY VIEW */} + {layout === "day" && <CalendarDayHeader date={selectedDate} isLoading={!issues} onChangeDate={handleChangeDate} />} + <div className="h-full w-full"> + {/* ---------------- MONTH VIEW ---------------- */} {layout === "month" && ( <div className="grid h-full w-full grid-cols-1 divide-y-[0.5px] divide-custom-border-200"> {allWeeksOfActiveMonth && @@ -181,36 +195,61 @@ export const CalendarChart: React.FC<Props> = observer((props) => { ))} </div> )} + + {/* ---------------- WEEK VIEW ---------------- */} {layout === "week" && ( - <CalendarWeekDays - selectedDate={selectedDate} - setSelectedDate={setSelectedDate} - handleDragAndDrop={handleDragAndDrop} - issuesFilterStore={issuesFilterStore} - week={issueCalendarView.allDaysOfActiveWeek} - issues={issues} - groupedIssueIds={groupedIssueIds} - loadMoreIssues={loadMoreIssues} - getPaginationData={getPaginationData} - getGroupIssueCount={getGroupIssueCount} - enableQuickIssueCreate={enableQuickAdd} - disableIssueCreation={!enableIssueCreation} - quickActions={quickActions} - quickAddCallback={quickAddCallback} - addIssuesToView={addIssuesToView} - readOnly={readOnly} - canEditProperties={canEditProperties} - isEpic={isEpic} - /> + <> + <CalendarWeekDays + selectedDate={selectedDate} + setSelectedDate={setSelectedDate} + handleDragAndDrop={handleDragAndDrop} + issuesFilterStore={issuesFilterStore} + week={issueCalendarView.allDaysOfActiveWeek} + issues={issues} + groupedIssueIds={groupedIssueIds} + loadMoreIssues={loadMoreIssues} + getPaginationData={getPaginationData} + getGroupIssueCount={getGroupIssueCount} + enableQuickIssueCreate={enableQuickAdd} + disableIssueCreation={!enableIssueCreation} + quickActions={quickActions} + quickAddCallback={quickAddCallback} + addIssuesToView={addIssuesToView} + readOnly={readOnly} + canEditProperties={canEditProperties} + isEpic={isEpic} + /> + </> + )} + + {layout === "day" && ( + <div className="h-full w-full"> + <DayView + date={selectedDate} + issues={issues} + groupedIssueIds={groupedIssueIds} + loadMoreIssues={loadMoreIssues} + getPaginationData={getPaginationData} + getGroupIssueCount={getGroupIssueCount} + quickActions={quickActions} + enableQuickIssueCreate={enableQuickAdd} + disableIssueCreation={!enableIssueCreation} + quickAddCallback={quickAddCallback} + addIssuesToView={addIssuesToView} + readOnly={readOnly} + canEditProperties={canEditProperties} + handleDragAndDrop={handleDragAndDrop} + isEpic={isEpic} + /> + </div> )} </div> {/* mobile view */} <div className="md:hidden"> <p className="p-4 text-xl font-semibold"> - {`${selectedDate.getDate()} ${ - MONTHS_LIST[selectedDate.getMonth() + 1].title - }, ${selectedDate.getFullYear()}`} + {`${selectedDate.getDate()} ${MONTHS_LIST[selectedDate.getMonth() + 1].title + }, ${selectedDate.getFullYear()}`} </p> <CalendarIssueBlocks date={selectedDate} @@ -236,9 +275,8 @@ export const CalendarChart: React.FC<Props> = observer((props) => { {/* mobile view */} <div className="md:hidden"> <p className="p-4 text-xl font-semibold"> - {`${selectedDate.getDate()} ${ - MONTHS_LIST[selectedDate.getMonth() + 1].title - }, ${selectedDate.getFullYear()}`} + {`${selectedDate.getDate()} ${MONTHS_LIST[selectedDate.getMonth() + 1].title + }, ${selectedDate.getFullYear()}`} </p> <CalendarIssueBlocks date={selectedDate} @@ -261,4 +299,4 @@ export const CalendarChart: React.FC<Props> = observer((props) => { </div> </> ); -}); +}); \ No newline at end of file diff --git a/apps/web/core/components/issues/issue-layouts/calendar/day-header.tsx b/apps/web/core/components/issues/issue-layouts/calendar/day-header.tsx new file mode 100644 index 00000000000..a2510ac9d4d --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/calendar/day-header.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { MONTHS_LIST } from "@/constants/calendar"; + + +type Props = { + date: Date; + isLoading: boolean; + onChangeDate: (newDate: Date) => void; +}; + +export const CalendarDayHeader: React.FC<Props> = ({ date, isLoading, onChangeDate }) => { + const weekday = date.toLocaleString("en-US", { weekday: "short" }); + const day = date.getDate(); + const month = MONTHS_LIST[date.getMonth() + 1].title; + const year = date.getFullYear(); + + const handlePrevDay = () => { + const prevDate = new Date(date); + prevDate.setDate(date.getDate() - 1); + onChangeDate(prevDate); + }; + + const handleNextDay = () => { + const nextDate = new Date(date); + nextDate.setDate(date.getDate() + 1); + onChangeDate(nextDate); + }; + + return ( + <div className="sticky top-0 z-[1] bg-custom-background-90 border-b border-custom-border-200 px-4 py-3 flex items-center justify-between"> + {isLoading && ( + <div className="absolute h-[1.5px] w-3/4 animate-[bar-loader_2s_linear_infinite] bg-custom-primary-100" /> + )} + + <button + type="button" + onClick={handlePrevDay} + className="grid place-items-center h-6 w-6 border border-custom-border-400 rounded-full" + > + <ChevronLeft size={14} /> + </button> + + <h2 className="text-lg font-semibold text-center flex-1"> + {weekday}, {day} {month} {year} + </h2> + + <button + type="button" + onClick={handleNextDay} + className="grid place-items-center h-6 w-6 border border-custom-border-400 rounded-full" + > + <ChevronRight size={14} /> + </button> + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx b/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx index 52ccf37e451..4eae3ee02a5 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx @@ -53,6 +53,7 @@ type Props = { setSelectedDate: (date: Date) => void; canEditProperties: (projectId: string | undefined) => boolean; isEpic?: boolean; + isDragDisabled?: boolean; }; export const CalendarDayTile: React.FC<Props> = observer((props) => { @@ -75,6 +76,7 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => { setSelectedDate, canEditProperties, isEpic = false, + isDragDisabled = false, } = props; const [isDraggingOver, setIsDraggingOver] = useState(false); @@ -142,9 +144,6 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => { const isWeekend = [0, 6].includes(date.date.getDay()); const isMonthLayout = calendarLayout === "month"; - const normalBackground = isWeekend ? "bg-custom-background-90" : "bg-custom-background-100"; - const draggingOverBackground = isWeekend ? "bg-custom-background-80" : "bg-custom-background-90"; - return ( <> <div ref={dayTileRef} className="group relative flex h-full w-full flex-col bg-custom-background-90"> @@ -156,7 +155,7 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => { ? "font-medium" : "text-custom-text-300" : "font-medium" // if week layout, highlight all days - } ${isWeekend ? "bg-custom-background-90" : "bg-custom-background-100"} `} + } ${isWeekend ? "bg-custom-background-100" : "bg-custom-background-100"} `} > {date.date.getDate() === 1 && MONTHS_LIST[date.date.getMonth() + 1].shortTitle + " "} {isToday ? ( @@ -172,7 +171,7 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => { <div className="h-full w-full hidden md:block"> <div className={cn( - `h-full w-full select-none ${isDraggingOver ? `${draggingOverBackground} opacity-70` : normalBackground}`, + `h-full w-full select-none ${isDraggingOver ? `bg-custom-background-90 opacity-70` : 'bg-custom-background-100'}`, { "min-h-[5rem]": isMonthLayout, } @@ -185,7 +184,7 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => { loadMoreIssues={loadMoreIssues} getPaginationData={getPaginationData} getGroupIssueCount={getGroupIssueCount} - isDragDisabled={readOnly} + isDragDisabled={readOnly || isDragDisabled} addIssuesToView={addIssuesToView} disableIssueCreation={disableIssueCreation} enableQuickIssueCreate={enableQuickIssueCreate} diff --git a/apps/web/core/components/issues/issue-layouts/calendar/day-view.tsx b/apps/web/core/components/issues/issue-layouts/calendar/day-view.tsx new file mode 100644 index 00000000000..69764376486 --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/calendar/day-view.tsx @@ -0,0 +1,315 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +import type { TIssueMap, TGroupedIssues, TPaginationData, TIssue } from "@plane/types"; +import type { TRenderQuickActions } from "../list/list-view-types"; +import { isoToLocalDateString, hourLabel } from "./calendar-time"; +import { CalendarIssueBlocks } from "./issue-blocks"; +import { renderFormattedPayloadDate } from "@plane/utils"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; + +type Props = { + date: Date; + groupedIssueIds: TGroupedIssues; + issues: TIssueMap | undefined; + loadMoreIssues: (dateString: string) => void; + getPaginationData: (groupId: string | undefined) => TPaginationData | undefined; + getGroupIssueCount: (groupId: string | undefined) => number | undefined; + quickActions: TRenderQuickActions; + enableQuickIssueCreate: boolean; + disableIssueCreation: boolean; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>; + addIssuesToView?: (issueIds: string[]) => Promise<any>; + readOnly: boolean; + canEditProperties: (projectId: string | undefined) => boolean; + handleDragAndDrop?: ( + issueId: string | undefined, + issueProjectId: string | undefined, + sourceDate: string | undefined, + destinationDate: string | undefined + ) => Promise<void>; + isEpic?: boolean; +}; + +const HOUR_HEIGHT = 60; // pixels per hour + +// Helper to calculate position and height of event blocks +const calculateEventPosition = (startTime: string, endTime?: string) => { + const start = new Date(startTime); + const startHour = start.getHours(); + const startMinute = start.getMinutes(); + + // Position from top of the hour + const topOffset = (startMinute / 60) * HOUR_HEIGHT; + + // Calculate height + let height = HOUR_HEIGHT; // default 1 hour + if (endTime) { + const end = new Date(endTime); + const durationMs = end.getTime() - start.getTime(); + const durationHours = durationMs / (1000 * 60 * 60); + height = Math.max(durationHours * HOUR_HEIGHT, 30); // min 30px + } + + return { + hourIndex: startHour, + topOffset, + height, + }; +}; + +export const DayView: React.FC<Props> = observer(({ + date, + groupedIssueIds, + issues, + loadMoreIssues, + getPaginationData, + getGroupIssueCount, + quickActions, + disableIssueCreation, + quickAddCallback, + addIssuesToView, + readOnly, + canEditProperties, + isEpic, +}) => { + const formattedDatePayload = renderFormattedPayloadDate(date); + const hourRefs = useRef<(HTMLDivElement | null)[]>([]); + const { issue: issueStore } = useIssueDetail(); + + // Group issues: events (with start_time) vs all-day (without) - using filtered issues from groupedIssueIds + const getIssuesForDay = () => { + if (!issues || !formattedDatePayload) return { eventIssues: [], allDayIssues: [] }; + + // Get only the issues that are in the filtered groupedIssueIds for this date + const issueIdsForDate = groupedIssueIds[formattedDatePayload] || []; + const allIssuesForDay = issueIdsForDate + .map(issueId => issues[issueId]) + .filter((issue): issue is TIssue => !!issue); + + return { + eventIssues: allIssuesForDay.filter((issue) => { + const latestIssue = issueStore.getIssueById(issue.id) || issue; + return latestIssue.start_time; + }), + allDayIssues: allIssuesForDay.filter((issue) => { + const latestIssue = issueStore.getIssueById(issue.id) || issue; + return !latestIssue.start_time; + }), + }; + }; + + const { eventIssues, allDayIssues } = getIssuesForDay(); + + // Group events by hour for positioning with overlap handling + const getEventsByHour = () => { + const map: Record<number, Array<{ issue: TIssue; position: ReturnType<typeof calculateEventPosition>; column: number; totalColumns: number }>> = {}; + + eventIssues.forEach((issue) => { + const latestIssue = issueStore.getIssueById(issue.id) || issue; + if (!latestIssue.start_time) return; + + const position = calculateEventPosition(latestIssue.start_time); + if (!map[position.hourIndex]) { + map[position.hourIndex] = []; + } + map[position.hourIndex].push({ issue: latestIssue, position, column: 0, totalColumns: 1 }); + }); + + // Calculate columns for overlapping events + Object.keys(map).forEach((hourKey) => { + const hour = parseInt(hourKey); + const events = map[hour]; + + // Sort events by start time + events.sort((a, b) => a.position.topOffset - b.position.topOffset); + + // Detect overlaps and assign columns + for (let i = 0; i < events.length; i++) { + const currentEvent = events[i]; + const currentEnd = currentEvent.position.topOffset + currentEvent.position.height; + + let column = 0; + const overlappingEvents = [currentEvent]; + + // Find all overlapping events + for (let j = 0; j < events.length; j++) { + if (i === j) continue; + + const otherEvent = events[j]; + const otherEnd = otherEvent.position.topOffset + otherEvent.position.height; + + // Check if events overlap + if ( + (currentEvent.position.topOffset < otherEnd && currentEnd > otherEvent.position.topOffset) + ) { + overlappingEvents.push(otherEvent); + } + } + + // Assign columns to overlapping events + overlappingEvents.forEach((event, index) => { + event.column = index; + event.totalColumns = overlappingEvents.length; + }); + } + }); + + return map; + }; + + const eventsByHour = getEventsByHour(); + + /** Auto-scroll to current hour */ + useEffect(() => { + const h = new Date().getHours(); + hourRefs.current[h]?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, []); + + return ( + <div className="border-t border-custom-border-200 w-full h-full flex flex-col"> + {/* All-day section */} + {allDayIssues.length > 0 && ( + <div className="border-b border-custom-border-200 bg-custom-background-90 p-2"> + <div className="text-xs font-medium text-custom-text-300 mb-2">All Day</div> + <div className="flex flex-wrap gap-1"> + <CalendarIssueBlocks + date={date} + issueIdList={allDayIssues.map((i) => i.id)} + loadMoreIssues={loadMoreIssues} + getPaginationData={getPaginationData} + getGroupIssueCount={getGroupIssueCount} + quickActions={quickActions} + enableQuickIssueCreate={false} + disableIssueCreation={disableIssueCreation} + quickAddCallback={quickAddCallback} + addIssuesToView={addIssuesToView} + readOnly={readOnly} + canEditProperties={canEditProperties} + isEpic={isEpic} + isDragDisabled={false} + showLoadMore={false} + /> + </div> + </div> + )} + + {/* Hourly timeline */} + <div className="relative w-full flex-1 overflow-y-auto"> + {/* Current time indicator */} + <CurrentTimeIndicator selectedDate={date} /> + + {Array.from({ length: 24 }).map((_, hour) => { + const eventsForHour = eventsByHour[hour] || []; + + return ( + <div + key={hour} + ref={(el) => { + hourRefs.current[hour] = el; + }} + className="flex border-b border-custom-border-200 relative" + style={{ height: `${HOUR_HEIGHT}px` }} + > + {/* Hour label */} + <div className="w-20 flex-shrink-0 flex items-start justify-end pr-3 pt-1 text-xs text-custom-text-300 font-medium"> + {hourLabel(hour)} + </div> + + {/* Event area */} + <div className="flex-1 relative border-l border-custom-border-200"> + {/* Render each event with CalendarIssueBlocks positioned absolutely */} + {eventsForHour.map(({ issue, position, column, totalColumns }) => { + const columnWidth = totalColumns > 1 ? `${100 / totalColumns}%` : '100%'; + const leftOffset = totalColumns > 1 ? `${(column * 100) / totalColumns}%` : '0%'; + + return ( + <div + key={`${issue.id}-${issueStore.getIssueById(issue.id)?.start_time}`} + className="absolute z-1-" + style={{ + top: `${position.topOffset}px`, + height: `${position.height}px`, + left: `calc(4px + ${leftOffset})`, + width: `calc(${columnWidth} - 4px)`, + }} + > + <CalendarIssueBlocks + date={date} + issueIdList={[issue.id]} + loadMoreIssues={loadMoreIssues} + getPaginationData={getPaginationData} + getGroupIssueCount={getGroupIssueCount} + quickActions={quickActions} + enableQuickIssueCreate={false} + disableIssueCreation={disableIssueCreation} + quickAddCallback={quickAddCallback} + addIssuesToView={addIssuesToView} + readOnly={readOnly} + canEditProperties={canEditProperties} + isEpic={isEpic} + isDragDisabled={false} + showLoadMore={false} + /> + </div> + ); + })} + </div> + </div> + ); + })} + </div> + </div> + ); +}); + +// Current time indicator component (unchanged) +const CurrentTimeIndicator: React.FC<{ selectedDate: Date }> = ({ selectedDate }) => { + const [position, setPosition] = React.useState<number | null>(null); + + useEffect(() => { + const updatePosition = () => { + const now = new Date(); + + // Only show if viewing today + if ( + now.getDate() !== selectedDate.getDate() || + now.getMonth() !== selectedDate.getMonth() || + now.getFullYear() !== selectedDate.getFullYear() + ) { + setPosition(null); + return; + } + + const hour = now.getHours(); + const minute = now.getMinutes(); + const totalMinutes = hour * 60 + minute; + const positionPx = (totalMinutes / 60) * HOUR_HEIGHT; + + setPosition(positionPx); + }; + + updatePosition(); + const interval = setInterval(updatePosition, 60000); // Update every minute + + return () => clearInterval(interval); + }, [selectedDate]); + + if (position === null) return null; + + return ( + <div className="absolute left-0 right-0 z-10 pointer-events-none" style={{ top: `${position}px` }}> + <div className="flex items-center"> + <div className="w-20 flex-shrink-0" /> + <div className="flex-1 flex items-center"> + <div className="w-2 h-2 rounded-full bg-red-500 -ml-1" /> + <div className="flex-1 h-0.5 bg-red-500" /> + </div> + </div> + </div> + ); +}; diff --git a/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/months-dropdown.tsx b/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/months-dropdown.tsx index 890aee1490c..c7d41e7cc35 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/months-dropdown.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/months-dropdown.tsx @@ -86,7 +86,7 @@ export const CalendarMonthsDropdown: React.FC<Props> = observer((props: Props) = type="button" ref={setReferenceElement} className="text-xl font-semibold outline-none" - disabled={calendarLayout === "week"} + disabled={calendarLayout === "day"} > {calendarLayout === "month" ? `${MONTHS_LIST[activeMonthDate.getMonth() + 1].title} ${activeMonthDate.getFullYear()}` diff --git a/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx b/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx index c9d74490d94..3f93f598d6c 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx @@ -65,7 +65,7 @@ export const CalendarOptionsDropdown: React.FC<ICalendarHeader> = observer((prop }); const calendarLayout = issuesFilterStore.issueFilters?.displayFilters?.calendar?.layout ?? "month"; - const showWeekends = issuesFilterStore.issueFilters?.displayFilters?.calendar?.show_weekends ?? false; + const showWeekends = issuesFilterStore.issueFilters?.displayFilters?.calendar?.show_weekends ?? true; const handleLayoutChange = (layout: TCalendarLayouts, closePopover: any) => { if (!updateFilters) return; @@ -86,16 +86,17 @@ export const CalendarOptionsDropdown: React.FC<ICalendarHeader> = observer((prop }; const handleToggleWeekends = () => { - const showWeekends = issuesFilterStore.issueFilters?.displayFilters?.calendar?.show_weekends ?? false; + // const showWeekends = issuesFilterStore.issueFilters?.displayFilters?.calendar?.show_weekends ?? true; if (!updateFilters) return; updateFilters(projectId?.toString(), EIssueFilterType.DISPLAY_FILTERS, { - calendar: { - ...issuesFilterStore.issueFilters?.displayFilters?.calendar, - show_weekends: !showWeekends, - }, - }); + calendar: { + ...issuesFilterStore.issueFilters?.displayFilters?.calendar, + show_weekends: true, + }, +}); + }; return ( @@ -149,7 +150,7 @@ export const CalendarOptionsDropdown: React.FC<ICalendarHeader> = observer((prop {calendarLayout === layout && <Check size={12} strokeWidth={2} />} </button> ))} - <button + {/* <button type="button" className="flex w-full items-center justify-between gap-2 rounded px-1 py-1.5 text-left text-xs hover:bg-custom-background-80" onClick={handleToggleWeekends} @@ -161,7 +162,7 @@ export const CalendarOptionsDropdown: React.FC<ICalendarHeader> = observer((prop if (windowWidth <= 768) closePopover(); // close the popover on mobile }} /> - </button> + </button> */} </div> </div> </Popover.Panel> diff --git a/apps/web/core/components/issues/issue-layouts/calendar/event-block.tsx b/apps/web/core/components/issues/issue-layouts/calendar/event-block.tsx new file mode 100644 index 00000000000..935a0d58300 --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/calendar/event-block.tsx @@ -0,0 +1,81 @@ +import React, { useRef } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +import type { TIssue } from "@plane/types"; +import { cn } from "@plane/utils"; +import type { TRenderQuickActions } from "../list/list-view-types"; + +type Props = { + issue: TIssue; + quickActions: TRenderQuickActions; + canEditProperties: (projectId: string | undefined) => boolean; + isEpic?: boolean; + style?: React.CSSProperties; +}; + +export const CalendarEventBlock: React.FC<Props> = observer( + ({ issue, quickActions, canEditProperties, isEpic = false, style }) => { + const router = useRouter(); + const issueRef = useRef<HTMLDivElement | null>(null); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Navigate to issue detail + const url = `/spip/projects/${issue.project_id}/${isEpic ? "epics" : "issues"}/${issue.id}`; + router.push(url); + }; + + // Format time display + const formatTime = (isoString: string) => { + const date = new Date(isoString); + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + }; + + const startTime = issue.start_time ? formatTime(issue.start_time) : null; + return ( + <div + ref={issueRef} + className={cn( + "absolute left-1 right-1 rounded-md border border-custom-border-300 bg-custom-background-100", + "hover:bg-custom-background-80 cursor-pointer transition-colors", + "overflow-hidden shadow-sm hover:shadow-md", + "group" + )} + style={style} + onClick={handleClick} + > + <div className="h-full p-2 flex flex-col gap-1"> + {/* Time range */} + {startTime && ( + <div className="text-[10px] font-medium text-custom-text-300"> + {startTime} + </div> + )} + + {/* Issue title */} + <div className="text-xs font-medium text-custom-text-100 line-clamp-2">{issue.name}</div> + + {/* Issue metadata */} + {/* <div className="flex items-center gap-2 text-[10px] text-custom-text-300 mt-auto"> + {issue.project_id && <span className="truncate">{issue.project_id}</span>} + {issue.sequence_id && <span className="font-mono">#{issue.sequence_id}</span>} + </div> */} + + {/* Quick actions on hover */} + <div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity"> + {quickActions({ + issue, + parentRef: issueRef, + })} + </div> + </div> + </div> + ); + } +); diff --git a/apps/web/core/components/issues/issue-layouts/calendar/issue-blocks.tsx b/apps/web/core/components/issues/issue-layouts/calendar/issue-blocks.tsx index b4af6296aa7..9c0cd612b90 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -26,6 +26,7 @@ type Props = { isMobileView?: boolean; canEditProperties: (projectId: string | undefined) => boolean; isEpic?: boolean; + showLoadMore?: boolean; }; export const CalendarIssueBlocks: React.FC<Props> = observer((props) => { @@ -43,6 +44,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => { isMobileView = false, canEditProperties, isEpic = false, + showLoadMore = true, } = props; const formattedDatePayload = renderFormattedPayloadDate(date); const { t } = useTranslation(); @@ -86,7 +88,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => { <div className="border-b border-custom-border-200 px-1 py-1 md:border-none md:px-2"> <CalendarQuickAddIssueActions prePopulatedData={{ - target_date: formattedDatePayload, + start_date: formattedDatePayload, }} quickAddCallback={quickAddCallback} addIssuesToView={addIssuesToView} @@ -95,7 +97,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => { </div> )} - {shouldLoadMore && !isPaginating && ( + {showLoadMore && shouldLoadMore && !isPaginating && ( <div className="flex items-center px-2.5 py-1"> <button type="button" diff --git a/apps/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx b/apps/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx index 3b8d7660abc..ed630b7eb65 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx @@ -40,9 +40,9 @@ export const CalendarQuickAddIssueActions: FC<TCalendarQuickAddIssueActions> = o // derived values const ExistingIssuesListModalPayload = addIssuesToView ? moduleId - ? { module: moduleId.toString(), target_date: "none" } - : { cycle: true, target_date: "none" } - : { target_date: "none" }; + ? { module: moduleId.toString(), start_date: "none" } + : { cycle: true, start_date: "none" } + : { start_date: "none" }; const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId) return; @@ -86,10 +86,10 @@ export const CalendarQuickAddIssueActions: FC<TCalendarQuickAddIssueActions> = o searchParams={ExistingIssuesListModalPayload} handleOnSubmit={handleAddIssuesToView} shouldHideIssue={(issue) => { - if (issue.start_date && prePopulatedData?.target_date) { - const issueStartDate = new Date(issue.start_date); - const targetDate = new Date(prePopulatedData.target_date); - const diffInDays = differenceInCalendarDays(targetDate, issueStartDate); + if (issue.target_date && prePopulatedData?.start_date) { + const issueTargetDate = new Date(issue.target_date); + const startDate = new Date(prePopulatedData.start_date); + const diffInDays = differenceInCalendarDays(issueTargetDate, startDate); if (diffInDays < 0) return true; } return false; diff --git a/apps/web/core/components/issues/issue-layouts/calendar/utils.ts b/apps/web/core/components/issues/issue-layouts/calendar/utils.ts index bab8c863b9d..eecd49993b7 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/utils.ts +++ b/apps/web/core/components/issues/issue-layouts/calendar/utils.ts @@ -14,7 +14,7 @@ export const handleDragDrop = async ( const updatedIssue = { id: issueId, - target_date: destinationDate, + start_date: destinationDate, }; return await updateIssue(projectId, updatedIssue.id, updatedIssue); diff --git a/apps/web/core/components/issues/issue-layouts/calendar/week-days.tsx b/apps/web/core/components/issues/issue-layouts/calendar/week-days.tsx index f234e09873c..2d4c74ba747 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/week-days.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/week-days.tsx @@ -70,7 +70,7 @@ export const CalendarWeekDays: React.FC<Props> = observer((props) => { const startOfWeek = data?.start_of_the_week; const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month"; - const showWeekends = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.show_weekends ?? false; + const showWeekends = true; if (!week) return null; @@ -81,6 +81,7 @@ export const CalendarWeekDays: React.FC<Props> = observer((props) => { }; const sortedWeekDays = getOrderedDays(Object.values(week), (item) => item.date.getDay(), startOfWeek); + const isDragDisabled = calendarLayout === "month"; return ( <div @@ -114,6 +115,7 @@ export const CalendarWeekDays: React.FC<Props> = observer((props) => { handleDragAndDrop={handleDragAndDrop} canEditProperties={canEditProperties} isEpic={isEpic} + isDragDisabled={isDragDisabled} /> ); })} diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/filters/project.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/filters/project.tsx index b04f426204b..2a201c5d58a 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/filters/project.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/header/filters/project.tsx @@ -49,7 +49,7 @@ export const FilterProjects: React.FC<Props> = observer((props) => { return ( <> <FilterHeader - title={`Project${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} + title={`Program${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} isPreviewEnabled={previewEnabled} handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} /> diff --git a/apps/web/core/components/issues/issue-layouts/issue-layouts - Shortcut.lnk b/apps/web/core/components/issues/issue-layouts/issue-layouts - Shortcut.lnk new file mode 100644 index 00000000000..f320f31942b Binary files /dev/null and b/apps/web/core/components/issues/issue-layouts/issue-layouts - Shortcut.lnk differ diff --git a/apps/web/core/components/issues/issue-layouts/kanban/block.tsx b/apps/web/core/components/issues/issue-layouts/kanban/block.tsx index 94b4a8d61ee..bc73bd2d439 100644 --- a/apps/web/core/components/issues/issue-layouts/kanban/block.tsx +++ b/apps/web/core/components/issues/issue-layouts/kanban/block.tsx @@ -7,7 +7,7 @@ import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-d import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane helpers -import { MoreHorizontal } from "lucide-react"; +import { Clock, MoreHorizontal } from "lucide-react"; import { useOutsideClickDetector } from "@plane/hooks"; // types import { TOAST_TYPE, setToast } from "@plane/propel/toast"; @@ -125,16 +125,23 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop </div> </Tooltip> - <IssueProperties - className="flex flex-wrap items-center gap-2 whitespace-nowrap text-custom-text-300 pt-1.5" - issue={issue} - displayProperties={displayProperties} - activeLayout="Kanban" - updateIssue={updateIssue} - isReadOnly={isReadOnly} - isEpic={isEpic} - /> + <div className="flex items-center gap-2 "> + <IssueProperties + className="flex flex-wrap items-center gap-2 whitespace-nowrap text-custom-text-300 pt-1.5" + issue={issue} + displayProperties={displayProperties} + activeLayout="Kanban" + updateIssue={updateIssue} + isReadOnly={isReadOnly} + isEpic={isEpic} + /> + {/* Show only the logo */} + {/* <div className="mt-2 gap-2 flex items-center"> + <OppositionTeamProperty onlyLogo={true} + /> +</div> */} +</div> {isEpic && displayProperties && ( <WithDisplayPropertiesHOC displayProperties={displayProperties} @@ -155,6 +162,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => { subGroupId, issuesMap, displayProperties, + canDropOverIssue, canDragIssuesInCurrentGrouping, updateIssue, diff --git a/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx b/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx index 0a5f0a392fb..0f23cf3f483 100644 --- a/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -6,32 +6,52 @@ import { xor } from "lodash-es"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons -import { CalendarCheck2, CalendarClock, Link, Paperclip } from "lucide-react"; +import { + CalendarCheck2, + CalendarClock, + Link, + Paperclip, + Clock, + SignalIcon, + Volleyball, + Calendar, + User, + Tag, +} from "lucide-react"; // types import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; // i18n import { useTranslation } from "@plane/i18n"; import { ViewsIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; import type { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; // ui import { cn, getDate, - renderFormattedPayloadDate, generateWorkItemLink, + isDateTimePast, + isDateTimePastWithOverrides, + renderFormattedPayloadDate, shouldHighlightIssueDueDate, } from "@plane/utils"; // components +import { CategoryDropdown } from "@/components/dropdowns/category-property"; import { CycleDropdown } from "@/components/dropdowns/cycle"; import { DateDropdown } from "@/components/dropdowns/date"; import { DateRangeDropdown } from "@/components/dropdowns/date-range"; import { EstimateDropdown } from "@/components/dropdowns/estimate"; +import LevelDropdown from "@/components/dropdowns/level-property"; import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; import { ModuleDropdown } from "@/components/dropdowns/module/dropdown"; import { PriorityDropdown } from "@/components/dropdowns/priority"; +import { ProgramDropdown } from "@/components/dropdowns/program-property"; +import SportDropdown from "@/components/dropdowns/sport-property"; import { StateDropdown } from "@/components/dropdowns/state/dropdown"; // helpers +import { TimeDropdown } from "@/components/dropdowns/time-picker"; +import { YearRangeDropdown } from "@/components/dropdowns/year-property"; import { captureSuccess } from "@/helpers/event-tracker.helper"; // hooks import { useProjectEstimates } from "@/hooks/store/estimates"; @@ -42,6 +62,7 @@ import { useProjectState } from "@/hooks/store/use-project-state"; import { useAppRouter } from "@/hooks/use-app-router"; import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { usePlatformOS } from "@/hooks/use-platform-os"; +import { MediaLibraryService } from "@/services/media-library.service"; // plane web components import { WorkItemLayoutAdditionalProperties } from "@/plane-web/components/issues/issue-layouts/additional-properties"; // local components @@ -76,6 +97,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { const { getStateById } = useProjectState(); const { isMobile } = usePlatformOS(); const projectDetails = getProjectById(issue.project_id); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); // router const router = useAppRouter(); @@ -84,6 +106,12 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { // derived values const stateDetails = getStateById(issue.state_id); const subIssueCount = issue?.sub_issues_count ?? 0; + const isEventLocked = isDateTimePast(issue.start_date, issue.start_time); + const isDateTimeLocked = isReadOnly || isEventLocked; + const projectSport = projectDetails?.sport?.trim() || null; + const issueSport = issue.sport?.trim() || null; + const shouldShowSportField = !!projectSport || !!issueSport; + const isSportLocked = isReadOnly || !!projectSport; const issueOperations = useMemo( () => ({ @@ -107,6 +135,54 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { [workspaceSlug, issue, changeModulesInIssue, addCycleToIssue, removeCycleFromIssue] ); + const buildManifestMeta = useCallback( + (currentIssue: TIssue) => ({ + category: currentIssue.category || "Work items", + start_date: currentIssue.start_date ?? null, + start_time: currentIssue.start_time ?? null, + level: currentIssue.level ?? null, + program: currentIssue.program ?? null, + sport: currentIssue.sport ?? null, + opposition: currentIssue.opposition_team ?? null, + season: currentIssue.year ?? null, + }), + [] + ); + + const updateManifestMeta = useCallback( + async (currentIssue: TIssue) => { + const resolvedWorkspace = workspaceSlug?.toString(); + if (!resolvedWorkspace || !currentIssue.project_id || !currentIssue.id) return; + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(resolvedWorkspace, currentIssue.project_id); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + if (!packageId) return; + await mediaLibraryService.updateManifestMetadata(resolvedWorkspace, currentIssue.project_id, packageId, { + work_item_id: currentIssue.id, + meta: buildManifestMeta(currentIssue), + }); + } catch { + // Skip manifest updates if artifacts don't exist. + } + }, + [buildManifestMeta, mediaLibraryService, workspaceSlug] + ); + + const handleEventPropertyUpdate = useCallback( + (data: Partial<TIssue>) => { + if (!updateIssue) return; + updateIssue(issue.project_id, issue.id, data).then(() => { + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: issue.id }, + }); + const nextIssue = { ...issue, ...data } as TIssue; + void updateManifestMeta(nextIssue); + }); + }, + [issue, updateIssue, updateManifestMeta] + ); + const handleState = (stateId: string) => { if (updateIssue) updateIssue(issue.project_id, issue.id, { state_id: stateId }).then(() => { @@ -183,15 +259,66 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { ); const handleStartDate = (date: Date | null) => { - if (updateIssue) - updateIssue(issue.project_id, issue.id, { start_date: date ? renderFormattedPayloadDate(date) : null }).then( - () => { - captureSuccess({ - eventName: WORK_ITEM_TRACKER_EVENTS.update, - payload: { id: issue.id }, - }); - } - ); + if (isDateTimeLocked) return; + const nextStartDate = date ? renderFormattedPayloadDate(date) : null; + + if ( + isDateTimePastWithOverrides({ + currentDateValue: issue.start_date, + currentTimeValue: issue.start_time, + nextDateValue: nextStartDate, + }) + ) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: "Event date and time cannot be earlier than the current time.", + }); + return; + } + handleEventPropertyUpdate({ start_date: nextStartDate }); + }; + + const handleStartTime = (time: string | null) => { + if (isDateTimeLocked) return; + + if ( + isDateTimePastWithOverrides({ + currentDateValue: issue.start_date, + currentTimeValue: issue.start_time, + nextTimeValue: time, + }) + ) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: "Event date and time cannot be earlier than the current time.", + }); + return; + } + + handleEventPropertyUpdate({ + start_time: time ?? null, + }); + }; + + const handleSport = (sport: string | null) => { + handleEventPropertyUpdate({ sport: sport ?? null }); + }; + const handleYear = (year: string | null) => { + handleEventPropertyUpdate({ year: year ?? null }); + }; + + const handleLevel = (level: string | null) => { + handleEventPropertyUpdate({ level: level ?? null }); + }; + + const handleCategory = (category: string | null) => { + handleEventPropertyUpdate({ category: category ?? null }); + }; + + const handleProgram = (program: string | null) => { + handleEventPropertyUpdate({ program: program ?? null }); }; const handleTargetDate = (date: Date | null) => { @@ -237,7 +364,8 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; - const minDate = getDate(issue.start_date); + const minDate = new Date(); + // const minDate = getDate(issue.start_date); const maxDate = getDate(issue.target_date); const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => { @@ -245,6 +373,8 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { e.preventDefault(); }; + // {console.log("Render all display propertie:", JSON.parse(JSON.stringify(displayProperties)) )} + return ( <div className={className}> {/* basic properties */} @@ -264,8 +394,25 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { </div> </WithDisplayPropertiesHOC> + {/* Season field */} + <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="year"> + <div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}> + <YearRangeDropdown + value={issue.year ?? null} + onChange={handleYear} + placeholder={t("year_field")} + icon={<Calendar className="h-3 w-3 flex-shrink-0" />} + buttonVariant={issue?.year ? "border-with-text" : "border-without-text"} + clearIconClassName="!text-custom-text-100" + disabled={isReadOnly} + renderByDefault={isMobile} + showTooltip + /> + </div> + </WithDisplayPropertiesHOC> + {/* priority */} - <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority"> + {/* <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority"> <div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}> <PriorityDropdown value={issue?.priority} @@ -277,10 +424,10 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { showTooltip /> </div> - </WithDisplayPropertiesHOC> + </WithDisplayPropertiesHOC> */} {/* merged dates */} - <WithDisplayPropertiesHOC + {/* <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey={["start_date", "due_date"]} shouldRenderProperty={() => isDateRangeEnabled} @@ -310,23 +457,52 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { customTooltipHeading="Date Range" /> </div> - </WithDisplayPropertiesHOC> + </WithDisplayPropertiesHOC> */} {/* start date */} - <WithDisplayPropertiesHOC - displayProperties={displayProperties} - displayPropertyKey="start_date" - shouldRenderProperty={() => !isDateRangeEnabled} - > + <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date"> <div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}> <DateDropdown value={issue.start_date ?? null} onChange={handleStartDate} - maxDate={maxDate} + minDate={minDate} placeholder={t("common.order_by.start_date")} icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />} buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} optionsClassName="z-10" + disabled={isDateTimeLocked} + renderByDefault={isMobile} + showTooltip + /> + </div> + </WithDisplayPropertiesHOC> + + {/* start time */} + <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_time"> + <div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}> + <TimeDropdown + value={issue.start_time ?? null} + onChange={handleStartTime} + placeholder={t("starting_time")} + icon={<Clock className="h-3 w-3 flex-shrink-0" />} + buttonVariant={issue.start_time ? "border-with-text" : "border-without-text"} + clearIconClassName="!text-custom-text-100" + disabled={isDateTimeLocked} + renderByDefault={isMobile} + showTooltip + /> + </div> + </WithDisplayPropertiesHOC> + {/* Level */} + <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="level"> + <div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}> + <LevelDropdown + value={issue.level ?? null} + onChange={handleLevel} + placeholder={t("level_field")} + icon={<SignalIcon className="h-3 w-3 flex-shrink-0" />} + buttonVariant={issue?.level ? "border-with-text" : "border-without-text"} + clearIconClassName="!text-custom-text-100" disabled={isReadOnly} renderByDefault={isMobile} showTooltip @@ -334,23 +510,49 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { </div> </WithDisplayPropertiesHOC> - {/* target/due date */} - <WithDisplayPropertiesHOC - displayProperties={displayProperties} - displayPropertyKey="due_date" - shouldRenderProperty={() => !isDateRangeEnabled} - > + <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="category"> <div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}> - <DateDropdown - value={issue?.target_date ?? null} - onChange={handleTargetDate} - minDate={minDate} - placeholder={t("common.order_by.due_date")} - icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />} - buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} - buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""} + <CategoryDropdown + value={issue.category ?? null} + onChange={handleCategory} + placeholder={t("category_field")} + icon={<Tag className="h-3 w-3 flex-shrink-0" />} + buttonVariant={issue?.category ? "border-with-text" : "border-without-text"} + clearIconClassName="!text-custom-text-100" + disabled={isReadOnly} + renderByDefault={isMobile} + showTooltip + /> + </div> + </WithDisplayPropertiesHOC> + + {shouldShowSportField ? ( + <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="sport"> + <div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}> + <SportDropdown + value={issue.sport ?? null} + onChange={handleSport} + placeholder={t("sport_field")} + icon={<Volleyball className="h-3 w-3 flex-shrink-0" />} + buttonVariant={issue?.sport ? "border-with-text" : "border-without-text"} + clearIconClassName="!text-custom-text-100" + disabled={isSportLocked} + renderByDefault={isMobile} + showTooltip + /> + </div> + </WithDisplayPropertiesHOC> + ) : null} + + <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="program"> + <div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}> + <ProgramDropdown + value={issue.program ?? null} + onChange={handleProgram} + placeholder={t("program_field")} + icon={<User className="h-3 w-3 flex-shrink-0" />} + buttonVariant={issue?.program ? "border-with-text" : "border-without-text"} clearIconClassName="!text-custom-text-100" - optionsClassName="z-10" disabled={isReadOnly} renderByDefault={isMobile} showTooltip @@ -524,7 +726,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { <WorkItemLayoutAdditionalProperties displayProperties={displayProperties} issue={issue} /> {/* label */} - <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="labels"> + {/* <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="labels"> <IssuePropertyLabels projectId={issue?.project_id || null} value={issue?.label_ids || []} @@ -535,7 +737,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { hideDropdownArrow maxRender={3} /> - </WithDisplayPropertiesHOC> + </WithDisplayPropertiesHOC> */} </div> ); }); diff --git a/apps/web/core/components/issues/issue-layouts/properties/with-display-properties-HOC.tsx b/apps/web/core/components/issues/issue-layouts/properties/with-display-properties-HOC.tsx index 24896f96072..537ede97017 100644 --- a/apps/web/core/components/issues/issue-layouts/properties/with-display-properties-HOC.tsx +++ b/apps/web/core/components/issues/issue-layouts/properties/with-display-properties-HOC.tsx @@ -3,23 +3,44 @@ import { observer } from "mobx-react"; import type { IIssueDisplayProperties } from "@plane/types"; interface IWithDisplayPropertiesHOC { - displayProperties: IIssueDisplayProperties; + displayProperties?: IIssueDisplayProperties; shouldRenderProperty?: (displayProperties: IIssueDisplayProperties) => boolean; displayPropertyKey: keyof IIssueDisplayProperties | (keyof IIssueDisplayProperties)[]; children: ReactNode; } export const WithDisplayPropertiesHOC = observer( - ({ displayProperties, shouldRenderProperty, displayPropertyKey, children }: IWithDisplayPropertiesHOC) => { - let shouldDisplayPropertyFromFilters = false; - if (Array.isArray(displayPropertyKey)) - shouldDisplayPropertyFromFilters = displayPropertyKey.every((key) => !!displayProperties[key]); - else shouldDisplayPropertyFromFilters = !!displayProperties[displayPropertyKey]; + ({ + displayProperties, + shouldRenderProperty, + displayPropertyKey, + children, + }: IWithDisplayPropertiesHOC) => { + // If displayProperties is not ready yet → allow render + if (!displayProperties) { + return <>{children}</>; + } - const renderProperty = - shouldDisplayPropertyFromFilters && (shouldRenderProperty ? shouldRenderProperty(displayProperties) : true); + const getDisplayFlag = (key: keyof IIssueDisplayProperties): boolean => { + // key missing → show by default + if (!Object.prototype.hasOwnProperty.call(displayProperties, key)) { + return true; + } - if (!renderProperty) return null; + // key exists → respect backend value + return Boolean(displayProperties[key]); + }; + + const shouldDisplay = + Array.isArray(displayPropertyKey) + ? displayPropertyKey.every(getDisplayFlag) + : getDisplayFlag(displayPropertyKey); + + if (!shouldDisplay) return null; + + if (shouldRenderProperty && !shouldRenderProperty(displayProperties)) { + return null; + } return <>{children}</>; } diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index 5d8ab87be3e..4f3fcb2b39e 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -5,7 +5,12 @@ import { omit } from "lodash-es"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { ARCHIVABLE_STATE_GROUPS, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants"; +import { + ARCHIVABLE_STATE_GROUPS, + EUserPermissions, + EUserPermissionsLevel, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; import type { TIssue } from "@plane/types"; import { EIssuesStoreType } from "@plane/types"; import type { TContextMenuItem } from "@plane/ui"; @@ -15,6 +20,7 @@ import { cn } from "@plane/utils"; import { captureClick } from "@/helpers/event-tracker.helper"; import { useProject } from "@/hooks/store/use-project"; import { useProjectState } from "@/hooks/store/use-project-state"; +import { useUserPermissions } from "@/hooks/store/user"; // plane-web components import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns"; // helper @@ -45,12 +51,19 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props const [duplicateWorkItemModal, setDuplicateWorkItemModal] = useState(false); // router const { workspaceSlug } = useParams(); + const { allowPermissions } = useUserPermissions(); const { getStateById } = useProjectState(); const { getProjectIdentifierById } = useProject(); // derived values const stateDetails = getStateById(issue.state_id); - const isEditingAllowed = !readOnly; const projectIdentifier = getProjectIdentifierById(issue?.project_id); + const canManageIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + issue.project_id ?? undefined + ); + const isEditingAllowed = canManageIssue && !readOnly; // auth const isArchivingAllowed = handleArchive && isEditingAllowed; const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group); @@ -72,7 +85,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props activeLayout: "Global issues", isEditingAllowed, isArchivingAllowed, - isDeletingAllowed: isEditingAllowed, + isDeletingAllowed: canManageIssue, isInArchivableGroup, setIssueToEdit, setCreateUpdateIssueModal, diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index 43102767bbc..b00061eb399 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -41,10 +41,14 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer(( // derived values const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; // auth - const isEditingAllowed = - allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && !readOnly; - const isRestoringAllowed = - handleRestore && allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT); + const canManageIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + issue.project_id ?? undefined + ); + const isEditingAllowed = canManageIssue && !readOnly; + const isRestoringAllowed = handleRestore && canManageIssue; // Menu items and modals using helper const menuItemProps: MenuItemFactoryProps = { @@ -52,7 +56,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer(( workspaceSlug: workspaceSlug?.toString(), activeLayout, isEditingAllowed, - isDeletingAllowed: isEditingAllowed, + isDeletingAllowed: canManageIssue, isRestoringAllowed: !!isRestoringAllowed, setIssueToEdit: () => {}, setCreateUpdateIssueModal: () => {}, diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index 17bd9155644..537ba791403 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -62,11 +62,16 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro const stateDetails = getStateById(issue.state_id); const projectIdentifier = getProjectIdentifierById(issue?.project_id); // auth - const isEditingAllowed = - allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && !readOnly; + const canManageIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + issue.project_id ?? undefined + ); + const isEditingAllowed = canManageIssue && !readOnly; const isArchivingAllowed = handleArchive && isEditingAllowed; const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group); - const isDeletingAllowed = isEditingAllowed; + const isDeletingAllowed = canManageIssue; const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx index 56affbdf0b7..109e21f9db4 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle, ArchiveRestoreIcon } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; @@ -6,7 +6,7 @@ import { ArchiveIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { EIssuesStoreType, TIssue } from "@plane/types"; import type { TContextMenuItem } from "@plane/ui"; -import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils"; +import { copyUrlToClipboard, generateWorkItemLink, isDateTimePast } from "@plane/utils"; // types import { createCopyMenuWithDuplication } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns"; @@ -111,7 +111,7 @@ export const useIssueActionHandlers = (props: MenuItemFactoryProps) => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Restore success", - message: "Your work item can be found in project work items.", + message: "Your work item can be found in program work items.", }); }) .catch(() => { @@ -242,7 +242,7 @@ export const useMenuItemFactory = (props: MenuItemFactoryProps) => { action: () => { setDeleteIssueModal(true); }, - shouldRender: isDeletingAllowed, + shouldRender: isDeletingAllowed && !isDateTimePast(issue.start_date, issue.start_time), }); return { @@ -287,7 +287,7 @@ export const useWorkItemDetailMenuItems = (props: MenuItemFactoryProps): TContex factory.createRestoreMenuItem(), factory.createDeleteMenuItem(), ], - [factory] + [factory, props.workspaceSlug] ); }; @@ -309,14 +309,15 @@ export const useAllIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuI export const useCycleIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => { const factory = useMenuItemFactory(props); + const { cycleId, issue, setCreateUpdateIssueModal, setIssueToEdit } = props; - const customEditAction = () => { - props.setIssueToEdit({ - ...props.issue, - cycle_id: props.cycleId ?? null, + const customEditAction = useCallback(() => { + setIssueToEdit({ + ...issue, + cycle_id: cycleId ?? null, }); - props.setCreateUpdateIssueModal(true); - }; + setCreateUpdateIssueModal(true); + }, [cycleId, issue, setCreateUpdateIssueModal, setIssueToEdit]); return useMemo( () => [ @@ -328,20 +329,21 @@ export const useCycleIssueMenuItems = (props: MenuItemFactoryProps): TContextMen factory.createArchiveMenuItem(), factory.createDeleteMenuItem(), ], - [factory, props.cycleId] + [factory, customEditAction] ); }; export const useModuleIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => { const factory = useMenuItemFactory(props); + const { issue, moduleId, setCreateUpdateIssueModal, setIssueToEdit } = props; - const customEditAction = () => { - props.setIssueToEdit({ - ...props.issue, - module_ids: props.moduleId ? [props.moduleId] : [], + const customEditAction = useCallback(() => { + setIssueToEdit({ + ...issue, + module_ids: moduleId ? [moduleId] : [], }); - props.setCreateUpdateIssueModal(true); - }; + setCreateUpdateIssueModal(true); + }, [issue, moduleId, setCreateUpdateIssueModal, setIssueToEdit]); return useMemo( () => [ @@ -353,7 +355,7 @@ export const useModuleIssueMenuItems = (props: MenuItemFactoryProps): TContextMe factory.createArchiveMenuItem(), factory.createDeleteMenuItem(), ], - [factory, props.moduleId] + [factory, customEditAction] ); }; diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx index b90ed2d1043..a07c4a2f526 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { omit } from "lodash-es"; import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; +import { useParams } from "next/navigation"; // plane imports import { ARCHIVABLE_STATE_GROUPS, @@ -60,7 +60,6 @@ export const WorkItemDetailQuickActions: React.FC<TWorkItemDetailQuickActionProp } = props; // router const { workspaceSlug } = useParams(); - const pathname = usePathname(); // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined); @@ -77,19 +76,19 @@ export const WorkItemDetailQuickActions: React.FC<TWorkItemDetailQuickActionProp const stateDetails = getStateById(issue.state_id); const projectIdentifier = getProjectIdentifierById(issue?.project_id); // auth - const isEditingAllowed = - allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT, - workspaceSlug?.toString(), - issue.project_id ?? undefined - ) && !readOnly; + const canManageIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + issue.project_id ?? undefined + ); + const isEditingAllowed = canManageIssue && !readOnly; const isArchivingAllowed = !issue.archived_at && isEditingAllowed; const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group); const isRestoringAllowed = !!issue.archived_at && isEditingAllowed; - const isDeletingAllowed = isEditingAllowed; + const isDeletingAllowed = canManageIssue; const duplicateIssuePayload = omit( { diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index c0ce0975110..2a4fe72333d 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -62,11 +62,16 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr const stateDetails = getStateById(issue.state_id); const projectIdentifier = getProjectIdentifierById(issue?.project_id); // auth - const isEditingAllowed = - allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && !readOnly; + const canManageIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + issue.project_id ?? undefined + ); + const isEditingAllowed = canManageIssue && !readOnly; const isArchivingAllowed = handleArchive && isEditingAllowed; const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group); - const isDeletingAllowed = isEditingAllowed; + const isDeletingAllowed = canManageIssue; const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 978d7729189..ad126db874c 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -62,16 +62,16 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p const stateDetails = getStateById(issue.state_id); const projectIdentifier = getProjectIdentifierById(issue?.project_id); // auth - const isEditingAllowed = - allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT, - workspaceSlug?.toString(), - issue.project_id ?? undefined - ) && !readOnly; + const canManageIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + issue.project_id ?? undefined + ); + const isEditingAllowed = canManageIssue && !readOnly; const isArchivingAllowed = handleArchive && isEditingAllowed; const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group); - const isDeletingAllowed = isEditingAllowed; + const isDeletingAllowed = canManageIssue; const duplicateIssuePayload = omit( { diff --git a/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx b/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx index f26a3f4fe0c..12216aec87c 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx @@ -10,9 +10,9 @@ import { PlusIcon } from "lucide-react"; // plane imports import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { setPromiseToast } from "@plane/propel/toast"; +import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast"; import type { IProject, TIssue, EIssueLayoutTypes } from "@plane/types"; -import { cn, createIssuePayload } from "@plane/utils"; +import { cn, createIssuePayload, isDateTimePast } from "@plane/utils"; // helpers import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; // plane web imports @@ -99,14 +99,23 @@ export const QuickAddIssueRoot: FC<TQuickAddIssueRoot> = observer((props) => { const onSubmitHandler = async (formData: TIssue) => { if (isSubmitting || !workspaceSlug || !projectId) return; - reset({ ...defaultValues }); - const payload = createIssuePayload(projectId.toString(), { ...(prePopulatedData ?? {}), ...formData, }); + if (isDateTimePast(payload.start_date, payload.start_time)) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("common.error.label"), + message: "Event date and time cannot be earlier than the current time.", + }); + return; + } + if (quickAddCallback) { + reset({ ...defaultValues }); + const quickAddPromise = quickAddCallback(projectId.toString(), { ...payload }); setPromiseToast<any>(quickAddPromise, { loading: isEpic ? t("epic.adding") : t("issue.adding"), diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/index.ts b/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/index.ts index 3439d398b41..6abaae4d353 100644 --- a/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/index.ts +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/index.ts @@ -7,6 +7,7 @@ export * from "./label-column"; export * from "./link-column"; export * from "./priority-column"; export * from "./start-date-column"; +export * from "./start-time-column"; export * from "./state-column"; export * from "./sub-issue-column"; export * from "./updated-on-column"; diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/start-time-column.tsx b/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/start-time-column.tsx new file mode 100644 index 00000000000..d968c96375b --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/start-time-column.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { Clock } from "lucide-react"; +// types +import type { TSpreadsheetColumn } from "@plane/types"; +// components +import { TimeDropdown } from "@/components/dropdowns/time-picker"; + +type Props = Parameters<TSpreadsheetColumn>[0]; + +export const SpreadsheetStartTimeColumn: React.FC<Props> = observer((props: Props) => { + const { issue, onChange, disabled } = props; + + return ( + <div className="h-11 border-b-[0.5px] border-custom-border-200"> + <TimeDropdown + value={issue.start_time} + onChange={(startTime) => { + onChange( + issue, + { start_time: startTime }, + { + changed_property: "start_time", + change_details: startTime, + } + ); + }} + disabled={disabled} + placeholder="Start time" + icon={<Clock className="h-3 w-3 flex-shrink-0" />} + buttonVariant="transparent-with-text" + buttonClassName="text-left rounded-none group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10 px-page-x" + buttonContainerClassName="w-full" + optionsClassName="z-[9]" + /> + </div> + ); +}); diff --git a/apps/web/core/components/issues/issue-modal/base.tsx b/apps/web/core/components/issues/issue-modal/base.tsx index 5de90bba4e7..396eabe4261 100644 --- a/apps/web/core/components/issues/issue-modal/base.tsx +++ b/apps/web/core/components/issues/issue-modal/base.tsx @@ -81,17 +81,42 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer(( const projectId = data?.project_id ?? routerProjectId?.toString() ?? projectIdFromRouter; const fetchIssueDetail = async (issueId: string | undefined) => { - setDescription(undefined); - if (!workspaceSlug) return; + console.log("Fetching issue detail for ID:", issueId); + + setDescription(undefined); + + if (!workspaceSlug) { + console.log("No workspace slug provided, exiting."); + return; + } + + if (!projectId || issueId === undefined || !fetchIssueDetails) { + console.log( + "Missing projectId, issueId, or fetchIssueDetails function. Using props data if available." + ); + console.log("data?.description_html:", data?.description_html); + setDescription(data?.description_html || "<p></p>"); + return; + } + + console.log( + "Calling fetchIssue with workspaceSlug:", + workspaceSlug, + "projectId:", + projectId, + "issueId:", + issueId + ); + + const response = await fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId); + console.log("fetchIssue response:", response); + + if (response) { + console.log("Setting description from response:", response.description_html); + setDescription(response?.description_html || "<p></p>"); + } +}; - if (!projectId || issueId === undefined || !fetchIssueDetails) { - // Set description to the issue description from the props if available - setDescription(data?.description_html || "<p></p>"); - return; - } - const response = await fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId); - if (response) setDescription(response?.description_html || "<p></p>"); - }; useEffect(() => { // fetching issue details @@ -265,62 +290,118 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer(( } }; - const handleUpdateIssue = async (payload: Partial<TIssue>): Promise<TIssue | undefined> => { - if (!workspaceSlug || !payload.project_id || !data?.id) return; - - try { - if (isDraft) await draftIssues.updateIssue(workspaceSlug.toString(), data.id, payload); - else if (updateIssue) await updateIssue(payload.project_id, data.id, payload); + const handleUpdateIssue = async ( + payload: Partial<TIssue> +): Promise<TIssue | undefined> => { + console.log("▶️ handleUpdateIssue called"); + console.log("📦 Payload:", payload); + console.log("🔑 workspaceSlug:", workspaceSlug); + console.log("🆔 issueId:", data?.id); + console.log("📁 projectId:", payload.project_id); + console.log("📝 isDraft:", isDraft); + console.log("🏪 storeType:", storeType); + + if (!workspaceSlug || !payload.project_id || !data?.id) { + console.warn("⛔ Missing required data, update aborted"); + return; + } - // check if we should add issue to cycle/module - if ( - payload.cycle_id && - payload.cycle_id !== "" && - (payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE) - ) { - await addIssueToCycle(data as TBaseIssue, payload.cycle_id); - } - if ( - payload.module_ids && - payload.module_ids.length > 0 && - (!payload.module_ids.includes(moduleId?.toString()) || storeType !== EIssuesStoreType.MODULE) - ) { - await addIssueToModule(data as TBaseIssue, payload.module_ids); - } + try { + console.log("🚀 Starting update request"); + + let updateResponse; + + if (isDraft) { + console.log("📝 Updating draft issue"); + updateResponse = await draftIssues.updateIssue( + workspaceSlug.toString(), + data.id, + payload + ); + } else if (updateIssue) { + console.log("📝 Updating regular issue"); + updateResponse = await updateIssue( + payload.project_id, + data.id, + payload + ); + } - // add other property values - await handleCreateUpdatePropertyValues({ - issueId: data.id, - issueTypeId: payload.type_id, - projectId: payload.project_id, - workspaceSlug: workspaceSlug?.toString(), - isDraft: isDraft, - }); + console.log("✅ Update API response:", updateResponse); + + // ─── Cycle handling ───────────────────────────────────── + if ( + payload.cycle_id && + payload.cycle_id !== "" && + (payload.cycle_id !== cycleId || + storeType !== EIssuesStoreType.CYCLE) + ) { + console.log("🔄 Adding issue to cycle:", payload.cycle_id); + await addIssueToCycle(data as TBaseIssue, payload.cycle_id); + } else { + console.log("✅ No cycle update needed"); + } - setToast({ - type: TOAST_TYPE.SUCCESS, - title: t("success"), - message: t("issue_updated_successfully"), - }); - captureSuccess({ - eventName: WORK_ITEM_TRACKER_EVENTS.update, - payload: { id: data.id }, - }); - handleClose(); - } catch (error: any) { - console.error(error); - setToast({ - type: TOAST_TYPE.ERROR, - title: t("error"), - message: error?.error ?? t("issue_could_not_be_updated"), - }); - captureError({ - eventName: WORK_ITEM_TRACKER_EVENTS.update, - payload: { id: data.id }, - error: error as Error, - }); + // ─── Module handling ──────────────────────────────────── + if ( + payload.module_ids && + payload.module_ids.length > 0 && + (!payload.module_ids.includes(moduleId?.toString()) || + storeType !== EIssuesStoreType.MODULE) + ) { + console.log("📦 Adding issue to modules:", payload.module_ids); + await addIssueToModule( + data as TBaseIssue, + payload.module_ids + ); + } else { + console.log("✅ No module update needed"); } - }; + + // ─── Property values ──────────────────────────────────── + console.log("⚙️ Updating custom properties"); + + await handleCreateUpdatePropertyValues({ + issueId: data.id, + issueTypeId: payload.type_id, + projectId: payload.project_id, + workspaceSlug: workspaceSlug?.toString(), + isDraft: isDraft, + }); + + console.log("✅ Property values updated"); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("success"), + message: t("issue_updated_successfully"), + }); + + captureSuccess({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: data.id }, + }); + + console.log("✅ Update flow completed successfully"); + + handleClose(); + } catch (error: any) { + console.error("❌ Error while updating issue:", error); + + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: error?.error ?? t("issue_could_not_be_updated"), + }); + + captureError({ + eventName: WORK_ITEM_TRACKER_EVENTS.update, + payload: { id: data.id }, + error: error as Error, + }); + } +}; + const handleFormSubmit = async (payload: Partial<TIssue>, is_draft_issue: boolean = false) => { if (!workspaceSlug || !payload.project_id || !storeType) return; diff --git a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx index f7bba82d309..17e04c37df2 100644 --- a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx +++ b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx @@ -11,26 +11,35 @@ import { useTranslation } from "@plane/i18n"; // types import type { ISearchIssueResponse, TIssue } from "@plane/types"; // ui -import { CustomMenu } from "@plane/ui"; -import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils"; +// import { CustomMenu } from "@plane/ui"; +import { getTabIndex, isDateTimePast, renderFormattedPayloadDate } from "@plane/utils"; // components -import { CycleDropdown } from "@/components/dropdowns/cycle"; +// import { CycleDropdown } from "@/components/dropdowns/cycle"; +import { CategoryDropdown } from "@/components/dropdowns/category-property"; import { DateDropdown } from "@/components/dropdowns/date"; -import { EstimateDropdown } from "@/components/dropdowns/estimate"; +// import { EstimateDropdown } from "@/components/dropdowns/estimate"; +import { LevelDropdown } from "@/components/dropdowns/level-property"; import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; -import { ModuleDropdown } from "@/components/dropdowns/module/dropdown"; -import { PriorityDropdown } from "@/components/dropdowns/priority"; -import { StateDropdown } from "@/components/dropdowns/state/dropdown"; -import { ParentIssuesListModal } from "@/components/issues/parent-issues-list-modal"; -import { IssueLabelSelect } from "@/components/issues/select"; +// import { ModuleDropdown } from "@/components/dropdowns/module/dropdown"; +// import { PriorityDropdown } from "@/components/dropdowns/priority"; +import { ProgramDropdown } from "@/components/dropdowns/program-property"; +// import { StateDropdown } from "@/components/dropdowns/state/dropdown"; + +import SportDropdown from "@/components/dropdowns/sport-property"; +import { TimeDropdown } from "@/components/dropdowns/time-picker"; +import { YearRangeDropdown } from "@/components/dropdowns/year-property"; +// import { ParentIssuesListModal } from "@/components/issues/parent-issues-list-modal"; +// import { IssueLabelSelect } from "@/components/issues/select"; // helpers // hooks import { useProjectEstimates } from "@/hooks/store/estimates"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; import { usePlatformOS } from "@/hooks/use-platform-os"; +import { parseOppositionTeam, serializeOppositionTeam } from "@/helpers/opposition-team"; // plane web components -import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; +// import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; +import OppositionTeamProperty from "@/plane-web/components/issues/issue-details/opposition-team-property"; type TIssueDefaultPropertiesProps = { control: Control<TIssue>; @@ -38,8 +47,13 @@ type TIssueDefaultPropertiesProps = { projectId: string | null; workspaceSlug: string; selectedParentIssue: ISearchIssueResponse | null; - startDate: string | null; - targetDate: string | null; + initialStartDate: string | null; + initialStartTime: string | null; + Level: string | null; + Sport: string | null; + Program: string | null; + Year: string | null; + Category: string | null; parentId: string | null; isDraft: boolean; handleFormChange: () => void; @@ -53,8 +67,8 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob projectId, workspaceSlug, selectedParentIssue, - startDate, - targetDate, + initialStartDate, + initialStartTime, parentId, isDraft, handleFormChange, @@ -76,15 +90,18 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob const canCreateLabel = projectId && allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId); - const minDate = getDate(startDate); - minDate?.setDate(minDate.getDate()); + const minDate = new Date(); + minDate.setHours(0, 0, 0, 0); - const maxDate = getDate(targetDate); - maxDate?.setDate(maxDate.getDate()); + const isDateTimeLocked = !!id && !isDraft && isDateTimePast(initialStartDate, initialStartTime); + const projectSport = projectDetails?.sport?.trim() || null; + const currentSport = props.Sport?.trim() || null; + const shouldShowSportField = !!projectSport || !!currentSport; + const isSportLockedForCreation = !id && !!projectSport; return ( <div className="flex flex-wrap items-center gap-2"> - <Controller + {/* <Controller control={control} name="state_id" render={({ field: { value, onChange } }) => ( @@ -102,8 +119,9 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob /> </div> )} - /> - <Controller + /> */} + + {/* <Controller control={control} name="priority" render={({ field: { value, onChange } }) => ( @@ -119,7 +137,44 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob /> </div> )} + /> */} + + <Controller + control={control} + name="year" + render={({ field: { value, onChange } }) => ( + <div className="h-7"> + <YearRangeDropdown + value={value} + onChange={(year) => { + onChange(year); + handleFormChange(); + }} + buttonVariant="border-with-text" + placeholder={t("year_field")} + /> + </div> + )} + /> + + <Controller + control={control} + name="category" + render={({ field: { value, onChange } }) => ( + <div className="h-7"> + <CategoryDropdown + value={value} + onChange={(category) => { + onChange(category); + handleFormChange(); + }} + buttonVariant="border-with-text" + placeholder={t("category_field")} + /> + </div> + )} /> + <Controller control={control} name="assignee_ids" @@ -141,7 +196,30 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob </div> )} /> - <Controller + + {shouldShowSportField ? ( + <Controller + control={control} + name="sport" + render={({ field: { value, onChange } }) => ( + <div className="h-7"> + <SportDropdown + value={value ?? null} + onChange={(sport) => { + onChange(sport); + handleFormChange(); + }} + placeholder={t("sport_field")} + buttonVariant="border-with-text" + tabIndex={getIndex("sport")} + disabled={isSportLockedForCreation} + /> + </div> + )} + /> + ) : null} + + {/* <Controller control={control} name="label_ids" render={({ field: { value, onChange } }) => ( @@ -158,32 +236,35 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob /> </div> )} - /> + /> */} + <Controller control={control} name="start_date" render={({ field: { value, onChange } }) => ( <div className="h-7"> <DateDropdown + disabled={isDateTimeLocked} value={value} onChange={(date) => { onChange(date ? renderFormattedPayloadDate(date) : null); handleFormChange(); }} buttonVariant="border-with-text" - maxDate={maxDate ?? undefined} + minDate={minDate ?? undefined} placeholder={t("start_date")} tabIndex={getIndex("start_date")} /> </div> )} /> - <Controller + {/* <Controller control={control} name="target_date" render={({ field: { value, onChange } }) => ( <div className="h-7"> <DateDropdown + disabled={isDateTimeLocked} value={value} onChange={(date) => { onChange(date ? renderFormattedPayloadDate(date) : null); @@ -196,8 +277,65 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob /> </div> )} + /> */} + + <Controller + control={control} + name="start_time" + render={({ field: { value, onChange } }) => ( + <div className="h-7"> + <TimeDropdown + disabled={isDateTimeLocked} + value={value ?? null} + onChange={(time) => { + onChange(time); + handleFormChange(); + }} + placeholder={t("starting_time")} + buttonVariant="border-with-text" + tabIndex={getIndex("start_time")} + /> + </div> + )} + /> + + <Controller + control={control} + name="program" + render={({ field: { value, onChange } }) => ( + <div className="h-7"> + <ProgramDropdown + value={value} + onChange={(program) => { + onChange(program); + handleFormChange(); + }} + buttonVariant="border-with-text" + placeholder={t("program_field")} + /> + </div> + )} + /> + + <Controller + control={control} + name="level" + render={({ field: { value, onChange } }) => ( + <div className="h-7"> + <LevelDropdown + value={value} + onChange={(level) => { + onChange(level); + handleFormChange(); + }} + buttonVariant="border-with-text" + placeholder={t("level_field")} + /> + </div> + )} /> - {projectDetails?.cycle_view && ( + + {/* {projectDetails?.cycle_view && ( <Controller control={control} name="cycle_id" @@ -217,8 +355,9 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob </div> )} /> - )} - {projectDetails?.module_view && workspaceSlug && ( + )} */} + + {/* {projectDetails?.module_view && workspaceSlug && ( <Controller control={control} name="module_ids" @@ -240,8 +379,9 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob </div> )} /> - )} - {projectId && areEstimateEnabledByProjectId(projectId) && ( + )} */} + + {/* {projectId && areEstimateEnabledByProjectId(projectId) && ( <Controller control={control} name="estimate_point" @@ -261,8 +401,9 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob </div> )} /> - )} - <div className="h-7"> + )} */} + + {/* <div className="h-7"> {parentId ? ( <CustomMenu customButton={ @@ -334,6 +475,27 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob issueId={isDraft ? undefined : id} /> )} + /> */} + + <Controller + control={control} + name="opposition_team" + defaultValue={null} + render={({ field: { value, onChange } }) => ( + <div + className="h-7 w-fit border rounded-md flex items-center" + style={{ borderColor: "rgba(var(--color-border-300))" }} + > + <OppositionTeamProperty + storageKey={`opp-team-${id}`} + value={parseOppositionTeam(value)} + onChange={(team) => { + onChange(serializeOppositionTeam(team)); + handleFormChange(); + }} + /> + </div> + )} /> </div> ); diff --git a/apps/web/core/components/issues/issue-modal/components/description-editor.tsx b/apps/web/core/components/issues/issue-modal/components/description-editor.tsx index 40b6fac4d11..955f7308ab2 100644 --- a/apps/web/core/components/issues/issue-modal/components/description-editor.tsx +++ b/apps/web/core/components/issues/issue-modal/components/description-editor.tsx @@ -177,6 +177,7 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob name="description_html" control={control} render={({ field: { value, onChange } }) => ( + // <></> <RichTextEditor editable id="issue-modal-editor" diff --git a/apps/web/core/components/issues/issue-modal/form.tsx b/apps/web/core/components/issues/issue-modal/form.tsx index 2d8e81f24f6..229f422986b 100644 --- a/apps/web/core/components/issues/issue-modal/form.tsx +++ b/apps/web/core/components/issues/issue-modal/form.tsx @@ -23,6 +23,7 @@ import { getTextContent, getChangedIssuefields, getTabIndex, + isDateTimePast, } from "@plane/utils"; // components import { @@ -87,7 +88,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => { onCreateMoreToggleChange, isDraft, moveToIssue = false, - modalTitle = `${data?.id ? t("update") : isDraft ? t("create_a_draft") : t("create_new_issue")}`, + modalTitle = `${data?.id ? t("update") : isDraft ? t("create_a_draft") : "Create new scheduled streaming event"}`, primaryButtonText = { default: `${data?.id ? t("update") : isDraft ? t("save_to_drafts") : t("save")}`, loading: `${data?.id ? t("updating") : t("saving")}`, @@ -161,6 +162,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => { // derived values const projectDetails = projectId ? getProjectById(projectId) : undefined; + const projectSport = projectDetails?.sport?.trim() || null; const isDisabled = isSubmitting || isApplyingTemplate; const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile); @@ -185,6 +187,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => { // Reset form when data prop changes useEffect(() => { if (data) { + console.log("Resetting form with data:", data); reset({ ...DEFAULT_WORK_ITEM_FORM_VALUES, project_id: projectId, ...data }); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -204,6 +207,16 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [data, projectId]); + useEffect(() => { + if (data?.id) return; + + const currentSport = getValues("sport")?.trim() || null; + if (currentSport === projectSport) return; + + setValue("sport", projectSport, { shouldDirty: false, shouldTouch: false, shouldValidate: false }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data?.id, projectSport, projectId]); + useEffect(() => { if (workItemTemplateId && editorRef.current) { handleTemplateChange({ @@ -236,6 +249,18 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => { ) return; + const shouldValidateStartDateTime = + !is_draft_issue && (!data?.id || !!dirtyFields.start_date || !!dirtyFields.start_time); + + if (shouldValidateStartDateTime && isDateTimePast(formData.start_date, formData.start_time)) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: "Event date and time cannot be earlier than the current time.", + }); + return; + } + const submitData = !data?.id ? formData : { @@ -263,6 +288,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => { ...DEFAULT_WORK_ITEM_FORM_VALUES, ...(isCreateMoreToggleEnabled ? { ...data } : {}), project_id: getValues<"project_id">("project_id"), + sport: projectSport, type_id: getValues<"type_id">("type_id"), description_html: data?.description_html ?? "<p></p>", }); @@ -294,7 +320,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Failed to move work item to project. Please try again.", + message: "Failed to move work item to program. Please try again.", }); } finally { setIsMoving(false); @@ -374,7 +400,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => { return ( <FormProvider {...methods}> - <div className="flex gap-2 bg-transparent"> + <div className="flex gap-2 bg-transparent border-custom-border-200"> <div className="rounded-lg w-full"> <form ref={formRef} @@ -497,8 +523,13 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => { projectId={projectId} workspaceSlug={workspaceSlug?.toString()} selectedParentIssue={selectedParentIssue} - startDate={watch("start_date")} - targetDate={watch("target_date")} + initialStartDate={data?.start_date ?? null} + initialStartTime={data?.start_time ?? null} + Sport={watch("sport")} + Level={watch("level")} + Program={watch("program")} + Year={watch("year")} + Category={watch("category")} parentId={watch("parent_id")} isDraft={isDraft} handleFormChange={handleFormChange} diff --git a/apps/web/core/components/issues/peek-overview/header.tsx b/apps/web/core/components/issues/peek-overview/header.tsx index 101605a73df..f64d099dc07 100644 --- a/apps/web/core/components/issues/peek-overview/header.tsx +++ b/apps/web/core/components/issues/peek-overview/header.tsx @@ -1,30 +1,45 @@ "use client"; import type { FC } from "react"; -import { useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { Link2, MoveDiagonal, MoveRight } from "lucide-react"; +import { Link2, MoveDiagonal, MoveRight, UploadCloud } from "lucide-react"; // plane imports -import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; +import { API_BASE_URL, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { TOAST_TYPE, setToast, updateToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import type { TNameDescriptionLoader } from "@plane/types"; +import type { TIssueAttachment, TNameDescriptionLoader } from "@plane/types"; import { EIssuesStoreType } from "@plane/types"; -import { CustomSelect } from "@plane/ui"; -import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils"; +import { AlertModalCore, CustomSelect } from "@plane/ui"; +import { copyUrlToClipboard, generateWorkItemLink, getAssetIdFromUrl, getFileName, getFileURL } from "@plane/utils"; // helpers import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useIssues } from "@/hooks/store/use-issues"; +import { useMember } from "@/hooks/store/use-member"; import { useProject } from "@/hooks/store/use-project"; import { useUser } from "@/hooks/store/user"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; +import { MediaLibraryService } from "@/services/media-library.service"; // local imports import { IssueSubscription } from "../issue-detail/subscription"; +import { + DOC_FORMATS, + IMAGE_FORMATS, + buildArtifactName, + buildEventMeta, + getErrorMessage, + isDuplicateArtifactError, + resolveArtifactAction, + resolveArtifactFormat, + resolveArtifactPathFromAssetUrl, + resolveAttachmentDownloadUrl, + resolveAttachmentFileName, +} from "../issue-detail-widgets/action-buttons"; import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns"; import { NameDescriptionUpdateStatus } from "../issue-update-status"; @@ -64,6 +79,350 @@ export type PeekOverviewHeaderProps = { toggleEditIssueModal: (value: boolean) => void; handleRestoreIssue: () => Promise<void>; isSubmitting: TNameDescriptionLoader; + descriptionImageUrls?: string[]; + onInlineCleanupModalChange?: (isOpen: boolean) => void; +}; + +type TMediaLibraryAddResult = { + total: number; + successCount: number; + skippedCount: number; + failedCount: number; + errorMessage?: string; +}; + +const resolveInlineAssetUrl = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("data:") || trimmed.startsWith("blob:")) return trimmed; + return getFileURL(trimmed) ?? trimmed; +}; + +const normalizeUrlForCompare = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("data:") || trimmed.startsWith("blob:")) return trimmed; + if (typeof window === "undefined") return trimmed; + try { + const parsed = new URL(trimmed, window.location.origin); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return trimmed; + } +}; + +const hashInlineSource = (value: string) => { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash).toString(16); +}; + +const normalizeInlineSourceKey = (value: string) => { + const resolved = resolveInlineAssetUrl(value); + if (!resolved) return ""; + const normalized = normalizeUrlForCompare(resolved); + if (!normalized) return ""; + if (normalized.startsWith("data:")) { + return `data:${hashInlineSource(normalized)}`; + } + return normalized; +}; + +const resolveInlineAssetId = (value: string) => { + const resolved = resolveInlineAssetUrl(value); + if (!resolved) return ""; + if (resolved.startsWith("data:") || resolved.startsWith("blob:")) return ""; + try { + const parsed = new URL(resolved, window.location.origin); + return getAssetIdFromUrl(parsed.pathname); + } catch { + return getAssetIdFromUrl(resolved); + } +}; + +const resolveManifestMeta = ( + artifact: Record<string, unknown>, + metadata: Record<string, Record<string, unknown>> | undefined +) => { + const direct = artifact.meta; + if (direct && typeof direct === "object" && !Array.isArray(direct)) return direct as Record<string, unknown>; + const metadataRef = (artifact.metadata_ref as string | undefined) || (artifact.name as string | undefined); + if (!metadataRef || !metadata || typeof metadata !== "object") return {}; + const resolved = metadata[metadataRef]; + if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) return resolved; + return {}; +}; + +const resolveInlineFileName = (value: string, index: number) => { + const trimmed = value.trim(); + if (!trimmed) return `image-${index}.png`; + if (trimmed.startsWith("data:")) { + const match = /^data:([^;]+);/i.exec(trimmed); + const mime = match?.[1]?.toLowerCase() ?? ""; + let extension = mime.startsWith("image/") ? mime.split("/")[1] : "png"; + if (extension === "svg+xml") extension = "svg"; + return `embedded-image-${index}.${extension}`; + } + if (typeof window !== "undefined") { + try { + const parsed = new URL(trimmed, window.location.origin); + const pathSegments = parsed.pathname.split("/").filter(Boolean); + const lastSegment = pathSegments[pathSegments.length - 1]; + if (lastSegment) return decodeURIComponent(lastSegment); + } catch { + // ignore parse error + } + } + return `image-${index}.png`; +}; + +const resolveInlineFileId = (value: string, index: number) => { + const trimmed = value.trim(); + if (!trimmed) return `inline-${index}`; + if (trimmed.startsWith("data:")) return `embedded-${index}`; + if (typeof window !== "undefined") { + try { + const parsed = new URL(trimmed, window.location.origin); + return getAssetIdFromUrl(parsed.pathname); + } catch { + return getAssetIdFromUrl(trimmed); + } + } + return getAssetIdFromUrl(trimmed); +}; + +const resolveInlineArtifactNames = (value: string, index: number) => { + const resolved = resolveInlineAssetUrl(value); + if (!resolved) return []; + const rawFileName = resolveInlineFileName(resolved, index + 1); + const fileId = resolveInlineFileId(resolved, index + 1); + if (!fileId) return []; + + const names: string[] = []; + if (rawFileName) { + names.push(buildArtifactName(rawFileName, fileId)); + } + if (rawFileName && !rawFileName.includes(".")) { + names.push(buildArtifactName(`${fileId}.asset`, fileId)); + } + return names.filter(Boolean); +}; + +const resolveFormatFromMime = (mime: string) => { + if (!mime) return ""; + const normalized = mime.toLowerCase(); + if (normalized.startsWith("image/")) { + const subtype = normalized.split("/")[1] ?? ""; + return subtype === "svg+xml" ? "svg" : subtype; + } + if (normalized.startsWith("video/")) return normalized.split("/")[1] ?? ""; + if (normalized === "application/pdf") return "pdf"; + if (normalized.includes("spreadsheet")) return "xlsx"; + if (normalized.includes("msword")) return "doc"; + return ""; +}; + +const resolveFormatFromDisposition = (value: string) => { + if (!value) return ""; + const filenameStarMatch = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(value); + if (filenameStarMatch?.[1]) { + return resolveArtifactFormat(decodeURIComponent(filenameStarMatch[1])); + } + const filenameMatch = /filename\s*=\s*"?([^\";]+)"?/i.exec(value); + if (filenameMatch?.[1]) { + return resolveArtifactFormat(decodeURIComponent(filenameMatch[1])); + } + return ""; +}; + +const resolveInlineImageFormatFromAssetUrl = async (url: string) => { + if (!url || !API_BASE_URL || !url.startsWith(API_BASE_URL)) return ""; + try { + const signedUrl = await resolveAttachmentDownloadUrl(url); + if (!signedUrl) return ""; + const parsed = new URL(signedUrl); + const disposition = parsed.searchParams.get("response-content-disposition") ?? ""; + const formatFromDisposition = resolveFormatFromDisposition(disposition); + if (formatFromDisposition) return formatFromDisposition; + const fileName = decodeURIComponent(parsed.pathname.split("/").pop() ?? ""); + return resolveArtifactFormat(fileName); + } catch { + return ""; + } +}; + +const getApiOrigin = () => { + if (!API_BASE_URL) return ""; + try { + return new URL(API_BASE_URL).origin; + } catch { + return ""; + } +}; + +const shouldIncludeCredentialsForUrl = (url: string) => { + if (typeof window === "undefined") return false; + try { + const parsed = new URL(url, window.location.origin); + const apiOrigin = getApiOrigin(); + return parsed.origin === window.location.origin || (apiOrigin && parsed.origin === apiOrigin); + } catch { + return false; + } +}; + +const appendJsonResponseParam = (url: string) => { + if (typeof window === "undefined") return url; + try { + const parsed = new URL(url, window.location.origin); + if (!parsed.searchParams.get("response")) { + parsed.searchParams.set("response", "json"); + } + return parsed.toString(); + } catch { + return url; + } +}; + +const fetchInlineImageResponse = async (url: string) => { + if (!url) { + throw new Error("Unable to access inline image."); + } + const apiOrigin = getApiOrigin(); + const parsedUrl = typeof window !== "undefined" ? new URL(url, window.location.origin) : null; + const isApiAssetUrl = + parsedUrl && apiOrigin && parsedUrl.origin === apiOrigin && parsedUrl.pathname.includes("/api/assets/v2/workspaces/"); + + const initialUrl = isApiAssetUrl ? appendJsonResponseParam(url) : url; + const response = await fetch(initialUrl, { + credentials: shouldIncludeCredentialsForUrl(initialUrl) ? "include" : "omit", + }); + if (response.ok) { + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data = (await response.json()) as { asset_url?: string; url?: string }; + const assetUrl = data.asset_url ?? data.url; + if (!assetUrl) { + throw new Error("Unable to access inline image."); + } + const assetResponse = await fetch(assetUrl, { credentials: "omit" }); + if (!assetResponse.ok) { + throw new Error("Unable to access inline image."); + } + return assetResponse; + } + return response; + } + + const fallbackUrl = await resolveAttachmentDownloadUrl(url); + if (!fallbackUrl) { + throw new Error("Unable to access inline image."); + } + const fallbackResponse = await fetch(fallbackUrl, { credentials: "omit" }); + if (!fallbackResponse.ok) { + throw new Error("Unable to access inline image."); + } + return fallbackResponse; +}; + +const resolveInlineManifestCleanupArtifacts = ({ + issueId, + candidates, + currentDescriptionImages, + manifestArtifacts, + manifestMetadata, +}: { + issueId: string; + candidates: Array<{ url: string; index: number }>; + currentDescriptionImages: string[]; + manifestArtifacts: Record<string, unknown>[]; + manifestMetadata?: Record<string, Record<string, unknown>>; +}) => { + const inlineSourceKeys = new Set(candidates.map(({ url }) => normalizeInlineSourceKey(url)).filter(Boolean)); + const inlineUrlKeys = new Set( + candidates + .map(({ url }) => normalizeUrlForCompare(resolveInlineAssetUrl(url))) + .filter((entry) => entry && !entry.startsWith("data:")) + ); + const inlineAssetIds = new Set(candidates.map(({ url }) => resolveInlineAssetId(url)).filter(Boolean)); + const currentInlineSourceKeys = new Set(currentDescriptionImages.map((url) => normalizeInlineSourceKey(url)).filter(Boolean)); + const currentInlineUrlKeys = new Set( + currentDescriptionImages + .map((url) => normalizeUrlForCompare(resolveInlineAssetUrl(url))) + .filter((entry) => entry && !entry.startsWith("data:")) + ); + const currentInlineAssetIds = new Set(currentDescriptionImages.map((url) => resolveInlineAssetId(url)).filter(Boolean)); + const artifactNameCandidates = new Set<string>(); + candidates.forEach(({ url, index }) => { + resolveInlineArtifactNames(url, index).forEach((name) => artifactNameCandidates.add(name)); + }); + + const namesToDelete = new Set<string>(); + + for (const artifact of manifestArtifacts) { + if (!artifact || typeof artifact !== "object") continue; + const artifactName = (artifact as { name?: string }).name; + if (!artifactName) continue; + const workItemId = (artifact as { work_item_id?: string | null }).work_item_id ?? ""; + if (workItemId && workItemId !== issueId) continue; + + const meta = resolveManifestMeta(artifact as Record<string, unknown>, manifestMetadata); + const inlineSource = typeof meta.inline_source === "string" ? meta.inline_source : ""; + if (inlineSource) { + if (currentInlineSourceKeys.has(inlineSource)) continue; + if (inlineSourceKeys.has(inlineSource)) { + namesToDelete.add(artifactName); + } + continue; + } + + const rawPath = (artifact as { path?: string }).path ?? ""; + if (rawPath && typeof rawPath === "string" && rawPath.startsWith("http")) { + const normalizedPath = normalizeUrlForCompare(rawPath); + if (normalizedPath) { + if (currentInlineUrlKeys.has(normalizedPath)) continue; + if (inlineUrlKeys.has(normalizedPath)) { + namesToDelete.add(artifactName); + continue; + } + } + } + + const lastSegment = artifactName.split("-").pop() ?? ""; + if (lastSegment) { + if (currentInlineAssetIds.has(lastSegment)) continue; + if (inlineAssetIds.has(lastSegment)) { + namesToDelete.add(artifactName); + continue; + } + } + + if (artifactNameCandidates.has(artifactName)) { + namesToDelete.add(artifactName); + } + } + + if (namesToDelete.size === 0 && currentDescriptionImages.length === 0) { + for (const artifact of manifestArtifacts) { + if (!artifact || typeof artifact !== "object") continue; + const artifactName = (artifact as { name?: string }).name; + if (!artifactName) continue; + const workItemId = (artifact as { work_item_id?: string | null }).work_item_id ?? ""; + if (workItemId && workItemId !== issueId) continue; + const meta = resolveManifestMeta(artifact as Record<string, unknown>, manifestMetadata); + const metaSource = typeof meta.source === "string" ? meta.source : ""; + if (metaSource === "work_item_description") { + namesToDelete.add(artifactName); + } + } + } + + return namesToDelete; }; export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((props) => { @@ -83,6 +442,8 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr toggleEditIssueModal, handleRestoreIssue, isSubmitting, + descriptionImageUrls = [], + onInlineCleanupModalChange, } = props; // ref const parentRef = useRef<HTMLDivElement>(null); @@ -91,13 +452,26 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr const { data: currentUser } = useUser(); const { issue: { getIssueById }, + attachment, + fetchAttachments, setPeekIssue, removeIssue, archiveIssue, getIsIssuePeeked, } = useIssueDetail(); + const { getUserDetails } = useMember(); const { isMobile } = usePlatformOS(); const { getProjectIdentifierById } = useProject(); + const [isAddingToMediaLibrary, setIsAddingToMediaLibrary] = useState(false); + const [isInlineCleanupModalOpen, setIsInlineCleanupModalOpen] = useState(false); + const [isInlineCleanupSubmitting, setIsInlineCleanupSubmitting] = useState(false); + const [inlineCleanupCandidates, setInlineCleanupCandidates] = useState< + Array<{ + url: string; + index: number; + }> + >([]); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); // derived values const issueDetails = getIssueById(issueId); const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode); @@ -105,6 +479,159 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr const { issues: { removeIssue: removeArchivedIssue }, } = useIssues(EIssuesStoreType.ARCHIVED); + const createdByDetails = issueDetails?.created_by ? getUserDetails(issueDetails.created_by) : undefined; + const createdByName = createdByDetails?.display_name?.includes("-intake") + ? "Plane" + : createdByDetails?.display_name ?? issueDetails?.created_by ?? ""; + const baseEventMeta = useMemo(() => buildEventMeta(issueDetails, createdByName), [issueDetails, createdByName]); + const attachmentIds = attachment.getAttachmentsByIssueId(issueId) ?? []; + const attachmentCount = issueDetails?.attachment_count ?? attachmentIds.length; + const normalizedDescriptionImages = useMemo(() => { + const uniqueImages = new Map<string, string>(); + for (const rawValue of descriptionImageUrls) { + const resolved = resolveInlineAssetUrl(rawValue); + if (!resolved) continue; + const key = normalizeUrlForCompare(resolved); + if (!uniqueImages.has(key)) uniqueImages.set(key, resolved); + } + return Array.from(uniqueImages.values()); + }, [descriptionImageUrls]); + const previousDescriptionImagesRef = useRef<string[]>([]); + const previousIssueIdRef = useRef(issueId); + const hasMediaAssets = attachmentCount > 0 || normalizedDescriptionImages.length > 0; + + const setInlineCleanupModalOpen = useCallback( + (next: boolean) => { + setIsInlineCleanupModalOpen(next); + onInlineCleanupModalChange?.(next); + }, + [onInlineCleanupModalChange] + ); + + useEffect(() => { + if (previousIssueIdRef.current !== issueId) { + previousIssueIdRef.current = issueId; + previousDescriptionImagesRef.current = normalizedDescriptionImages; + setInlineCleanupCandidates([]); + setInlineCleanupModalOpen(false); + return; + } + + const previous = previousDescriptionImagesRef.current; + if (previous.length === 0) { + previousDescriptionImagesRef.current = normalizedDescriptionImages; + return; + } + + const currentKeys = new Set(normalizedDescriptionImages.map((url) => normalizeUrlForCompare(url))); + const removedImages = previous + .map((url, index) => ({ url, index })) + .filter(({ url }) => !currentKeys.has(normalizeUrlForCompare(url))); + + if (removedImages.length === 0) { + previousDescriptionImagesRef.current = normalizedDescriptionImages; + return; + } + + previousDescriptionImagesRef.current = normalizedDescriptionImages; + if (!workspaceSlug || !projectId) return; + + const candidates = removedImages; + if (candidates.length === 0) return; + let isMounted = true; + const verifyAndOpenInlineCleanup = async () => { + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const manifestArtifacts = Array.isArray(manifest?.artifacts) + ? (manifest.artifacts as unknown as Record<string, unknown>[]) + : []; + const manifestMetadata = + manifest && typeof manifest === "object" && manifest.metadata && typeof manifest.metadata === "object" + ? (manifest.metadata as Record<string, Record<string, unknown>>) + : undefined; + const namesToDelete = resolveInlineManifestCleanupArtifacts({ + issueId, + candidates, + currentDescriptionImages: normalizedDescriptionImages, + manifestArtifacts, + manifestMetadata, + }); + if (!isMounted || namesToDelete.size === 0) return; + setInlineCleanupCandidates((prev) => { + const merged = new Map<string, { url: string; index: number }>(); + prev.forEach((entry) => merged.set(`${entry.url}::${entry.index}`, entry)); + candidates.forEach((entry) => merged.set(`${entry.url}::${entry.index}`, entry)); + return Array.from(merged.values()); + }); + setInlineCleanupModalOpen(true); + } catch { + // Ignore manifest lookup failures; do not prompt cleanup without verification. + } + }; + void verifyAndOpenInlineCleanup(); + + return () => { + isMounted = false; + }; + }, [issueId, normalizedDescriptionImages, mediaLibraryService, projectId, workspaceSlug, setInlineCleanupModalOpen]); + + const handleInlineCleanupClose = useCallback(() => { + setInlineCleanupModalOpen(false); + setInlineCleanupCandidates([]); + setIsInlineCleanupSubmitting(false); + }, [setInlineCleanupModalOpen]); + + const handleInlineCleanupConfirm = useCallback(async () => { + if (!workspaceSlug || !projectId) { + handleInlineCleanupClose(); + return; + } + setIsInlineCleanupSubmitting(true); + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + if (!packageId) { + handleInlineCleanupClose(); + return; + } + const manifestArtifacts = Array.isArray(manifest?.artifacts) + ? (manifest.artifacts as unknown as Record<string, unknown>[]) + : []; + const manifestMetadata = + manifest && typeof manifest === "object" && manifest.metadata && typeof manifest.metadata === "object" + ? (manifest.metadata as Record<string, Record<string, unknown>>) + : undefined; + const namesToDelete = resolveInlineManifestCleanupArtifacts({ + issueId, + candidates: inlineCleanupCandidates, + currentDescriptionImages: normalizedDescriptionImages, + manifestArtifacts, + manifestMetadata, + }); + + if (namesToDelete.size > 0) { + await Promise.all( + Array.from(namesToDelete).map(async (artifactName) => { + try { + await mediaLibraryService.deleteArtifact(workspaceSlug, projectId, packageId, artifactName); + } catch { + // ignore cleanup errors + } + }) + ); + } + } finally { + handleInlineCleanupClose(); + } + }, [ + handleInlineCleanupClose, + inlineCleanupCandidates, + issueId, + mediaLibraryService, + normalizedDescriptionImages, + projectId, + workspaceSlug, + ]); const workItemLink = generateWorkItemLink({ workspaceSlug, @@ -127,6 +654,300 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr }); }; + const handleAddAssetsToMediaLibrary = useCallback(async (): Promise<TMediaLibraryAddResult> => { + if (!workspaceSlug || !projectId || !issueId) { + return { + total: 0, + successCount: 0, + skippedCount: 0, + failedCount: 0, + errorMessage: "Missing required fields.", + }; + } + + setIsAddingToMediaLibrary(true); + try { + let resolvedAttachments = attachmentIds + .map((attachmentId) => attachment.getAttachmentById(attachmentId)) + .filter((item): item is TIssueAttachment => Boolean(item)); + + if (resolvedAttachments.length === 0) { + resolvedAttachments = await fetchAttachments(workspaceSlug, projectId, issueId); + } + + if (resolvedAttachments.length === 0 && normalizedDescriptionImages.length === 0) { + return { + total: 0, + successCount: 0, + skippedCount: 0, + failedCount: 0, + errorMessage: "No attachments or inline images found for this work item.", + }; + } + + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + if (!packageId) { + return { + total: 0, + successCount: 0, + skippedCount: 0, + failedCount: 0, + errorMessage: "Media library package not available.", + }; + } + + const attachmentUrlKeys = new Set( + resolvedAttachments + .map((attachmentItem) => resolveInlineAssetUrl(attachmentItem?.asset_url ?? "")) + .filter(Boolean) + .map((url) => normalizeUrlForCompare(url)) + ); + const uniqueInlineImages = normalizedDescriptionImages.filter( + (url) => !attachmentUrlKeys.has(normalizeUrlForCompare(url)) + ); + const result: TMediaLibraryAddResult = { + total: resolvedAttachments.length + uniqueInlineImages.length, + successCount: 0, + skippedCount: 0, + failedCount: 0, + }; + + for (const attachmentItem of resolvedAttachments) { + const fileName = resolveAttachmentFileName(attachmentItem); + const format = resolveArtifactFormat(fileName); + if (!format) { + result.skippedCount += 1; + continue; + } + + const assetUrl = resolveInlineAssetUrl(attachmentItem.asset_url ?? ""); + if (!assetUrl) { + result.failedCount += 1; + continue; + } + + try { + const directPath = resolveArtifactPathFromAssetUrl(assetUrl); + const artifactName = buildArtifactName(fileName, attachmentItem.id); + const title = getFileName(fileName) || "Attachment"; + const action = resolveArtifactAction(format); + const meta: Record<string, unknown> = { ...baseEventMeta }; + + if (DOC_FORMATS.has(format)) { + meta.kind = "document_file"; + meta.file_size = attachmentItem.attributes?.size; + meta.file_type = format; + } + + if (directPath) { + await mediaLibraryService.createArtifact(workspaceSlug, projectId, packageId, { + name: artifactName, + title, + format, + link: null, + action, + meta, + work_item_id: issueId, + path: directPath, + }); + result.successCount += 1; + continue; + } + + const downloadUrl = await resolveAttachmentDownloadUrl(assetUrl); + if (!downloadUrl) { + throw new Error(`Unable to fetch "${fileName}".`); + } + const response = await fetch(downloadUrl); + if (!response.ok) { + throw new Error(`Unable to fetch "${fileName}".`); + } + const blob = await response.blob(); + const file = new File([blob], fileName, { type: blob.type || undefined }); + + await mediaLibraryService.uploadArtifact( + workspaceSlug, + projectId, + packageId, + { + name: artifactName, + title, + format, + link: null, + action, + meta, + work_item_id: issueId, + }, + file + ); + result.successCount += 1; + } catch (error) { + if (isDuplicateArtifactError(error)) { + result.skippedCount += 1; + } else { + result.failedCount += 1; + } + } + } + + for (const [index, rawUrl] of uniqueInlineImages.entries()) { + const resolvedUrl = resolveInlineAssetUrl(rawUrl); + if (!resolvedUrl) { + result.failedCount += 1; + continue; + } + let fileName = resolveInlineFileName(resolvedUrl, index + 1); + let format = resolveArtifactFormat(fileName); + + try { + const action = resolveArtifactAction(format); + const meta: Record<string, unknown> = { + ...baseEventMeta, + source: "work_item_description", + inline_source: normalizeInlineSourceKey(resolvedUrl) || undefined, + }; + const directPath = resolveArtifactPathFromAssetUrl(resolvedUrl); + + if (directPath && !format) { + format = await resolveInlineImageFormatFromAssetUrl(directPath); + } + + if (directPath && format && IMAGE_FORMATS.has(format)) { + if (!fileName.toLowerCase().includes(".") && format) { + fileName = `${fileName}.${format}`; + } + const artifactName = buildArtifactName(fileName, resolveInlineFileId(resolvedUrl, index + 1)); + const title = getFileName(fileName) || "Inline image"; + await mediaLibraryService.createArtifact(workspaceSlug, projectId, packageId, { + name: artifactName, + title, + format, + link: null, + action, + meta, + work_item_id: issueId, + path: directPath, + }); + result.successCount += 1; + continue; + } + + const response = await fetchInlineImageResponse(resolvedUrl); + const blob = await response.blob(); + if (!format) { + format = resolveFormatFromMime(blob.type || ""); + } + if (!format || !IMAGE_FORMATS.has(format)) { + result.skippedCount += 1; + continue; + } + if (!fileName.toLowerCase().includes(".") && format) { + fileName = `${fileName}.${format}`; + } + const artifactName = buildArtifactName(fileName, resolveInlineFileId(resolvedUrl, index + 1)); + const title = getFileName(fileName) || "Inline image"; + const file = new File([blob], fileName, { type: blob.type || undefined }); + + await mediaLibraryService.uploadArtifact( + workspaceSlug, + projectId, + packageId, + { + name: artifactName, + title, + format, + link: null, + action, + meta, + work_item_id: issueId, + }, + file + ); + result.successCount += 1; + } catch (error) { + if (isDuplicateArtifactError(error)) { + result.skippedCount += 1; + } else { + result.failedCount += 1; + } + } + } + + if (result.successCount === 0) { + if (result.skippedCount > 0 && result.failedCount === 0) { + return { + ...result, + errorMessage: "Assets already exist in the media library.", + }; + } + if (result.skippedCount > 0 && result.failedCount > 0) { + return { + ...result, + errorMessage: "Some assets could not be added to the media library.", + }; + } + return { + ...result, + errorMessage: "Unable to add assets to the media library.", + }; + } + + return result; + } finally { + setIsAddingToMediaLibrary(false); + } + }, [ + attachment, + attachmentIds, + baseEventMeta, + fetchAttachments, + issueId, + mediaLibraryService, + normalizedDescriptionImages, + projectId, + workspaceSlug, + ]); + + const handleAddAssetsClick = useCallback(async () => { + if (disabled || isAddingToMediaLibrary || !hasMediaAssets) return; + const toastId = setToast({ + type: TOAST_TYPE.LOADING, + title: "Adding assets to media library...", + }); + try { + const data = await handleAddAssetsToMediaLibrary(); + if (data?.errorMessage) { + updateToast(toastId, { + type: TOAST_TYPE.ERROR, + title: "Assets not added", + message: data.errorMessage, + }); + return; + } + const { total, successCount, skippedCount, failedCount } = data; + let message = "Assets added to the media library."; + if (failedCount === 0 && skippedCount === 0) { + message = `${successCount} of ${total} assets added to the media library.`; + } else if (failedCount === 0) { + message = `${successCount} of ${total} assets added. ${skippedCount} skipped.`; + } else { + message = `${successCount} of ${total} assets added. ${skippedCount} skipped, ${failedCount} failed.`; + } + updateToast(toastId, { + type: TOAST_TYPE.SUCCESS, + title: "Assets added", + message, + }); + } catch (error) { + updateToast(toastId, { + type: TOAST_TYPE.ERROR, + title: "Assets not added", + message: getErrorMessage(error) || "Unable to add assets to the media library.", + }); + } + }, [disabled, handleAddAssetsToMediaLibrary, hasMediaAssets, isAddingToMediaLibrary]); + const handleDeleteIssue = async () => { try { const deleteIssue = issueDetails?.archived_at ? removeArchivedIssue : removeIssue; @@ -173,82 +994,121 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr }; return ( - <div - className={`relative flex items-center justify-between p-4 ${ - currentMode?.key === "full-screen" ? "border-b border-custom-border-200" : "" - }`} - > - <div className="flex items-center gap-4"> - <Tooltip tooltipContent={t("common.close_peek_view")} isMobile={isMobile}> - <button onClick={removeRoutePeekId}> - <MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> - </button> - </Tooltip> - - <Tooltip tooltipContent={t("issue.open_in_full_screen")} isMobile={isMobile}> - <Link href={workItemLink} onClick={() => removeRoutePeekId()}> - <MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> - </Link> - </Tooltip> - {currentMode && embedIssue === false && ( - <div className="flex flex-shrink-0 items-center gap-2"> - <CustomSelect - value={currentMode} - onChange={(val: any) => setPeekMode(val)} - customButton={ - <Tooltip tooltipContent={t("common.toggle_peek_view_layout")} isMobile={isMobile}> - <button type="button" className=""> - <currentMode.icon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> - </button> - </Tooltip> - } - > - {PEEK_OPTIONS.map((mode) => ( - <CustomSelect.Option key={mode.key} value={mode.key}> - <div - className={`flex items-center gap-1.5 ${ - currentMode.key === mode.key - ? "text-custom-text-200" - : "text-custom-text-400 hover:text-custom-text-200" - }`} - > - <mode.icon className="-my-1 h-4 w-4 flex-shrink-0" /> - {t(mode.i18n_title)} - </div> - </CustomSelect.Option> - ))} - </CustomSelect> - </div> - )} - </div> - <div className="flex items-center gap-x-4"> - <NameDescriptionUpdateStatus isSubmitting={isSubmitting} /> + <> + <AlertModalCore + isOpen={isInlineCleanupModalOpen} + handleClose={handleInlineCleanupClose} + handleSubmit={handleInlineCleanupConfirm} + isSubmitting={isInlineCleanupSubmitting} + title="Remove from media library?" + variant="danger" + primaryButtonText={{ + default: "Remove", + loading: "Removing", + }} + secondaryButtonText="Keep" + content={ + <> + You removed {inlineCleanupCandidates.length} inline image + {inlineCleanupCandidates.length === 1 ? "" : "s"} from the description. Do you also want to remove from the media library? + </> + } + /> + <div + className={`relative flex items-center justify-between p-4 ${ + currentMode?.key === "full-screen" ? "border-b border-custom-border-200" : "" + }`} + > <div className="flex items-center gap-4"> - {currentUser && !isArchived && ( - <IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} /> - )} - <Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}> - <button type="button" onClick={handleCopyText}> - <Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" /> + <Tooltip tooltipContent={t("common.close_peek_view")} isMobile={isMobile}> + <button onClick={removeRoutePeekId}> + <MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> </button> </Tooltip> - {issueDetails && ( - <WorkItemDetailQuickActions - parentRef={parentRef} - issue={issueDetails} - handleDelete={handleDeleteIssue} - handleArchive={handleArchiveIssue} - handleRestore={handleRestoreIssue} - readOnly={disabled} - toggleDeleteIssueModal={toggleDeleteIssueModal} - toggleArchiveIssueModal={toggleArchiveIssueModal} - toggleDuplicateIssueModal={toggleDuplicateIssueModal} - toggleEditIssueModal={toggleEditIssueModal} - isPeekMode - /> + + <Tooltip tooltipContent={t("issue.open_in_full_screen")} isMobile={isMobile}> + <Link href={workItemLink} onClick={() => removeRoutePeekId()}> + <MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> + </Link> + </Tooltip> + {currentMode && embedIssue === false && ( + <div className="flex flex-shrink-0 items-center gap-2"> + <CustomSelect + value={currentMode} + onChange={(val: any) => setPeekMode(val)} + customButton={ + <Tooltip tooltipContent={t("common.toggle_peek_view_layout")} isMobile={isMobile}> + <button type="button" className=""> + <currentMode.icon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> + </button> + </Tooltip> + } + > + {PEEK_OPTIONS.map((mode) => ( + <CustomSelect.Option key={mode.key} value={mode.key}> + <div + className={`flex items-center gap-1.5 ${ + currentMode.key === mode.key + ? "text-custom-text-200" + : "text-custom-text-400 hover:text-custom-text-200" + }`} + > + <mode.icon className="-my-1 h-4 w-4 flex-shrink-0" /> + {t(mode.i18n_title)} + </div> + </CustomSelect.Option> + ))} + </CustomSelect> + </div> )} </div> + <div className="flex items-center gap-x-4"> + <NameDescriptionUpdateStatus isSubmitting={isSubmitting} /> + <div className="flex items-center gap-4"> + {currentUser && !isArchived && ( + <IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} /> + )} + {hasMediaAssets && ( + <Tooltip tooltipContent="Add assets in media library" isMobile={isMobile}> + <button + type="button" + onClick={handleAddAssetsClick} + disabled={disabled || isAddingToMediaLibrary} + className="disabled:cursor-not-allowed" + > + <UploadCloud + className={`h-4 w-4 ${ + disabled || isAddingToMediaLibrary + ? "text-custom-text-400" + : "text-custom-text-300 hover:text-custom-text-200" + }`} + /> + </button> + </Tooltip> + )} + <Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}> + <button type="button" onClick={handleCopyText}> + <Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" /> + </button> + </Tooltip> + {issueDetails && ( + <WorkItemDetailQuickActions + parentRef={parentRef} + issue={issueDetails} + handleDelete={handleDeleteIssue} + handleArchive={handleArchiveIssue} + handleRestore={handleRestoreIssue} + readOnly={disabled} + toggleDeleteIssueModal={toggleDeleteIssueModal} + toggleArchiveIssueModal={toggleArchiveIssueModal} + toggleDuplicateIssueModal={toggleDuplicateIssueModal} + toggleEditIssueModal={toggleEditIssueModal} + isPeekMode + /> + )} + </div> + </div> </div> - </div> + </> ); }); diff --git a/apps/web/core/components/issues/peek-overview/issue-detail.tsx b/apps/web/core/components/issues/peek-overview/issue-detail.tsx index 5d93e2d7071..237f209d199 100644 --- a/apps/web/core/components/issues/peek-overview/issue-detail.tsx +++ b/apps/web/core/components/issues/peek-overview/issue-detail.tsx @@ -40,11 +40,13 @@ type Props = { isArchived: boolean; isSubmitting: TNameDescriptionLoader; setIsSubmitting: (value: TNameDescriptionLoader) => void; + onDescriptionChange?: (value: string) => void; }; export const PeekOverviewIssueDetails: FC<Props> = observer((props) => { const { editorRef, workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } = props; + const { onDescriptionChange } = props; // store hooks const { data: currentUser } = useUser(); const { @@ -135,6 +137,7 @@ export const PeekOverviewIssueDetails: FC<Props> = observer((props) => { issueOperations={issueOperations} setIsSubmitting={(value) => setIsSubmitting(value)} containerClassName="-ml-3 border-none" + onDescriptionChange={onDescriptionChange} /> <div className="flex items-center justify-between gap-2"> diff --git a/apps/web/core/components/issues/peek-overview/properties.tsx b/apps/web/core/components/issues/peek-overview/properties.tsx index bd6a727ea03..57f42c7f9b5 100644 --- a/apps/web/core/components/issues/peek-overview/properties.tsx +++ b/apps/web/core/components/issues/peek-overview/properties.tsx @@ -1,33 +1,37 @@ "use client"; import type { FC } from "react"; +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; -import { Signal, Tag, Triangle, LayoutPanelTop, CalendarClock, CalendarCheck2, Users, UserCircle2 } from "lucide-react"; +import { Signal, Tag, CalendarClock, User, UserCircle2, Handshake, Volleyball, Calendar, Clock } from "lucide-react"; + // i18n import { useTranslation } from "@plane/i18n"; -// ui icons -import { CycleIcon, DoubleCircleIcon, ModuleIcon } from "@plane/propel/icons"; -import { cn, getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssue } from "@plane/types"; + +// utils +import { isDateTimePast, isDateTimePastWithOverrides, renderFormattedPayloadDate } from "@plane/utils"; + // components +import { CategoryDropdown } from "@/components/dropdowns/category-property"; import { DateDropdown } from "@/components/dropdowns/date"; -import { EstimateDropdown } from "@/components/dropdowns/estimate"; +import { LevelDropdown } from "@/components/dropdowns/level-property"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; -import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; -import { PriorityDropdown } from "@/components/dropdowns/priority"; -import { StateDropdown } from "@/components/dropdowns/state/dropdown"; -// helpers +import { ProgramDropdown } from "@/components/dropdowns/program-property"; +import SportDropdown from "@/components/dropdowns/sport-property"; +import { TimeDropdown } from "@/components/dropdowns/time-picker"; +import { YearRangeDropdown } from "@/components/dropdowns/year-property"; +import { parseOppositionTeam, serializeOppositionTeam } from "@/helpers/opposition-team"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useMember } from "@/hooks/store/use-member"; import { useProject } from "@/hooks/store/use-project"; import { useProjectState } from "@/hooks/store/use-project-state"; -// plane web components -import { WorkItemAdditionalSidebarProperties } from "@/plane-web/components/issues/issue-details/additional-properties"; -import { IssueParentSelectRoot } from "@/plane-web/components/issues/issue-details/parent-select-root"; -import { IssueWorklogProperty } from "@/plane-web/components/issues/worklog/property"; +import { MediaLibraryService } from "@/services/media-library.service"; + +import OppositionTeamProperty from "@/plane-web/components/issues/issue-details/opposition-team-property"; + import type { TIssueOperations } from "../issue-detail"; -import { IssueCycleSelect } from "../issue-detail/cycle-select"; -import { IssueLabel } from "../issue-detail/label"; -import { IssueModuleSelect } from "../issue-detail/module-select"; interface IPeekOverviewProperties { workspaceSlug: string; @@ -40,6 +44,7 @@ interface IPeekOverviewProperties { export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((props) => { const { workspaceSlug, projectId, issueId, issueOperations, disabled } = props; const { t } = useTranslation(); + // store hooks const { getProjectById } = useProject(); const { @@ -47,85 +52,96 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro } = useIssueDetail(); const { getStateById } = useProjectState(); const { getUserDetails } = useMember(); + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + // derived values const issue = getIssueById(issueId); if (!issue) return <></>; + const createdByDetails = getUserDetails(issue?.created_by); const projectDetails = getProjectById(issue.project_id); - const isEstimateEnabled = projectDetails?.estimate; const stateDetails = getStateById(issue.state_id); + const projectSport = projectDetails?.sport?.trim() || null; + const issueSport = issue.sport?.trim() || null; + const shouldShowSportField = !!projectSport || !!issueSport; - const minDate = getDate(issue.start_date); + const minDate = new Date(); minDate?.setDate(minDate.getDate()); + const isReadOnly = disabled; + const isDateTimeLocked = isReadOnly || isDateTimePast(issue.start_date, issue.start_time); + const isSportLocked = isReadOnly || !!projectSport; - const maxDate = getDate(issue.target_date); - maxDate?.setDate(maxDate.getDate()); + const buildManifestMeta = useCallback( + (currentIssue: TIssue) => ({ + category: currentIssue.category || "Work items", + start_date: currentIssue.start_date ?? null, + start_time: currentIssue.start_time ?? null, + level: currentIssue.level ?? null, + program: currentIssue.program ?? null, + sport: currentIssue.sport ?? null, + opposition: currentIssue.opposition_team ?? null, + season: currentIssue.year ?? null, + }), + [] + ); - return ( - <div> - <h6 className="text-sm font-medium">{t("common.properties")}</h6> - {/* TODO: render properties using a common component */} - <div className={`w-full space-y-2 mt-3 ${disabled ? "opacity-60" : ""}`}> - {/* state */} - <div className="flex w-full items-center gap-3 h-8"> - <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> - <DoubleCircleIcon className="h-4 w-4 flex-shrink-0" /> - <span>{t("common.state")}</span> - </div> - <StateDropdown - value={issue?.state_id} - onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} - projectId={projectId} - disabled={disabled} - buttonVariant="transparent-with-text" - className="w-3/4 flex-grow group" - buttonContainerClassName="w-full text-left" - buttonClassName="text-sm" - dropdownArrow - dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" - /> - </div> + const updateManifestMeta = useCallback( + async (currentIssue: TIssue) => { + if (!workspaceSlug || !projectId || !issueId) return; + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : null; + if (!packageId) return; + await mediaLibraryService.updateManifestMetadata(workspaceSlug, projectId, packageId, { + work_item_id: issueId, + meta: buildManifestMeta(currentIssue), + }); + } catch { + // Skip manifest updates if artifacts don't exist. + } + }, + [buildManifestMeta, issueId, mediaLibraryService, projectId, workspaceSlug] + ); - {/* assignee */} - <div className="flex w-full items-center gap-3 h-8"> - <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> - <Users className="h-4 w-4 flex-shrink-0" /> - <span>{t("common.assignees")}</span> - </div> - <MemberDropdown - value={issue?.assignee_ids ?? undefined} - onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} - disabled={disabled} - projectId={projectId} - placeholder={t("issue.add.assignee")} - multiple - buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"} - className="w-3/4 flex-grow group" - buttonContainerClassName="w-full text-left" - buttonClassName={`text-sm justify-between ${issue?.assignee_ids?.length > 0 ? "" : "text-custom-text-400"}`} - hideIcon={issue.assignee_ids?.length === 0} - dropdownArrow - dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" - /> - </div> + const handlePropertyUpdate = useCallback( + async (data: Partial<TIssue>) => { + if (isReadOnly) return; + await issueOperations.update(workspaceSlug, projectId, issueId, data); + const updatedIssue = getIssueById(issueId); + if (!updatedIssue) return; + await updateManifestMeta(updatedIssue); + }, + [getIssueById, isReadOnly, issueId, issueOperations, projectId, updateManifestMeta, workspaceSlug] + ); - {/* priority */} - <div className="flex w-full items-center gap-3 h-8"> - <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> - <Signal className="h-4 w-4 flex-shrink-0" /> - <span>{t("common.priority")}</span> - </div> - <PriorityDropdown - value={issue?.priority} - onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })} - disabled={disabled} - buttonVariant="border-with-text" - className="w-3/4 flex-grow rounded px-2 hover:bg-custom-background-80 group" - buttonContainerClassName="w-full text-left" - buttonClassName="w-min h-auto whitespace-nowrap" - /> - </div> + const handleDateTimeUpdate = useCallback( + async (data: Partial<TIssue>) => { + if (isDateTimeLocked) return; + if ( + isDateTimePastWithOverrides({ + currentDateValue: issue.start_date, + currentTimeValue: issue.start_time, + nextDateValue: data.start_date, + nextTimeValue: data.start_time, + }) + ) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: "Event date and time cannot be earlier than the current time.", + }); + return; + } + await handlePropertyUpdate(data); + }, + [handlePropertyUpdate, isDateTimeLocked, issue.start_date, issue.start_time, t] + ); + + return ( + <div> + <h6 className="text-sm font-medium">Event Details</h6> + <div className="w-full space-y-2 mt-3"> {/* created by */} {createdByDetails && ( <div className="flex w-full items-center gap-3 h-8"> @@ -138,7 +154,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro showTooltip userIds={createdByDetails?.display_name.includes("-intake") ? null : createdByDetails?.id} /> - <span className="flex-grow truncate leading-5"> + <span className="flex-grow truncate leading-5"> {createdByDetails?.display_name.includes("-intake") ? "Plane" : createdByDetails?.display_name} </span> </div> @@ -154,154 +170,190 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro <DateDropdown value={issue.start_date} onChange={(val) => - issueOperations.update(workspaceSlug, projectId, issueId, { + void handleDateTimeUpdate({ start_date: val ? renderFormattedPayloadDate(val) : null, }) } placeholder={t("issue.add.start_date")} buttonVariant="transparent-with-text" - maxDate={maxDate ?? undefined} - disabled={disabled} + minDate={minDate ?? undefined} + disabled={isDateTimeLocked} className="w-3/4 flex-grow group" buttonContainerClassName="w-full text-left" buttonClassName={`text-sm ${issue?.start_date ? "" : "text-custom-text-400"}`} hideIcon clearIconClassName="h-3 w-3 hidden group-hover:inline" - // TODO: add this logic - // showPlaceholderIcon /> </div> - {/* due date */} + {/* start time */} + <div className="flex h-8 items-center gap-3 w-full"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <Clock className="h-4 w-4 flex-shrink-0" /> + <span>{t("starting_time")}</span> + </div> + <TimeDropdown + value={issue.start_time} + onChange={(val) => { + void handleDateTimeUpdate({ + start_time: val, + }); + }} + placeholder={t("add_start_time")} + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + disabled={isDateTimeLocked} + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${issue?.start_time ? "" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + {/* Level */} <div className="flex w-full items-center gap-3 h-8"> <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> - <CalendarCheck2 className="h-4 w-4 flex-shrink-0" /> - <span>{t("common.order_by.due_date")}</span> + <Signal className="h-4 w-4 flex-shrink-0" /> + <p>{t("level_field")}</p> </div> - <DateDropdown - value={issue.target_date} - onChange={(val) => - issueOperations.update(workspaceSlug, projectId, issueId, { - target_date: val ? renderFormattedPayloadDate(val) : null, - }) - } - placeholder={t("issue.add.due_date")} + + <LevelDropdown + value={issue?.level} + onChange={(level) => { + void handlePropertyUpdate({ + level: level, + }); + }} + placeholder={t("add_level")} buttonVariant="transparent-with-text" - minDate={minDate ?? undefined} - disabled={disabled} className="w-3/4 flex-grow group" + disabled={isReadOnly} buttonContainerClassName="w-full text-left" - buttonClassName={cn("text-sm", { - "text-custom-text-400": !issue.target_date, - "text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group), - })} + buttonClassName={`text-sm ${issue?.level ? "" : "text-custom-text-400"}`} hideIcon - clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100" - // TODO: add this logic - // showPlaceholderIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" /> </div> - {/* estimate */} - {isEstimateEnabled && ( + {/* Program */} + <div className="flex w-full items-center gap-3 h-8"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <User className="h-4 w-4 flex-shrink-0" /> + <p>Program</p> + </div> + + <ProgramDropdown + value={issue?.program} + onChange={(program) => { + void handlePropertyUpdate({ + program: program, + }); + }} + placeholder={t("add_program")} + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + disabled={isReadOnly} + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${issue?.program ? "" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> + + {shouldShowSportField ? ( <div className="flex w-full items-center gap-3 h-8"> <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> - <Triangle className="h-4 w-4 flex-shrink-0" /> - <span>{t("common.estimate")}</span> + <Volleyball className="h-4 w-4 flex-shrink-0" /> + <p>Sport</p> </div> - <EstimateDropdown - value={issue.estimate_point ?? undefined} - onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })} - projectId={projectId} - disabled={disabled} + + <SportDropdown + value={issue?.sport} + onChange={(sport) => { + void handlePropertyUpdate({ + sport: sport, + }); + }} + placeholder={t("add_sport")} buttonVariant="transparent-with-text" className="w-3/4 flex-grow group" + disabled={isSportLocked} buttonContainerClassName="w-full text-left" - buttonClassName={`text-sm ${issue?.estimate_point !== undefined ? "" : "text-custom-text-400"}`} - placeholder="None" + buttonClassName={`text-sm ${issue?.sport ? "" : "text-custom-text-400"}`} hideIcon - dropdownArrow - dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" + clearIconClassName="h-3 w-3 hidden group-hover:inline" /> </div> - )} + ) : null} - {projectDetails?.module_view && ( - <div className="flex w-full items-center gap-3 min-h-8 h-full"> - <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> - <ModuleIcon className="h-4 w-4 flex-shrink-0" /> - <span>{t("common.modules")}</span> - </div> - <IssueModuleSelect - className="w-3/4 flex-grow" - workspaceSlug={workspaceSlug} - projectId={projectId} - issueId={issueId} - issueOperations={issueOperations} - disabled={disabled} - /> + {/* Opposition */} + <div className="flex w-full items-center gap-3 h-8"> + <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> + <Handshake className="h-4 w-4 flex-shrink-0" /> + <p>Opposition</p> </div> - )} - {projectDetails?.cycle_view && ( - <div className="flex w-full items-center gap-3 h-8"> - <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> - <CycleIcon className="h-4 w-4 flex-shrink-0" /> - <span>{t("common.cycle")}</span> - </div> - <IssueCycleSelect - className="w-3/4 flex-grow" - workspaceSlug={workspaceSlug} - projectId={projectId} - issueId={issueId} - issueOperations={issueOperations} - disabled={disabled} - /> - </div> - )} + <OppositionTeamProperty + storageKey={`opp-team-${issueId}`} + value={parseOppositionTeam(issue?.opposition_team)} + onChange={(team) => + void handlePropertyUpdate({ + opposition_team: serializeOppositionTeam(team), + }) + } + disabled={isReadOnly} + /> + </div> - {/* parent */} + {/* Category */} <div className="flex w-full items-center gap-3 h-8"> <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> - <LayoutPanelTop className="h-4 w-4 flex-shrink-0" /> - <p>{t("common.parent")}</p> + <Tag className="h-4 w-4 flex-shrink-0" /> + <p>Category</p> </div> - <IssueParentSelectRoot - className="w-3/4 flex-grow h-full" - disabled={disabled} - issueId={issueId} - issueOperations={issueOperations} - projectId={projectId} - workspaceSlug={workspaceSlug} + + <CategoryDropdown + value={issue?.category} + onChange={(category) => { + void handlePropertyUpdate({ + category: category, + }); + }} + placeholder={t("add_category")} + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + disabled={isReadOnly} + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${issue?.category ? "" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" /> </div> - {/* label */} - <div className="flex w-full items-center gap-3 min-h-8"> + {/* Year */} + <div className="flex w-full items-center gap-3 h-8"> <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> - <Tag className="h-4 w-4 flex-shrink-0" /> - <span>{t("common.labels")}</span> + <Calendar className="h-4 w-4 flex-shrink-0" /> + <p>Season</p> </div> - <div className="flex w-full flex-col gap-3 truncate"> - <IssueLabel workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={disabled} /> - </div> - </div> - <IssueWorklogProperty - workspaceSlug={workspaceSlug} - projectId={projectId} - issueId={issueId} - disabled={disabled} - /> - - <WorkItemAdditionalSidebarProperties - workItemId={issue.id} - workItemTypeId={issue.type_id} - projectId={projectId} - workspaceSlug={workspaceSlug} - isEditable={!disabled} - isPeekView - /> + <YearRangeDropdown + value={issue?.year} + onChange={(year) => { + void handlePropertyUpdate({ + year: year, + }); + }} + placeholder={t("add_year")} + buttonVariant="transparent-with-text" + className="w-3/4 flex-grow group" + disabled={isReadOnly} + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${issue?.year ? "" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> + </div> </div> </div> ); diff --git a/apps/web/core/components/issues/peek-overview/view.tsx b/apps/web/core/components/issues/peek-overview/view.tsx index fa4f5143120..f278d54e2a9 100644 --- a/apps/web/core/components/issues/peek-overview/view.tsx +++ b/apps/web/core/components/issues/peek-overview/view.tsx @@ -1,12 +1,12 @@ import type { FC } from "react"; -import { useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; import { createPortal } from "react-dom"; // plane imports import type { EditorRefApi } from "@plane/editor"; import type { TNameDescriptionLoader } from "@plane/types"; import { EIssueServiceType } from "@plane/types"; -import { cn } from "@plane/utils"; +import { cn, getEditorAssetSrc, getFileURL } from "@plane/utils"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import useKeypress from "@/hooks/use-keypress"; @@ -21,6 +21,63 @@ import { IssuePeekOverviewHeader } from "./header"; import { PeekOverviewIssueDetails } from "./issue-detail"; import { IssuePeekOverviewLoader } from "./loader"; import { PeekOverviewProperties } from "./properties"; +import { PeekOverviewWebhookArtifacts } from "./webhook-artifacts"; + +const resolveDescriptionImageSrc = (value: string, workspaceSlug: string, projectId: string) => { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("data:") || trimmed.startsWith("blob:") || trimmed.startsWith("http")) { + return trimmed; + } + if (!trimmed.includes("/") && workspaceSlug) { + return ( + getEditorAssetSrc({ + assetId: trimmed, + workspaceSlug, + projectId, + }) ?? trimmed + ); + } + return getFileURL(trimmed) ?? trimmed; +}; + +const extractDescriptionImageUrls = (descriptionHtml: string | null | undefined, workspaceSlug: string, projectId: string) => { + if (!descriptionHtml) return []; + const sources = new Set<string>(); + + if (typeof window !== "undefined" && "DOMParser" in window) { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(descriptionHtml, "text/html"); + doc.querySelectorAll("img, image-component").forEach((element) => { + const src = + element.getAttribute("src")?.trim() || + element.getAttribute("data-src")?.trim() || + element.getAttribute("data-source")?.trim(); + if (src) { + const resolved = resolveDescriptionImageSrc(src, workspaceSlug, projectId); + if (resolved) sources.add(resolved); + } + }); + } catch { + // fall back to regex parsing + } + } + + if (sources.size === 0) { + const regex = /<(?:img|image-component)[^>]+src=["']?([^"'>\s]+)["']?/gi; + let match = regex.exec(descriptionHtml); + while (match) { + if (match[1]) { + const resolved = resolveDescriptionImageSrc(match[1], workspaceSlug, projectId); + if (resolved) sources.add(resolved); + } + match = regex.exec(descriptionHtml); + } + } + + return Array.from(sources); +}; interface IIssueView { workspaceSlug: string; @@ -55,9 +112,13 @@ export const IssueView: FC<IIssueView> = observer((props) => { const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false); const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false); const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false); + const [isInlineCleanupModalOpen, setIsInlineCleanupModalOpen] = useState(false); + const [isWebhookVideoModalOpen, setIsWebhookVideoModalOpen] = useState(false); + const [descriptionHtmlOverride, setDescriptionHtmlOverride] = useState<string | null>(null); // ref const issuePeekOverviewRef = useRef<HTMLDivElement>(null); const editorRef = useRef<EditorRefApi>(null); + const webhookVideoModalCloseGuardRef = useRef<number>(0); // store hooks const { setPeekIssue, @@ -66,6 +127,17 @@ export const IssueView: FC<IIssueView> = observer((props) => { } = useIssueDetail(); const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS); const issue = getIssueById(issueId); + useEffect(() => { + setDescriptionHtmlOverride(null); + }, [issueId]); + const descriptionHtmlSource = descriptionHtmlOverride ?? issue?.description_html ?? null; + const descriptionImageUrls = useMemo( + () => extractDescriptionImageUrls(descriptionHtmlSource, workspaceSlug, projectId), + [descriptionHtmlSource, projectId, workspaceSlug] + ); + const handleDescriptionChange = useCallback((value: string) => { + setDescriptionHtmlOverride(value); + }, []); // remove peek id const removeRoutePeekId = () => { setPeekIssue(undefined); @@ -78,16 +150,25 @@ export const IssueView: FC<IIssueView> = observer((props) => { const toggleArchiveIssueModal = (value: boolean) => setIsArchiveIssueModalOpen(value); const toggleDuplicateIssueModal = (value: boolean) => setIsDuplicateIssueModalOpen(value); const toggleEditIssueModal = (value: boolean) => setIsEditIssueModalOpen(value); + const handleWebhookVideoModalOpenChange = useCallback((isOpen: boolean) => { + setIsWebhookVideoModalOpen(isOpen); + webhookVideoModalCloseGuardRef.current = isOpen ? Date.now() + 60_000 : Date.now() + 300; + }, []); const isAnyLocalModalOpen = isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen; + const isAnyLocalModalOpenWithInline = + isAnyLocalModalOpen || + isInlineCleanupModalOpen || + isWebhookVideoModalOpen || + Date.now() < webhookVideoModalCloseGuardRef.current; usePeekOverviewOutsideClickDetector( issuePeekOverviewRef, () => { const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen(); if (!embedIssue) { - if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpen && !isAnyDropbarOpen) { + if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpenWithInline && !isAnyDropbarOpen) { removeRoutePeekId(); } } @@ -166,6 +247,8 @@ export const IssueView: FC<IIssueView> = observer((props) => { isSubmitting={isSubmitting} disabled={disabled} embedIssue={embedIssue} + descriptionImageUrls={descriptionImageUrls} + onInlineCleanupModalChange={setIsInlineCleanupModalOpen} /> {/* content */} <div className="vertical-scrollbar scrollbar-md relative h-full w-full overflow-hidden overflow-y-auto"> @@ -181,6 +264,14 @@ export const IssueView: FC<IIssueView> = observer((props) => { isArchived={is_archived} isSubmitting={isSubmitting} setIsSubmitting={(value) => setIsSubmitting(value)} + onDescriptionChange={handleDescriptionChange} + /> + + <PeekOverviewWebhookArtifacts + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + onVideoModalOpenChange={handleWebhookVideoModalOpenChange} /> <div className="py-2"> @@ -190,6 +281,8 @@ export const IssueView: FC<IIssueView> = observer((props) => { issueId={issueId} disabled={disabled || is_archived} issueServiceType={EIssueServiceType.ISSUES} + hideMediaLibraryButton + confirmManifestOnDelete /> </div> @@ -222,6 +315,14 @@ export const IssueView: FC<IIssueView> = observer((props) => { isArchived={is_archived} isSubmitting={isSubmitting} setIsSubmitting={(value) => setIsSubmitting(value)} + onDescriptionChange={handleDescriptionChange} + /> + + <PeekOverviewWebhookArtifacts + workspaceSlug={workspaceSlug} + projectId={projectId} + issueId={issueId} + onVideoModalOpenChange={handleWebhookVideoModalOpenChange} /> <div className="py-2"> @@ -231,6 +332,8 @@ export const IssueView: FC<IIssueView> = observer((props) => { issueId={issueId} disabled={disabled} issueServiceType={EIssueServiceType.ISSUES} + hideMediaLibraryButton + confirmManifestOnDelete /> </div> diff --git a/apps/web/core/components/issues/peek-overview/webhook-artifacts.tsx b/apps/web/core/components/issues/peek-overview/webhook-artifacts.tsx new file mode 100644 index 00000000000..5b38f6c2c4b --- /dev/null +++ b/apps/web/core/components/issues/peek-overview/webhook-artifacts.tsx @@ -0,0 +1,138 @@ +"use client"; + +import type { FC } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { Copy, ExternalLink } from "lucide-react"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { copyUrlToClipboard } from "@plane/utils"; + +import { useWebhookArtifactsData } from "./webhook-utils/use-webhook-artifacts-data"; +import { useWebhookDocumentPreview } from "./webhook-utils/use-webhook-document-preview"; +import { useWebhookVideoPlayer } from "./webhook-utils/use-webhook-video-player"; +import { WebhookArtifactModal } from "./webhook-utils/webhook-artifact-modal"; +import type { PeekOverviewWebhookArtifactsProps, TWebhookArtifact } from "./webhook-utils/webhook-artifacts-types"; + +export const PeekOverviewWebhookArtifacts: FC<PeekOverviewWebhookArtifactsProps> = observer((props) => { + const { workspaceSlug, projectId, issueId, onVideoModalOpenChange } = props; + const { artifacts } = useWebhookArtifactsData({ workspaceSlug, projectId, issueId }); + + const [activeArtifact, setActiveArtifact] = useState<TWebhookArtifact | null>(null); + const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null); + + const { + effectiveDocumentSrc, + isTextDocument, + isBinaryDocument, + isUnsupportedDocument, + isDocumentPreviewLoading, + documentPreviewError, + documentPreviewHtml, + sanitizedDocumentPreviewHtml, + documentPreviewUrl, + isTextPreviewLoading, + textPreviewError, + textPreview, + } = useWebhookDocumentPreview(activeArtifact); + + useWebhookVideoPlayer(activeArtifact, videoElement); + + const handleCopyPath = useCallback((value: string) => { + copyUrlToClipboard(value) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Path copied", + message: "Artifact path copied to clipboard.", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Copy failed", + message: "Unable to copy artifact path.", + }); + }); + }, []); + + const handleOpenArtifactModal = useCallback( + (artifact: TWebhookArtifact) => { + onVideoModalOpenChange?.(true); + setActiveArtifact(artifact); + }, + [onVideoModalOpenChange] + ); + + const handleCloseArtifactModal = useCallback(() => { + onVideoModalOpenChange?.(false); + setActiveArtifact(null); + }, [onVideoModalOpenChange]); + + useEffect( + () => () => { + onVideoModalOpenChange?.(false); + }, + [onVideoModalOpenChange] + ); + + if (artifacts.length === 0) return <></>; + + return ( + <div className="space-y-2"> + {artifacts.map((artifact) => ( + <div + key={artifact.id} + className="group rounded-lg border border-custom-border-200 bg-custom-background-90 px-3 py-2.5 transition-colors hover:border-custom-border-300" + > + <div className="mb-2 flex items-start justify-between gap-2"> + <div className="min-w-0"> + <p className="truncate text-sm font-medium text-custom-text-100">{artifact.title}</p> + <p className="text-[11px] uppercase tracking-wide text-custom-text-300"> + {artifact.mediaType} {artifact.format ? `• ${artifact.format}` : ""} + </p> + </div> + <div className="flex items-center gap-1"> + <button + type="button" + onClick={() => handleOpenArtifactModal(artifact)} + className="rounded p-1.5 text-custom-text-300 transition-colors hover:bg-custom-background-100 hover:text-custom-text-100" + title="Open preview" + > + <ExternalLink className="h-3.5 w-3.5" /> + </button> + <button + type="button" + onClick={() => handleCopyPath(artifact.path)} + className="rounded p-1.5 text-custom-text-300 transition-colors hover:bg-custom-background-100 hover:text-custom-text-100" + title="Copy path" + > + <Copy className="h-3.5 w-3.5" /> + </button> + </div> + </div> + <p className="break-all rounded-md bg-custom-background-100 px-2 py-1.5 text-xs leading-5 text-custom-text-300"> + {artifact.path} + </p> + </div> + ))} + + <WebhookArtifactModal + activeArtifact={activeArtifact} + onClose={handleCloseArtifactModal} + setVideoElement={setVideoElement} + effectiveDocumentSrc={effectiveDocumentSrc} + isTextDocument={isTextDocument} + isBinaryDocument={isBinaryDocument} + isUnsupportedDocument={isUnsupportedDocument} + isDocumentPreviewLoading={isDocumentPreviewLoading} + documentPreviewError={documentPreviewError} + documentPreviewHtml={documentPreviewHtml} + sanitizedDocumentPreviewHtml={sanitizedDocumentPreviewHtml} + documentPreviewUrl={documentPreviewUrl} + isTextPreviewLoading={isTextPreviewLoading} + textPreviewError={textPreviewError} + textPreview={textPreview} + /> + </div> + ); +}); diff --git a/apps/web/core/components/issues/peek-overview/webhook-utils/use-webhook-artifacts-data.ts b/apps/web/core/components/issues/peek-overview/webhook-utils/use-webhook-artifacts-data.ts new file mode 100644 index 00000000000..b46507c2f2a --- /dev/null +++ b/apps/web/core/components/issues/peek-overview/webhook-utils/use-webhook-artifacts-data.ts @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useState } from "react"; + +import { MediaLibraryService } from "@/services/media-library.service"; +import type { TMediaArtifact } from "@/services/media-library.service"; +import type { TWebhookArtifact } from "./webhook-artifacts-types"; +import { inferFormatFromPath, resolveManifestMeta, resolveOpenUrl, resolveWebhookArtifactType, toTimestamp } from "./webhook-artifacts-utils"; + +type TUseWebhookArtifactsDataProps = { + workspaceSlug: string; + projectId: string; + issueId: string; +}; + +export const useWebhookArtifactsData = ({ workspaceSlug, projectId, issueId }: TUseWebhookArtifactsDataProps) => { + const mediaLibraryService = useMemo(() => new MediaLibraryService(), []); + const [artifacts, setArtifacts] = useState<TWebhookArtifact[]>([]); + + useEffect(() => { + let isCancelled = false; + + const loadWebhookArtifacts = async () => { + if (!workspaceSlug || !projectId || !issueId) { + if (!isCancelled) setArtifacts([]); + return; + } + + if (!isCancelled) { + setArtifacts([]); + } + + try { + const manifest = await mediaLibraryService.ensureProjectLibrary(workspaceSlug, projectId); + const packageId = typeof manifest?.id === "string" ? manifest.id : ""; + if (!packageId) { + if (!isCancelled) setArtifacts([]); + return; + } + + const manifestArtifacts: TMediaArtifact[] = Array.isArray(manifest?.artifacts) ? manifest.artifacts : []; + const manifestMetadata = + manifest && typeof manifest === "object" && manifest.metadata && typeof manifest.metadata === "object" + ? (manifest.metadata as Record<string, Record<string, unknown>>) + : undefined; + + const nextArtifacts = manifestArtifacts + .filter((artifact) => { + const name = artifact.name?.trim() ?? ""; + if (!name) return false; + + const format = (artifact.format || "").toLowerCase(); + if (format === "thumbnail") return false; + + const meta = resolveManifestMeta(artifact, manifestMetadata); + const source = typeof meta.source === "string" ? meta.source.toLowerCase().trim() : ""; + if (source !== "webhook") return false; + + const artifactWorkItemId = artifact.work_item_id ?? ""; + const metaWorkItemId = typeof meta.work_item_id === "string" ? meta.work_item_id : ""; + if (artifactWorkItemId) return artifactWorkItemId === issueId; + if (metaWorkItemId) return metaWorkItemId === issueId; + return false; + }) + .sort( + (a, b) => + toTimestamp((b.updated_at as string) || (b.created_at as string)) - + toTimestamp((a.updated_at as string) || (a.created_at as string)) + ) + .map((artifact) => { + const name = artifact.name ?? ""; + const title = artifact.title?.trim() ? artifact.title : name || "Webhook asset"; + const action = artifact.action || ""; + const path = artifact.path || ""; + const openUrl = resolveOpenUrl(path, workspaceSlug, projectId, packageId, name); + const inferredFormat = inferFormatFromPath(path) || inferFormatFromPath(openUrl) || "file"; + const format = (artifact.format || inferredFormat || "file").toLowerCase(); + const mediaType = resolveWebhookArtifactType(format, action, path, openUrl); + + return { + id: name || `${title}-${openUrl}`, + title, + format, + action, + path: path || openUrl, + openUrl, + mediaType, + }; + }); + + if (!isCancelled) { + setArtifacts(nextArtifacts); + } + } catch { + if (!isCancelled) { + setArtifacts([]); + } + } + }; + + void loadWebhookArtifacts(); + + return () => { + isCancelled = true; + }; + }, [issueId, mediaLibraryService, projectId, workspaceSlug]); + + return { + artifacts, + }; +}; diff --git a/apps/web/core/components/issues/peek-overview/webhook-utils/use-webhook-document-preview.ts b/apps/web/core/components/issues/peek-overview/webhook-utils/use-webhook-document-preview.ts new file mode 100644 index 00000000000..5b37677ab86 --- /dev/null +++ b/apps/web/core/components/issues/peek-overview/webhook-utils/use-webhook-document-preview.ts @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useState } from "react"; +import DOMPurify from "dompurify"; +import { API_BASE_URL } from "@plane/constants"; + +import { resolveAttachmentDownloadUrl } from "@/components/issues/issue-detail-widgets/media-library-utils"; +import { useDocumentPreview } from "@/plane-web/features/media-library/hooks/media-detail-hooks"; +import { addInlineDisposition } from "@/plane-web/features/media-library/utils/media-detail-utils"; + +import type { TWebhookArtifact } from "./webhook-artifacts-types"; +import { inferFormatFromPath, shouldUseCredentialsForSource } from "./webhook-artifacts-utils"; +import { SPREADSHEET_FORMATS, SUPPORTED_DOCUMENT_FORMATS, TEXT_DOCUMENT_FORMATS } from "./webhook-artifacts-constants"; + +export const useWebhookDocumentPreview = (activeArtifact: TWebhookArtifact | null) => { + const [resolvedDocumentSrc, setResolvedDocumentSrc] = useState(""); + + const rawActiveDocumentSrc = activeArtifact?.mediaType === "document" ? activeArtifact.openUrl : ""; + + const activeDocumentFormat = useMemo(() => { + if (activeArtifact?.mediaType !== "document") return ""; + return (activeArtifact.format || inferFormatFromPath(activeArtifact.path) || inferFormatFromPath(activeArtifact.openUrl) || "") + .toLowerCase() + .trim(); + }, [activeArtifact]); + + const effectiveDocumentSrc = useMemo( + () => (activeArtifact?.mediaType === "document" ? resolvedDocumentSrc || rawActiveDocumentSrc : ""), + [activeArtifact?.mediaType, rawActiveDocumentSrc, resolvedDocumentSrc] + ); + + const isTextDocument = Boolean(activeArtifact?.mediaType === "document" && TEXT_DOCUMENT_FORMATS.has(activeDocumentFormat)); + const isDocx = Boolean(activeArtifact?.mediaType === "document" && activeDocumentFormat === "docx"); + const isSpreadsheet = Boolean(activeArtifact?.mediaType === "document" && SPREADSHEET_FORMATS.has(activeDocumentFormat)); + const isPptx = Boolean(activeArtifact?.mediaType === "document" && activeDocumentFormat === "pptx"); + const isBinaryDocument = Boolean(activeArtifact?.mediaType === "document" && !isTextDocument); + const isUnsupportedDocument = Boolean( + activeArtifact?.mediaType === "document" && !SUPPORTED_DOCUMENT_FORMATS.has(activeDocumentFormat) + ); + + useEffect(() => { + let isMounted = true; + + if (activeArtifact?.mediaType !== "document" || !rawActiveDocumentSrc) { + setResolvedDocumentSrc(""); + return () => { + isMounted = false; + }; + } + + const isAssetApiUrl = + Boolean(API_BASE_URL) && + typeof rawActiveDocumentSrc === "string" && + rawActiveDocumentSrc.startsWith(API_BASE_URL) && + rawActiveDocumentSrc.includes("/api/assets/v2/"); + + if (!isAssetApiUrl) { + setResolvedDocumentSrc(rawActiveDocumentSrc); + return () => { + isMounted = false; + }; + } + + setResolvedDocumentSrc(""); + const resolveUrl = async () => { + try { + const resolved = await resolveAttachmentDownloadUrl(addInlineDisposition(rawActiveDocumentSrc)); + if (isMounted) setResolvedDocumentSrc(resolved || rawActiveDocumentSrc); + } catch { + if (isMounted) setResolvedDocumentSrc(rawActiveDocumentSrc); + } + }; + + void resolveUrl(); + + return () => { + isMounted = false; + }; + }, [activeArtifact?.mediaType, rawActiveDocumentSrc]); + + const useDocumentCredentials = useMemo( + () => shouldUseCredentialsForSource(effectiveDocumentSrc), + [effectiveDocumentSrc] + ); + + const previewDocumentItem = useMemo( + () => (activeArtifact?.mediaType === "document" ? { mediaType: "document", title: activeArtifact.title } : null), + [activeArtifact] + ); + + const { + textPreview, + textPreviewError, + isTextPreviewLoading, + documentPreviewUrl, + documentPreviewHtml, + documentPreviewError, + isDocumentPreviewLoading, + } = useDocumentPreview({ + item: previewDocumentItem, + documentFormat: activeDocumentFormat, + effectiveDocumentSrc, + isTextDocument, + isBinaryDocument, + isUnsupportedDocument, + isDocx, + isSpreadsheet, + isPptx, + useDocumentCredentials, + }); + + const sanitizedDocumentPreviewHtml = useMemo( + () => (documentPreviewHtml ? DOMPurify.sanitize(documentPreviewHtml, { USE_PROFILES: { html: true } }) : ""), + [documentPreviewHtml] + ); + + return { + activeDocumentFormat, + effectiveDocumentSrc, + isTextDocument, + isBinaryDocument, + isUnsupportedDocument, + textPreview, + textPreviewError, + isTextPreviewLoading, + documentPreviewUrl, + documentPreviewHtml, + sanitizedDocumentPreviewHtml, + documentPreviewError, + isDocumentPreviewLoading, + }; +}; diff --git a/apps/web/core/components/issues/peek-overview/webhook-utils/use-webhook-video-player.ts b/apps/web/core/components/issues/peek-overview/webhook-utils/use-webhook-video-player.ts new file mode 100644 index 00000000000..1ceb843c3ff --- /dev/null +++ b/apps/web/core/components/issues/peek-overview/webhook-utils/use-webhook-video-player.ts @@ -0,0 +1,150 @@ +import { useEffect, useRef } from "react"; +import videojs from "video.js"; +// import "video.js/dist/video-js.css"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; + + +import { buildSourceCandidates } from "./webhook-artifacts-utils"; +import { TWebhookArtifact } from "./webhook-artifacts-types"; + +export const useWebhookVideoPlayer = (activeArtifact: TWebhookArtifact | null, videoElement: HTMLVideoElement | null) => { + const playerRef = useRef<ReturnType<typeof videojs> | null>(null); + + useEffect( + () => () => { + if (playerRef.current) { + playerRef.current.dispose(); + playerRef.current = null; + } + }, + [] + ); + + useEffect(() => { + if (activeArtifact?.mediaType !== "video" || !videoElement) { + if (playerRef.current) { + playerRef.current.dispose(); + playerRef.current = null; + } + return; + } + + const mountedVideoElement: HTMLVideoElement = videoElement; + const rawSource = activeArtifact.openUrl.trim(); + if (!rawSource) return; + + const normalizedFormat = activeArtifact.format.toLowerCase().trim(); + const normalizedAction = activeArtifact.action.toLowerCase().trim(); + const normalizedSource = rawSource.toLowerCase(); + const normalizedPath = activeArtifact.path.toLowerCase(); + const isHlsStream = + normalizedFormat === "m3u8" || + normalizedFormat === "stream" || + normalizedAction === "play_hls" || + normalizedAction === "play_streaming" || + normalizedAction === "stream" || + normalizedSource.includes(".m3u8") || + normalizedPath.includes(".m3u8"); + + const sourceCandidates = buildSourceCandidates(rawSource, isHlsStream, normalizedFormat); + if (sourceCandidates.length === 0) return; + + let candidateIndex = 0; + let isDisposed = false; + let sourceStartupTimer: ReturnType<typeof setTimeout> | null = null; + + function switchToCandidate(nextIndex: number) { + if (isDisposed || nextIndex < 0 || nextIndex >= sourceCandidates.length) return; + const nextCandidate = sourceCandidates[nextIndex]; + + if (playerRef.current) { + playerRef.current.off("error", handlePlayerError); + playerRef.current.dispose(); + playerRef.current = null; + } + + const player = videojs(mountedVideoElement, { + controls: true, + preload: "auto", + autoplay: false, + fluid: true, + responsive: true, + playsinline: true, + crossOrigin: nextCandidate.crossOrigin, + html5: { + vhs: { + withCredentials: nextCandidate.withCredentials, + overrideNative: true, + }, + }, + }); + + playerRef.current = player; + player.on("error", handlePlayerError); + player.one("loadeddata", () => { + if (sourceStartupTimer) { + clearTimeout(sourceStartupTimer); + sourceStartupTimer = null; + } + }); + + player.src(nextCandidate.type ? { src: nextCandidate.src, type: nextCandidate.type } : { src: nextCandidate.src }); + player.load(); + + const playAttempt = player.play(); + if (playAttempt && typeof playAttempt.catch === "function") { + void playAttempt.catch(() => { + // Ignore autoplay failures and let the user start playback manually. + }); + } + + if (sourceStartupTimer) { + clearTimeout(sourceStartupTimer); + } + sourceStartupTimer = setTimeout(() => { + if (isDisposed) return; + const currentPlayer = playerRef.current; + if (!currentPlayer) return; + const duration = currentPlayer.duration(); + const hasMetadata = typeof duration === "number" && Number.isFinite(duration) && duration > 0; + if (!hasMetadata) { + handlePlayerError(); + } + }, 8000); + } + + function handlePlayerError() { + if (sourceStartupTimer) { + clearTimeout(sourceStartupTimer); + sourceStartupTimer = null; + } + const nextIndex = candidateIndex + 1; + if (nextIndex >= sourceCandidates.length) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Unable to preview video", + message: "No compatible source was found for this artifact.", + }); + return; + } + + candidateIndex = nextIndex; + switchToCandidate(candidateIndex); + } + + switchToCandidate(candidateIndex); + + return () => { + isDisposed = true; + if (sourceStartupTimer) { + clearTimeout(sourceStartupTimer); + sourceStartupTimer = null; + } + if (playerRef.current) { + playerRef.current.off("error", handlePlayerError); + playerRef.current.dispose(); + playerRef.current = null; + } + }; + }, [activeArtifact, videoElement]); +}; diff --git a/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifact-modal.tsx b/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifact-modal.tsx new file mode 100644 index 00000000000..d2e13b6133c --- /dev/null +++ b/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifact-modal.tsx @@ -0,0 +1,193 @@ +import { Download, FileText, X } from "lucide-react"; +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; + +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { DOCUMENT_PREVIEW_STYLE } from "@/plane-web/features/media-library/utils/media-detail-utils"; + +import type { TWebhookArtifact } from "./webhook-artifacts-types"; +import { WEBHOOK_DOCUMENT_PREVIEW_HEIGHT_CLASS } from "./webhook-artifacts-constants"; + +type TWebhookArtifactModalProps = { + activeArtifact: TWebhookArtifact | null; + onClose: () => void; + setVideoElement: (element: HTMLVideoElement | null) => void; + effectiveDocumentSrc: string; + isTextDocument: boolean; + isBinaryDocument: boolean; + isUnsupportedDocument: boolean; + isDocumentPreviewLoading: boolean; + documentPreviewError: string | null; + documentPreviewHtml: string | null; + sanitizedDocumentPreviewHtml: string; + documentPreviewUrl: string | null; + isTextPreviewLoading: boolean; + textPreviewError: string | null; + textPreview: string | null; +}; + +export const WebhookArtifactModal = ({ + activeArtifact, + onClose, + setVideoElement, + effectiveDocumentSrc, + isTextDocument, + isBinaryDocument, + isUnsupportedDocument, + isDocumentPreviewLoading, + documentPreviewError, + documentPreviewHtml, + sanitizedDocumentPreviewHtml, + documentPreviewUrl, + isTextPreviewLoading, + textPreviewError, + textPreview, +}: TWebhookArtifactModalProps) => ( + <ModalCore + isOpen={Boolean(activeArtifact)} + handleClose={onClose} + position={EModalPosition.CENTER} + width={EModalWidth.XXXXL} + className="overflow-hidden p-0" + > + <div + data-prevent-outside-click + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + <div className="flex items-center justify-between border-b border-custom-border-200 px-4 py-3"> + <div className="min-w-0"> + <p className="truncate text-sm font-semibold text-custom-text-100">{activeArtifact?.title ?? "Artifact"}</p> + <p className="text-[11px] uppercase tracking-wide text-custom-text-300"> + {activeArtifact?.mediaType ?? "preview"} {activeArtifact?.format ? `• ${activeArtifact.format}` : ""} + </p> + </div> + <button + type="button" + onClick={onClose} + className="rounded p-1.5 text-custom-text-300 transition-colors hover:bg-custom-background-90 hover:text-custom-text-100" + title="Close" + > + <X className="h-4 w-4" /> + </button> + </div> + + <div className="space-y-3 p-4"> + {activeArtifact?.mediaType === "video" && ( + <div className="aspect-video w-full overflow-hidden rounded-md bg-black"> + <div data-vjs-player className="h-full w-full"> + <video ref={setVideoElement} className="video-js vjs-default-skin h-full w-full" playsInline preload="auto" /> + </div> + </div> + )} + + {activeArtifact?.mediaType === "image" && ( + <div className="flex min-h-[420px] max-h-[70vh] w-full items-center justify-center overflow-hidden rounded-md bg-black/80 p-2"> + <img + src={activeArtifact.openUrl} + alt={activeArtifact.title} + className="max-h-[68vh] w-auto max-w-full object-contain" + loading="lazy" + /> + </div> + )} + + {activeArtifact?.mediaType === "document" && ( + <div className="rounded-lg border border-custom-border-200 bg-custom-background-90"> + {isUnsupportedDocument ? ( + <div + className={`flex ${WEBHOOK_DOCUMENT_PREVIEW_HEIGHT_CLASS} items-center justify-center rounded-lg bg-custom-background-100 text-xs text-custom-text-300`} + > + Only PDF, DOCX, XLSX, CSV, and text files are supported. + </div> + ) : isBinaryDocument ? ( + <div className={`${WEBHOOK_DOCUMENT_PREVIEW_HEIGHT_CLASS} rounded-lg bg-custom-background-100`}> + {isDocumentPreviewLoading ? ( + <div className="flex h-full flex-col items-center justify-center gap-2 text-xs text-custom-text-300"> + <LogoSpinner /> + <span>Loading preview...</span> + </div> + ) : documentPreviewError ? ( + <div className="flex h-full items-center justify-center text-xs text-custom-text-300">{documentPreviewError}</div> + ) : documentPreviewHtml ? ( + <div className="h-full overflow-hidden rounded-lg bg-white"> + <iframe + title={`${activeArtifact.title}-preview`} + className="h-full w-full" + sandbox="" + srcDoc={`<!doctype html><html><head>${DOCUMENT_PREVIEW_STYLE}</head><body><div class=\"document-preview\">${sanitizedDocumentPreviewHtml}</div></body></html>`} + /> + </div> + ) : documentPreviewUrl ? ( + <iframe src={documentPreviewUrl} title={activeArtifact.title} className="h-full w-full rounded-lg bg-white" /> + ) : ( + <div className="flex h-full items-center justify-center text-xs text-custom-text-300"> + No preview available for this file. + </div> + )} + </div> + ) : isTextDocument ? ( + <div + className={`${WEBHOOK_DOCUMENT_PREVIEW_HEIGHT_CLASS} overflow-auto rounded-lg bg-custom-background-100 p-4 text-xs text-custom-text-100`} + > + {isTextPreviewLoading ? ( + <div className="flex flex-col items-center gap-2 text-custom-text-300"> + <LogoSpinner /> + <span>Loading preview...</span> + </div> + ) : textPreviewError ? ( + <div className="text-custom-text-300">{textPreviewError}</div> + ) : ( + <pre className="whitespace-pre-wrap break-words">{textPreview}</pre> + )} + </div> + ) : effectiveDocumentSrc ? ( + <iframe + src={effectiveDocumentSrc} + title={activeArtifact.title} + className={`${WEBHOOK_DOCUMENT_PREVIEW_HEIGHT_CLASS} w-full rounded-lg bg-white`} + /> + ) : ( + <div + className={`flex ${WEBHOOK_DOCUMENT_PREVIEW_HEIGHT_CLASS} flex-col items-center justify-center gap-3 rounded-lg text-custom-text-300`} + > + <div className="flex flex-col items-center gap-2 text-sm"> + <FileText className="h-8 w-8" /> + <span>No preview available for this file.</span> + </div> + </div> + )} + {effectiveDocumentSrc && !isUnsupportedDocument ? ( + <div className="flex justify-end border-t border-custom-border-200 p-3"> + <a + href={effectiveDocumentSrc} + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-3 rounded-md bg-custom-primary-100 px-2 py-1 text-sm font-medium text-custom-100" + > + <span className="flex h-6 w-6 items-center justify-center"> + <Download className="h-4 w-4" /> + </span> + Download + </a> + </div> + ) : null} + </div> + )} + + <div className="flex items-center justify-between gap-2"> + <p className="break-all rounded-md bg-custom-background-90 px-2 py-1.5 text-xs leading-5 text-custom-text-300"> + {activeArtifact?.path} + </p> + <a + href={activeArtifact?.openUrl} + target="_blank" + rel="noreferrer" + className="shrink-0 rounded-md border border-custom-border-200 px-2 py-1.5 text-xs text-custom-text-200 hover:text-custom-text-100" + > + Open file + </a> + </div> + </div> + </div> + </ModalCore> +); diff --git a/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifacts-constants.ts b/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifacts-constants.ts new file mode 100644 index 00000000000..06bd166372b --- /dev/null +++ b/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifacts-constants.ts @@ -0,0 +1,51 @@ +export const VIDEO_ARTIFACT_FORMATS = new Set([ + "mov", + "webm", + "avi", + "mkv", + "mpeg", + "mpg", + "m4v", + "mp4", + "m3u8", + "stream", +]); + +export const IMAGE_ARTIFACT_FORMATS = new Set([ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "bmp", + "svg", + "avif", + "heic", + "heif", + "tif", + "tiff", +]); + +export const VIDEO_ARTIFACT_ACTIONS = new Set(["play", "stream", "play_hls", "play_streaming", "open_mp4"]); +export const IMAGE_ARTIFACT_ACTIONS = new Set(["open_image", "view_image"]); + +export const HLS_MIME_TYPES = ["application/x-mpegURL", "application/vnd.apple.mpegurl"] as const; + +export const TEXT_DOCUMENT_FORMATS = new Set(["txt", "json", "md", "log", "yaml", "yml", "xml"]); +export const SPREADSHEET_FORMATS = new Set(["xlsx", "xls", "csv"]); +export const SUPPORTED_DOCUMENT_FORMATS = new Set([ + "pdf", + "docx", + "xlsx", + "xls", + "csv", + "txt", + "json", + "md", + "log", + "yaml", + "yml", + "xml", +]); + +export const WEBHOOK_DOCUMENT_PREVIEW_HEIGHT_CLASS = "h-[220px] sm:h-[320px] md:h-[420px] lg:h-[505px]"; diff --git a/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifacts-types.ts b/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifacts-types.ts new file mode 100644 index 00000000000..82028619406 --- /dev/null +++ b/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifacts-types.ts @@ -0,0 +1,25 @@ +export type TWebhookArtifactMediaType = "video" | "image" | "document"; + +export type TWebhookArtifact = { + id: string; + title: string; + format: string; + action: string; + path: string; + openUrl: string; + mediaType: TWebhookArtifactMediaType; +}; + +export type PeekOverviewWebhookArtifactsProps = { + workspaceSlug: string; + projectId: string; + issueId: string; + onVideoModalOpenChange?: (isOpen: boolean) => void; +}; + +export type TVideoSourceCandidate = { + src: string; + type?: string; + withCredentials: boolean; + crossOrigin: "anonymous" | "use-credentials"; +}; diff --git a/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifacts-utils.ts b/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifacts-utils.ts new file mode 100644 index 00000000000..99219918096 --- /dev/null +++ b/apps/web/core/components/issues/peek-overview/webhook-utils/webhook-artifacts-utils.ts @@ -0,0 +1,229 @@ +import { API_BASE_URL } from "@plane/constants"; + +import type { TMediaArtifact } from "@/services/media-library.service"; +import { + HLS_MIME_TYPES, + IMAGE_ARTIFACT_ACTIONS, + IMAGE_ARTIFACT_FORMATS, + VIDEO_ARTIFACT_ACTIONS, + VIDEO_ARTIFACT_FORMATS, +} from "./webhook-artifacts-constants"; +import type { TWebhookArtifactMediaType, TVideoSourceCandidate } from "./webhook-artifacts-types"; + +export const inferFormatFromPath = (value: string) => { + const normalized = value.trim().toLowerCase(); + if (!normalized) return ""; + const withoutQuery = normalized.split("?")[0].split("#")[0]; + const fileName = withoutQuery.split("/").pop() ?? ""; + const dotIndex = fileName.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === fileName.length - 1) return ""; + return fileName.slice(dotIndex + 1); +}; + +export const resolveWebhookArtifactType = ( + format: string, + action: string, + path: string, + openUrl: string +): TWebhookArtifactMediaType => { + const normalizedFormat = format.toLowerCase().trim(); + const normalizedAction = action.toLowerCase().trim(); + const inferredFormat = inferFormatFromPath(path) || inferFormatFromPath(openUrl); + + if ( + VIDEO_ARTIFACT_FORMATS.has(normalizedFormat) || + VIDEO_ARTIFACT_ACTIONS.has(normalizedAction) || + inferredFormat === "m3u8" || + openUrl.toLowerCase().includes(".m3u8") + ) { + return "video"; + } + + if ( + IMAGE_ARTIFACT_FORMATS.has(normalizedFormat) || + IMAGE_ARTIFACT_ACTIONS.has(normalizedAction) || + IMAGE_ARTIFACT_FORMATS.has(inferredFormat) + ) { + return "image"; + } + + return "document"; +}; + +export const getVideoMimeType = (format: string) => { + const normalized = format.toLowerCase(); + if (normalized === "mp4" || normalized === "m4v") return "video/mp4"; + if (normalized === "m3u8" || normalized === "stream") return "application/x-mpegURL"; + if (normalized === "mov") return "video/quicktime"; + if (normalized === "webm") return "video/webm"; + if (normalized === "avi") return "video/x-msvideo"; + if (normalized === "mkv") return "video/x-matroska"; + if (normalized === "mpeg" || normalized === "mpg") return "video/mpeg"; + return ""; +}; + +export const getCredentialModeForSource = (source: string) => { + if (typeof window === "undefined") { + return { + withCredentials: true, + crossOrigin: "use-credentials" as const, + }; + } + + if (!source || source.startsWith("/")) { + return { + withCredentials: true, + crossOrigin: "use-credentials" as const, + }; + } + + try { + const parsed = new URL(source, window.location.origin); + const isSameOrigin = parsed.origin === window.location.origin; + return { + withCredentials: isSameOrigin, + crossOrigin: isSameOrigin ? ("use-credentials" as const) : ("anonymous" as const), + }; + } catch { + return { + withCredentials: true, + crossOrigin: "use-credentials" as const, + }; + } +}; + +export const shouldUseCredentialsForSource = (source: string) => { + if (typeof window === "undefined") return true; + if (!source || source.startsWith("/")) return true; + if (!/^https?:\/\//i.test(source)) return true; + + const credentialOrigins = new Set<string>([window.location.origin]); + if (API_BASE_URL) { + try { + credentialOrigins.add(new URL(API_BASE_URL).origin); + } catch { + // ignore invalid API base URL + } + } + + try { + const parsed = new URL(source, window.location.origin); + return credentialOrigins.has(parsed.origin); + } catch { + return true; + } +}; + +const dedupeSourceCandidates = (candidates: TVideoSourceCandidate[]) => { + const seen = new Set<string>(); + return candidates.filter((candidate) => { + const key = `${candidate.src}|${candidate.type ?? ""}|${candidate.withCredentials ? "1" : "0"}|${candidate.crossOrigin}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +export const buildSourceCandidates = (rawSource: string, isHlsStream: boolean, format: string): TVideoSourceCandidate[] => { + const trimmed = rawSource.trim(); + if (!trimmed) return []; + + let directSource = trimmed; + let proxiedSource = ""; + let isMixedContentBlocked = false; + + if (typeof window !== "undefined" && !trimmed.startsWith("/")) { + try { + const parsed = new URL(trimmed, window.location.origin); + const absolute = parsed.toString(); + const isCrossOrigin = parsed.origin !== window.location.origin; + isMixedContentBlocked = window.location.protocol === "https:" && parsed.protocol === "http:"; + + directSource = absolute; + if (isHlsStream && isCrossOrigin) { + proxiedSource = `/api/hls?url=${encodeURIComponent(absolute)}`; + if (isMixedContentBlocked) { + directSource = ""; + } + } + } catch { + directSource = trimmed; + proxiedSource = ""; + } + } + + const candidates: TVideoSourceCandidate[] = []; + const appendSource = (source: string, type?: string) => { + if (!source) return; + const { withCredentials, crossOrigin } = getCredentialModeForSource(source); + candidates.push({ + src: source, + type, + withCredentials, + crossOrigin, + }); + }; + + if (isHlsStream) { + if (!isMixedContentBlocked && directSource) { + HLS_MIME_TYPES.forEach((type) => appendSource(directSource, type)); + } + if (proxiedSource) { + HLS_MIME_TYPES.forEach((type) => appendSource(proxiedSource, type)); + } + if (isMixedContentBlocked && directSource) { + HLS_MIME_TYPES.forEach((type) => appendSource(directSource, type)); + } + } else { + const type = getVideoMimeType(format) || undefined; + appendSource(directSource, type); + } + + return dedupeSourceCandidates(candidates); +}; + +export const resolveManifestMeta = ( + artifact: TMediaArtifact, + metadata: Record<string, Record<string, unknown>> | undefined +): Record<string, unknown> => { + const direct = artifact.meta; + if (direct && typeof direct === "object" && !Array.isArray(direct)) { + return direct as Record<string, unknown>; + } + const metadataRef = artifact.metadata_ref || artifact.name; + if (!metadataRef || !metadata || typeof metadata !== "object") return {}; + const resolved = metadata[metadataRef]; + if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) { + return resolved; + } + return {}; +}; + +export const toTimestamp = (value: string | undefined) => { + if (!value) return 0; + const ts = Date.parse(value); + return Number.isNaN(ts) ? 0 : ts; +}; + +export const resolveOpenUrl = ( + path: string, + workspaceSlug: string, + projectId: string, + packageId: string, + artifactId: string +) => { + const trimmed = path.trim(); + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed; + + const endpoint = `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/artifacts/${encodeURIComponent( + artifactId + )}/file/`; + + if (typeof window === "undefined") return endpoint; + + try { + return new URL(endpoint, window.location.origin).toString(); + } catch { + return endpoint; + } +}; diff --git a/apps/web/core/components/modules/archived-modules/modal.tsx b/apps/web/core/components/modules/archived-modules/modal.tsx index 340b44979cf..2b1eeab0881 100644 --- a/apps/web/core/components/modules/archived-modules/modal.tsx +++ b/apps/web/core/components/modules/archived-modules/modal.tsx @@ -41,7 +41,7 @@ export const ArchiveModuleModal: React.FC<Props> = (props) => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Archive success", - message: "Your archives can be found in project archives.", + message: "Your archives can be found in program archives.", }); onClose(); router.push(`/${workspaceSlug}/projects/${projectId}/modules`); diff --git a/apps/web/core/components/modules/quick-actions.tsx b/apps/web/core/components/modules/quick-actions.tsx index cc7c470a5db..daf4c9c1ef5 100644 --- a/apps/web/core/components/modules/quick-actions.tsx +++ b/apps/web/core/components/modules/quick-actions.tsx @@ -87,7 +87,7 @@ export const ModuleQuickActions: React.FC<Props> = observer((props) => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Restore success", - message: "Your module can be found in project modules.", + message: "Your module can be found in program modules.", }); captureSuccess({ eventName: MODULE_TRACKER_EVENTS.restore, diff --git a/apps/web/core/components/onboarding/profile-setup.tsx b/apps/web/core/components/onboarding/profile-setup.tsx index 1dc8c716c3c..83cf4ce63a1 100644 --- a/apps/web/core/components/onboarding/profile-setup.tsx +++ b/apps/web/core/components/onboarding/profile-setup.tsx @@ -72,7 +72,7 @@ const USER_DOMAIN = [ "Legal", "Finance", "Human Resources", - "Project", + "Program", "Other", ]; diff --git a/apps/web/core/components/onboarding/tour/root.tsx b/apps/web/core/components/onboarding/tour/root.tsx index ffa3eefe381..b26fc45f39c 100644 --- a/apps/web/core/components/onboarding/tour/root.tsx +++ b/apps/web/core/components/onboarding/tour/root.tsx @@ -57,7 +57,7 @@ const TOUR_STEPS: { { key: "modules", title: "Break into modules", - description: "Modules break your big thing into Projects or Features, to help you organize better.", + description: "Modules break your big thing into Programs or Features, to help you organize better.", image: ModulesTour, prevStep: "cycles", nextStep: "views", diff --git a/apps/web/core/components/profile/form.tsx b/apps/web/core/components/profile/form.tsx index e6c041d3aa0..18cfba6a19d 100644 --- a/apps/web/core/components/profile/form.tsx +++ b/apps/web/core/components/profile/form.tsx @@ -66,7 +66,7 @@ export const ProfileForm = observer((props: TProfileFormProps) => { last_name: user.last_name || "", display_name: user.display_name || "", email: user.email || "", - role: profile.role || "Product / Project Manager", + role: profile.role || "Product / Program Manager", language: profile.language || "en", user_timezone: user.user_timezone || "Asia/Kolkata", }, diff --git a/apps/web/core/components/project/card-list.tsx b/apps/web/core/components/project/card-list.tsx index ba8f7a6bb58..156e24ff23c 100644 --- a/apps/web/core/components/project/card-list.tsx +++ b/apps/web/core/components/project/card-list.tsx @@ -90,8 +90,10 @@ export const ProjectCardList = observer((props: TProjectCardListProps) => { <div className="text-center"> <Image src={searchQuery.trim() === "" ? resolvedFiltersImage : resolvedNameFilterImage} + width={192} + height={192} className="mx-auto h-36 w-36 sm:h-48 sm:w-48" - alt="No matching projects" + alt="No matching programs" /> <h5 className="mb-1 mt-7 text-xl font-medium">{t("workspace_projects.empty_state.filter.title")}</h5> <p className="whitespace-pre-line text-base text-custom-text-400"> diff --git a/apps/web/core/components/project/card.tsx b/apps/web/core/components/project/card.tsx index d4ba496f0c9..039e071c5ff 100644 --- a/apps/web/core/components/project/card.tsx +++ b/apps/web/core/components/project/card.tsx @@ -72,10 +72,10 @@ export const ProjectCard: React.FC<Props> = observer((props) => { const addToFavoritePromise = addProjectToFavorites(workspaceSlug.toString(), project.id); setPromiseToast(addToFavoritePromise, { - loading: "Adding project to favorites...", + loading: "Adding program to favorites...", success: { title: "Success!", - message: () => "Project added to favorites.", + message: () => "Program added to favorites.", actionItems: () => { if (!isFavoriteMenuOpen) toggleFavoriteMenu(true); return <></>; @@ -83,7 +83,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => { }, error: { title: "Error!", - message: () => "Couldn't add the project to favorites. Please try again.", + message: () => "Couldn't add the program to favorites. Please try again.", }, }); }; @@ -93,14 +93,14 @@ export const ProjectCard: React.FC<Props> = observer((props) => { const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id); setPromiseToast(removeFromFavoritePromise, { - loading: "Removing project from favorites...", + loading: "Removing program from favorites...", success: { title: "Success!", - message: () => "Project removed from favorites.", + message: () => "Program removed from favorites.", }, error: { title: "Error!", - message: () => "Couldn't remove the project from favorites. Please try again.", + message: () => "Couldn't remove the program from favorites. Please try again.", }, }); }; @@ -111,7 +111,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => { setToast({ type: TOAST_TYPE.INFO, title: "Link Copied!", - message: "Project link copied to clipboard.", + message: "Program link copied to clipboard.", }) ); const handleOpenInNewTab = () => window.open(`/${projectLink}`, "_blank"); diff --git a/apps/web/core/components/project/confirm-project-member-remove.tsx b/apps/web/core/components/project/confirm-project-member-remove.tsx index 34443c28d42..fb0c7fe4d88 100644 --- a/apps/web/core/components/project/confirm-project-member-remove.tsx +++ b/apps/web/core/components/project/confirm-project-member-remove.tsx @@ -83,7 +83,7 @@ export const ConfirmProjectMemberRemove: React.FC<Props> = observer((props) => { </div> <div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100"> - {isCurrentUser ? "Leave project?" : `Remove ${data?.display_name}?`} + {isCurrentUser ? "Leave program?" : `Remove ${data?.display_name}?`} </Dialog.Title> <div className="mt-2"> <p className="text-sm text-custom-text-200"> diff --git a/apps/web/core/components/project/delete-project-modal.tsx b/apps/web/core/components/project/delete-project-modal.tsx index 15b02a2b2cb..ca271d945f7 100644 --- a/apps/web/core/components/project/delete-project-modal.tsx +++ b/apps/web/core/components/project/delete-project-modal.tsx @@ -45,7 +45,7 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => { watch, } = useForm({ defaultValues }); - const canDelete = watch("projectName") === project?.name && watch("confirmDelete") === "delete my project"; + const canDelete = watch("projectName") === project?.name && watch("confirmDelete") === "delete my program"; const handleClose = () => { const timer = setTimeout(() => { @@ -73,7 +73,7 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", - message: "Project deleted successfully.", + message: "Program deleted successfully.", }); }) .catch(() => { @@ -124,19 +124,19 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => { <AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" /> </span> <span className="flex items-center justify-start"> - <h3 className="text-xl font-medium 2xl:text-2xl">Delete project</h3> + <h3 className="text-xl font-medium 2xl:text-2xl">Delete program</h3> </span> </div> <span> <p className="text-sm leading-7 text-custom-text-200"> - Are you sure you want to delete project{" "} + Are you sure you want to delete program{" "} <span className="break-words font-semibold">{project?.name}</span>? All of the data related to the - project will be permanently removed. This action cannot be undone + program will be permanently removed. This action cannot be undone </p> </span> <div className="text-custom-text-200"> <p className="break-words text-sm "> - Enter the project name <span className="font-medium text-custom-text-100">{project?.name}</span>{" "} + Enter the program name <span className="font-medium text-custom-text-100">{project?.name}</span>{" "} to continue: </p> <Controller @@ -151,7 +151,7 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => { onChange={onChange} ref={ref} hasError={Boolean(errors.projectName)} - placeholder="Project name" + placeholder="Program name" className="mt-2 w-full" autoComplete="off" /> @@ -160,7 +160,7 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => { </div> <div className="text-custom-text-200"> <p className="text-sm"> - To confirm, type <span className="font-medium text-custom-text-100">delete my project</span>{" "} + To confirm, type <span className="font-medium text-custom-text-100">delete my program</span>{" "} below: </p> <Controller @@ -175,7 +175,7 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => { onChange={onChange} ref={ref} hasError={Boolean(errors.confirmDelete)} - placeholder="Enter 'delete my project'" + placeholder="Enter 'delete my program'" className="mt-2 w-full" autoComplete="off" /> @@ -187,7 +187,7 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => { Cancel </Button> <Button variant="danger" size="sm" type="submit" disabled={!canDelete} loading={isSubmitting}> - {isSubmitting ? "Deleting" : "Delete project"} + {isSubmitting ? "Deleting" : "Delete program"} </Button> </div> </form> diff --git a/apps/web/core/components/project/dropdowns/filters/root.tsx b/apps/web/core/components/project/dropdowns/filters/root.tsx index e304a157ccc..d4e78d99031 100644 --- a/apps/web/core/components/project/dropdowns/filters/root.tsx +++ b/apps/web/core/components/project/dropdowns/filters/root.tsx @@ -57,7 +57,7 @@ export const ProjectFiltersSelection: React.FC<Props> = observer((props) => { my_projects: !displayFilters.my_projects, }) } - title="My projects" + title="My programs" /> </div> diff --git a/apps/web/core/components/project/form-loader.tsx b/apps/web/core/components/project/form-loader.tsx index 433a5beef00..7a312b9c57b 100644 --- a/apps/web/core/components/project/form-loader.tsx +++ b/apps/web/core/components/project/form-loader.tsx @@ -27,7 +27,7 @@ export const ProjectDetailsFormLoader: FC = () => ( </div> <div className="my-8 flex flex-col gap-8"> <div className="flex flex-col gap-1"> - <h4 className="text-sm">Project name</h4> + <h4 className="text-sm">Program name</h4> <Loader> <Loader.Item height="46px" width="100%" /> </Loader> diff --git a/apps/web/core/components/project/form.tsx b/apps/web/core/components/project/form.tsx index 1af37a63639..225c0af3e99 100644 --- a/apps/web/core/components/project/form.tsx +++ b/apps/web/core/components/project/form.tsx @@ -16,6 +16,7 @@ import { CustomSelect, Input, TextArea, EmojiIconPickerTypes } from "@plane/ui"; import { renderFormattedDate, getFileURL } from "@plane/utils"; // components import { Logo } from "@/components/common/logo"; +import SportDropdown from "@/components/dropdowns/sport-property"; import { ImagePickerPopover } from "@/components/core/image-picker-popover"; import { TimezoneSelect } from "@/components/global"; // helpers @@ -64,6 +65,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => { // derived values const currentNetwork = NETWORK_CHOICES.find((n) => n.key === project?.network); const coverImage = watch("cover_image_url"); + const isSportLocked = !!project?.sport?.trim(); useEffect(() => { if (project && projectId !== getValues("id")) { @@ -157,6 +159,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => { network: formData.network, identifier: formData.identifier, description: formData.description, + sport: formData.sport ?? null, logo_props: formData.logo_props, timezone: formData.timezone, @@ -187,9 +190,9 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => { <img src={getFileURL( coverImage ?? - "https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80" + "https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80" )} - alt="Project cover image" + alt="Program cover image" className="h-44 w-full rounded-md object-cover" /> <div className="z-5 absolute bottom-4 flex w-full items-end justify-between gap-3 px-4"> @@ -271,7 +274,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => { required: t("name_is_required"), maxLength: { value: 255, - message: "Project name should be less than 255 characters", + message: "Program name should be less than 255 characters", }, }} render={({ field: { value, onChange, ref } }) => ( @@ -312,7 +315,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => { </div> <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="flex flex-col gap-1"> - <h4 className="text-sm">Project ID</h4> + <h4 className="text-sm">Program ID</h4> <div className="relative"> <Controller control={control} @@ -346,7 +349,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => { /> <Tooltip isMobile={isMobile} - tooltipContent="Helps you identify work items in the project uniquely. Max 5 characters." + tooltipContent="Helps you identify work items in the program uniquely. Max 5 characters." className="text-sm" position="right-start" > @@ -383,7 +386,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => { buttonClassName="!border-custom-border-200 !shadow-none font-medium rounded-md" input disabled={!isAdmin} - // optionsClassName="w-full" + // optionsClassName="w-full" > {NETWORK_CHOICES.map((network) => ( <CustomSelect.Option key={network.key} value={network.key}> @@ -401,6 +404,27 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => { }} /> </div> + <div className="flex flex-col gap-1"> + <h4 className="text-sm">Sport</h4> + <Controller + name="sport" + control={control} + render={({ field: { value, onChange } }) => ( + <> + <SportDropdown + value={value ?? null} + onChange={onChange} + placeholder={t("add_sport")} + buttonVariant="border-with-text" + className="w-full" + buttonContainerClassName="w-full text-left" + buttonClassName="w-full rounded-md border border-custom-border-200 px-3 text-sm" + disabled={!isAdmin || isSportLocked} + /> + </> + )} + /> + </div> <div className="flex flex-col gap-1 col-span-1 sm:col-span-2 xl:col-span-1"> <h4 className="text-sm">{t("common.project_timezone")}</h4> <Controller diff --git a/apps/web/core/components/project/integration-card.tsx b/apps/web/core/components/project/integration-card.tsx index 87e3519a5cd..4e7c8b100bd 100644 --- a/apps/web/core/components/project/integration-card.tsx +++ b/apps/web/core/components/project/integration-card.tsx @@ -76,7 +76,7 @@ export const IntegrationCard: React.FC<Props> = ({ integration }) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Repository could not be synced with the project. Please try again.", + message: "Repository could not be synced with the program. Please try again.", }); }); }; diff --git a/apps/web/core/components/project/join-project-modal.tsx b/apps/web/core/components/project/join-project-modal.tsx index d4ee8ae1008..aed15dc8c0d 100644 --- a/apps/web/core/components/project/join-project-modal.tsx +++ b/apps/web/core/components/project/join-project-modal.tsx @@ -94,7 +94,7 @@ export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => { onClick={handleJoin} loading={isJoiningLoading} > - {isJoiningLoading ? "Joining..." : "Join Project"} + {isJoiningLoading ? "Joining..." : "Join Program"} </Button> </div> </Dialog.Panel> diff --git a/apps/web/core/components/project/leave-project-modal.tsx b/apps/web/core/components/project/leave-project-modal.tsx index 436927a431f..b69db411735 100644 --- a/apps/web/core/components/project/leave-project-modal.tsx +++ b/apps/web/core/components/project/leave-project-modal.tsx @@ -62,7 +62,7 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => { if (data) { if (data.projectName === project?.name) { - if (data.confirmLeave === "Leave Project") { + if (data.confirmLeave === "Leave Program") { router.push(`/${workspaceSlug}/projects`); return leaveProject(workspaceSlug.toString(), project.id) .then(() => { @@ -92,14 +92,14 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Please confirm leaving the project by typing the 'Leave Project'.", + message: "Please confirm leaving the program by typing the 'Leave Program'.", }); } } else { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Please enter the project name as shown in the description.", + message: "Please enter the program name as shown in the description.", }); } } else { @@ -144,13 +144,13 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => { <AlertTriangleIcon className="h-6 w-6 text-red-600" aria-hidden="true" /> </span> <span className="flex items-center justify-start"> - <h3 className="text-xl font-medium 2xl:text-2xl">Leave Project</h3> + <h3 className="text-xl font-medium 2xl:text-2xl">Leave Program</h3> </span> </div> <span> <p className="text-sm leading-7 text-custom-text-200"> - Are you sure you want to leave the project - + Are you sure you want to leave the program - <span className="font-medium text-custom-text-100">{` "${project?.name}" `}</span>? All of the work items associated with you will become inaccessible. </p> @@ -158,7 +158,7 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => { <div className="text-custom-text-200"> <p className="break-words text-sm "> - Enter the project name <span className="font-medium text-custom-text-100">{project?.name}</span>{" "} + Enter the program name <span className="font-medium text-custom-text-100">{project?.name}</span>{" "} to continue: </p> <Controller @@ -176,7 +176,7 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => { onChange={onChange} ref={ref} hasError={Boolean(errors.projectName)} - placeholder="Enter project name" + placeholder="Enter program name" className="mt-2 w-full" /> )} @@ -185,7 +185,7 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => { <div className="text-custom-text-200"> <p className="text-sm"> - To confirm, type <span className="font-medium text-custom-text-100">Leave Project</span> below: + To confirm, type <span className="font-medium text-custom-text-100">Leave Program</span> below: </p> <Controller control={control} @@ -199,7 +199,7 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => { onChange={onChange} ref={ref} hasError={Boolean(errors.confirmLeave)} - placeholder="Enter 'leave project'" + placeholder="Enter 'leave program'" className="mt-2 w-full" /> )} @@ -210,7 +210,7 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => { Cancel </Button> <Button variant="danger" size="sm" type="submit" loading={isSubmitting}> - {isSubmitting ? "Leaving..." : "Leave Project"} + {isSubmitting ? "Leaving..." : "Leave Program"} </Button> </div> </form> diff --git a/apps/web/core/components/project/member-list-item.tsx b/apps/web/core/components/project/member-list-item.tsx index 2d3a2351607..273c7bd22d0 100644 --- a/apps/web/core/components/project/member-list-item.tsx +++ b/apps/web/core/components/project/member-list-item.tsx @@ -64,7 +64,7 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => { }); setToast({ type: TOAST_TYPE.ERROR, - title: "You can’t leave this project yet.", + title: "You can’t leave this program yet.", message: err?.error || "Something went wrong. Please try again.", }); }); @@ -72,7 +72,7 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => { await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) => setToast({ type: TOAST_TYPE.ERROR, - title: "You can't remove the member from this project yet.", + title: "You can't remove the member from this program yet.", message: err?.error || "Something went wrong. Please try again.", }) ); diff --git a/apps/web/core/components/project/multi-select-modal.tsx b/apps/web/core/components/project/multi-select-modal.tsx index 5a0e7b85b17..0d83ab2cc59 100644 --- a/apps/web/core/components/project/multi-select-modal.tsx +++ b/apps/web/core/components/project/multi-select-modal.tsx @@ -85,7 +85,7 @@ export const ProjectMultiSelectModal: React.FC<Props> = observer((props) => { <Search className="flex-shrink-0 size-4 text-custom-text-400" aria-hidden="true" /> <Combobox.Input className="h-12 w-full border-0 bg-transparent text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0" - placeholder="Search for projects" + placeholder="Search for programs" displayValue={() => ""} value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} diff --git a/apps/web/core/components/project/project-settings-member-defaults.tsx b/apps/web/core/components/project/project-settings-member-defaults.tsx index f20b1f16ce5..bf08c785572 100644 --- a/apps/web/core/components/project/project-settings-member-defaults.tsx +++ b/apps/web/core/components/project/project-settings-member-defaults.tsx @@ -131,7 +131,7 @@ export const ProjectSettingsMemberDefaults: React.FC<TProjectSettingsMemberDefau return ( <div className="flex flex-col gap-y-6 my-6"> - <DefaultSettingItem title="Project Lead" description="Select the project lead for the project."> + <DefaultSettingItem title="Program Lead" description="Select the program lead for the program."> {currentProjectDetails ? ( <Controller control={control} @@ -152,7 +152,7 @@ export const ProjectSettingsMemberDefaults: React.FC<TProjectSettingsMemberDefau </Loader> )} </DefaultSettingItem> - <DefaultSettingItem title="Default Assignee" description="Select the default assignee for the project."> + <DefaultSettingItem title="Default Assignee" description="Select the default assignee for the program."> {currentProjectDetails ? ( <Controller control={control} @@ -176,7 +176,7 @@ export const ProjectSettingsMemberDefaults: React.FC<TProjectSettingsMemberDefau {currentProjectDetails && ( <DefaultSettingItem title="Guest access" - description="This will allow guests to have view access to all the project work items." + description="This will allow guests to have view access to all the program work items." > <div className="flex items-center justify-end"> <ToggleSwitch diff --git a/apps/web/core/components/project/publish-project/modal.tsx b/apps/web/core/components/project/publish-project/modal.tsx index a69a419f25d..fc14617a491 100644 --- a/apps/web/core/components/project/publish-project/modal.tsx +++ b/apps/web/core/components/project/publish-project/modal.tsx @@ -114,7 +114,7 @@ export const PublishProjectModal: React.FC<Props> = observer((props) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Something went wrong while unpublishing the project.", + message: "Something went wrong while unpublishing the program.", }) ) .finally(() => setIsUnPublishing(false)); @@ -132,7 +132,7 @@ export const PublishProjectModal: React.FC<Props> = observer((props) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Please select at least one view layout to publish the project.", + message: "Please select at least one view layout to publish the program.", }); return; } @@ -175,7 +175,7 @@ export const PublishProjectModal: React.FC<Props> = observer((props) => { <ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.XXL}> <form onSubmit={handleSubmit(handleFormSubmit)}> <div className="flex items-center justify-between gap-2 p-5"> - <h5 className="text-xl font-medium text-custom-text-200">Publish project</h5> + <h5 className="text-xl font-medium text-custom-text-200">Publish program</h5> {isProjectPublished && ( <Button variant="danger" onClick={() => handleUnPublishProject(watch("id") ?? "")} loading={isUnPublishing}> {isUnPublishing ? "Unpublishing" : "Unpublish"} @@ -227,7 +227,7 @@ export const PublishProjectModal: React.FC<Props> = observer((props) => { <span className="animate-ping absolute inline-flex size-full rounded-full bg-custom-primary-100 opacity-75" /> <span className="relative inline-flex rounded-full size-1.5 bg-custom-primary-100" /> </span> - This project is now live on web + This program is now live on web </p> </> )} diff --git a/apps/web/core/components/project/settings/archive-project/archive-restore-modal.tsx b/apps/web/core/components/project/settings/archive-project/archive-restore-modal.tsx index 1bac0814590..13bcba6b14c 100644 --- a/apps/web/core/components/project/settings/archive-project/archive-restore-modal.tsx +++ b/apps/web/core/components/project/settings/archive-project/archive-restore-modal.tsx @@ -51,7 +51,7 @@ export const ArchiveRestoreProjectModal: React.FC<Props> = (props) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Project could not be archived. Please try again.", + message: "Program could not be archived. Please try again.", }) ) .finally(() => setIsLoading(false)); @@ -64,7 +64,7 @@ export const ArchiveRestoreProjectModal: React.FC<Props> = (props) => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Restore success", - message: `You can find ${projectDetails.name} in your projects.`, + message: `You can find ${projectDetails.name} in your programs.`, }); onClose(); router.push(`/${workspaceSlug}/projects/`); @@ -73,7 +73,7 @@ export const ArchiveRestoreProjectModal: React.FC<Props> = (props) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Project could not be restored. Please try again.", + message: "Program could not be restored. Please try again.", }) ) .finally(() => setIsLoading(false)); @@ -112,8 +112,8 @@ export const ArchiveRestoreProjectModal: React.FC<Props> = (props) => { </h3> <p className="mt-3 text-sm text-custom-text-200"> {archive - ? "This project and its work items, cycles, modules, and pages will be archived. Its work items won’t appear in search. Only project admins can restore the project." - : "Restoring a project will activate it and make it visible to all members of the project. Are you sure you want to continue?"} + ? "This program and its work items, cycles, modules, and pages will be archived. Its work items won’t appear in search. Only program admins can restore the program." + : "Restoring a program will activate it and make it visible to all members of the program. Are you sure you want to continue?"} </p> <div className="mt-3 flex justify-end gap-2"> <Button variant="neutral-primary" size="sm" onClick={onClose}> diff --git a/apps/web/core/components/project/settings/archive-project/selection.tsx b/apps/web/core/components/project/settings/archive-project/selection.tsx index e542396f49a..73d2cb80a52 100644 --- a/apps/web/core/components/project/settings/archive-project/selection.tsx +++ b/apps/web/core/components/project/settings/archive-project/selection.tsx @@ -22,7 +22,7 @@ export const ArchiveProjectSelection: React.FC<IArchiveProject> = (props) => { {({ open }) => ( <div className="w-full"> <Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between"> - <span className="text-xl tracking-tight">Archive project</span> + <span className="text-xl tracking-tight">Archive program</span> {open ? <ChevronUp className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />} </Disclosure.Button> <Transition @@ -37,14 +37,14 @@ export const ArchiveProjectSelection: React.FC<IArchiveProject> = (props) => { <Disclosure.Panel> <div className="flex flex-col gap-8 pt-4"> <span className="text-sm tracking-tight"> - Archiving a project will unlist your project from your side navigation although you will still be able - to access it from your projects page. You can restore the project or delete it whenever you want. + Archiving a program will unlist your program from your side navigation although you will still be able + to access it from your programs page. You can restore the program or delete it whenever you want. </span> <div> {projectDetails ? ( <div> <Button variant="outline-danger" onClick={handleArchive}> - Archive project + Archive program </Button> </div> ) : ( diff --git a/apps/web/core/components/project/settings/delete-project-section.tsx b/apps/web/core/components/project/settings/delete-project-section.tsx index ce397fb6f5a..adde0c45ae5 100644 --- a/apps/web/core/components/project/settings/delete-project-section.tsx +++ b/apps/web/core/components/project/settings/delete-project-section.tsx @@ -23,7 +23,7 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) => {({ open }) => ( <div className="w-full"> <Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between"> - <span className="text-xl tracking-tight">Delete project</span> + <span className="text-xl tracking-tight">Delete program</span> {open ? <ChevronUp className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />} </Disclosure.Button> @@ -39,7 +39,7 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) => <Disclosure.Panel> <div className="flex flex-col gap-8 pt-4"> <span className="text-sm tracking-tight"> - When deleting a project, all of the data and resources within that project will be permanently removed + When deleting a program, all of the data and resources within that program will be permanently removed and cannot be recovered. </span> <div> @@ -50,7 +50,7 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) => onClick={handleDelete} data-ph-element={PROJECT_TRACKER_ELEMENTS.DELETE_PROJECT_BUTTON} > - Delete my project + Delete my program </Button> </div> ) : ( diff --git a/apps/web/core/components/project/settings/features-list.tsx b/apps/web/core/components/project/settings/features-list.tsx index 8024def86a2..76789ce1383 100644 --- a/apps/web/core/components/project/settings/features-list.tsx +++ b/apps/web/core/components/project/settings/features-list.tsx @@ -45,14 +45,14 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => { const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload); setPromiseToast(updateProjectPromise, { - loading: "Updating project feature...", + loading: "Updating program feature...", success: { title: "Success!", - message: () => "Project feature updated successfully.", + message: () => "Program feature updated successfully.", }, error: { title: "Error!", - message: () => "Something went wrong while updating project feature. Please try again.", + message: () => "Something went wrong while updating program feature. Please try again.", }, }); updateProjectPromise.then(() => { diff --git a/apps/web/core/components/rich-filters/filter-value-input/root.tsx b/apps/web/core/components/rich-filters/filter-value-input/root.tsx index be90b30d994..9c26a04bcb1 100644 --- a/apps/web/core/components/rich-filters/filter-value-input/root.tsx +++ b/apps/web/core/components/rich-filters/filter-value-input/root.tsx @@ -21,10 +21,22 @@ import { DateRangeFilterValueInput } from "./date/range"; import { SingleDateFilterValueInput } from "./date/single"; import { MultiSelectFilterValueInput } from "./select/multi"; import { SingleSelectFilterValueInput } from "./select/single"; +import { TimeRangeFilterValueInput } from "./time/range"; +import { SingleTimeFilterValueInput } from "./time/single"; + +const normalizeFilterProperty = (property: string) => + property + .replace(/^meta\./, "") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); export const FilterValueInput = observer( <P extends TFilterProperty, V extends TFilterValue>(props: TFilterValueInputProps<P, V>) => { const { condition, filterFieldConfig, isDisabled = false, onChange } = props; + const isStartTimeFilter = normalizeFilterProperty(condition.property) === "start time"; // Single select input if (filterFieldConfig?.type === FILTER_FIELD_TYPE.SINGLE_SELECT) { @@ -52,6 +64,16 @@ export const FilterValueInput = observer( // Date filter input if (filterFieldConfig?.type === FILTER_FIELD_TYPE.DATE) { + if (isStartTimeFilter) { + return ( + <SingleTimeFilterValueInput<P> + config={filterFieldConfig as TDateFilterFieldConfig<string>} + condition={condition as TFilterConditionNodeForDisplay<P, string>} + isDisabled={isDisabled} + onChange={(value) => onChange(value as SingleOrArray<V>)} + /> + ); + } return ( <SingleDateFilterValueInput<P> config={filterFieldConfig as TDateFilterFieldConfig<string>} @@ -64,6 +86,16 @@ export const FilterValueInput = observer( // Date range filter input if (filterFieldConfig?.type === FILTER_FIELD_TYPE.DATE_RANGE) { + if (isStartTimeFilter) { + return ( + <TimeRangeFilterValueInput<P> + config={filterFieldConfig as TDateRangeFilterFieldConfig<string>} + condition={condition as TFilterConditionNodeForDisplay<P, string>} + isDisabled={isDisabled} + onChange={(value) => onChange(value as SingleOrArray<V>)} + /> + ); + } return ( <DateRangeFilterValueInput<P> config={filterFieldConfig as TDateRangeFilterFieldConfig<string>} diff --git a/apps/web/core/components/rich-filters/filter-value-input/time/range.tsx b/apps/web/core/components/rich-filters/filter-value-input/time/range.tsx new file mode 100644 index 00000000000..f0bee5bfa31 --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/time/range.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { ArrowRight } from "lucide-react"; +// plane imports +import type { TDateRangeFilterFieldConfig, TFilterConditionNodeForDisplay, TFilterProperty } from "@plane/types"; +import { cn, toFilterArray } from "@plane/utils"; +import { TimeDropdown } from "@/components/dropdowns/time-picker"; +// local imports +import { COMMON_FILTER_ITEM_BORDER_CLASSNAME, EMPTY_FILTER_PLACEHOLDER_TEXT } from "../../shared"; + +type TTimeRangeFilterValueInputProps<P extends TFilterProperty> = { + config: TDateRangeFilterFieldConfig<string>; + condition: TFilterConditionNodeForDisplay<P, string>; + isDisabled?: boolean; + onChange: (value: string[]) => void; +}; + +export const TimeRangeFilterValueInput = observer( + <P extends TFilterProperty>(props: TTimeRangeFilterValueInputProps<P>) => { + const { condition, isDisabled, onChange } = props; + const [fromRaw, toRaw] = toFilterArray(condition.value) ?? []; + const from = typeof fromRaw === "string" && fromRaw.trim() ? fromRaw : null; + const to = typeof toRaw === "string" && toRaw.trim() ? toRaw : null; + const isIncomplete = Boolean((from && !to) || (!from && to)); + + const updateRange = (nextFrom: string | null, nextTo: string | null) => { + const nextValues = [nextFrom, nextTo].filter((value): value is string => Boolean(value && value.trim())); + onChange(nextValues); + }; + + return ( + <div + className={cn("flex h-full items-center gap-1 px-1", { + [COMMON_FILTER_ITEM_BORDER_CLASSNAME]: !isDisabled, + "text-red-500": isIncomplete, + "hover:bg-custom-background-100": isDisabled, + })} + > + <TimeDropdown + value={from} + onChange={(value) => updateRange(value, to)} + placeholder={EMPTY_FILTER_PLACEHOLDER_TEXT} + buttonVariant="transparent-with-text" + hideIcon + buttonClassName={cn("rounded-none px-2 text-sm", { + "text-custom-text-400": !from, + })} + isClearable={!isDisabled} + disabled={isDisabled} + /> + <ArrowRight className="h-3.5 w-3.5 text-custom-text-300" /> + <TimeDropdown + value={to} + onChange={(value) => updateRange(from, value)} + placeholder={EMPTY_FILTER_PLACEHOLDER_TEXT} + buttonVariant="transparent-with-text" + hideIcon + buttonClassName={cn("rounded-none px-2 text-sm", { + "text-custom-text-400": !to, + })} + isClearable={!isDisabled} + disabled={isDisabled} + /> + </div> + ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-value-input/time/single.tsx b/apps/web/core/components/rich-filters/filter-value-input/time/single.tsx new file mode 100644 index 00000000000..1eb1d168038 --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/time/single.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { TDateFilterFieldConfig, TFilterConditionNodeForDisplay, TFilterProperty } from "@plane/types"; +import { cn } from "@plane/utils"; +import { TimeDropdown } from "@/components/dropdowns/time-picker"; +import { COMMON_FILTER_ITEM_BORDER_CLASSNAME, EMPTY_FILTER_PLACEHOLDER_TEXT } from "../../shared"; + +type TSingleTimeFilterValueInputProps<P extends TFilterProperty> = { + config: TDateFilterFieldConfig<string>; + condition: TFilterConditionNodeForDisplay<P, string>; + isDisabled?: boolean; + onChange: (value: string | null | undefined) => void; +}; + +export const SingleTimeFilterValueInput = observer( + <P extends TFilterProperty>(props: TSingleTimeFilterValueInputProps<P>) => { + const { condition, isDisabled, onChange } = props; + const conditionValue = typeof condition.value === "string" ? condition.value : null; + + return ( + <TimeDropdown + value={conditionValue} + onChange={(value) => onChange(value)} + buttonClassName={cn("rounded-none", { + [COMMON_FILTER_ITEM_BORDER_CLASSNAME]: !isDisabled, + "text-custom-text-400": !conditionValue, + "hover:bg-custom-background-100": isDisabled, + })} + hideIcon + placeholder={EMPTY_FILTER_PLACEHOLDER_TEXT} + buttonVariant="transparent-with-text" + disabled={isDisabled} + /> + ); + } +); diff --git a/apps/web/core/components/settings/project/sidebar/nav-item-children.tsx b/apps/web/core/components/settings/project/sidebar/nav-item-children.tsx index 0812b887146..36ad30a9531 100644 --- a/apps/web/core/components/settings/project/sidebar/nav-item-children.tsx +++ b/apps/web/core/components/settings/project/sidebar/nav-item-children.tsx @@ -54,6 +54,7 @@ export const NavItemChildren = observer((props: { projectId: string }) => { <Link key={link.key} href={`/${workspaceSlug}/settings/projects/${projectId}${link.href}`} + prefetch={false} onClick={() => toggleSidebar(true)} > <div diff --git a/apps/web/core/components/settings/sidebar/nav-item.tsx b/apps/web/core/components/settings/sidebar/nav-item.tsx index 3fc56e63f1d..6891fd27dd2 100644 --- a/apps/web/core/components/settings/sidebar/nav-item.tsx +++ b/apps/web/core/components/settings/sidebar/nav-item.tsx @@ -74,6 +74,7 @@ const SettingsSidebarNavItem = observer((props: TSettingsSidebarNavItemProps) => ) : ( <Link href={joinUrlPath(workspaceSlug, setting.href)} + prefetch={false} className={buttonClass} onClick={() => toggleSidebar(true)} > diff --git a/apps/web/core/components/settings/tabs.tsx b/apps/web/core/components/settings/tabs.tsx index 33ac26a7639..c42ebe40530 100644 --- a/apps/web/core/components/settings/tabs.tsx +++ b/apps/web/core/components/settings/tabs.tsx @@ -17,7 +17,7 @@ const TABS = { }, projects: { key: "projects", - label: "Projects", + label: "Programs", href: `/settings/projects/`, }, }; @@ -44,6 +44,7 @@ const SettingsTabs = observer(() => { <Link key={tab.key} href={`/${workspaceSlug}${href}`} + prefetch={false} className={cn( "flex items-center justify-center p-1 min-w-fit w-full font-medium outline-none focus:outline-none cursor-pointer transition-all rounded text-custom-text-200 ", { diff --git a/apps/web/core/components/web-hooks/form/individual-event-options.tsx b/apps/web/core/components/web-hooks/form/individual-event-options.tsx index a8216c65365..3dec3a1a619 100644 --- a/apps/web/core/components/web-hooks/form/individual-event-options.tsx +++ b/apps/web/core/components/web-hooks/form/individual-event-options.tsx @@ -9,8 +9,8 @@ export const INDIVIDUAL_WEBHOOK_OPTIONS: { }[] = [ { key: "project", - label: "Projects", - description: "Project created, updated, or deleted", + label: "Programs", + description: "Program created, updated, or deleted", }, { key: "cycle", diff --git a/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx b/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx index 1eb2bb9776b..4cd258c855f 100644 --- a/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx +++ b/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx @@ -179,7 +179,7 @@ export const FavoriteFolder: React.FC<Props> = (props) => { <Tooltip isMobile={isMobile} tooltipContent={ - favorite.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange" + favorite.sort_order === null ? "Join the program to rearrange" : "Drag to rearrange" } position="top-end" disabled={isDragging} diff --git a/apps/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-drag-handle.tsx b/apps/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-drag-handle.tsx index b13c175de6f..bdc17167686 100644 --- a/apps/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-drag-handle.tsx +++ b/apps/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-drag-handle.tsx @@ -23,7 +23,7 @@ export const FavoriteItemDragHandle: FC<Props> = observer((props) => { return ( <Tooltip isMobile={isMobile} - tooltipContent={sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"} + tooltipContent={sort_order === null ? "Join the program to rearrange" : "Drag to rearrange"} position="top-end" disabled={isDragging} > diff --git a/apps/web/core/components/workspace/sidebar/project-navigation.tsx b/apps/web/core/components/workspace/sidebar/project-navigation.tsx index 7b678e4e987..dbb1ff5e4fe 100644 --- a/apps/web/core/components/workspace/sidebar/project-navigation.tsx +++ b/apps/web/core/components/workspace/sidebar/project-navigation.tsx @@ -17,6 +17,7 @@ import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; +import { Users2Icon, VideoIcon } from "lucide-react"; export type TNavigationItem = { name: string; @@ -125,6 +126,26 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => { shouldRender: project.inbox_view, sortOrder: 6, }, + { + i18n_key: "Media Library", + key: "media_library", + name: "Media Library", + href: `/${workspaceSlug}/projects/${projectId}/media-library`, + icon: VideoIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: true, + sortOrder: 7, + }, + { + i18n_key: "Roster", + key: "roster", + name: "Roster", + href: `/${workspaceSlug}/projects/${projectId}/roster`, + icon: Users2Icon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: true, + sortOrder: 8, + }, ], [project] ); diff --git a/apps/web/core/components/workspace/sidebar/sidebar-item.tsx b/apps/web/core/components/workspace/sidebar/sidebar-item.tsx index 1ed760ab4d2..03e7e461cce 100644 --- a/apps/web/core/components/workspace/sidebar/sidebar-item.tsx +++ b/apps/web/core/components/workspace/sidebar/sidebar-item.tsx @@ -40,7 +40,7 @@ export const SidebarItemBase: FC<Props> = observer(({ item, additionalRender, ad if (isExtendedSidebarOpened) toggleExtendedSidebar(false); }; - const staticItems = ["home", "inbox", "pi_chat", "projects", "your_work", ...(additionalStaticItems || [])]; + const staticItems = ["home", "inbox", "pi_chat", "projects", "your_work", "opposition", ...(additionalStaticItems || [])]; const slug = workspaceSlug?.toString() || ""; if (!allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug)) return null; diff --git a/apps/web/core/constants/calendar.ts b/apps/web/core/constants/calendar.ts index 2fca3539d0a..c2cc86ade20 100644 --- a/apps/web/core/constants/calendar.ts +++ b/apps/web/core/constants/calendar.ts @@ -111,8 +111,12 @@ export const CALENDAR_LAYOUTS: { key: "month", title: "Month layout", }, - week: { - key: "week", - title: "Week layout", - }, + // week: { + // key: "week", + // title: "Week layout", + // }, + day: { + key: "day", + title: "Daily layout" + } }; diff --git a/apps/web/core/constants/plans.tsx b/apps/web/core/constants/plans.tsx index 717e2ac7d07..31dd104bed9 100644 --- a/apps/web/core/constants/plans.tsx +++ b/apps/web/core/constants/plans.tsx @@ -65,11 +65,11 @@ export const PLANS_LIST: TPlanePlans[] = ["free", "one", "pro", "business", "ent export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ { id: "project-work-tracking", - title: "Project + work tracking", + title: "Program + work tracking", features: [ { - title: "Projects", - description: "Add projects to house work items, cycles, and modules.", + title: "Programs", + description: "Add programs to house work items, cycles, and modules.", cloud: { free: true, one: true, @@ -149,7 +149,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { id: "project-work-management", - title: "Project + work management", + title: "Program + work management", features: [ { title: "Bulk Ops", @@ -185,7 +185,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Active Cycles", - description: "See all running cycles across all projects, or soon, in\na single project.", + description: "See all running cycles across all programs, or soon, in\na single program.", cloud: { free: false, one: true, @@ -207,11 +207,11 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Custom Properties", - description: "Create your own properties and apply them to your\nworkspace or project.", + description: "Create your own properties and apply them to your\nworkspace or program.", cloud: { free: false, one: false, - pro: "Project-level\ncustom properties", + pro: "Program-level\ncustom properties", business: "Workspace-level\nproperties and roll-ups", enterprise: "Workspace-level\nproperties and roll-ups", }, @@ -229,7 +229,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Work item Transfers", - description: "Move a work item from a project or a cycle to\nanother.", + description: "Move a work item from a program or a cycle to\nanother.", cloud: { free: false, one: false, @@ -241,7 +241,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ { title: "Auto-transfer Cycle Work items", description: - "Transfer incomplete work items from a completed cycle\nto the next cycle or to the default project state. ", + "Transfer incomplete work items from a completed cycle\nto the next cycle or to the default program state. ", cloud: { free: false, one: false, @@ -276,7 +276,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ { title: "Checkpoints", description: - "Add markers to Projects, Epics and Initiatives to keep your\nteam on track and report on progress.", + "Add markers to Programs, Epics and Initiatives to keep your\nteam on track and report on progress.", comingSoon: true, cloud: { free: false, @@ -309,8 +309,8 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, }, // { - // title: "Project Overview", - // description: "See just-in-time snapshots of your project with\nessential metrics.", + // title: "Program Overview", + // description: "See just-in-time snapshots of your program with\nessential metrics.", // comingSoon: true, // cloud: { // free: false, @@ -321,9 +321,9 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ // }, // }, { - title: "Public, Private, and Secret projects", + title: "Public, Private, and Secret programs", description: - "Public projects are visible and accessible to\neveryone. Private ones are visible but need approval\nto join. Secret projects aren't visible or accessible.", + "Public programs are visible and accessible to\neveryone. Private ones are visible but need approval\nto join. Secret programs aren't visible or accessible.", cloud: { free: false, one: false, @@ -333,9 +333,9 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, }, { - title: "State Of Projects", + title: "State Of Programs", description: - "See all projects laid across states that highlight\nthose that need attention and those on track.", + "See all programs laid across states that highlight\nthose that need attention and those on track.", cloud: { free: false, one: false, @@ -345,9 +345,9 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, }, // { - // title: "Project Updates", + // title: "Program Updates", // description: - // "Keep stakeholders in the loop with a dedicated\nspace for updates that everyone in the project can\nsee.", + // "Keep stakeholders in the loop with a dedicated\nspace for updates that everyone in the program can\nsee.", // comingSoon: true, // cloud: { // free: false, @@ -372,7 +372,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Teamspace Cycles", - description: "See multiple cycles in multiple projects at once.", + description: "See multiple cycles in multiple programs at once.", cloud: { free: false, one: false, @@ -382,8 +382,8 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, }, { - title: "Project Templates", - description: "Save states, workflows, automation, and other project\nsettings into templates.", + title: "Program Templates", + description: "Save states, workflows, automation, and other program\nsettings into templates.", cloud: { free: false, one: false, @@ -394,7 +394,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Baselines And Deviations", - description: "Declare baselines for how your projects progress\nand zoom in on deviations.", + description: "Declare baselines for how your programs progress\nand zoom in on deviations.", cloud: { free: false, one: false, @@ -449,7 +449,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Emails For Intake", - description: "Get an email address for reporting work items\ndirectly into a project's Intake.", + description: "Get an email address for reporting work items\ndirectly into a program's Intake.", comingSoon: true, cloud: { free: false, @@ -564,7 +564,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, // { // title: "Time Capsule", - // description: "Go back in your project's timeline and see point-in-\ntime snapshots.", + // description: "Go back in your program's timeline and see point-in-\ntime snapshots.", // comingSoon: true, // cloud: { // free: false, @@ -588,7 +588,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Custom Reports", - description: "Generate reports by any dimension and metric\nacross your project or workspace.", + description: "Generate reports by any dimension and metric\nacross your program or workspace.", comingSoon: true, cloud: { free: false, @@ -667,7 +667,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ features: [ { title: "Member limit", - description: "Number of seats that can use project and work management features", + description: "Number of seats that can use program and work management features", selfHostedDescription: "Number of users that our standard infra supports\nIncrease infra to get more users", cloud: { free: "12", @@ -697,7 +697,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Guests", - description: "Let some users see everything or just their work items in\na project.", + description: "Let some users see everything or just their work items in\na program.", cloud: { free: false, one: "5 per paid member", @@ -708,7 +708,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Approvals", - description: "Set workspace, project, and work item type approvals to\ndesignated admins.", + description: "Set workspace, program, and work item type approvals to\ndesignated admins.", comingSoon: true, cloud: { free: false, @@ -811,7 +811,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Real-time Collab", - description: "Edit a page together with members in your project,\nteam, or workspace.", + description: "Edit a page together with members in your program,\nteam, or workspace.", cloud: { free: false, one: true, @@ -822,7 +822,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Work item Embeds", - description: "Embed work items from any project you are a member\nof.", + description: "Embed work items from any program you are a member\nof.", cloud: { free: false, one: true, @@ -856,7 +856,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Wiki", - description: "Create company-wide wikis or knowledge bases\nwithout creating a project.", + description: "Create company-wide wikis or knowledge bases\nwithout creating a program.", cloud: { free: false, one: true, @@ -878,7 +878,7 @@ export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ }, { title: "Templates", - description: "Use pages as templates for your project, team, or\nworkspace.", + description: "Use pages as templates for your program, team, or\nworkspace.", cloud: { free: false, one: false, @@ -1294,10 +1294,10 @@ export const PLANE_PLANS: PlanePlans = { }, }, planHighlights: { - free: ["Upto 12 users", "Pages", "Unlimited projects", "Unlimited cycles and modules"], + free: ["Upto 12 users", "Pages", "Unlimited programs", "Unlimited cycles and modules"], one: ["Upto 50 users", "OIDC and SAML", "Active cycles", "Limited time tracking"], pro: ["Unlimited users", "Custom work items + Properties", "Work item templates", "Full Time Tracking"], - business: ["RBAC", "Project Templates", "Baselines And Deviations", "Custom Reports"], + business: ["RBAC", "Program Templates", "Baselines And Deviations", "Custom Reports"], enterprise: ["Private + managed deployments", "GAC", "LDAP support", "Databases + Formulas"], }, planComparison: PLANS_COMPARISON_LIST, diff --git a/apps/web/core/hooks/use-dropdown.ts b/apps/web/core/hooks/use-dropdown.ts index 5568305e833..7d941a91475 100644 --- a/apps/web/core/hooks/use-dropdown.ts +++ b/apps/web/core/hooks/use-dropdown.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; // plane helpers -import { useOutsideClickDetector } from "@plane/hooks"; +import { useOutsideClickDetector, useOutsidePointerClickDetector } from "@plane/hooks"; // hooks import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; import { usePlatformOS } from "./use-platform-os"; @@ -14,10 +14,23 @@ type TArguments = { query?: string; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setQuery?: React.Dispatch<React.SetStateAction<string>>; + useCaptureForOutsideClick?: boolean; + usePointerOutsideClick?: boolean; }; export const useDropdown = (args: TArguments) => { - const { dropdownRef, inputRef, isOpen, onClose, onOpen, query, setIsOpen, setQuery } = args; + const { + dropdownRef, + inputRef, + isOpen, + onClose, + onOpen, + query, + setIsOpen, + setQuery, + useCaptureForOutsideClick = false, + usePointerOutsideClick = false, + } = args; const { isMobile } = usePlatformOS(); @@ -62,7 +75,21 @@ export const useDropdown = (args: TArguments) => { }; // close the dropdown when the user clicks outside of the dropdown - useOutsideClickDetector(dropdownRef, handleClose); + useOutsideClickDetector( + dropdownRef, + () => { + if (!usePointerOutsideClick) handleClose(); + }, + useCaptureForOutsideClick + ); + useOutsidePointerClickDetector( + dropdownRef, + () => { + if (usePointerOutsideClick) handleClose(); + }, + useCaptureForOutsideClick, + usePointerOutsideClick + ); // focus the search input when the dropdown is open useEffect(() => { diff --git a/apps/web/core/lib/kanavio-tagging-service.ts b/apps/web/core/lib/kanavio-tagging-service.ts new file mode 100644 index 00000000000..a47d367d787 --- /dev/null +++ b/apps/web/core/lib/kanavio-tagging-service.ts @@ -0,0 +1,33 @@ +type KanavioTaggingServiceEnv = Record<string, string | undefined>; + +export const getKanavioTaggingServiceBaseUrl = ( + env: KanavioTaggingServiceEnv = process.env +): string | null => { + const configuredUrl = + env.KANAVIO_TAGGING_SERVICE_URL?.trim() || + env.NEXT_PUBLIC_KANAVIO_TAGGING_SERVICE_URL?.trim() || + ""; + const fallbackUrl = env.NODE_ENV === "development" ? "http://localhost:3015" : ""; + + return (configuredUrl || fallbackUrl).replace(/\/+$/g, "") || null; +}; + +export const getKanavioTaggingServiceToken = ( + env: KanavioTaggingServiceEnv = process.env +): string | null => { + const configuredToken = env.KANAVIO_TAGGING_SERVICE_TOKEN?.trim() || ""; + + return configuredToken || null; +}; + +export const getKanavioTaggingServiceHeaders = ( + headers: Record<string, string> = {}, + env: KanavioTaggingServiceEnv = process.env +): Record<string, string> => { + const token = getKanavioTaggingServiceToken(env); + + return { + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...headers, + }; +}; diff --git a/apps/web/core/local-db/utils/schemas.ts b/apps/web/core/local-db/utils/schemas.ts index 068ab9234f3..e84f39af847 100644 --- a/apps/web/core/local-db/utils/schemas.ts +++ b/apps/web/core/local-db/utils/schemas.ts @@ -5,6 +5,7 @@ export type Schema = { export const issueSchema: Schema = { id: "TEXT UNIQUE", name: "TEXT", + sg_event_id: "TEXT", state_id: "TEXT", sort_order: "REAL", completed_at: "TEXT", @@ -12,6 +13,7 @@ export const issueSchema: Schema = { priority: "TEXT", priority_proxy: "INTEGER", start_date: "TEXT", + start_time: "TEXT", target_date: "TEXT", sequence_id: "INTEGER", project_id: "TEXT", @@ -32,6 +34,12 @@ export const issueSchema: Schema = { assignee_ids: "TEXT", module_ids: "TEXT", description_html: "TEXT", + opposition_team: "TEXT", + level: "TEXT", + sport: "TEXT", + program: "TEXT", + year: "TEXT", + category: "TEXT", is_local_update: "INTEGER", }; diff --git a/apps/web/core/local-db/utils/utils.ts b/apps/web/core/local-db/utils/utils.ts index 1cb44c6724e..e92e57cf619 100644 --- a/apps/web/core/local-db/utils/utils.ts +++ b/apps/web/core/local-db/utils/utils.ts @@ -22,14 +22,17 @@ export const addIssueToPersistanceLayer = async (issue: TIssue) => { const issuePartial = pick({ ...JSON.parse(JSON.stringify(issue)) }, [ "id", "name", + "sg_event_id", "state_id", "sort_order", "completed_at", "estimate_point", "priority", "start_date", + "start_time", "target_date", "sequence_id", + "sg_event_id", "project_id", "parent_id", "created_at", @@ -48,6 +51,12 @@ export const addIssueToPersistanceLayer = async (issue: TIssue) => { "module_ids", "type_id", "description_html", + "opposition_team", + "level", + "sport", + "program", + "year", + "category", ]); await updateIssue({ ...issuePartial, is_local_update: 1 }); } catch (e) { diff --git a/apps/web/core/services/media-library.service.ts b/apps/web/core/services/media-library.service.ts new file mode 100644 index 00000000000..e889d399faa --- /dev/null +++ b/apps/web/core/services/media-library.service.ts @@ -0,0 +1,653 @@ +import type { AxiosRequestConfig } from "axios"; +import { API_BASE_URL } from "@plane/constants"; + +import { APIService } from "@/services/api.service"; + +export type TMediaArtifact = { + name: string; + title: string; + description?: string | null; + format: string; + path: string; + link: string | null; + action: string; + metadata_ref?: string | null; + meta?: Record<string, unknown>; + work_item_id?: string | null; + created_at: string; + updated_at: string; + transcode_job?: TMediaTranscodeJobResponse; + transcode_job_error?: unknown; +}; + +export type TMediaArtifactPayload = { + name: string; + title: string; + description?: string | null; + format: string; + link?: string | null; + action: string; + metadata_ref?: string | null; + meta?: Record<string, unknown>; + work_item_id?: string | null; + created_at?: string; + updated_at?: string; + path?: string; +}; + +export type TMediaLibraryManifest = { + id?: string; + artifacts?: TMediaArtifact[]; + metadata?: Record<string, Record<string, unknown>>; +}; + +export type TMediaArtifactsPaginatedResponse = { + results: TMediaArtifact[]; + total_results?: number; + total_count?: number; + total_pages?: number; + next_cursor?: string; + prev_cursor?: string; + next_page_results?: boolean; + prev_page_results?: boolean; + count?: number; +}; + +export type TMediaArtifactsResponse = TMediaArtifact[] | TMediaArtifactsPaginatedResponse; + +type TMediaLibraryArtifactsQuery = { + q?: string; + filters?: string; + formats?: string; + section?: string; + cursor?: string; + per_page?: string; +}; + +type TMediaManifestMetaUpdatePayload = { + work_item_id: string; + meta: Record<string, unknown>; +}; + +type TMediaManifestArtifactUpdatePayload = { + artifact_id: string; + artifact: { + action?: string | null; + title?: string | null; + description?: string | null; + format?: string | null; + link?: string | null; + meta?: Record<string, unknown>; + path?: string | null; + }; +}; + +export type TEventVideoAnnotationUpdatePayload = { + annotations: TCustomPlaylistAnnotation[]; + device_id?: string | number | null; + stream_id?: string | number | null; + stream_name?: string | null; + view_key?: string | null; +}; + +export type TEventVideoAnnotationUpdateResponse = { + annotations?: TCustomPlaylistAnnotation[]; + eventPayload?: Record<string, unknown>; + mediaReference?: Record<string, unknown>; + updated?: number; +}; + +export type TMediaTranscodeJobStatus = + | "UPLOAD_COMPLETE" + | "QUEUED" + | "CLAIMED" + | "PROBING" + | "PROCESSING" + | "TRANSCODING" + | "PACKAGING" + | "VALIDATING" + | "COMPLETED" + | "READY" + | "UPLOADED" + | "FAILED" + | "QUEUE_FAILED" + | "RETRY_PENDING" + | "CANCEL_REQUESTED" + | "CANCELLED"; + +export type TMediaTranscodeJobResponse = { + job_id: string; + asset_id: string; + status: TMediaTranscodeJobStatus; + progress?: number; + attempt_count?: number; + max_attempts?: number; + created_at?: string; + started_at?: string | null; + completed_at?: string | null; + playable_url?: string | null; + output?: { + master_playlist_location?: string | null; + public_or_internal_url?: string | null; + renditions?: unknown; + thumbnails?: unknown; + } | null; + error?: { + code?: string; + message?: string; + } | null; +}; + +type TMediaTranscodeEnqueuePayload = { + encoding_profile?: string; + generate_thumbnails?: boolean; +}; + +type TMediaLibraryPackagePayload = { + id?: string; + name: string; + title: string; +}; + +type TCreatePlaylistPayload = { + original_stream_name: string; + timestamp: string; +}; + +export type TCustomPlaylist = { + id: string; + event_id: number | string; + name: string; + subtitle?: string | null; + url: string; + thumbnail: string | null; + clip: number; + clips?: TCustomPlaylistClip[]; +}; + +export type TCustomPlaylistClip = { + groupValue?: string; + id: string; + player?: string; + primaryDetail?: string; + result?: string; + sourceTagId?: string | null; + subtitle?: string; + tags?: string[]; + team?: string; + thumbnail?: string | null; + timestamp?: string | null; + title: string; +}; + +export type TCustomPlaylistAnnotationTool = "text" | "rectangle" | "ellipse" | "line" | "arrow" | "image" | "pen"; + +export type TCustomPlaylistAnnotationPoint = { + x: number; + y: number; +}; + +export type TCustomPlaylistAnnotationStrokeStyle = "solid" | "dotted"; + +export type TCustomPlaylistAnnotationStyle = { + [key: string]: boolean | number | string | null | undefined; + backgroundColor?: string; + color?: string; + fontFamily?: string; + fontSize?: number; + fontWeight?: number | string; + opacity?: number; + stroke?: string; + strokeStyle?: TCustomPlaylistAnnotationStrokeStyle; + strokeWidth?: number; +}; + +export type TCustomPlaylistAnnotation = { + content?: string; + createdAt?: string; + endTime: number; + height?: number; + id: string; + points?: TCustomPlaylistAnnotationPoint[]; + rotation?: number; + startTime: number; + style?: TCustomPlaylistAnnotationStyle; + title?: string; + trackIndex?: number; + type: TCustomPlaylistAnnotationTool; + width?: number; + x: number; + y: number; +}; + +type TCustomPlaylistPayload = { + event_id: number | string; + name: string; + subtitle?: string | null; + url: string; + thumbnail?: string | null; + clip?: number; + clips?: TCustomPlaylistClip[]; + project_id?: string; + workspace_slug?: string; +}; + +export type TCustomPlaylistUpdatePayload = { + name?: string; + subtitle?: string | null; + thumbnail?: string | null; + clip?: number; + clips?: TCustomPlaylistClip[]; +}; + +type TCustomPlaylistListParams = { + projectId?: string; + workspaceSlug?: string; +}; + +const sanitizePlaylistFileName = (value: string) => { + const normalizedValue = value.trim(); + if (!normalizedValue) return ""; + + let fileName = normalizedValue; + try { + const parsedUrl = new URL(normalizedValue, "http://localhost"); + fileName = decodeURIComponent(parsedUrl.pathname.replace(/\/+$/, "").split("/").pop() ?? ""); + } catch { + fileName = decodeURIComponent(normalizedValue.replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop() ?? ""); + } + + return /^[A-Za-z0-9_-]+\.m3u8$/i.test(fileName) ? fileName : ""; +}; + +const readPlaylistFileName = (value: unknown): string | null => { + if (typeof value === "string") { + const normalized = sanitizePlaylistFileName(value); + return normalized || null; + } + + if (Array.isArray(value)) { + for (const entry of value) { + const fileName = readPlaylistFileName(entry); + if (fileName) { + return fileName; + } + } + return null; + } + + if (!value || typeof value !== "object") { + return null; + } + + const record = value as Record<string, unknown>; + for (const key of [ + "file-name", + "file_name", + "fileName", + "filename", + "file", + "name", + "playlist", + "playlistFile", + "playlist_file", + "playlistFileName", + "playlist_file_name", + "playlistUrl", + "playlist_url", + "url", + "path", + ]) { + const directFileName = readPlaylistFileName(record[key]); + if (directFileName) { + return directFileName; + } + } + + if (typeof record.field === "string") { + const normalizedField = record.field + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, ""); + if ( + ["filename", "file", "name", "playlist", "playlistfile", "playlistfilename", "url", "path"].includes( + normalizedField + ) + ) { + const fieldFileName = readPlaylistFileName(record.value); + if (fieldFileName) { + return fieldFileName; + } + } + } + + if ("Gateway Response" in record) { + const gatewayFileName = readPlaylistFileName(record["Gateway Response"]); + if (gatewayFileName) { + return gatewayFileName; + } + } + + if ("result" in record) { + const resultFileName = readPlaylistFileName(record.result); + if (resultFileName) { + return resultFileName; + } + } + + return readPlaylistFileName(Object.values(record)); +}; + +export class MediaLibraryService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async ensureProjectLibrary( + workspaceSlug: string, + projectId: string, + config?: AxiosRequestConfig + ): Promise<TMediaLibraryManifest | null> { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/`, {}, config) + .then((response) => response?.data ?? null) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async ensurePackage( + workspaceSlug: string, + projectId: string, + data: TMediaLibraryPackagePayload + ): Promise<Record<string, unknown> | null> { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/`, data) + .then((response) => response?.data ?? null) + .catch((error) => { + if (error?.response?.status === 409) { + return null; + } + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async getArtifacts( + workspaceSlug: string, + projectId: string, + packageId: string, + params?: TMediaLibraryArtifactsQuery, + config?: AxiosRequestConfig + ): Promise<TMediaArtifactsResponse> { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/artifacts/`, + params ? { params } : {}, + config + ) + .then((response) => response?.data ?? []) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async getArtifactDetail( + workspaceSlug: string, + projectId: string, + packageId: string, + artifactId: string + ): Promise<TMediaArtifact[]> { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/artifacts/${encodeURIComponent( + artifactId + )}/` + ) + .then((response) => response?.data ?? []) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async uploadArtifact( + workspaceSlug: string, + projectId: string, + packageId: string, + payload: TMediaArtifactPayload, + file: File, + onUploadProgress?: AxiosRequestConfig["onUploadProgress"], + config?: AxiosRequestConfig + ): Promise<TMediaArtifact> { + const formData = new FormData(); + formData.append("file", file); + formData.append("name", payload.name); + formData.append("title", payload.title); + if (payload.description !== undefined) { + formData.append("description", payload.description ?? ""); + } + formData.append("format", payload.format); + formData.append("action", payload.action); + formData.append("meta", JSON.stringify(payload.meta ?? {})); + if (payload.work_item_id !== undefined) { + formData.append("work_item_id", payload.work_item_id ?? ""); + } + if (payload.link !== undefined) { + formData.append("link", payload.link ?? ""); + } + if (payload.created_at) formData.append("created_at", payload.created_at); + if (payload.updated_at) formData.append("updated_at", payload.updated_at); + if (payload.path) formData.append("path", payload.path); + + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/artifacts/`, + formData, + { ...config, onUploadProgress } + ) + .then((response) => response?.data as TMediaArtifact) + .catch((error) => { + if (error?.response?.status === 413) throw error.response; + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async enqueueArtifactTranscode( + workspaceSlug: string, + projectId: string, + packageId: string, + artifactId: string, + payload: TMediaTranscodeEnqueuePayload = {} + ): Promise<TMediaTranscodeJobResponse> { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/artifacts/${encodeURIComponent( + artifactId + )}/transcode/`, + payload + ) + .then((response) => response?.data as TMediaTranscodeJobResponse) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async getArtifactTranscodeJob( + workspaceSlug: string, + projectId: string, + packageId: string, + artifactId: string, + jobId: string + ): Promise<TMediaTranscodeJobResponse> { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/artifacts/${encodeURIComponent( + artifactId + )}/transcode/jobs/${encodeURIComponent(jobId)}/` + ) + .then((response) => response?.data as TMediaTranscodeJobResponse) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async retryArtifactTranscodeJob( + workspaceSlug: string, + projectId: string, + packageId: string, + artifactId: string, + jobId: string + ): Promise<TMediaTranscodeJobResponse> { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/artifacts/${encodeURIComponent( + artifactId + )}/transcode/jobs/${encodeURIComponent(jobId)}/retry/`, + {} + ) + .then((response) => response?.data as TMediaTranscodeJobResponse) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async cancelArtifactTranscodeJob( + workspaceSlug: string, + projectId: string, + packageId: string, + artifactId: string, + jobId: string + ): Promise<TMediaTranscodeJobResponse> { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/artifacts/${encodeURIComponent( + artifactId + )}/transcode/jobs/${encodeURIComponent(jobId)}/cancel/`, + {} + ) + .then((response) => response?.data as TMediaTranscodeJobResponse) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async createArtifact( + workspaceSlug: string, + projectId: string, + packageId: string, + payload: TMediaArtifactPayload | TMediaArtifactPayload[] + ): Promise<TMediaArtifact | TMediaArtifact[]> { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/artifacts/`, + payload + ) + .then((response) => response?.data ?? null) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async createPlaylist(payload: TCreatePlaylistPayload[]): Promise<string | null> { + const cpServerBaseUrl = process.env.NEXT_PUBLIC_CP_SERVER_URL?.replace(/\/$/, "") ?? ""; + if (!cpServerBaseUrl) { + throw new Error("NEXT_PUBLIC_CP_SERVER_URL is not configured."); + } + + return this.post(`${cpServerBaseUrl}/query-engine/create-playlist`, payload, { withCredentials: false }) + .then((response) => readPlaylistFileName(response?.data)) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async createCustomPlaylist(payload: TCustomPlaylistPayload): Promise<TCustomPlaylist> { + return this.post("/api/custom-playlists/", payload) + .then((response) => response?.data as TCustomPlaylist) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async getCustomPlaylists(eventId: string, params: TCustomPlaylistListParams = {}): Promise<TCustomPlaylist[]> { + return this.get("/api/custom-playlists/", { + params: { + event_id: eventId, + project_id: params.projectId, + workspace_slug: params.workspaceSlug, + }, + }) + .then((response) => (Array.isArray(response?.data) ? (response.data as TCustomPlaylist[]) : [])) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async updateCustomPlaylist(playlistId: string, payload: TCustomPlaylistUpdatePayload): Promise<TCustomPlaylist> { + return this.patch(`/api/custom-playlists/${playlistId}/`, payload) + .then((response) => response?.data as TCustomPlaylist) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async deleteCustomPlaylist(playlistId: string): Promise<void> { + return this.delete(`/api/custom-playlists/${playlistId}/`) + .then(() => undefined) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async deleteArtifact(workspaceSlug: string, projectId: string, packageId: string, artifactId: string): Promise<void> { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/artifacts/${encodeURIComponent( + artifactId + )}/` + ) + .then(() => undefined) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async updateManifestMetadata( + workspaceSlug: string, + projectId: string, + packageId: string, + payload: TMediaManifestMetaUpdatePayload + ): Promise<{ updated?: number } | null> { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/manifest/`, + payload + ) + .then((response) => response?.data ?? null) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async updateManifestArtifacts( + workspaceSlug: string, + projectId: string, + packageId: string, + payload: TMediaManifestArtifactUpdatePayload + ): Promise<{ updated?: number } | null> { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/manifest/`, + payload + ) + .then((response) => response?.data ?? null) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } + + async updateEventVideoAnnotations( + workspaceSlug: string, + projectId: string, + packageId: string, + artifactId: string, + payload: TEventVideoAnnotationUpdatePayload + ): Promise<TEventVideoAnnotationUpdateResponse> { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/media-library/packages/${packageId}/artifacts/${encodeURIComponent( + artifactId + )}/file/`, + payload + ) + .then((response) => response?.data ?? {}) + .catch((error) => { + throw error?.response?.data ?? error?.response ?? error; + }); + } +} diff --git a/apps/web/core/services/roster.service.ts b/apps/web/core/services/roster.service.ts new file mode 100644 index 00000000000..7baa20ff1b4 --- /dev/null +++ b/apps/web/core/services/roster.service.ts @@ -0,0 +1,81 @@ +import { API_BASE_URL } from "@plane/constants"; +import type { IRosterPlayer, IRosterPlayerPayload } from "@plane/types"; +import { APIService } from "@/services/api.service"; + +type TRosterImportPayload = { + players: IRosterPlayerPayload[]; +}; + +type TRosterImportResponse = { + success: boolean; + data: IRosterPlayer[]; + imported_count: number; + message: string; +}; + +export class RosterService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getRoster(workspaceSlug: string, projectId: string): Promise<IRosterPlayer[]> { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/roster/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getRosterPlayer(workspaceSlug: string, projectId: string, playerId: string): Promise<IRosterPlayer> { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/roster/${playerId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async createRosterPlayer( + workspaceSlug: string, + projectId: string, + payload: IRosterPlayerPayload + ): Promise<IRosterPlayer> { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/roster/`, payload) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async updateRosterPlayer( + workspaceSlug: string, + projectId: string, + playerId: string, + payload: Partial<IRosterPlayerPayload> + ): Promise<IRosterPlayer> { + return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/roster/${playerId}/`, payload) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteRosterPlayer(workspaceSlug: string, projectId: string, playerId: string): Promise<{ message: string }> { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/roster/${playerId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async importRoster( + workspaceSlug: string, + projectId: string, + payload: TRosterImportPayload + ): Promise<TRosterImportResponse> { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/roster/import/`, payload) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/apps/web/core/store/issue/helpers/base-issues.store.ts b/apps/web/core/store/issue/helpers/base-issues.store.ts index f9e0f7207c0..8a6aa6893af 100644 --- a/apps/web/core/store/issue/helpers/base-issues.store.ts +++ b/apps/web/core/store/issue/helpers/base-issues.store.ts @@ -22,7 +22,7 @@ import type { } from "@plane/types"; import { EIssueServiceType, EIssueLayoutTypes } from "@plane/types"; // helpers -import { convertToISODateString } from "@plane/utils"; +import { convertToISODateString, isDateTimePast } from "@plane/utils"; // local-db import { SPECIAL_ORDER_BY } from "@/local-db/utils/query-constructor"; import { updatePersistentLayer } from "@/local-db/utils/utils"; @@ -43,7 +43,7 @@ import { } from "./base-issues-utils"; import type { IBaseIssueFilterStore } from "./issue-filter-helper.store"; -export type TIssueDisplayFilterOptions = Exclude<TIssueGroupByOptions, null> | "target_date"; +export type TIssueDisplayFilterOptions = Exclude<TIssueGroupByOptions, null> | "target_date" | "start_date"; export enum EIssueGroupedAction { ADD = "ADD", @@ -118,6 +118,7 @@ export const ISSUE_GROUP_BY_KEY: Record<TIssueDisplayFilterOptions, keyof TIssue created_by: "created_by", assignees: "assignee_ids", target_date: "target_date", + start_date: "start_date", cycle: "cycle_id", module: "module_ids", team_project: "project_id", @@ -134,6 +135,7 @@ export const ISSUE_FILTER_DEFAULT_DATA: Record<TIssueDisplayFilterOptions, keyof created_by: "created_by", assignees: "assignee_ids", target_date: "target_date", + start_date: "start_date", team_project: "project_id", }; @@ -162,6 +164,18 @@ const ISSUE_ORDERBY_KEY: Record<TIssueOrderByOptions, keyof TIssue> = { "-estimate_point__key": "estimate_point", start_date: "start_date", "-start_date": "start_date", + start_time : "start_time", + "-start_time": "start_time", + level : "level", + "-level": "level", + sport : "sport", + "-sport" : "sport", + year : "year", + "-year": "year", + program: "program", + "-program": "program", + category: "category", + "-category" : "category", link_count: "link_count", "-link_count": "link_count", attachment_count: "attachment_count", @@ -257,6 +271,20 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { this.controller = new AbortController(); } + protected isIssueDateTimeLocked(issueId: string) { + const issue = this.rootIssueStore.issues.getIssueById(issueId); + + return isDateTimePast(issue?.start_date, issue?.start_time); + } + + protected getUnlockedDateTimeUpdate(data: Partial<TIssue>) { + const unlockedData = { ...data }; + delete unlockedData.start_date; + delete unlockedData.start_time; + + return unlockedData; + } + // Abstract class to be implemented to fetch parent stats such as project, module or cycle details abstract fetchParentStats: (workspaceSlug: string, projectId?: string, id?: string) => void; @@ -302,7 +330,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { const layout = displayFilters?.layout; return layout === EIssueLayoutTypes.CALENDAR - ? "target_date" + ? "start_date" : [EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN]?.includes(layout) ? displayFilters?.group_by : undefined; @@ -570,12 +598,15 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { data: Partial<TIssue>, shouldSync = true ) { + const nextData = this.isIssueDateTimeLocked(issueId) ? this.getUnlockedDateTimeUpdate(data) : data; + if (!Object.keys(nextData).length) return; + // Store Before state of the issue const issueBeforeUpdate = clone(this.rootIssueStore.issues.getIssueById(issueId)); try { // Update the Respective Stores - this.rootIssueStore.issues.updateIssue(issueId, data); - this.updateIssueList({ ...issueBeforeUpdate, ...data } as TIssue, issueBeforeUpdate); + this.rootIssueStore.issues.updateIssue(issueId, nextData); + this.updateIssueList({ ...issueBeforeUpdate, ...nextData } as TIssue, issueBeforeUpdate); // Check if should Sync if (!shouldSync) return; @@ -583,18 +614,18 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { // update parent stats optimistically this.updateParentStats(issueBeforeUpdate, { ...issueBeforeUpdate, - ...data, + ...nextData, } as TIssue); // call API to update the issue - await this.issueService.patchIssue(workspaceSlug, projectId, issueId, data); + await this.issueService.patchIssue(workspaceSlug, projectId, issueId, nextData); // call fetch Parent Stats this.fetchParentStats(workspaceSlug, projectId); } catch (error) { // If errored out update store again to revert the change this.rootIssueStore.issues.updateIssue(issueId, issueBeforeUpdate ?? {}); - this.updateIssueList(issueBeforeUpdate, { ...issueBeforeUpdate, ...data } as TIssue); + this.updateIssueList(issueBeforeUpdate, { ...issueBeforeUpdate, ...nextData } as TIssue); throw error; } } @@ -731,17 +762,77 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { * @param {TBulkOperationsPayload} data */ bulkUpdateProperties = async (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => { - const issueIds = data.issue_ids; - // make request to update issue properties - await this.issueService.bulkOperations(workspaceSlug, projectId, data); + const lockedIssueIds = new Set(data.issue_ids.filter((issueId) => this.isIssueDateTimeLocked(issueId))); + const hasDateTimeUpdate = data.properties.start_date !== undefined || data.properties.start_time !== undefined; + + if (!hasDateTimeUpdate || !lockedIssueIds.size) { + await this.issueService.bulkOperations(workspaceSlug, projectId, data); + + runInAction(() => { + data.issue_ids.forEach((issueId) => { + const issueBeforeUpdate = clone(this.rootIssueStore.issues.getIssueById(issueId)); + if (!issueBeforeUpdate) throw new Error("Work item not found"); + Object.keys(data.properties).forEach((key) => { + const property = key as keyof TBulkOperationsPayload["properties"]; + const propertyValue = data.properties[property]; + if (Array.isArray(propertyValue)) { + const existingValue = issueBeforeUpdate[property]; + const newExistingValue = Array.isArray(existingValue) ? existingValue : []; + this.rootIssueStore.issues.updateIssue(issueId, { + [property]: uniq([...newExistingValue, ...propertyValue]), + }); + } else { + this.rootIssueStore.issues.updateIssue(issueId, { + [property]: propertyValue, + }); + } + }); + const issueDetails = this.rootIssueStore.issues.getIssueById(issueId); + this.updateIssueList(issueDetails, issueBeforeUpdate); + }); + }); + + return; + } + + const unlockedIssueIds = data.issue_ids.filter((issueId) => !lockedIssueIds.has(issueId)); + const { start_date, start_time, ...otherProperties } = data.properties; + const dateTimeProperties: TBulkOperationsPayload["properties"] = {}; + + if (start_date !== undefined) dateTimeProperties.start_date = start_date; + if (start_time !== undefined) dateTimeProperties.start_time = start_time; + + if (Object.keys(otherProperties).length) { + await this.issueService.bulkOperations(workspaceSlug, projectId, { + ...data, + properties: otherProperties, + }); + } + + if (Object.keys(dateTimeProperties).length && unlockedIssueIds.length) { + await this.issueService.bulkOperations(workspaceSlug, projectId, { + ...data, + issue_ids: unlockedIssueIds, + properties: dateTimeProperties, + }); + } + // update issues in the store - runInAction(() => { - issueIds.forEach((issueId) => { + runInAction(() => { + data.issue_ids.forEach((issueId) => { + const properties: TBulkOperationsPayload["properties"] = lockedIssueIds.has(issueId) + ? otherProperties + : { + ...otherProperties, + ...dateTimeProperties, + }; + if (!Object.keys(properties).length) return; + const issueBeforeUpdate = clone(this.rootIssueStore.issues.getIssueById(issueId)); if (!issueBeforeUpdate) throw new Error("Work item not found"); - Object.keys(data.properties).forEach((key) => { + Object.keys(properties).forEach((key) => { const property = key as keyof TBulkOperationsPayload["properties"]; - const propertyValue = data.properties[property]; + const propertyValue = properties[property]; // update root issue map properties if (Array.isArray(propertyValue)) { // if property value is array, append it to the existing values @@ -770,11 +861,23 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { projectId?: string ) { if (!projectId) return; + const sanitizedUpdates: { id: string; start_date?: string; target_date?: string }[] = updates + .map((update) => { + if (!this.isIssueDateTimeLocked(update.id)) return update; + + return { + id: update.id, + target_date: update.target_date, + }; + }) + .filter((update) => update.start_date !== undefined || update.target_date !== undefined); + if (!sanitizedUpdates.length) return; + const issueDatesBeforeChange: { id: string; start_date?: string; target_date?: string }[] = []; try { const getIssueById = this.rootIssueStore.issues.getIssueById; runInAction(() => { - for (const update of updates) { + for (const update of sanitizedUpdates) { const dates: Partial<TIssue> = {}; if (update.start_date) dates.start_date = update.start_date; if (update.target_date) dates.target_date = update.target_date; @@ -793,7 +896,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { } }); - await this.issueService.updateIssueDates(workspaceSlug, projectId, updates); + await this.issueService.updateIssueDates(workspaceSlug, projectId, sanitizedUpdates); } catch (e) { runInAction(() => { for (const update of issueDatesBeforeChange) { diff --git a/apps/web/core/store/issue/helpers/issue-filter-helper.store.ts b/apps/web/core/store/issue/helpers/issue-filter-helper.store.ts index e99eb7c9c20..e1cf1d0d400 100644 --- a/apps/web/core/store/issue/helpers/issue-filter-helper.store.ts +++ b/apps/web/core/store/issue/helpers/issue-filter-helper.store.ts @@ -310,7 +310,9 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { // 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`; + // Use the same date field as groupedBy (e.g., start_date for calendar, target_date otherwise) + const dateField = options.groupedBy === "start_date" ? "start_date" : "target_date"; + paginationParams[dateField] = `${options.after};after,${options.before};before`; } // If groupId is passed down, add a filter param for that group Id diff --git a/apps/web/core/store/issue/issue-details/issue.store.ts b/apps/web/core/store/issue/issue-details/issue.store.ts index a5c7eb5174e..71e8d22c681 100644 --- a/apps/web/core/store/issue/issue-details/issue.store.ts +++ b/apps/web/core/store/issue/issue-details/issue.store.ts @@ -162,6 +162,7 @@ export class IssueStore implements IIssueStore { const issuePayload: TIssue = { id: issue?.id, sequence_id: issue?.sequence_id, + sg_event_id: issue?.sg_event_id, name: issue?.name, description_html: issue?.description_html, sort_order: issue?.sort_order, @@ -181,6 +182,13 @@ export class IssueStore implements IIssueStore { created_at: issue?.created_at, updated_at: issue?.updated_at, start_date: issue?.start_date, + start_time: issue?.start_time, + opposition_team: issue?.opposition_team, + level: issue?.level, + sport: issue?.sport, + program: issue?.program, + year: issue?.year, + category: issue?.category, target_date: issue?.target_date, completed_at: issue?.completed_at, archived_at: issue?.archived_at, diff --git a/apps/web/core/store/issue/issue_calendar_view.store.ts b/apps/web/core/store/issue/issue_calendar_view.store.ts index 9e00cbd7eb4..f3248f2d1b1 100644 --- a/apps/web/core/store/issue/issue_calendar_view.store.ts +++ b/apps/web/core/store/issue/issue_calendar_view.store.ts @@ -112,16 +112,39 @@ export class CalendarStore implements ICalendarStore { return getWeekNumberOfDate(this.calendarFilters.activeWeekDate); } - get allDaysOfActiveWeek() { - if (!this.calendarPayload) return undefined; +get allDaysOfActiveWeek() { + if (!this.calendarPayload) return undefined; + + const activeDate = this.calendarFilters.activeWeekDate; + const yearKey = `y-${activeDate.getFullYear()}`; + const isoString = activeDate.toISOString().split("T")[0]; + + const yearData = this.calendarPayload[yearKey]; + if (!yearData) return undefined; + + const monthsToCheck = [ + `m-${activeDate.getMonth()}`, + `m-${activeDate.getMonth() + 1}`, + `m-${activeDate.getMonth() - 1}`, + ]; + + for (const monthKey of monthsToCheck) { + const monthData = yearData[monthKey]; + if (!monthData) continue; - const { activeWeekDate } = this.calendarFilters; + for (const [weekKey, weekData] of Object.entries(monthData)) { + const dates = Object.keys(weekData); - return this.calendarPayload[`y-${activeWeekDate.getFullYear()}`][`m-${activeWeekDate.getMonth()}`][ - `w-${this.activeWeekNumber - 1}` - ]; + if (dates.includes(isoString)) { + return weekData; + } + } } + return undefined; +} + + getStartAndEndDate = computedFn((layout: "week" | "month") => { switch (layout) { case "week": { diff --git a/apps/web/helpers/opposition-team.ts b/apps/web/helpers/opposition-team.ts new file mode 100644 index 00000000000..c9e5747de03 --- /dev/null +++ b/apps/web/helpers/opposition-team.ts @@ -0,0 +1,44 @@ +export type TOppositionTeamOption = { + name: string; + logo: string; +}; + +export const normalizeOppositionTeam = (value: unknown): TOppositionTeamOption | null => { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + + const name = typeof (value as { name?: unknown }).name === "string" ? (value as { name: string }).name.trim() : ""; + const logo = typeof (value as { logo?: unknown }).logo === "string" ? (value as { logo: string }).logo.trim() : ""; + + if (!name) return null; + + return { name, logo }; +}; + +export const parseOppositionTeam = (value: unknown): TOppositionTeamOption | null => { + const normalizedObject = normalizeOppositionTeam(value); + if (normalizedObject) return normalizedObject; + + if (typeof value !== "string") return null; + + const trimmedValue = value.trim(); + if (!trimmedValue) return null; + + try { + const parsedValue = JSON.parse(trimmedValue); + const normalizedParsedValue = normalizeOppositionTeam(parsedValue); + if (normalizedParsedValue) return normalizedParsedValue; + } catch { + return { name: trimmedValue, logo: "" }; + } + + return { name: trimmedValue, logo: "" }; +}; + +export const serializeOppositionTeam = (team: TOppositionTeamOption | null): string | null => { + if (!team) return null; + + const normalizedTeam = normalizeOppositionTeam(team); + if (!normalizedTeam) return null; + + return JSON.stringify(normalizedTeam); +}; diff --git a/apps/web/next.config.js b/apps/web/next.config.js index c20d64ed5e8..865ca8c5aeb 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -39,7 +39,6 @@ const nextConfig = { "@plane/propel", "@plane/services", "@plane/shared-state", - "@plane/types", "@plane/ui", "@plane/utils", ], diff --git a/apps/web/package.json b/apps/web/package.json index 81e60e2b2ee..63457218fa4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ "check:lint": "eslint . --max-warnings 821", "check:types": "tsc --noEmit", "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", + "test:matrix": "node --experimental-strip-types --test --experimental-test-isolation=none core/components/issues/issue-detail/sg-event-detail-page/matrix-view/__tests__/matrix-model.test.ts", "fix:lint": "eslint . --fix", "fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"" }, @@ -39,11 +40,14 @@ "cmdk": "^1.0.0", "comlink": "^4.4.1", "date-fns": "^4.1.0", + "dompurify": "3.2.7", "dotenv": "^16.0.3", "emoji-picker-react": "^4.5.16", "export-to-csv": "^1.4.0", + "hls.js": "^1.5.13", "lodash-es": "catalog:", "lucide-react": "catalog:", + "mammoth": "^1.11.0", "mobx": "catalog:", "mobx-react": "catalog:", "mobx-utils": "catalog:", @@ -58,14 +62,18 @@ "react-markdown": "^8.0.7", "react-masonry-component": "^6.3.0", "react-pdf-html": "^2.1.2", + "react-phone-input-2": "^2.15.1", "react-popper": "^2.3.0", "recharts": "^2.12.7", "sharp": "catalog:", "smooth-scroll-into-view-if-needed": "^2.0.2", + "swiper": "^12.0.3", "swr": "catalog:", "tailwind-merge": "^2.0.0", "use-font-face-observer": "^1.2.2", - "uuid": "catalog:" + "uuid": "catalog:", + "video.js": "^8.23.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@plane/eslint-config": "workspace:*", diff --git a/apps/web/public/templates/roster-template.xlsx b/apps/web/public/templates/roster-template.xlsx new file mode 100644 index 00000000000..f1638c72c5d Binary files /dev/null and b/apps/web/public/templates/roster-template.xlsx differ diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index 5fbc91843c8..d42454489f2 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -1,10 +1,40 @@ @import "@plane/propel/styles/fonts"; @import "@plane/editor/styles"; +@import "react-phone-input-2/lib/style.css"; +@import "swiper/css"; +@import "swiper/css/navigation"; +@import "swiper/css/scrollbar"; +@import "video.js/dist/video-js.css"; @tailwind base; @tailwind components; @tailwind utilities; +/* Fix flag area */ +.react-tel-input .selected-flag { + background: transparent !important; +} + +/* Country dropdown list */ +.react-tel-input .country-list { + background-color: rgba(var(--color-background-90), 1) !important; +} + +.react-tel-input .country-list .country.highlight { + background: #27272a !important; + border: 1px solid rgb(var(--color-background-80)) !important; +} + +/* Selected country (after clicking) */ +.react-tel-input .country-list .country.active { + background: rgb(var(--color-background-80)) !important; +} + +/* Hover state */ +.react-tel-input .country-list .country:hover { + background: rgb(var(--color-background-80)) !important; +} + @layer components { .text-1\.5xl { font-size: 1.375rem; @@ -15,6 +45,74 @@ font-size: 1.75rem; line-height: 2.25rem; } + + .sg-matrix-workspace { + --sg-matrix-page: #171717; + --sg-matrix-sidebar: #181818; + --sg-matrix-panel: #1d1d1d; + --sg-matrix-panel-secondary: #202020; + --sg-matrix-video-bg: #111111; + --sg-matrix-hover: #272727; + --sg-matrix-selected-nav: #242424; + --sg-matrix-popover: #222222; + --sg-matrix-border: #2c2c2c; + --sg-matrix-grid-border: #303030; + --sg-matrix-active-border: #4ea1ff; + --sg-matrix-selected-card-border: #8b8b8b; + --sg-matrix-text: #e5e5e5; + --sg-matrix-text-secondary: #a6a6a6; + --sg-matrix-tag-title: #b8b8b8; + --sg-matrix-text-muted: #737373; + --sg-matrix-text-disabled: #5f5f5f; + --sg-matrix-header-text: #121212; + --sg-matrix-primary-blue: #3b9af8; + --sg-matrix-blue-hover: #5aacff; + --sg-matrix-toggle-active: #45a5f5; + --sg-matrix-cell-empty: #1f1f1f; + --sg-matrix-cell-empty-hover: #292929; + --sg-matrix-row-label-bg: #232323; + --sg-matrix-row-label-text: #afafaf; + --sg-matrix-cell-l1: #3e8de3; + --sg-matrix-cell-l2: #2879d3; + --sg-matrix-cell-l3: #1665bc; + --sg-matrix-cell-l4: #0d53a7; + --sg-matrix-selected-cell: #ffc107; + --sg-matrix-selected-cell-inner: #ffe082; + --sg-matrix-cell-text: #b9d8ff; + --sg-matrix-selected-cell-text: #101010; + --sg-matrix-offense-bg: #9cc7f5; + --sg-matrix-offense-accent: #3698f5; + --sg-matrix-defense-bg: #ffb8ba; + --sg-matrix-defense-accent: #ff5257; + --sg-matrix-special-bg: #aef0e2; + --sg-matrix-special-accent: #24cda9; + --sg-matrix-period-bg: #f2adf3; + --sg-matrix-period-accent: #ee42ef; + --sg-matrix-neutral-bg: #aef0e2; + --sg-matrix-neutral-accent: #24cda9; + } + + [data-theme="light"] .sg-matrix-workspace, + [data-theme="light-contrast"] .sg-matrix-workspace { + --sg-matrix-page: #f4f4f5; + --sg-matrix-sidebar: #f7f7f8; + --sg-matrix-panel: #ffffff; + --sg-matrix-panel-secondary: #f1f1f2; + --sg-matrix-video-bg: #111111; + --sg-matrix-hover: #e8e8ea; + --sg-matrix-selected-nav: #e4e4e7; + --sg-matrix-popover: #ffffff; + --sg-matrix-border: #d4d4d8; + --sg-matrix-grid-border: #d8d8dc; + --sg-matrix-text: #171717; + --sg-matrix-text-secondary: #525252; + --sg-matrix-tag-title: #3f3f46; + --sg-matrix-text-muted: #737373; + --sg-matrix-cell-empty: #f2f2f3; + --sg-matrix-cell-empty-hover: #e8e8ea; + --sg-matrix-row-label-bg: #f7f7f8; + --sg-matrix-row-label-text: #525252; + } } @layer utilities { @@ -81,6 +179,13 @@ --color-text-350: 130, 130, 130; --color-text-400: 163, 163, 163; /* placeholder text */ + --media-library-upload-text-primary: var(--color-text-100); + --media-library-upload-text-body: var(--color-text-100); + --media-library-upload-text-label: var(--color-text-300); + --media-library-upload-text-muted: var(--color-text-400); + --media-library-upload-text-optional: var(--color-text-400); + --media-library-upload-placeholder-text: var(--color-text-400); + --color-text-primary: var(--color-primary-100); --color-text-error: var(--color-error-200); @@ -393,6 +498,13 @@ --color-text-350: 130, 130, 130; --color-text-400: 82, 82, 82; /* placeholder text */ + --media-library-upload-text-primary: 229, 231, 235; + --media-library-upload-text-body: 255, 255, 255; + --media-library-upload-text-label: 163, 163, 159; + --media-library-upload-text-muted: 163, 163, 159; + --media-library-upload-text-optional: 117, 117, 117; + --media-library-upload-placeholder-text: 229, 231, 235; + --color-scrollbar: 82, 82, 82; /* scrollbar thumb */ --color-border-100: 34, 34, 34; /* subtle border= 1 */ @@ -428,6 +540,13 @@ --color-text-300: 212, 212, 212; /* tertiary text */ --color-text-350: 190, 190, 190 --color-text-400: 115, 115, 115; /* placeholder text */ + --media-library-upload-text-primary: 229, 231, 235; + --media-library-upload-text-body: 255, 255, 255; + --media-library-upload-text-label: 163, 163, 159; + --media-library-upload-text-muted: 163, 163, 159; + --media-library-upload-text-optional: 117, 117, 117; + --media-library-upload-placeholder-text: 229, 231, 235; + --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ --color-border-100: 245, 245, 245; /* subtle border= 1 */ @@ -794,6 +913,56 @@ div.web-view-spinner div.bar12 { .horizontal-scrollbar::-webkit-scrollbar-corner { background-color: transparent; } + +.sg-event-tags-list-scrollbar { + scrollbar-color: #1780d5 transparent; + scrollbar-width: thin; +} +.sg-event-tags-list-scrollbar:hover, +.sg-event-tags-list-scrollbar:active { + scrollbar-color: #1780d5 transparent; +} +.sg-event-tags-list-scrollbar.scrollbar-lg::-webkit-scrollbar { + height: 6px; + width: 6px; +} +.sg-event-tags-list-scrollbar::-webkit-scrollbar-thumb, +.sg-event-tags-list-scrollbar:hover::-webkit-scrollbar-thumb, +.sg-event-tags-list-scrollbar::-webkit-scrollbar-thumb:hover, +.sg-event-tags-list-scrollbar::-webkit-scrollbar-thumb:active { + background-color: #1780d5; +} +.sg-event-tags-list-scrollbar.scrollbar-lg::-webkit-scrollbar-thumb { + border: 1px solid rgba(0, 0, 0, 0); +} + +.sg-event-timeline-scrollbar { + scrollbar-color: rgba(59, 130, 246, 0.85) rgba(15, 23, 42, 0.72); + scrollbar-width: thin; +} +.sg-event-timeline-scrollbar:hover, +.sg-event-timeline-scrollbar:active { + scrollbar-color: rgba(96, 165, 250, 0.95) rgba(15, 23, 42, 0.86); +} +.sg-event-timeline-scrollbar::-webkit-scrollbar-track { + background-color: rgba(15, 23, 42, 0.72); + border-radius: 9999px; +} +.sg-event-timeline-scrollbar::-webkit-scrollbar-track:hover { + background-color: rgba(15, 23, 42, 0.9); +} +.sg-event-timeline-scrollbar::-webkit-scrollbar-thumb, +.sg-event-timeline-scrollbar:hover::-webkit-scrollbar-thumb, +.sg-event-timeline-scrollbar::-webkit-scrollbar-thumb:hover, +.sg-event-timeline-scrollbar::-webkit-scrollbar-thumb:active { + background-color: rgba(59, 130, 246, 0.88); + border-color: rgba(15, 23, 42, 0.72); +} +.sg-event-timeline-scrollbar:hover::-webkit-scrollbar-thumb, +.sg-event-timeline-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(96, 165, 250, 0.98); +} + .vertical-scrollbar-margin-top-md::-webkit-scrollbar-track { margin-top: 44px; } @@ -850,6 +1019,12 @@ div.web-view-spinner div.bar12 { background: rgb(var(--color-background-80)); } +/* Media library row alignment when swiper doesn't overflow */ +.media-swiper.swiper-locked .swiper-wrapper { + justify-content: space-between; + width: 100%; +} + /* By applying below class, the autofilled text in form fields will not have the default autofill background color and styles applied by WebKit browsers */ .disable-autofill-style:-webkit-autofill, .disable-autofill-style:-webkit-autofill:hover, diff --git a/apps/web/third-party.d.ts b/apps/web/third-party.d.ts new file mode 100644 index 00000000000..348e20cc4f8 --- /dev/null +++ b/apps/web/third-party.d.ts @@ -0,0 +1,49 @@ +declare module "mammoth" { + export type MammothResult = { + value: string; + messages?: unknown[]; + }; + + export type ConvertToHtmlOptions = { + arrayBuffer: ArrayBuffer; + }; + + export function convertToHtml(options: ConvertToHtmlOptions): Promise<MammothResult>; + + const defaultExport: { + convertToHtml: typeof convertToHtml; + }; + + export default defaultExport; +} + +declare module "xlsx" { + export type WorkBook = { + SheetNames: string[]; + Sheets: Record<string, unknown>; + }; + + export type SheetToJsonOptions = { + defval?: unknown; + raw?: boolean; + }; + + export type ReadOptions = { + type: "array"; + sheetRows?: number; + }; + + export function read(data: ArrayBuffer, options: ReadOptions): WorkBook; + + export const utils: { + sheet_to_html(sheet: unknown): string; + sheet_to_json<T = Record<string, unknown>>(sheet: unknown, options?: SheetToJsonOptions): T[]; + }; + + const defaultExport: { + read: typeof read; + utils: typeof utils; + }; + + export default defaultExport; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f2589c8db37..27cbf0530e1 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,17 +1,39 @@ { "extends": "@plane/typescript-config/nextjs.json", "compilerOptions": { + "jsx": "preserve", "baseUrl": ".", "paths": { - "@/*": ["core/*"], - "@/helpers/*": ["helpers/*"], - "@/public/*": ["public/*"], - "@/styles/*": ["styles/*"], - "@/plane-web/*": ["ce/*"] + "@/*": [ + "core/*" + ], + "@/helpers/*": [ + "helpers/*" + ], + "@/public/*": [ + "public/*" + ], + "@/styles/*": [ + "styles/*" + ], + "@/plane-web/*": [ + "ce/*" + ] }, - "plugins": [{ "name": "next" }], + "plugins": [ + { + "name": "next" + } + ], "strictNullChecks": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/deployments/aio/community/Dockerfile b/deployments/aio/community/Dockerfile index b217d09f8bb..24b175af606 100644 --- a/deployments/aio/community/Dockerfile +++ b/deployments/aio/community/Dockerfile @@ -47,7 +47,7 @@ COPY --from=backend-img /code /app/backend COPY --from=backend-img /usr/local/lib/python3.12/site-packages/ /usr/local/lib/python3.12/site-packages/ COPY --from=backend-img /usr/local/bin/ /usr/local/bin/ -RUN apk add --no-cache nss-tools bash curl uuidgen ncdu vim +RUN apk add --no-cache nss-tools bash curl uuidgen ncdu vim ffmpeg RUN pip install supervisor RUN mkdir -p /etc/supervisor/conf.d diff --git a/deployments/aio/community/variables.env b/deployments/aio/community/variables.env index 99d93e3fda2..0458b33fb67 100644 --- a/deployments/aio/community/variables.env +++ b/deployments/aio/community/variables.env @@ -36,7 +36,7 @@ AWS_SECRET_ACCESS_KEY= AWS_S3_ENDPOINT_URL=https://s3.amazonaws.com AWS_S3_BUCKET_NAME= BUCKET_NAME= -FILE_SIZE_LIMIT=5242880 +FILE_SIZE_LIMIT=524288000 # Gunicorn Workers GUNICORN_WORKERS=1 diff --git a/deployments/cli/community/docker-compose.yml b/deployments/cli/community/docker-compose.yml index 3833b96bad4..8286cecb5b7 100644 --- a/deployments/cli/community/docker-compose.yml +++ b/deployments/cli/community/docker-compose.yml @@ -26,13 +26,13 @@ x-aws-s3-env: &aws-s3-env x-proxy-env: &proxy-env APP_DOMAIN: ${APP_DOMAIN:-localhost} FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} - CERT_EMAIL: ${CERT_EMAIL} - CERT_ACME_CA: ${CERT_ACME_CA} - CERT_ACME_DNS: ${CERT_ACME_DNS} - LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-80} - LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-443} + CERT_EMAIL: ${CERT_EMAIL:-} + CERT_ACME_CA: ${CERT_ACME_CA:-https://acme-v02.api.letsencrypt.org/directory} + CERT_ACME_DNS: ${CERT_ACME_DNS:-} + LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-8081} +# LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-443} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} - SITE_ADDRESS: ${SITE_ADDRESS:-:80} + SITE_ADDRESS: ${SITE_ADDRESS:-:8081} x-mq-env: &mq-env # RabbitMQ Settings RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq} @@ -54,10 +54,11 @@ x-app-env: &app-env USE_MINIO: ${USE_MINIO:-1} DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane} SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} + LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW} AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane} API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute} MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0} - LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW} + ENABLE_DRF_SPECTACULAR: 1 services: web: @@ -66,6 +67,8 @@ services: replicas: ${WEB_REPLICAS:-1} restart_policy: condition: any + profiles: + - full depends_on: - api - worker @@ -76,6 +79,8 @@ services: replicas: ${SPACE_REPLICAS:-1} restart_policy: condition: any + profiles: + - full depends_on: - api - worker @@ -87,6 +92,8 @@ services: replicas: ${ADMIN_REPLICAS:-1} restart_policy: condition: any + profiles: + - full depends_on: - api - web @@ -94,11 +101,13 @@ services: live: image: artifacts.plane.so/makeplane/plane-live:${APP_RELEASE:-stable} environment: - <<: [*live-env] + <<: [*live-env, *redis-env] deploy: replicas: ${LIVE_REPLICAS:-1} restart_policy: condition: any + profiles: + - full depends_on: - api - web @@ -112,6 +121,8 @@ services: condition: any volumes: - logs_api:/code/plane/logs + ports: + - "8000:8000" environment: <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: @@ -176,13 +187,15 @@ services: replicas: 1 restart_policy: condition: any + ports: + - "5432:5432" environment: <<: *db-env volumes: - pgdata:/var/lib/postgresql/data plane-redis: - image: valkey/valkey:7.2.11-alpine + image: valkey/valkey:7.2.5-alpine deploy: replicas: 1 restart_policy: @@ -224,23 +237,25 @@ services: environment: <<: *proxy-env ports: - - target: 80 - published: ${LISTEN_HTTP_PORT:-80} - protocol: tcp - mode: host - - target: 443 - published: ${LISTEN_HTTPS_PORT:-443} + - target: 8081 + published: ${LISTEN_HTTP_PORT:-8081} protocol: tcp mode: host +# - target: 443 +# published: ${LISTEN_HTTPS_PORT:-443} +# protocol: tcp +# mode: host volumes: - proxy_config:/config - proxy_data:/data + profiles: + - full depends_on: - web - api - space - admin - - live +# - live volumes: pgdata: @@ -252,4 +267,4 @@ volumes: logs_migrator: rabbitmq_data: proxy_config: - proxy_data: + proxy_data: \ No newline at end of file diff --git a/deployments/cli/community/variables.env b/deployments/cli/community/variables.env index 5a6c03f5311..b34af004d7a 100644 --- a/deployments/cli/community/variables.env +++ b/deployments/cli/community/variables.env @@ -9,13 +9,17 @@ WORKER_REPLICAS=1 BEAT_WORKER_REPLICAS=1 LIVE_REPLICAS=1 -LISTEN_HTTP_PORT=80 -LISTEN_HTTPS_PORT=443 +LISTEN_HTTP_PORT=8081 +#LISTEN_HTTPS_PORT=443 -WEB_URL=http://${APP_DOMAIN} +WEB_URL=http://${APP_DOMAIN}:8081 DEBUG=0 -CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN} +CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}:8081,http://localhost:3000,http://localhost:8000 API_BASE_URL=http://api:8000 +ALLOWED_HOSTS=http://${APP_DOMAIN}:8081,http://localhost,http://localhost:8000 +CORS_ALLOW_CREDENTIALS=true +CSRF_TRUSTED_ORIGINS=http://${APP_DOMAIN}:8081,http://localhost:3000,http://localhost:8000,http://192.168.1.55:8000 +ENABLE_DRF_SPECTACULAR=1 #DB SETTINGS PGHOST=plane-db @@ -43,11 +47,10 @@ AMQP_URL= # If SSL Cert to be generated, set CERT_EMAIl="email <EMAIL_ADDRESS>" CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory TRUSTED_PROXIES=0.0.0.0/0 -SITE_ADDRESS=:80 +SITE_ADDRESS=http://${APP_DOMAIN}:8081 CERT_EMAIL= - # For DNS Challenge based certificate generation, set the CERT_ACME_DNS, CERT_EMAIL # CERT_ACME_DNS="acme_dns <CERT_DNS_PROVIDER> <CERT_DNS_PROVIDER_API_KEY>" CERT_ACME_DNS= @@ -55,6 +58,7 @@ CERT_ACME_DNS= # Secret Key SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 +LIVE_SERVER_SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b6 # DATA STORE SETTINGS USE_MINIO=1 @@ -63,7 +67,7 @@ AWS_ACCESS_KEY_ID=access-key AWS_SECRET_ACCESS_KEY=secret-key AWS_S3_ENDPOINT_URL=http://plane-minio:9000 AWS_S3_BUCKET_NAME=uploads -FILE_SIZE_LIMIT=5242880 +FILE_SIZE_LIMIT=524288000 # Gunicorn Workers GUNICORN_WORKERS=1 @@ -75,8 +79,4 @@ GUNICORN_WORKERS=1 MINIO_ENDPOINT_SSL=0 # API key rate limit -API_KEY_RATE_LIMIT=60/minute - -# Live server environment variables -# WARNING: You must set a secure value for LIVE_SERVER_SECRET_KEY in production environments. -LIVE_SERVER_SECRET_KEY= +API_KEY_RATE_LIMIT=60/minute \ No newline at end of file diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 5b7cba39e11..ee6b7b59eb3 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -22,6 +22,9 @@ services: RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST} + ports: + - "5672:5672" + - "15672:15672" plane-minio: image: minio/minio @@ -34,7 +37,9 @@ services: minio server /export --console-address ':9090' & sleep 5 && mc alias set myminio http://localhost:9000 ${AWS_ACCESS_KEY_ID} ${AWS_SECRET_ACCESS_KEY} && - mc mb myminio/${AWS_S3_BUCKET_NAME} -p || true + mc mb myminio/${AWS_S3_BUCKET_NAME} -p || true && + printf '%s\n' '[{"AllowedOrigins":["http://localhost:3000","http://localhost:3001","http://localhost:3002","http://localhost:3100","http://localhost:8000"],"AllowedMethods":["GET","HEAD","PUT","POST","DELETE"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag","Content-Length","Content-Range","Accept-Ranges","Content-Disposition"],"MaxAgeSeconds":3000}]' > /tmp/minio-cors.json && + mc cors set myminio/${AWS_S3_BUCKET_NAME} /tmp/minio-cors.json || true && tail -f /dev/null " volumes: @@ -131,9 +136,14 @@ services: - dev_env volumes: - ./apps/api:/code + - ./apps/api/plane/media-library:/data/media-library + - ./apps/api/plane/transcode-sources:/data/transcode-sources command: ./bin/docker-entrypoint-api-local.sh env_file: - ./apps/api/.env + environment: + MEDIA_LIBRARY_FILE_SIZE_LIMIT: ${MEDIA_LIBRARY_FILE_SIZE_LIMIT:-5368709120} + RABBITMQ_HOST: plane-mq depends_on: - plane-db - plane-redis @@ -152,9 +162,14 @@ services: - dev_env volumes: - ./apps/api:/code + - ./apps/api/plane/media-library:/data/media-library + - ./apps/api/plane/transcode-sources:/data/transcode-sources command: ./bin/docker-entrypoint-worker.sh env_file: - ./apps/api/.env + environment: + MEDIA_LIBRARY_FILE_SIZE_LIMIT: ${MEDIA_LIBRARY_FILE_SIZE_LIMIT:-5368709120} + RABBITMQ_HOST: plane-mq depends_on: - api - plane-db @@ -171,9 +186,14 @@ services: - dev_env volumes: - ./apps/api:/code + - ./apps/api/plane/media-library:/data/media-library + - ./apps/api/plane/transcode-sources:/data/transcode-sources command: ./bin/docker-entrypoint-beat.sh env_file: - ./apps/api/.env + environment: + MEDIA_LIBRARY_FILE_SIZE_LIMIT: ${MEDIA_LIBRARY_FILE_SIZE_LIMIT:-5368709120} + RABBITMQ_HOST: plane-mq depends_on: - api - plane-db @@ -190,9 +210,14 @@ services: - dev_env volumes: - ./apps/api:/code + - ./apps/api/plane/media-library:/data/media-library + - ./apps/api/plane/transcode-sources:/data/transcode-sources command: ./bin/docker-entrypoint-migrator.sh --settings=plane.settings.local env_file: - ./apps/api/.env + environment: + MEDIA_LIBRARY_FILE_SIZE_LIMIT: ${MEDIA_LIBRARY_FILE_SIZE_LIMIT:-5368709120} + RABBITMQ_HOST: plane-mq depends_on: - plane-db - plane-redis @@ -223,7 +248,6 @@ volumes: uploads: pgdata: rabbitmq_data: - networks: dev_env: driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 787ba640aa6..d2f024191ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,39 @@ services: web: container_name: web + image: ${PLANE_WEB_IMAGE:-plane-web:local} build: context: . dockerfile: ./apps/web/Dockerfile.web args: DOCKER_BUILDKIT: 1 + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-https://sports.kanavio.com} + NEXT_PUBLIC_ADMIN_BASE_URL: ${NEXT_PUBLIC_ADMIN_BASE_URL:-https://sports.kanavio.com} + NEXT_PUBLIC_ADMIN_BASE_PATH: ${NEXT_PUBLIC_ADMIN_BASE_PATH:-/god-mode} + NEXT_PUBLIC_LIVE_BASE_URL: ${NEXT_PUBLIC_LIVE_BASE_URL:-https://sports.kanavio.com} + NEXT_PUBLIC_LIVE_BASE_PATH: ${NEXT_PUBLIC_LIVE_BASE_PATH:-/live} + NEXT_PUBLIC_SPACE_BASE_URL: ${NEXT_PUBLIC_SPACE_BASE_URL:-https://sports.kanavio.com} + NEXT_PUBLIC_SPACE_BASE_PATH: ${NEXT_PUBLIC_SPACE_BASE_PATH:-/spaces} + NEXT_PUBLIC_WEB_BASE_URL: ${NEXT_PUBLIC_WEB_BASE_URL:-https://sports.kanavio.com} + NEXT_PUBLIC_CP_SERVER_URL: ${NEXT_PUBLIC_CP_SERVER_URL:-https://sports.kanavio.com/sports/api} restart: always depends_on: - api admin: container_name: admin + image: ${PLANE_ADMIN_IMAGE:-plane-admin:local} build: context: . dockerfile: ./apps/admin/Dockerfile.admin args: DOCKER_BUILDKIT: 1 + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-https://sports.kanavio.com} + NEXT_PUBLIC_ADMIN_BASE_URL: ${NEXT_PUBLIC_ADMIN_BASE_URL:-https://sports.kanavio.com} + NEXT_PUBLIC_ADMIN_BASE_PATH: ${NEXT_PUBLIC_ADMIN_BASE_PATH:-/god-mode} + NEXT_PUBLIC_SPACE_BASE_URL: ${NEXT_PUBLIC_SPACE_BASE_URL:-https://sports.kanavio.com} + NEXT_PUBLIC_SPACE_BASE_PATH: ${NEXT_PUBLIC_SPACE_BASE_PATH:-/spaces} + NEXT_PUBLIC_WEB_BASE_URL: ${NEXT_PUBLIC_WEB_BASE_URL:-https://sports.kanavio.com} restart: always depends_on: - api @@ -24,11 +41,18 @@ services: space: container_name: space + image: ${PLANE_SPACE_IMAGE:-plane-space:local} build: context: . dockerfile: ./apps/space/Dockerfile.space args: DOCKER_BUILDKIT: 1 + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-https://sports.kanavio.com} + NEXT_PUBLIC_ADMIN_BASE_URL: ${NEXT_PUBLIC_ADMIN_BASE_URL:-https://sports.kanavio.com} + NEXT_PUBLIC_ADMIN_BASE_PATH: ${NEXT_PUBLIC_ADMIN_BASE_PATH:-/god-mode} + NEXT_PUBLIC_SPACE_BASE_URL: ${NEXT_PUBLIC_SPACE_BASE_URL:-https://sports.kanavio.com} + NEXT_PUBLIC_SPACE_BASE_PATH: ${NEXT_PUBLIC_SPACE_BASE_PATH:-/spaces} + NEXT_PUBLIC_WEB_BASE_URL: ${NEXT_PUBLIC_WEB_BASE_URL:-https://sports.kanavio.com} restart: always depends_on: - api @@ -36,6 +60,7 @@ services: api: container_name: api + image: ${PLANE_API_IMAGE:-plane-api:local} build: context: ./apps/api dockerfile: Dockerfile.api @@ -51,6 +76,7 @@ services: worker: container_name: bgworker + image: ${PLANE_API_IMAGE:-plane-api:local} build: context: ./apps/api dockerfile: Dockerfile.api @@ -67,6 +93,7 @@ services: beat-worker: container_name: beatworker + image: ${PLANE_API_IMAGE:-plane-api:local} build: context: ./apps/api dockerfile: Dockerfile.api @@ -83,6 +110,7 @@ services: migrator: container_name: plane-migrator + image: ${PLANE_API_IMAGE:-plane-api:local} build: context: ./apps/api dockerfile: Dockerfile.api @@ -98,6 +126,7 @@ services: live: container_name: plane-live + image: ${PLANE_LIVE_IMAGE:-plane-live:local} build: context: . dockerfile: ./apps/live/Dockerfile.live @@ -154,6 +183,7 @@ services: # Comment this if you already have a reverse proxy running proxy: container_name: proxy + image: ${PLANE_PROXY_IMAGE:-plane-proxy:local} build: context: ./apps/proxy dockerfile: Dockerfile.ce diff --git a/package.json b/package.json index 7a487e286a0..e3403cea5d8 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "build": "turbo run build", "dev": "turbo run dev --concurrency=18", + "dev:light": "turbo run dev --filter=admin --filter=web --concurrency=4", "start": "turbo run start", "clean": "turbo run clean && rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", "fix": "turbo run fix", diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts index 665fa930ad9..59814e1de78 100644 --- a/packages/constants/src/issue/common.ts +++ b/packages/constants/src/issue/common.ts @@ -27,6 +27,7 @@ export enum EIssueGroupByToServerOptions { "cycle" = "cycle_id", "module" = "issue_module__module_id", "target_date" = "target_date", + "start_date" = "start_date", "project" = "project_id", "created_by" = "created_by", // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values @@ -42,6 +43,7 @@ export enum EIssueGroupBYServerToProperty { "cycle_id" = "cycle_id", "issue_module__module_id" = "module_ids", "target_date" = "target_date", + "start_date" = "start_date", "project_id" = "project_id", "created_by" = "created_by", } @@ -135,6 +137,7 @@ export const ISSUE_ORDER_BY_OPTIONS: { export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = [ "assignee", + "start_time", "start_date", "due_date", "labels", @@ -150,6 +153,13 @@ export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = "modules", "cycle", "issue_type", + + // sport app fields + "level", + "sport", + "program", + "year", + "category", ]; export const SUB_ISSUES_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = [ @@ -181,6 +191,10 @@ export const ISSUE_DISPLAY_PROPERTIES: { key: "start_date", titleTranslationKey: "common.order_by.start_date", }, + { + key: "start_time", + titleTranslationKey: "starting_time", + }, { key: "due_date", titleTranslationKey: "common.order_by.due_date", @@ -206,6 +220,11 @@ export const ISSUE_DISPLAY_PROPERTIES: { }, { key: "modules", titleTranslationKey: "common.module" }, { key: "cycle", titleTranslationKey: "common.cycle" }, + { key: "level", titleTranslationKey: "level_field" }, + { key: "sport", titleTranslationKey: "sport_field" }, + { key: "program", titleTranslationKey: "program_field" }, + { key: "year", titleTranslationKey: "year_field" }, + { key: "category", titleTranslationKey: "category_field" }, ]; export const SPREADSHEET_PROPERTY_LIST: (keyof IIssueDisplayProperties)[] = [ @@ -215,6 +234,7 @@ export const SPREADSHEET_PROPERTY_LIST: (keyof IIssueDisplayProperties)[] = [ "labels", "modules", "cycle", + "start_time", "start_date", "due_date", "estimate", @@ -307,6 +327,14 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderTitle: "Old", icon: "CalendarClock", }, + start_time: { + i18n_title: "starting_time", + ascendingOrderKey: "start_time", + ascendingOrderTitle: "Early", + descendingOrderKey: "-start_time", + descendingOrderTitle: "Late", + icon: "Clock", + }, state: { i18n_title: "common.state", ascendingOrderKey: "state__name", diff --git a/packages/constants/src/issue/filter.ts b/packages/constants/src/issue/filter.ts index 61fcccc3bbe..bb6b3540842 100644 --- a/packages/constants/src/issue/filter.ts +++ b/packages/constants/src/issue/filter.ts @@ -23,6 +23,7 @@ export enum EServerGroupByToFilterOptions { "assignees__id" = "assignees", "cycle_id" = "cycle", "issue_module__module_id" = "module", + "start_date" = "start_date", "target_date" = "target_date", "project_id" = "project", "created_by" = "created_by", diff --git a/packages/constants/src/issue/modal.ts b/packages/constants/src/issue/modal.ts index c2697ca9ddc..b73ea7a95b5 100644 --- a/packages/constants/src/issue/modal.ts +++ b/packages/constants/src/issue/modal.ts @@ -15,5 +15,13 @@ export const DEFAULT_WORK_ITEM_FORM_VALUES: Partial<TIssue> = { cycle_id: null, module_ids: null, start_date: null, + start_time: null, target_date: null, + + // Sport App Fields + level : null, + sport: null, + program: null, + year: null, + category: null, }; diff --git a/packages/constants/src/metadata.ts b/packages/constants/src/metadata.ts index 5bd6e36b4e0..da5c14e8acc 100644 --- a/packages/constants/src/metadata.ts +++ b/packages/constants/src/metadata.ts @@ -1,17 +1,17 @@ -export const SITE_NAME = "Plane | Simple, extensible, open-source project management tool."; -export const SITE_TITLE = "Plane | Simple, extensible, open-source project management tool."; +export const SITE_NAME = "Plane | Simple, extensible, open-source program management tool."; +export const SITE_TITLE = "Plane | Simple, extensible, open-source program management tool."; export const SITE_DESCRIPTION = - "Open-source project management tool to manage work items, cycles, and product roadmaps easily"; + "Open-source program management tool to manage work items, cycles, and product roadmaps easily"; export const SITE_KEYWORDS = - "software development, plan, ship, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration"; + "software development, plan, ship, software, accelerate, code management, release management, program management, work items tracking, agile, scrum, kanban, collaboration"; export const SITE_URL = "https://app.plane.so/"; -export const TWITTER_USER_NAME = "Plane | Simple, extensible, open-source project management tool."; +export const TWITTER_USER_NAME = "Plane | Simple, extensible, open-source program management tool."; // Plane Sites Metadata export const SPACE_SITE_NAME = "Plane Publish | Make your Plane boards and roadmaps pubic with just one-click. "; export const SPACE_SITE_TITLE = "Plane Publish | Make your Plane boards public with one-click"; export const SPACE_SITE_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so"; export const SPACE_SITE_KEYWORDS = - "software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration"; + "software development, customer feedback, software, accelerate, code management, release management, program management, work items tracking, agile, scrum, kanban, collaboration"; export const SPACE_SITE_URL = "https://app.plane.so/"; export const SPACE_TWITTER_USER_NAME = "planepowers"; diff --git a/packages/constants/src/project.ts b/packages/constants/src/project.ts index 590dbb89ecc..f7b5fbd8d85 100644 --- a/packages/constants/src/project.ts +++ b/packages/constants/src/project.ts @@ -148,6 +148,7 @@ export const DEFAULT_PROJECT_FORM_VALUES: Partial<IProject> = { name: "", network: 2, project_lead: null, + sport: null, }; export enum EProjectFeatureKey { diff --git a/packages/constants/src/settings.ts b/packages/constants/src/settings.ts index 2c55a6a2dd7..290ec3fef09 100644 --- a/packages/constants/src/settings.ts +++ b/packages/constants/src/settings.ts @@ -35,6 +35,8 @@ export const GROUPED_WORKSPACE_SETTINGS = { WORKSPACE_SETTINGS["members"], WORKSPACE_SETTINGS["billing-and-plans"], WORKSPACE_SETTINGS["export"], + WORKSPACE_SETTINGS["media-server"], + WORKSPACE_SETTINGS["devices"], ], [WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [], [WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]], diff --git a/packages/constants/src/subscription.ts b/packages/constants/src/subscription.ts index c2d2cfa1336..d8fdbe09153 100644 --- a/packages/constants/src/subscription.ts +++ b/packages/constants/src/subscription.ts @@ -8,7 +8,7 @@ export const ENTERPRISE_PLAN_FEATURES = [ ]; export const BUSINESS_PLAN_FEATURES = [ - "Project Templates", + "Program Templates", "Workflows + Approvals", "Decision + Loops Automation", "Custom Reports", diff --git a/packages/constants/src/tab-indices.ts b/packages/constants/src/tab-indices.ts index 82958413161..1d574f95792 100644 --- a/packages/constants/src/tab-indices.ts +++ b/packages/constants/src/tab-indices.ts @@ -8,6 +8,12 @@ export const ISSUE_FORM_TAB_INDICES = [ "label_ids", "start_date", "target_date", + "start_time", + "level", + "sport", + "program", + "year", + "category", "cycle_id", "module_ids", "estimate_point", @@ -26,9 +32,15 @@ export const INTAKE_ISSUE_CREATE_FORM_TAB_INDICES = [ "state_id", "priority", "assignee_ids", + "year", // sport app field + "category", // sport app field + "sport", // sport app field "label_ids", "start_date", "target_date", + "start_time", // sport app field + "program", // sport app field + "level", // sport app field "cycle_id", "module_ids", "estimate_point", diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index 17da97296a7..7ee2def1e0b 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -68,6 +68,7 @@ export const RESTRICTED_URLS = [ "licenses", "instances", "instance", + "opposition" ]; export const WORKSPACE_SETTINGS = { @@ -106,6 +107,20 @@ export const WORKSPACE_SETTINGS = { access: [EUserWorkspaceRoles.ADMIN], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`, }, + "media-server": { + key: "media-server", + i18n_label: "Media Server", + href: `/settings/media-server`, + access: [EUserWorkspaceRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/media-server/`, + }, + devices: { + key: "devices", + i18n_label: "Devices", + href: `/settings/devices`, + access: [EUserWorkspaceRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/devices/`, + } }; export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries( @@ -123,6 +138,8 @@ export const WORKSPACE_SETTINGS_LINKS: { WORKSPACE_SETTINGS["members"], WORKSPACE_SETTINGS["billing-and-plans"], WORKSPACE_SETTINGS["export"], + WORKSPACE_SETTINGS["media-server"], + WORKSPACE_SETTINGS["devices"], WORKSPACE_SETTINGS["webhooks"], ]; @@ -149,7 +166,7 @@ export const ROLE_DETAILS = { export const USER_ROLES = [ { - value: "Product / Project Manager", + value: "Product / Program Manager", i18n_label: "user_roles.product_or_project_manager", }, { @@ -314,6 +331,13 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST], highlight: (pathname: string, url: string) => pathname === url, }, + opposition: { + key: "opposition", + labelTranslationKey: "Opposition", + href: `/opposition/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST], + highlight: (pathname: string, url: string) => pathname === url, + }, }; export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [ @@ -324,6 +348,14 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarN export const WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [ WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["projects"]!, + WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["opposition"]!, + { + key: "calendar", + labelTranslationKey: "Calendar", + href: `/calendar/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST], + highlight: (pathname: string, url: string) => pathname.includes(url), + }, ]; export const IS_FAVORITE_MENU_OPEN = "is_favorite_menu_open"; @@ -342,7 +374,7 @@ export const WORKSPACE_DEFAULT_SEARCH_RESULT: IWorkspaceSearchResults = { export const USE_CASES = [ "Plan and track product roadmaps", "Manage engineering sprints", - "Coordinate cross-functional projects", + "Coordinate cross-functional programs", "Replace our current tool", "Just exploring", ]; diff --git a/packages/decorators/tsconfig.json b/packages/decorators/tsconfig.json index 0fd863c96ab..4b3fc26ffa9 100644 --- a/packages/decorators/tsconfig.json +++ b/packages/decorators/tsconfig.json @@ -5,6 +5,7 @@ "emitDecoratorMetadata": true, "lib": ["ES2020"], "rootDir": ".", + "ignoreDeprecations": "6.0", "baseUrl": ".", "paths": { "@/*": ["./src/*"] diff --git a/packages/editor/package.json b/packages/editor/package.json index 985a3f4405c..490a683a45c 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -21,7 +21,7 @@ "./styles": "./dist/styles/index.css" }, "scripts": { - "build": "tsc && tsdown", + "build": "tsdown", "dev": "tsdown --watch", "check:lint": "eslint . --max-warnings 30", "check:types": "tsc --noEmit", diff --git a/packages/editor/src/ce/extensions/core/extensions.ts b/packages/editor/src/ce/extensions/core/extensions.ts index e09038bca56..9989128a086 100644 --- a/packages/editor/src/ce/extensions/core/extensions.ts +++ b/packages/editor/src/ce/extensions/core/extensions.ts @@ -1,5 +1,8 @@ import type { Extensions } from "@tiptap/core"; +// extensions +import { WorkItemEmbedExtension } from "@/extensions"; // types +import type { TIssueEmbedConfig } from "@/plane-editor/types"; import type { IEditorProps } from "@/types"; export type TCoreAdditionalExtensionsProps = Pick< @@ -8,6 +11,20 @@ export type TCoreAdditionalExtensionsProps = Pick< >; export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => { - const {} = props; - return []; -}; + const { extendedEditorProps } = props; + const extensions: Extensions = []; + + // Always enable issue-embed extension if widgetCallback is provided (ignore disabledExtensions) + type TExtendedPropsWithIssueEmbed = { + embed?: { issue?: { widgetCallback?: TIssueEmbedConfig["widgetCallback"] } }; + }; + + const issueConfig = (extendedEditorProps as TExtendedPropsWithIssueEmbed | undefined)?.embed?.issue; + const widgetCallback = issueConfig?.widgetCallback; + + if (typeof widgetCallback === "function") { + extensions.push(WorkItemEmbedExtension({ widgetCallback })); + } + + return extensions; +}; \ No newline at end of file diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx index 40a2b0c0be4..a6959e32a1d 100644 --- a/packages/editor/src/core/components/editors/rich-text/editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -5,6 +5,7 @@ import { BlockMenu, EditorBubbleMenu } from "@/components/menus"; // extensions import { SideMenuExtension } from "@/extensions"; // plane editor imports +import { EmbedDialog } from "@/extensions/slash-commands/embed-dialog-wrapper"; import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text-extensions"; // types import { EditorRefApi, IRichTextEditorProps } from "@/types"; @@ -44,6 +45,7 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => { <> {editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />} <BlockMenu editor={editor} flaggedExtensions={flaggedExtensions} disabledExtensions={disabledExtensions} /> + <EmbedDialog /> </> )} </EditorWrapper> diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index 002cf0ab66d..b26ffee1c1d 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -12,6 +12,7 @@ import { CodeItem, EditorMenuItem, ItalicItem, + LinkItem, StrikeThroughItem, TextAlignItem, TextColorItem, @@ -31,6 +32,7 @@ import { BubbleMenuLinkSelector } from "./link-selector"; type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">; export type EditorStateType = { + link: boolean; code: boolean; bold: boolean; italic: boolean; @@ -69,6 +71,7 @@ export const EditorBubbleMenu: FC<Props> = (props) => { const menuRef = useRef<HTMLDivElement>(null); const formattingItems = { + link: LinkItem(editor), code: CodeItem(editor), bold: BoldItem(editor), italic: ItalicItem(editor), @@ -83,6 +86,7 @@ export const EditorBubbleMenu: FC<Props> = (props) => { editor, selector: ({ editor }) => ({ code: formattingItems.code.isActive(), + link: formattingItems.link.isActive(), bold: formattingItems.bold.isActive(), italic: formattingItems.italic.isActive(), underline: formattingItems.underline.isActive(), diff --git a/packages/editor/src/core/constants/extension.ts b/packages/editor/src/core/constants/extension.ts index eaec3e88f5b..fbaed44c4b2 100644 --- a/packages/editor/src/core/constants/extension.ts +++ b/packages/editor/src/core/constants/extension.ts @@ -41,5 +41,6 @@ export enum CORE_EXTENSIONS { UNDERLINE = "underline", UTILITY = "utility", WORK_ITEM_EMBED = "issue-embed-component", + LINK_EMBED = "linkEmbed", EMOJI = "emoji", } diff --git a/packages/editor/src/core/extensions/extensions.ts b/packages/editor/src/core/extensions/extensions.ts index f17d115871a..58a74f15861 100644 --- a/packages/editor/src/core/extensions/extensions.ts +++ b/packages/editor/src/core/extensions/extensions.ts @@ -19,6 +19,7 @@ import { CustomTextAlignExtension, CustomTypographyExtension, ImageExtension, + LinkEmbedExtension, ListKeymap, Table, TableCell, @@ -75,6 +76,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { CustomKeymap, ListKeymap({ tabIndex }), CustomLinkExtension, + LinkEmbedExtension(), CustomTypographyExtension, Underline, TextStyle, diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index 48692c09181..a9995188346 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -3,6 +3,7 @@ export * from "./code"; export * from "./code-inline"; export * from "./custom-link"; export * from "./custom-list-keymap"; +export * from "./link-embed"; export * from "./image"; export * from "./mentions"; export * from "./slash-commands"; diff --git a/packages/editor/src/core/extensions/link-embed/extension-config.tsx b/packages/editor/src/core/extensions/link-embed/extension-config.tsx new file mode 100644 index 00000000000..2477229fa42 --- /dev/null +++ b/packages/editor/src/core/extensions/link-embed/extension-config.tsx @@ -0,0 +1,62 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; + +export const LinkEmbedExtensionConfig = Node.create({ + name: CORE_EXTENSIONS.LINK_EMBED, + group: "block", + atom: true, + selectable: true, + draggable: true, + + addAttributes() { + return { + url: { + default: null, + }, + title: { + default: null, + }, + description: { + default: null, + }, + image: { + default: null, + }, + favicon: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "link-embed-component", + getAttrs: (element) => { + if (typeof element === "string") return {}; + return { + url: element.getAttribute("data-url"), + title: element.getAttribute("data-title"), + description: element.getAttribute("data-description"), + image: element.getAttribute("data-image"), + favicon: element.getAttribute("data-favicon"), + }; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "link-embed-component", + mergeAttributes(HTMLAttributes, { + "data-url": HTMLAttributes.url, + "data-title": HTMLAttributes.title, + "data-description": HTMLAttributes.description, + "data-image": HTMLAttributes.image, + "data-favicon": HTMLAttributes.favicon, + }), + ]; + }, +}); diff --git a/packages/editor/src/core/extensions/link-embed/extension.tsx b/packages/editor/src/core/extensions/link-embed/extension.tsx new file mode 100644 index 00000000000..0e7d3aa54e2 --- /dev/null +++ b/packages/editor/src/core/extensions/link-embed/extension.tsx @@ -0,0 +1,10 @@ +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { LinkEmbedExtensionConfig } from "./extension-config"; +import { LinkEmbedPreview } from "./preview-component"; + +export const LinkEmbedExtension = () => + LinkEmbedExtensionConfig.extend({ + addNodeView() { + return ReactNodeViewRenderer(LinkEmbedPreview); + }, + }); diff --git a/packages/editor/src/core/extensions/link-embed/index.ts b/packages/editor/src/core/extensions/link-embed/index.ts new file mode 100644 index 00000000000..dbbd4d525be --- /dev/null +++ b/packages/editor/src/core/extensions/link-embed/index.ts @@ -0,0 +1,3 @@ +export { LinkEmbedExtension } from "./extension"; +export { LinkEmbedExtensionConfig } from "./extension-config"; +export { LinkEmbedPreview } from "./preview-component"; diff --git a/packages/editor/src/core/extensions/link-embed/preview-component.tsx b/packages/editor/src/core/extensions/link-embed/preview-component.tsx new file mode 100644 index 00000000000..38eb3499003 --- /dev/null +++ b/packages/editor/src/core/extensions/link-embed/preview-component.tsx @@ -0,0 +1,61 @@ +import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; + +export const LinkEmbedPreview = ({ node }: NodeViewProps) => { + const { url, title, description, image, favicon } = node.attrs; + + return ( + <NodeViewWrapper className="link-embed-preview"> + <a + href={url} + target="_blank" + rel="noopener noreferrer" + className="flex max-w-full rounded-lg border border-custom-border-300 mb-2 + bg-custom-background-100 hover:bg-custom-background-90 transition + no-underline hover:no-underline [&_*]:no-underline" + > + {/* LEFT IMAGE – FULL HEIGHT */} + {image && ( + <div className="w-52 h-32 flex-shrink-0"> + <img src={image} alt={title || "Preview"} className="w-full h-full object-cover rounded-l-lg" /> + </div> + )} + + {/* RIGHT CONTENT */} + <div className="flex flex-col justify-center gap-1 pl-2 min-w-0"> + {/* Title */} + {title && <h3 className="text-sm font-semibold text-custom-text-100 truncate">{title}</h3>} + + {/* Description */} + {description && ( + <p + className="text-xs text-custom-text-300" + style={{ + display: "-webkit-box", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", + overflow: "hidden", + }} + > + {description} + </p> + )} + + {/* URL */} + <div className="flex items-center gap-1 text-xs text-custom-text-400 mt-1"> + {favicon && ( + <img + src={favicon} + alt="" + className="w-4 h-4" + onError={(e) => { + e.currentTarget.style.display = "none"; + }} + /> + )} + <span className="truncate">{url}</span> + </div> + </div> + </a> + </NodeViewWrapper> + ); +}; diff --git a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx index 54d717de38b..d0700e83211 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx @@ -11,6 +11,7 @@ import { ImageIcon, List, ListOrdered, + FileCodeIcon, ListTodo, MessageSquareText, MinusSquare, @@ -163,6 +164,27 @@ export const getSlashCommandFilteredSections = icon: <TextQuote className="size-3.5" />, command: ({ editor, range }) => toggleBlockquote(editor, range), }, + // { + // commandKey: "link", + // key: "link", + // title: "Link", + // description: "Embed an issue link preview.", + // searchTerms: ["url", "link"], + // icon: <Link2 className="size-3.5" />, + // command: ({ editor, range }) => { + // // Get full command text + // const text = editor.state.doc.textBetween(range.from, range.to, " ").trim(); + + // // Remove slash command keyword + // const url = text.replace(/^\/?(issue-embed|embed|link)\s*/i, ""); + + // if (!url) return; + + // // Delete the slash command text + // editor.chain().focus().deleteRange(range).setLink({ href: url }).insertContent(url).unsetLink().run(); + // }, + // }, + { commandKey: "code", key: "code", @@ -190,6 +212,26 @@ export const getSlashCommandFilteredSections = icon: <MinusSquare className="size-3.5" />, command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), }, + { + commandKey: "embed", + key: "embed", + title: "Embed", + description: "Insert a URL", + searchTerms: ["url", "hyperlink", "website"], + icon: <FileCodeIcon className="size-3.5" />, + command: ({ editor, range }) => { + // Remove /embed text + editor.chain().focus().deleteRange(range).run(); + + // Open embed dialog + window.dispatchEvent( + new CustomEvent("open-embed-dialog", { + detail: { editor }, + }) + ); + }, + }, + { commandKey: "emoji", key: "emoji", diff --git a/packages/editor/src/core/extensions/slash-commands/embed-dialog-wrapper.tsx b/packages/editor/src/core/extensions/slash-commands/embed-dialog-wrapper.tsx new file mode 100644 index 00000000000..7acb885fd88 --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/embed-dialog-wrapper.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { AlertModalCore } from "@plane/ui"; +import { validateUrl } from "@/helpers/urls"; +import EmbedInput from "./embed"; + +export function EmbedDialog() { + const [editor, setEditor] = useState<any>(null); + const [open, setOpen] = useState(false); + const [showInvalidUrlAlert, setShowInvalidUrlAlert] = useState(false); + + useEffect(() => { + const handler = (e: any) => { + setEditor(e.detail.editor); + setOpen(true); + }; + + window.addEventListener("open-embed-dialog", handler); + return () => window.removeEventListener("open-embed-dialog", handler); + }, []); + + if (!open || !editor) return null; + + const insertFallbackLink = (url: string) => { + editor.chain().focus().setLink({ href: url }).insertContent(url).unsetLink().run(); + }; + + const handleEmbed = async (rawUrl: string) => { + const url = validateUrl(rawUrl); + + if (!url) { + setShowInvalidUrlAlert(true); + return; + } + + try { + const res = await fetch("/api/link-preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + + const data = await res.json(); + + if (!data?.error) { + editor + .chain() + .focus() + .insertContent({ + type: "linkEmbed", + attrs: { + url, + title: data.title ?? url, + description: data.description ?? "", + image: data.image ?? null, + favicon: data.favicon ?? null, + }, + }) + .run(); + } else { + insertFallbackLink(url); + } + } catch { + insertFallbackLink(url); + } + + close(); + }; + + const close = () => { + setOpen(false); + setEditor(null); + }; + + return createPortal( + <> + <div className="fixed inset-0 z-[9999] flex items-start justify-center pt-24"> + <EmbedInput onEmbed={handleEmbed} onCancel={close} /> + </div> + <AlertModalCore + isOpen={showInvalidUrlAlert} + title="Invalid URL" + content={<p>Your URL is not valid. Please enter a valid URL.</p>} + handleClose={() => setShowInvalidUrlAlert(false)} + handleSubmit={() => setShowInvalidUrlAlert(false)} + isSubmitting={false} + variant="danger" + /> + </>, + document.body + ); +} diff --git a/packages/editor/src/core/extensions/slash-commands/embed.tsx b/packages/editor/src/core/extensions/slash-commands/embed.tsx new file mode 100644 index 00000000000..de7ee738b29 --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/embed.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState } from "react"; + +interface EmbedInputProps { + onEmbed: (url: string) => void; + onCancel?: () => void; +} + +export default function EmbedInput({ + onEmbed, + onCancel, +}: EmbedInputProps) { + const [link, setLink] = useState(""); + + const handleEmbed = () => { + if (!link.trim()) return; + onEmbed(link.trim()); + setLink(""); + }; + + return ( + <div + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + className="bg-custom-background-90 border border-custom-border-300 rounded-md p-3 pt-1 shadow-lg z-[9999] w-75" + > + <p className="text-gray-400 text-sm mb-2"> + Works with YouTube, Figma, Google Docs and more + </p> + + <div className="flex gap-2"> + <input + autoFocus + type="url" + value={link} + onChange={(e) => setLink(e.target.value)} + placeholder="Enter or paste a link" + className="block bg-transparent text-sm placeholder-custom-text-400 rounded-md border-[0.5px] px-3 py-2 w-full min-w-[250px] focus:outline-none focus:ring-1 border-custom-border-300 focus:ring-custom-primary-200" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleEmbed(); + } + }} + /> + + <button + type="button" + onClick={handleEmbed} + className="text-white bg-custom-primary-100 hover:bg-custom-primary-200 px-3 py-1.5 text-xs rounded" + > + Embed + </button> + </div> + + {onCancel && ( + <button + type="button" + onClick={onCancel} + className="mt-2 text-xs text-gray-400 hover:text-gray-200" + > + Cancel + </button> + )} + </div> + ); +} diff --git a/packages/editor/src/core/extensions/slash-commands/index.ts b/packages/editor/src/core/extensions/slash-commands/index.ts index 1efe34c51ec..2eeeb210e87 100644 --- a/packages/editor/src/core/extensions/slash-commands/index.ts +++ b/packages/editor/src/core/extensions/slash-commands/index.ts @@ -1 +1,2 @@ export * from "./root"; +export { EmbedDialog } from "./embed-dialog-wrapper"; diff --git a/packages/editor/src/core/helpers/urls.ts b/packages/editor/src/core/helpers/urls.ts new file mode 100644 index 00000000000..43802a9a7f2 --- /dev/null +++ b/packages/editor/src/core/helpers/urls.ts @@ -0,0 +1,20 @@ +export function validateUrl(input: string): string | null { + const value = input.trim(); + + // allow www.* + const wwwRegex = /^www\.[^\s/$.?#].[^\s]*$/i; + + // allow only these protocols + const protocolRegex = /^(https?|rtmps?):\/\/[^\s/$.?#].[^\s]*$/i; + + if (wwwRegex.test(value)) { + return `https://${value}`; // add https:// to www URLs + } + + if (protocolRegex.test(value)) { + return value; // valid protocol URL + } + + return null; // ❌ invalid +} + diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index a99870c05ad..3666a806444 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -48,13 +48,13 @@ export type TEditorCommands = | "table" | "image" | "divider" - | "link" | "issue-embed" | "text-color" | "background-color" | "text-align" | "callout" | "attachment" + | "embed" | "emoji" | "external-embed" | TExtendedEditorCommands; diff --git a/packages/editor/src/core/types/extensions.ts b/packages/editor/src/core/types/extensions.ts index 8c1c0a48038..d6c64687187 100644 --- a/packages/editor/src/core/types/extensions.ts +++ b/packages/editor/src/core/types/extensions.ts @@ -1 +1 @@ -export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands" | "enter-key" | "image"; +export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands" | "enter-key" | "image"; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 3cf3b6fcef3..362e0184309 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -21,3 +21,4 @@ export * from "@/types"; // additional exports export { TrailingNode } from "./core/extensions/trailing-node"; +export { ImageFullScreenModal } from "@/extensions/custom-image/components/toolbar/full-screen/modal"; diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json index b5634236f9e..59cc4879067 100644 --- a/packages/editor/tsconfig.json +++ b/packages/editor/tsconfig.json @@ -10,6 +10,7 @@ "skipLibCheck": true, "sourceMap": true, "baseUrl": ".", + "ignoreDeprecations": "6.0", "paths": { "@/*": ["./src/core/*"], "@/styles/*": ["./src/styles/*"], diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index a71e06bf5fc..c57b7455e2a 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -1,4 +1,5 @@ export * from "./use-hash-scroll"; export * from "./use-local-storage"; export * from "./use-outside-click-detector"; +export * from "./use-outside-pointer-click-detector"; export * from "./use-platform-os"; diff --git a/packages/hooks/src/use-outside-pointer-click-detector.tsx b/packages/hooks/src/use-outside-pointer-click-detector.tsx new file mode 100644 index 00000000000..52c73c041aa --- /dev/null +++ b/packages/hooks/src/use-outside-pointer-click-detector.tsx @@ -0,0 +1,36 @@ +import React, { useEffect } from "react"; + +export const useOutsidePointerClickDetector = ( + ref: React.RefObject<HTMLElement> | any, + callback: () => void, + useCapture = false, + enabled = true +) => { + useEffect(() => { + if (!enabled) return; + + const outsideClickEvent = typeof window !== "undefined" && "PointerEvent" in window ? "pointerdown" : "mousedown"; + const handleClick = (event: MouseEvent | PointerEvent) => { + if (ref.current && !ref.current.contains(event.target as any)) { + // check for the closest element with attribute name data-prevent-outside-click + const preventOutsideClickElement = (event.target as unknown as HTMLElement | undefined)?.closest( + "[data-prevent-outside-click]" + ); + // if the closest element with attribute name data-prevent-outside-click is found, return + if (preventOutsideClickElement) { + return; + } + // else call the callback + callback(); + } + }; + const onDocumentPointerDown = (event: Event) => { + handleClick(event as MouseEvent | PointerEvent); + }; + + document.addEventListener(outsideClickEvent, onDocumentPointerDown, useCapture); + return () => { + document.removeEventListener(outsideClickEvent, onDocumentPointerDown, useCapture); + }; + }, [enabled, ref, callback, useCapture]); +}; diff --git a/packages/i18n/src/locales/en/accessibility.ts b/packages/i18n/src/locales/en/accessibility.ts index c9fa1b8baa7..8b208206984 100644 --- a/packages/i18n/src/locales/en/accessibility.ts +++ b/packages/i18n/src/locales/en/accessibility.ts @@ -13,12 +13,12 @@ export default { open_favorites_menu: "Open favorites menu", close_favorites_menu: "Close favorites menu", enter_folder_name: "Enter folder name", - create_new_project: "Create new project", - open_projects_menu: "Open projects menu", - close_projects_menu: "Close projects menu", + create_new_project: "Create new program", + open_projects_menu: "Open programs menu", + close_projects_menu: "Close programs menu", toggle_quick_actions_menu: "Toggle quick actions menu", - open_project_menu: "Open project menu", - close_project_menu: "Close project menu", + open_project_menu: "Open program menu", + close_project_menu: "Close program menu", collapse_sidebar: "Collapse sidebar", expand_sidebar: "Expand sidebar", edition_badge: "Open paid plans' modal", diff --git a/packages/i18n/src/locales/en/core.ts b/packages/i18n/src/locales/en/core.ts index bce2ee96eb2..9d2438ba909 100644 --- a/packages/i18n/src/locales/en/core.ts +++ b/packages/i18n/src/locales/en/core.ts @@ -1,6 +1,7 @@ export default { sidebar: { - projects: "Projects", + projects: "Programs", + opposition: "Opposition", pages: "Pages", new_work_item: "New work item", home: "Home", diff --git a/packages/i18n/src/locales/en/translations.ts b/packages/i18n/src/locales/en/translations.ts index 5c49e5e4144..f781876ca8a 100644 --- a/packages/i18n/src/locales/en/translations.ts +++ b/packages/i18n/src/locales/en/translations.ts @@ -109,12 +109,12 @@ export default { signing_out: "Signing out", active_cycles: "Active cycles", active_cycles_description: - "Monitor cycles across projects, track high-priority work items, and zoom in cycles that need attention.", + "Monitor cycles across programs, track high-priority work items, and zoom in cycles that need attention.", on_demand_snapshots_of_all_your_cycles: "On-demand snapshots of all your cycles", upgrade: "Upgrade", "10000_feet_view": "10,000-feet view of all active cycles.", "10000_feet_view_description": - "Zoom out to see running cycles across all your projects at once instead of going from Cycle to Cycle in each project.", + "Zoom out to see running cycles across all your programs at once instead of going from Cycle to Cycle in each program.", get_snapshot_of_each_active_cycle: "Get a snapshot of each active cycle.", get_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.", @@ -129,7 +129,7 @@ export default { "Investigate the state of any cycle that doesn't conform to expectations in one click.", stay_ahead_of_blockers: "Stay ahead of blockers.", 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.", + "Spot challenges from one program to another and see inter-cycle dependencies that aren't obvious from any other view.", analytics: "Analytics", workspace_invites: "Workspace invites", enter_god_mode: "Enter god mode", @@ -137,7 +137,7 @@ export default { new_issue: "New work item", your_work: "Your work", drafts: "Drafts", - projects: "Projects", + projects: "Programs", views: "Views", workspace: "Workspace", archives: "Archives", @@ -156,30 +156,30 @@ export default { favorite_removed_successfully: "Favorite removed successfully", failed_to_create_favorite: "Failed to create favorite", failed_to_rename_favorite: "Failed to rename favorite", - project_link_copied_to_clipboard: "Project link copied to clipboard", + project_link_copied_to_clipboard: "Program link copied to clipboard", link_copied: "Link copied", - add_project: "Add project", - create_project: "Create project", - failed_to_remove_project_from_favorites: "Couldn't remove the project from favorites. Please try again.", - project_created_successfully: "Project created successfully", - project_created_successfully_description: "Project created successfully. You can now start adding work items to it.", - project_name_already_taken: "The project name is already taken.", - project_identifier_already_taken: "The project identifier is already taken.", - project_cover_image_alt: "Project cover image", + add_project: "Add program", + create_project: "Create program", + failed_to_remove_project_from_favorites: "Couldn't remove the program from favorites. Please try again.", + project_created_successfully: "Program created successfully", + project_created_successfully_description: "Program created successfully. You can now start adding work items to it.", + project_name_already_taken: "The program name is already taken.", + project_identifier_already_taken: "The program identifier is already taken.", + project_cover_image_alt: "Program cover image", name_is_required: "Name is required", title_should_be_less_than_255_characters: "Title should be less than 255 characters", - project_name: "Project name", - project_id_must_be_at_least_1_character: "Project ID must at least be of 1 character", - project_id_must_be_at_most_5_characters: "Project ID must at most be of 5 characters", - project_id: "Project ID", - project_id_tooltip_content: "Helps you identify work items in the project uniquely. Max 5 characters.", + project_name: "Program name", + project_id_must_be_at_least_1_character: "Program ID must at least be of 1 character", + project_id_must_be_at_most_5_characters: "Program ID must at most be of 5 characters", + project_id: "Program ID", + project_id_tooltip_content: "Helps you identify work items in the program uniquely. Max 5 characters.", description_placeholder: "Description", only_alphanumeric_non_latin_characters_allowed: "Only Alphanumeric & Non-latin characters are allowed.", - project_id_is_required: "Project ID is required", + project_id_is_required: "Program ID is required", project_id_allowed_char: "Only Alphanumeric & Non-latin characters are allowed.", - project_id_min_char: "Project ID must at least be of 1 character", - project_id_max_char: "Project ID must at most be of 5 characters", - project_description_placeholder: "Enter project description", + project_id_min_char: "Program ID must at least be of 1 character", + project_id_max_char: "Program ID must at most be of 5 characters", + project_description_placeholder: "Enter program description", select_network: "Select network", lead: "Lead", date_range: "Date range", @@ -188,23 +188,23 @@ export default { accessible_only_by_invite: "Accessible only by invite", anyone_in_the_workspace_except_guests_can_join: "Anyone in the workspace except Guests can join", creating: "Creating", - creating_project: "Creating project", - adding_project_to_favorites: "Adding project to favorites", - project_added_to_favorites: "Project added to favorites", - couldnt_add_the_project_to_favorites: "Couldn't add the project to favorites. Please try again.", - removing_project_from_favorites: "Removing project from favorites", - project_removed_from_favorites: "Project removed from favorites", - couldnt_remove_the_project_from_favorites: "Couldn't remove the project from favorites. Please try again.", + creating_project: "Creating program", + adding_project_to_favorites: "Adding program to favorites", + project_added_to_favorites: "Program added to favorites", + couldnt_add_the_project_to_favorites: "Couldn't add the program to favorites. Please try again.", + removing_project_from_favorites: "Removing program from favorites", + project_removed_from_favorites: "Program removed from favorites", + couldnt_remove_the_project_from_favorites: "Couldn't remove the program from favorites. Please try again.", add_to_favorites: "Add to favorites", remove_from_favorites: "Remove from favorites", - publish_project: "Publish project", + publish_project: "Publish program", publish: "Publish", copy_link: "Copy link", - leave_project: "Leave project", - join_the_project_to_rearrange: "Join the project to rearrange", + leave_project: "Leave program", + join_the_project_to_rearrange: "Join the program to rearrange", drag_to_rearrange: "Drag to rearrange", congrats: "Congrats!", - open_project: "Open project", + open_project: "Open program", issues: "Work items", cycles: "Cycles", modules: "Modules", @@ -212,16 +212,16 @@ export default { intake: "Intake", time_tracking: "Time Tracking", work_management: "Work management", - projects_and_issues: "Projects and work items", - projects_and_issues_description: "Toggle these on or off this project.", + projects_and_issues: "Programs and work items", + projects_and_issues_description: "Toggle these on or off this program.", cycles_description: - "Timebox work per project and adjust the time period as needed. One cycle can be 2 weeks, the next 1 week.", - modules_description: "Organize work into sub-projects with dedicated leads and assignees.", + "Timebox work per program and adjust the time period as needed. One cycle can be 2 weeks, the next 1 week.", + modules_description: "Organize work into sub-programs with dedicated leads and assignees.", views_description: "Save custom sorts, filters, and display options or share them with your team.", pages_description: "Create and edit free-form content; notes, docs, anything.", intake_description: "Let non-members share bugs, feedback, and suggestions; without disrupting your workflow.", - time_tracking_description: "Log time spent on work items and projects.", - work_management_description: "Manage your work and projects with ease.", + time_tracking_description: "Log time spent on work items and programs.", + work_management_description: "Manage your work and programs with ease.", documentation: "Documentation", message_support: "Message support", contact_sales: "Contact sales", @@ -271,9 +271,9 @@ export default { updating: "Updating", create_new_issue: "Create new work item", editor_is_not_ready_to_discard_changes: "Editor is not ready to discard changes", - failed_to_move_issue_to_project: "Failed to move work item to project", + failed_to_move_issue_to_project: "Failed to move work item to program", create_more: "Create more", - add_to_project: "Add to project", + add_to_project: "Add to program", discard: "Discard", duplicate_issue_found: "Duplicate work item found", duplicate_issues_found: "Duplicate work items found", @@ -296,6 +296,18 @@ export default { start_date: "Start date", end_date: "End date", due_date: "Due date", + starting_time: "Start Time", + add_start_time: "Add start time", + level_field: "Level", + add_level: "Add Level", + sport_field: "Sport", + add_sport: "Add Sport", + program_field: "Program", + add_program: "Add Program", + year_field: "Season", + add_year: "Add Season", + category_field: "Category", + add_category:"Add Category", estimate: "Estimate", change_parent_issue: "Change parent work item", remove_parent_issue: "Remove parent work item", @@ -312,7 +324,7 @@ export default { delete: "Delete", deleting: "Deleting", make_a_copy: "Make a copy", - move_to_project: "Move to project", + move_to_project: "Move to program", good: "Good", morning: "morning", afternoon: "afternoon", @@ -355,7 +367,7 @@ export default { edited: "edited", bot: "Bot", settings_description: - "Manage your account, workspace, and project preferences all in one place. Switch between tabs to easily configure.", + "Manage your account, workspace, and program preferences all in one place. Switch between tabs to easily configure.", back_to_workspace: "Back to workspace", project_view: { sort_by: { @@ -401,8 +413,8 @@ export default { quickstart_guide: "Your quickstart guide", not_right_now: "Not right now", create_project: { - title: "Create a project", - description: "Most things start with a project in Plane.", + title: "Create a program", + description: "Most things start with a program in Plane.", cta: "Get started", }, invite_team: { @@ -437,14 +449,14 @@ export default { recents: { title: "Recents", empty: { - project: "Your recent projects will appear here once you visit one.", + project: "Your recent programs will appear here once you visit one.", page: "Your recent pages will appear here once you visit one.", issue: "Your recent work items will appear here once you visit one.", default: "You don't have any recents yet.", }, filters: { all: "All", - projects: "Projects", + projects: "Programs", pages: "Pages", issues: "Work items", }, @@ -484,8 +496,8 @@ export default { state_group: "State group", priorities: "Priorities", priority: "Priority", - team_project: "Team project", - project: "Project", + team_project: "Team program", + project: "Program", cycle: "Cycle", cycles: "Cycles", module: "Module", @@ -537,11 +549,11 @@ export default { general: "General", features: "Features", automation: "Automation", - project_name: "Project name", - project_id: "Project ID", - project_timezone: "Project Timezone", + project_name: "Program name", + project_id: "Program ID", + project_timezone: "Program Timezone", created_on: "Created on", - update_project: "Update project", + update_project: "Update program", identifier_already_exists: "Identifier already exists", add_more: "Add more", defaults: "Defaults", @@ -697,7 +709,7 @@ export default { select: "Select", upgrade: "Upgrade", add_seats: "Add Seats", - projects: "Projects", + projects: "Programs", workspace: "Workspace", workspaces: "Workspaces", team: "Team", @@ -929,7 +941,7 @@ export default { "Are you sure you want to archive the work item? All your archived work items can be restored later.", success: { label: "Archive success", - message: "Your archives can be found in project archives.", + message: "Your archives can be found in program archives.", }, failed: { message: "Work item could not be archived. Please try again.", @@ -938,7 +950,7 @@ export default { restore: { success: { title: "Restore success", - message: "Your work item can be found in project work items.", + message: "Your work item can be found in program work items.", }, failed: { message: "Work item could not be restored. Please try again.", @@ -1052,9 +1064,9 @@ export default { }, }, errors: { - snooze_permission: "Only project admins can snooze/Un-snooze work items", - accept_permission: "Only project admins can accept work items", - decline_permission: "Only project admins can deny work items", + snooze_permission: "Only program admins can snooze/Un-snooze work items", + accept_permission: "Only program admins can accept work items", + decline_permission: "Only program admins can deny work items", }, actions: { accept: "Accept", @@ -1065,7 +1077,7 @@ export default { delete: "Delete", open: "Open work item", mark_as_duplicate: "Mark as duplicate", - move: "Move {value} to project work items", + move: "Move {value} to program work items", }, source: { "in-app": "in-app", @@ -1157,14 +1169,14 @@ export default { workspace_dashboard: { empty_state: { general: { - title: "Overview of your projects, activity, and metrics", + title: "Overview of your programs, activity, and metrics", description: - "Welcome to Plane, we are excited to have you here. Create your first project and track your work items, and this page will transform into a space that helps you progress. Admins will also see items which help their team progress.", + "Welcome to Plane, we are excited to have you here. Create your first program and track your work items, and this page will transform into a space that helps you progress. Admins will also see items which help their team progress.", primary_button: { - text: "Build your first project", + text: "Build your first program", comic: { - title: "Everything starts with a project in Plane", - description: "A project could be a product's roadmap, a marketing campaign, or launching a new car.", + title: "Everything starts with a program in Plane", + description: "A program could be a product's roadmap, a marketing campaign, or launching a new car.", }, }, }, @@ -1176,7 +1188,7 @@ export default { open_tasks: "Total open tasks", error: "There was some error in fetching the data.", work_items_closed_in: "Work items closed in", - selected_projects: "Selected projects", + selected_projects: "Selected programs", total_members: "Total members", total_cycles: "Total cycles", total_modules: "Total modules", @@ -1205,11 +1217,11 @@ export default { backlog_work_items: "Backlog {entity}", un_started_work_items: "Unstarted {entity}", completed_work_items: "Completed {entity}", - project_insights: "Project Insights", - summary_of_projects: "Summary of Projects", - all_projects: "All Projects", + project_insights: "Program Insights", + summary_of_projects: "Summary of Programs", + all_projects: "All Programs", trend_on_charts: "Trend on charts", - active_projects: "Active Projects", + active_projects: "Active Programs", customized_insights: "Customized Insights", created_vs_resolved: "Created vs Resolved", empty_state: { @@ -1228,9 +1240,9 @@ export default { general: { title: "Track progress, workloads, and allocations. Spot trends, remove blockers, and move work faster", description: - "See scope versus demand, estimates, and scope creep. Get performance by team members and teams, and make sure your project runs on time.", + "See scope versus demand, estimates, and scope creep. Get performance by team members and teams, and make sure your program runs on time.", primary_button: { - text: "Start your first project", + text: "Start your first program", comic: { title: "Analytics works best with Cycles + Modules", description: @@ -1241,9 +1253,9 @@ export default { }, }, workspace_projects: { - label: "{count, plural, one {Project} other {Projects}}", + label: "{count, plural, one {Program} other {Programs}}", create: { - label: "Add Project", + label: "Add Program", }, network: { label: "Network", @@ -1276,7 +1288,7 @@ export default { members_length: "Number of members", }, scope: { - my_projects: "My projects", + my_projects: "My programs", archived_projects: "Archived", }, common: { @@ -1284,34 +1296,34 @@ export default { }, empty_state: { general: { - title: "No active projects", + title: "No active programs", description: - "Think of each project as the parent for goal-oriented work. Projects are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal. Create a new project or filter for archived projects.", + "Think of each program as the parent for goal-oriented work. Programs are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal. Create a new program or filter for archived programs.", primary_button: { - text: "Start your first project", + text: "Start your first program", comic: { - title: "Everything starts with a project in Plane", - description: "A project could be a product's roadmap, a marketing campaign, or launching a new car.", + title: "Everything starts with a program in Plane", + description: "A program could be a product's roadmap, a marketing campaign, or launching a new car.", }, }, }, no_projects: { - title: "No project", - description: "To create work items or manage your work, you need to create a project or be a part of one.", + title: "No program", + description: "To create work items or manage your work, you need to create a program or be a part of one.", primary_button: { - text: "Start your first project", + text: "Start your first program", comic: { - title: "Everything starts with a project in Plane", - description: "A project could be a product's roadmap, a marketing campaign, or launching a new car.", + title: "Everything starts with a program in Plane", + description: "A program could be a product's roadmap, a marketing campaign, or launching a new car.", }, }, }, filter: { - title: "No matching projects", - description: "No projects detected with the matching criteria. \n Create a new project instead.", + title: "No matching programs", + description: "No programs detected with the matching criteria. \n Create a new program instead.", }, search: { - description: "No projects detected with the matching criteria.\nCreate a new project instead", + description: "No programs detected with the matching criteria.\nCreate a new program instead", }, }, }, @@ -1319,8 +1331,8 @@ export default { add_view: "Add view", empty_state: { "all-issues": { - title: "No work items in the project", - description: "First project done! Now, slice your work into trackable pieces with work items. Let's go!", + title: "No work items in the program", + description: "First program done! Now, slice your work into trackable pieces with work items. Let's go!", primary_button: { text: "Create new work item", }, @@ -1373,7 +1385,7 @@ export default { }, activity: { heading: "Activity", - description: "Track your recent actions and changes across all projects and work items.", + description: "Track your recent actions and changes across all programs and work items.", }, }, workspace_settings: { @@ -1454,12 +1466,12 @@ export default { }, exports: { heading: "Exports", - description: "Export your project data in various formats and access your export history with download links.", + description: "Export your program data in various formats and access your export history with download links.", title: "Exports", exporting: "Exporting", previous_exports: "Previous exports", export_separate_files: "Export the data into separate files", - exporting_projects: "Exporting project", + exporting_projects: "Exporting program", format: "Format", modal: { title: "Export to", @@ -1477,7 +1489,7 @@ export default { }, webhooks: { heading: "Webhooks", - description: "Automate notifications to external services when project events occur.", + description: "Automate notifications to external services when program events occur.", title: "Webhooks", add_webhook: "Add webhook", modal: { @@ -1636,36 +1648,36 @@ export default { }, project_settings: { general: { - enter_project_id: "Enter project ID", + enter_project_id: "Enter program ID", please_select_a_timezone: "Please select a timezone", archive_project: { - title: "Archive project", + title: "Archive program", description: - "Archiving a project will unlist your project from your side navigation although you will still be able to access it from your projects page. You can restore the project or delete it whenever you want.", - button: "Archive project", + "Archiving a program will unlist your program from your side navigation although you will still be able to access it from your programs page. You can restore the program or delete it whenever you want.", + button: "Archive program", }, delete_project: { - title: "Delete project", + title: "Delete program", description: - "When deleting a project, all of the data and resources within that project will be permanently removed and cannot be recovered.", - button: "Delete my project", + "When deleting a program, all of the data and resources within that program will be permanently removed and cannot be recovered.", + button: "Delete my program", }, toast: { - success: "Project updated successfully", - error: "Project could not be updated. Please try again.", + success: "Program updated successfully", + error: "Program could not be updated. Please try again.", }, }, members: { label: "Members", - project_lead: "Project lead", + project_lead: "Program lead", default_assignee: "Default assignee", guest_super_permissions: { title: "Grant view access to all work items for guest users:", - sub_heading: "This will allow guests to have view access to all the project work items.", + sub_heading: "This will allow guests to have view access to all the program work items.", }, invite_members: { title: "Invite members", - sub_heading: "Invite members to work on your project.", + sub_heading: "Invite members to work on your program.", select_co_worker: "Select co-worker", }, }, @@ -1692,7 +1704,7 @@ export default { heading: "Estimates", description: "Set up estimation systems to track and communicate the effort required for each work item.", label: "Estimates", - title: "Enable estimates for my project", + title: "Enable estimates for my program", enable_description: "They help you in communicating complexity and workload of the team.", no_estimate: "No estimate", new: "New estimate system", @@ -1719,7 +1731,7 @@ export default { updated: { success: { title: "Estimate modified", - message: "The estimate has been updated in your project.", + message: "The estimate has been updated in your program.", }, error: { title: "Estimate modification failed", @@ -1777,7 +1789,7 @@ export default { label: "Automations", heading: "Automations", description: - "Configure automated actions to streamline your project management workflow and reduce manual tasks.", + "Configure automated actions to streamline your program management workflow and reduce manual tasks.", "auto-archive": { title: "Auto-archive closed work items", description: "Plane will auto archive work items that have been completed or canceled.", @@ -1793,7 +1805,7 @@ export default { empty_state: { labels: { title: "No labels yet", - description: "Create labels to help organize and filter work items in you project.", + description: "Create labels to help organize and filter work items in you program.", }, estimates: { title: "No estimate systems yet", @@ -1896,7 +1908,7 @@ export default { general: { title: "Group and timebox your work in Cycles.", description: - "Break work down by timeboxed chunks, work backwards from your project deadline to set dates, and make tangible progress as a team.", + "Break work down by timeboxed chunks, work backwards from your program deadline to set dates, and make tangible progress as a team.", primary_button: { text: "Set your first cycle", comic: { @@ -1928,7 +1940,7 @@ export default { }, archived: { title: "No archived cycles yet", - description: "To tidy up your project, archive completed cycles. Find them here once archived.", + description: "To tidy up your program, archive completed cycles. Find them here once archived.", }, }, }, @@ -1937,7 +1949,7 @@ export default { no_issues: { title: "Create a work item and assign it to someone, even yourself", description: - "Think of work items as jobs, tasks, work, or JTBD. Which we like. A work item and its sub-work items are usually time-based actionables assigned to members of your team. Your team creates, assigns, and completes work items to move your project towards its goal.", + "Think of work items as jobs, tasks, work, or JTBD. Which we like. A work item and its sub-work items are usually time-based actionables assigned to members of your team. Your team creates, assigns, and completes work items to move your program towards its goal.", primary_button: { text: "Create your first work item", comic: { @@ -1972,9 +1984,9 @@ export default { delete_module: "Delete module", empty_state: { general: { - title: "Map your project milestones to Modules and track aggregated work easily.", + title: "Map your program milestones to Modules and track aggregated work easily.", description: - "A group of work items that belong to a logical, hierarchical parent form a module. Think of them as a way to track work by project milestones. They have their own periods and deadlines as well as analytics to help you see how close or far you are from a milestone.", + "A group of work items that belong to a logical, hierarchical parent form a module. Think of them as a way to track work by program milestones. They have their own periods and deadlines as well as analytics to help you see how close or far you are from a milestone.", primary_button: { text: "Build your first module", comic: { @@ -1996,7 +2008,7 @@ export default { }, archived: { title: "No archived Modules yet", - description: "To tidy up your project, archive completed or cancelled modules. Find them here once archived.", + description: "To tidy up your program, archive completed or cancelled modules. Find them here once archived.", }, sidebar: { in_active: "This module isn't active yet.", @@ -2021,9 +2033,9 @@ export default { project_views: { empty_state: { general: { - title: "Save filtered views for your project. Create as many as you need", + title: "Save filtered views for your program. Create as many as you need", description: - "Views are a set of saved filters that you use frequently or want easy access to. All your colleagues in a project can see everyone’s views and choose whichever suits their needs best.", + "Views are a set of saved filters that you use frequently or want easy access to. All your colleagues in a program can see everyone’s views and choose whichever suits their needs best.", primary_button: { text: "Create your first view", comic: { @@ -2049,7 +2061,7 @@ export default { title: "Write a note, a doc, or a full knowledge base. Get Galileo, Plane's AI assistant, to help you get started", description: - "Pages are thoughts potting space in Plane. Take down meeting notes, format them easily, embed work items, lay them out using a library of components, and keep them all in your project's context. To make short work of any doc, invoke Galileo, Plane's AI, with a shortcut or the click of a button.", + "Pages are thoughts potting space in Plane. Take down meeting notes, format them easily, embed work items, lay them out using a library of components, and keep them all in your program's context. To make short work of any doc, invoke Galileo, Plane's AI, with a shortcut or the click of a button.", primary_button: { text: "Create your first page", }, @@ -2063,7 +2075,7 @@ export default { }, public: { title: "No public pages yet", - description: "See pages shared with everyone in your project right here.", + description: "See pages shared with everyone in your program right here.", primary_button: { text: "Create your first page", }, @@ -2177,41 +2189,41 @@ export default { disabled_project: { empty_state: { inbox: { - title: "Intake is not enabled for the project.", + title: "Intake is not enabled for the program.", description: - "Intake helps you manage incoming requests to your project and add them as work items in your workflow. Enable intake from project settings to manage requests.", + "Intake helps you manage incoming requests to your program and add them as work items in your workflow. Enable intake from program settings to manage requests.", primary_button: { text: "Manage features", }, }, cycle: { - title: "Cycles is not enabled for this project.", + title: "Cycles is not enabled for this program.", description: - "Break work down by timeboxed chunks, work backwards from your project deadline to set dates, and make tangible progress as a team. Enable the cycles feature for your project to start using them.", + "Break work down by timeboxed chunks, work backwards from your program deadline to set dates, and make tangible progress as a team. Enable the cycles feature for your program to start using them.", primary_button: { text: "Manage features", }, }, module: { - title: "Modules are not enabled for the project.", + title: "Modules are not enabled for the program.", description: - "Modules are the building blocks of your project. Enable modules from project settings to start using them.", + "Modules are the building blocks of your program. Enable modules from program settings to start using them.", primary_button: { text: "Manage features", }, }, page: { - title: "Pages are not enabled for the project.", + title: "Pages are not enabled for the program.", description: - "Pages are the building blocks of your project. Enable pages from project settings to start using them.", + "Pages are the building blocks of your program. Enable pages from program settings to start using them.", primary_button: { text: "Manage features", }, }, view: { - title: "Views are not enabled for the project.", + title: "Views are not enabled for the program.", description: - "Views are the building blocks of your project. Enable views from project settings to start using them.", + "Views are the building blocks of your program. Enable views from program settings to start using them.", primary_button: { text: "Manage features", }, @@ -2307,7 +2319,7 @@ export default { }, member: { title: "Member", - description: "Ability to read, write, edit, and delete entities inside projects, cycles, and modules", + description: "Ability to read, write, edit, and delete entities inside programs, cycles, and modules", }, admin: { title: "Admin", @@ -2315,7 +2327,7 @@ export default { }, }, user_roles: { - product_or_project_manager: "Product / Project Manager", + product_or_project_manager: "Product / Program Manager", development_or_engineering: "Development / Engineering", founder_or_executive: "Founder / Executive", freelancer_or_consultant: "Freelancer / Consultant", @@ -2333,7 +2345,7 @@ export default { }, jira: { title: "Jira", - description: "Import work items and epics from Jira projects and epics.", + description: "Import work items and epics from Jira programs and epics.", }, }, exporter: { diff --git a/packages/propel/src/accordion/accordion.stories.tsx b/packages/propel/src/accordion/accordion.stories.tsx index 5850c5062dd..1998903dd4b 100644 --- a/packages/propel/src/accordion/accordion.stories.tsx +++ b/packages/propel/src/accordion/accordion.stories.tsx @@ -29,7 +29,7 @@ export const Default: Story = { <Accordion.Item value="item-1"> <Accordion.Trigger>What is Plane?</Accordion.Trigger> <Accordion.Content> - Plane is an open-source project management tool designed for developers and teams to plan, track, and manage + Plane is an open-source program management tool designed for developers and teams to plan, track, and manage their work efficiently. </Accordion.Content> </Accordion.Item> diff --git a/packages/propel/src/emoji-icon-picker/emoji-picker.stories.tsx b/packages/propel/src/emoji-icon-picker/emoji-picker.stories.tsx index 3e00e6af389..68a43e54943 100644 --- a/packages/propel/src/emoji-icon-picker/emoji-picker.stories.tsx +++ b/packages/propel/src/emoji-icon-picker/emoji-picker.stories.tsx @@ -335,17 +335,17 @@ export const InFormContext: Story = { <div className="max-w-md p-4"> <form onSubmit={handleSubmit} className="space-y-4 p-6 border border-custom-border-200 rounded-lg"> <div> - <label className="block text-sm font-medium mb-2">Project Title</label> + <label className="block text-sm font-medium mb-2">Program Title</label> <input type="text" value={formData.title} onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))} className="w-full px-3 py-2 bg-custom-background-80 border border-custom-border-200 rounded" - placeholder="Enter project title" + placeholder="Enter program title" /> </div> <div> - <label className="block text-sm font-medium mb-2">Project Icon</label> + <label className="block text-sm font-medium mb-2">Program Icon</label> <EmojiPicker isOpen={isOpen} handleToggle={setIsOpen} @@ -360,7 +360,7 @@ export const InFormContext: Story = { type="submit" className="w-full px-4 py-2 bg-custom-primary-100 text-white rounded hover:bg-custom-primary-200" > - Create Project + Create Program </button> </form> </div> diff --git a/packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx b/packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx index d0117c81d33..929f4015500 100644 --- a/packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx +++ b/packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx @@ -263,7 +263,7 @@ export const InMessageContext: Story = { <div className="flex-1"> <div className="font-medium text-sm">Alice Brown</div> <div className="text-sm text-custom-text-300 mt-1"> - Hey everyone! Just wanted to share some exciting news about our project launch next week! + Hey everyone! Just wanted to share some exciting news about our program launch next week! </div> </div> </div> diff --git a/packages/propel/src/empty-state/assets/vertical-stack/constant.tsx b/packages/propel/src/empty-state/assets/vertical-stack/constant.tsx index 2756a436a3f..830389d1e2c 100644 --- a/packages/propel/src/empty-state/assets/vertical-stack/constant.tsx +++ b/packages/propel/src/empty-state/assets/vertical-stack/constant.tsx @@ -74,7 +74,7 @@ export const VerticalStackAssetsMap = [ }, { asset: <ProjectVerticalStackIllustration />, - title: "ProjectVerticalStackIllustration", + title: "ProgramVerticalStackIllustration", }, { asset: <ServerErrorVerticalStackIllustration />, diff --git a/packages/propel/src/table/table.stories.tsx b/packages/propel/src/table/table.stories.tsx index c38edcef148..7544ce60344 100644 --- a/packages/propel/src/table/table.stories.tsx +++ b/packages/propel/src/table/table.stories.tsx @@ -163,7 +163,7 @@ export const WithBadges: Story = { <Table> <TableHeader> <TableRow> - <TableHead>Project</TableHead> + <TableHead>Program</TableHead> <TableHead>Status</TableHead> <TableHead>Priority</TableHead> <TableHead>Assignee</TableHead> diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ea6ee408026..79a14b9b64a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -33,6 +33,7 @@ export * from "./project"; export * from "./publish"; export * from "./reaction"; export * from "./rich-filters"; +export * from "./roster"; export * from "./search"; export * from "./state"; export * from "./stickies"; diff --git a/packages/types/src/instance/base.ts b/packages/types/src/instance/base.ts index 79b1e642f2a..efb7eca7381 100644 --- a/packages/types/src/instance/base.ts +++ b/packages/types/src/instance/base.ts @@ -51,6 +51,7 @@ export interface IInstanceConfig { has_unsplash_configured: boolean; has_llm_configured: boolean; file_size_limit: number | undefined; + media_library_file_size_limit: number | undefined; is_smtp_configured: boolean; app_base_url: string | undefined; space_base_url: string | undefined; diff --git a/packages/types/src/issues.ts b/packages/types/src/issues.ts index 44bb09e7547..d86cff99064 100644 --- a/packages/types/src/issues.ts +++ b/packages/types/src/issues.ts @@ -47,8 +47,16 @@ export interface IIssueParent { project_detail: IProjectLite; sequence_id: number; start_date: string | null; + start_time: string | null; state_detail: IStateLite; target_date: string | null; + + // Sport App Fields + level: string | null; + program: string | null; + sport: string | null; + year: string | null; + category: string | null; } export interface IIssueLink { @@ -151,7 +159,15 @@ export interface IIssueLite { project_id: string; start_date?: string | null; target_date?: string | null; + start_time?: string | null; workspace__slug: string; + + // Sport App Fields + level: string | null; + sport: string | null; + program: string | null; + category: string | null; + year: string | null; } export interface IIssueAttachment { diff --git a/packages/types/src/issues/issue.ts b/packages/types/src/issues/issue.ts index 85069632eb0..d648cd33cff 100644 --- a/packages/types/src/issues/issue.ts +++ b/packages/types/src/issues/issue.ts @@ -35,11 +35,18 @@ export enum EIssuesStoreType { TEAM_PROJECT_WORK_ITEMS = "TEAM_PROJECT_WORK_ITEMS", } +export type TOppositionTeam = { + name: string; + logo: string; +}; + export type TBaseIssue = { id: string; sequence_id: number; name: string; sort_order: number; + sg_event_id: string | number | null; + opposition_team: TOppositionTeam | string | null; state_id: string | null; priority: TIssuePriorities | null; @@ -60,6 +67,7 @@ export type TBaseIssue = { created_at: string; updated_at: string; start_date: string | null; + start_time: string | null; target_date: string | null; completed_at: string | null; archived_at: string | null; @@ -67,6 +75,13 @@ export type TBaseIssue = { created_by: string; updated_by: string; + // Sport App Fields + level: string | null; + program: string | null; + sport: string | null; + year: string | null; + category: string | null; + is_draft: boolean; is_epic?: boolean; is_intake?: boolean; @@ -137,10 +152,16 @@ export type TBulkIssueProperties = Pick< | "label_ids" | "assignee_ids" | "start_date" + | "start_time" | "target_date" | "module_ids" | "cycle_id" | "estimate_point" + | "level" + | "sport" + | "program" + | "year" + | "category" >; export type TBulkOperationsPayload = { @@ -167,6 +188,7 @@ export interface IPublicIssue | "sequence_id" | "sort_order" | "start_date" + | "start_time" | "target_date" | "cycle_id" | "module_ids" @@ -176,6 +198,11 @@ export interface IPublicIssue | "sub_issues_count" | "link_count" | "estimate_point" + | "level" + | "sport" + | "program" + | "year" + | "category" > { comments: TIssuePublicComment[]; reaction_items: IIssuePublicReaction[]; diff --git a/packages/types/src/project/projects.ts b/packages/types/src/project/projects.ts index 6927688752d..07a3eb4b51b 100644 --- a/packages/types/src/project/projects.ts +++ b/packages/types/src/project/projects.ts @@ -27,6 +27,7 @@ export interface IPartialProject { guest_view_all_features?: boolean; project_lead?: IUserLite | string | null; network?: number; + sport?: string | null; // Timestamps created_at?: Date; updated_at?: Date; @@ -71,6 +72,7 @@ export interface IProjectLite { name: string; identifier: string; logo_props: TLogoProps; + sport?: string | null; } export type ProjectPreferences = { @@ -143,11 +145,18 @@ export interface ISearchIssueResponse { project__name: string; sequence_id: number; start_date: string | null; + start_time: string | null; + target_date: string | null; state__color: string; state__group: TStateGroups; state__name: string; workspace__slug: string; type_id: string; + level: string | null; + sport: string | null; + program: string | null; + year: string | null; + category: string | null; } export type TPartialProject = IPartialProject; diff --git a/packages/types/src/roster/index.ts b/packages/types/src/roster/index.ts new file mode 100644 index 00000000000..f11b50e8782 --- /dev/null +++ b/packages/types/src/roster/index.ts @@ -0,0 +1 @@ +export * from "./roster"; diff --git a/packages/types/src/roster/roster.ts b/packages/types/src/roster/roster.ts new file mode 100644 index 00000000000..61431d515b0 --- /dev/null +++ b/packages/types/src/roster/roster.ts @@ -0,0 +1,45 @@ +export type TRosterPlayerStatus = "active" | "injured" | "inactive" | "pending"; + +export interface IRosterPlayer { + id: string; + program_id: string; + player_name: string; + jersey_number: string | null; + position: string | null; + height: string | null; + weight: string | null; + class_year: string | null; + status: TRosterPlayerStatus; + notes: string | null; + created_at: string; + updated_at: string; +} + +export interface IRosterFilters { + search?: string; + position?: string; + status?: TRosterPlayerStatus | ""; + class_year?: string; +} + +export interface IRosterPlayerPayload { + player_name: string; + jersey_number?: string | null; + position?: string | null; + height?: string | null; + weight?: string | null; + class_year?: string | null; + status?: TRosterPlayerStatus; + notes?: string | null; +} + +export interface IRosterImportPayload { + players: IRosterPlayerPayload[]; +} + +export interface IRosterImportResponse { + success: boolean; + data: IRosterPlayer[]; + imported_count: number; + message: string; +} diff --git a/packages/types/src/view-props.ts b/packages/types/src/view-props.ts index 04f4dcc85b1..0363affeb04 100644 --- a/packages/types/src/view-props.ts +++ b/packages/types/src/view-props.ts @@ -14,6 +14,7 @@ export type TIssueGroupByOptions = | "assignees" | "cycle" | "module" + | "start_date" | "target_date" | "team_project" | null; @@ -38,6 +39,8 @@ export type TIssueOrderByOptions = | "-issue_cycle__cycle__name" | "target_date" | "-target_date" + | "start_time" + | "-start_time" | "estimate_point__key" | "-estimate_point__key" | "start_date" @@ -47,7 +50,17 @@ export type TIssueOrderByOptions = | "attachment_count" | "-attachment_count" | "sub_issues_count" - | "-sub_issues_count"; + | "-sub_issues_count" + | "sport" + | "-sport" + | "program" + | "-program" + | "level" + | "-level" + | "year" + | "-year" + | "category" + | "-category"; export type TIssueGroupingFilters = "active" | "backlog"; @@ -65,7 +78,13 @@ export type TIssueParams = | "cycle" | "module" | "start_date" + | "start_time" | "target_date" + | "level" + | "sport" + | "program" + | "year" + | "category" | "project" | "team_project" | "group_by" @@ -81,7 +100,7 @@ export type TIssueParams = | "expand" | "filters"; -export type TCalendarLayouts = "month" | "week"; +export type TCalendarLayouts = "month" | "day"; /** * Keys for the work item filter properties @@ -90,6 +109,12 @@ export const WORK_ITEM_FILTER_PROPERTY_KEYS = [ "state_group", "priority", "start_date", + "start_time", + "level", + "sport", + "program", + "year", + "category", "target_date", "assignee_id", "mention_id", @@ -132,11 +157,19 @@ export interface IIssueFilterOptions { project?: string[] | null; team_project?: string[] | null; start_date?: string[] | null; + start_time?: string[] | null; state?: string[] | null; state_group?: string[] | null; subscriber?: string[] | null; target_date?: string[] | null; issue_type?: string[] | null; + + // Sport App Fields + level?: string[] | null; + sport?: string[] | null; + program?: string[] | null; + year?: string[] | null; + category?: string[] | null; } export interface IIssueDisplayFilterOptions { @@ -153,6 +186,7 @@ export interface IIssueDisplayFilterOptions { } export interface IIssueDisplayProperties { assignee?: boolean; + start_time?: boolean; start_date?: boolean; due_date?: boolean; labels?: boolean; @@ -168,6 +202,11 @@ export interface IIssueDisplayProperties { modules?: boolean; cycle?: boolean; issue_type?: boolean; + level?: boolean; + sport?: boolean; + program?: boolean; + year?: boolean; + category?: boolean; } export type TIssueKanbanFilters = { @@ -202,8 +241,16 @@ export interface IWorkspaceIssueFilterOptions { state_group?: string[] | null; subscriber?: string[] | null; start_date?: string[] | null; + start_time?: string[] | null; target_date?: string[] | null; project?: string[] | null; + + // Sport App Fields + level?: string[] | null; + sport?: string[] | null; + program?: string[] | null; + year?: string[] | null; + category?: string[] | null; } export interface IWorkspaceViewIssuesParams { @@ -212,6 +259,7 @@ export interface IWorkspaceViewIssuesParams { labels?: string | undefined; priority?: string | undefined; start_date?: string | undefined; + start_time?: string | undefined; state?: string | undefined; state_group?: string | undefined; subscriber?: string | undefined; @@ -219,6 +267,13 @@ export interface IWorkspaceViewIssuesParams { project?: string | undefined; order_by?: string | undefined; sub_issue?: boolean; + + // Sport App Fields + level?: string | undefined; + sport?: string | undefined; + program?: string | undefined; + year?: string | undefined; + category?: string | undefined; } export interface IProjectViewProps { diff --git a/packages/ui/package.json b/packages/ui/package.json index 0f0ce349d4f..653555b7905 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -41,9 +41,9 @@ "@headlessui/react": "^1.7.3", "@plane/constants": "workspace:*", "@plane/hooks": "workspace:*", + "@plane/propel": "workspace:*", "@plane/types": "workspace:*", "@plane/utils": "workspace:*", - "@plane/propel": "workspace:*", "@popperjs/core": "^2.11.8", "@radix-ui/react-scroll-area": "^1.2.3", "clsx": "^2.0.0", @@ -52,6 +52,7 @@ "lucide-react": "catalog:", "react-color": "^2.19.3", "react-day-picker": "9.5.0", + "react-phone-input-2": "^2.15.1", "react-popper": "^2.3.0", "tailwind-merge": "^2.0.0", "use-font-face-observer": "^1.2.2" diff --git a/packages/utils/src/color.ts b/packages/utils/src/color.ts index 017c594b7e4..93bee45bad6 100644 --- a/packages/utils/src/color.ts +++ b/packages/utils/src/color.ts @@ -40,6 +40,7 @@ export const toHex = (value: number) => validateColor(value).toString(16).padSta * hexToRgb("#00ff00") // returns { r: 0, g: 255, b: 0 } * hexToRgb("#0000ff") // returns { r: 0, g: 0, b: 255 } */ + export const hexToRgb = (hex: string): TRgb => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim()); return result @@ -124,6 +125,8 @@ export const hexToHsl = (hex: string): THsl => { * hslToHex({ h: 120, s: 100, l: 50 }) // returns "#00ff00" * hslToHex({ h: 240, s: 100, l: 50 }) // returns "#0000ff" */ + + export const hslToHex = ({ h, s, l }: THsl): string => { if (h < 0 || h > 360) return "#000000"; if (s < 0 || s > 100) return "#000000"; diff --git a/packages/utils/src/datetime.ts b/packages/utils/src/datetime.ts index bc9d06964bf..08f6124ea02 100644 --- a/packages/utils/src/datetime.ts +++ b/packages/utils/src/datetime.ts @@ -93,6 +93,95 @@ export const renderFormattedTime = (date: string | Date, timeFormat: "12-hour" | return formattedTime; }; +//Format ISO Time + +/** + * Convert ISO string to 12-hour format for display + * @param iso - ISO date string like "2025-12-08T11:38:00.000Z" + * @returns Formatted time like "11:38 AM" or null + */ +export const isoTo12Hour = (iso: string | null): string | null => { + if (!iso) return null; + const date = new Date(iso); + const h = date.getHours(); + const m = date.getMinutes(); + const period = h >= 12 ? "PM" : "AM"; + const hour12 = h % 12 === 0 ? 12 : h % 12; + return `${hour12.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")} ${period}`; +}; + +/** + * Convert ISO string to 24-hour format for HTML time input + * @param iso - ISO date string like "2025-12-08T11:38:00.000Z" + * @returns Time string like "11:38" + */ +export const isoTo24Hour = (iso: string | null): string => { + if (!iso) return ""; + const date = new Date(iso); + const h = date.getHours().toString().padStart(2, "0"); + const m = date.getMinutes().toString().padStart(2, "0"); + return `${h}:${m}`; +}; + +/** + * Update time in an ISO string + * @param oldISO - Existing ISO string or null + * @param time24 - Time in 24-hour format like "11:38" + * @returns New ISO string with updated time + */ +export const updateISOTime = (oldISO: string | null, time24: string): string | null => { + if (!time24) return null; + const date = oldISO ? new Date(oldISO) : new Date(); + const [h, m] = time24.split(":").map(Number); + date.setHours(h, m, 0, 0); + return date.toISOString(); +}; + +/** + * Combines a date-only value with a time-only ISO value into a single local Date instance. + */ +export const getDateTimeFromDateAndTime = ( + dateValue: string | Date | undefined | null, + timeValue: string | Date | undefined | null +): Date | undefined => { + const date = getDate(dateValue); + if (!date || !timeValue) return; + + const time = new Date(timeValue); + if (!isValid(time)) return; + + const combinedDateTime = new Date(date); + combinedDateTime.setHours(time.getHours(), time.getMinutes(), time.getSeconds(), time.getMilliseconds()); + + return combinedDateTime; +}; + +export const isDateTimePast = ( + dateValue: string | Date | undefined | null, + timeValue: string | Date | undefined | null +): boolean => { + const dateTime = getDateTimeFromDateAndTime(dateValue, timeValue); + if (!dateTime) return false; + + return dateTime.getTime() <= Date.now(); +}; + +export const isDateTimePastWithOverrides = ({ + currentDateValue, + currentTimeValue, + nextDateValue, + nextTimeValue, +}: { + currentDateValue: string | Date | undefined | null; + currentTimeValue: string | Date | undefined | null; + nextDateValue?: string | Date | undefined | null; + nextTimeValue?: string | Date | undefined | null; +}): boolean => + isDateTimePast( + nextDateValue !== undefined ? nextDateValue : currentDateValue, + nextTimeValue !== undefined ? nextTimeValue : currentTimeValue + ); + // Date Difference Helpers /** * @returns {number} total number of days in range diff --git a/packages/utils/src/work-item-filters/configs/filters/project.ts b/packages/utils/src/work-item-filters/configs/filters/project.ts index fcefa309c21..3348b324152 100644 --- a/packages/utils/src/work-item-filters/configs/filters/project.ts +++ b/packages/utils/src/work-item-filters/configs/filters/project.ts @@ -22,7 +22,7 @@ export const getProjectFilterConfig = (params: TCreateProjectFilterParams) => createFilterConfig<P, string>({ id: key, - label: "Projects", + label: "Programs", ...params, icon: params.filterIcon, supportedOperatorConfigsMap: new Map([ diff --git a/packages/utils/src/work-item/base.ts b/packages/utils/src/work-item/base.ts index 826fb7c54fd..95982e634c2 100644 --- a/packages/utils/src/work-item/base.ts +++ b/packages/utils/src/work-item/base.ts @@ -274,7 +274,7 @@ export const getComputedDisplayFilters = ( return { calendar: { - show_weekends: filters?.calendar?.show_weekends || false, + show_weekends: filters?.calendar?.show_weekends || true, layout: filters?.calendar?.layout || "month", }, layout: filters?.layout || EIssueLayoutTypes.LIST, @@ -295,6 +295,7 @@ export const getComputedDisplayProperties = ( displayProperties: IIssueDisplayProperties = {} ): IIssueDisplayProperties => ({ assignee: displayProperties?.assignee ?? true, + start_time: displayProperties?.start_time ?? true, start_date: displayProperties?.start_date ?? true, due_date: displayProperties?.due_date ?? true, labels: displayProperties?.labels ?? true, @@ -310,6 +311,11 @@ export const getComputedDisplayProperties = ( modules: displayProperties?.modules ?? true, cycle: displayProperties?.cycle ?? true, issue_type: displayProperties?.issue_type ?? true, + level: displayProperties?.level ?? true, + sport: displayProperties?.sport ?? true, + program: displayProperties?.program ?? true, + year: displayProperties?.year ?? true, + category: displayProperties?.category ?? true, }); /** diff --git a/packages/utils/src/work-item/modal.ts b/packages/utils/src/work-item/modal.ts index ec07c5508be..8348010c78a 100644 --- a/packages/utils/src/work-item/modal.ts +++ b/packages/utils/src/work-item/modal.ts @@ -10,7 +10,13 @@ export const getUpdateFormDataForReset = (projectId: string | null | undefined, description_html: formData.description_html, priority: formData.priority, start_date: formData.start_date, + start_time: formData.start_time, target_date: formData.target_date, + level: formData.level, + sport: formData.sport, + program: formData.program, + year: formData.year, + category: formData.category, }); export const convertWorkItemDataToSearchResponse = ( @@ -28,9 +34,18 @@ export const convertWorkItemDataToSearchResponse = ( type_id: workItem.type_id ?? "", state__color: state?.color ?? "", start_date: workItem.start_date, + start_time: workItem.start_time, + target_date: workItem.target_date, state__group: state?.group ?? "backlog", state__name: state?.name ?? "", workspace__slug: workspaceSlug, + + // Sport App Fields + level: workItem.level, + sport: workItem.sport, + program: workItem.program, + year: workItem.year, + category: workItem.category, }); export function getChangedIssuefields(formData: Partial<TIssue>, dirtyFields: { [key: string]: boolean | undefined }) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bca8100f89d..8c12e04cb6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,10 +97,10 @@ importers: devDependencies: prettier: specifier: latest - version: 3.6.2 + version: 3.8.0 prettier-plugin-tailwindcss: specifier: ^0.6.14 - version: 0.6.14(prettier@3.6.2) + version: 0.6.14(prettier@3.8.0) turbo: specifier: ^2.5.8 version: 2.5.8 @@ -530,6 +530,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + dompurify: + specifier: 3.2.7 + version: 3.2.7 dotenv: specifier: ^16.0.3 version: 16.6.1 @@ -539,12 +542,18 @@ importers: export-to-csv: specifier: ^1.4.0 version: 1.4.0 + hls.js: + specifier: ^1.5.13 + version: 1.6.15 lodash-es: specifier: 'catalog:' version: 4.17.21 lucide-react: specifier: 'catalog:' version: 0.469.0(react@18.3.1) + mammoth: + specifier: ^1.11.0 + version: 1.11.0 mobx: specifier: 'catalog:' version: 6.12.0 @@ -587,6 +596,9 @@ importers: react-pdf-html: specifier: ^2.1.2 version: 2.1.3(@react-pdf/renderer@3.4.5(react@18.3.1))(react@18.3.1) + react-phone-input-2: + specifier: ^2.15.1 + version: 2.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-popper: specifier: ^2.3.0 version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -599,6 +611,9 @@ importers: smooth-scroll-into-view-if-needed: specifier: ^2.0.2 version: 2.0.2 + swiper: + specifier: ^12.0.3 + version: 12.0.3 swr: specifier: 'catalog:' version: 2.2.4(react@18.3.1) @@ -611,6 +626,12 @@ importers: uuid: specifier: 'catalog:' version: 13.0.0 + video.js: + specifier: ^8.23.4 + version: 8.23.4 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 devDependencies: '@plane/eslint-config': specifier: workspace:* @@ -1052,13 +1073,13 @@ importers: version: link:../typescript-config '@storybook/addon-designs': specifier: 10.0.2 - version: 10.0.2(@storybook/addon-docs@9.1.10(@types/react@18.3.11)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + version: 10.0.2(@storybook/addon-docs@9.1.10(@types/react@18.3.11)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) '@storybook/addon-docs': specifier: 9.1.10 - version: 9.1.10(@types/react@18.3.11)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + version: 9.1.10(@types/react@18.3.11)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) '@storybook/react-vite': specifier: 9.1.10 - version: 9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.52.4)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + version: 9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.52.4)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) '@types/react': specifier: 'catalog:' version: 18.3.11 @@ -1067,10 +1088,10 @@ importers: version: 18.3.1 eslint-plugin-storybook: specifier: 9.1.10 - version: 9.1.10(eslint@8.57.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3) + version: 9.1.10(eslint@8.57.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3) storybook: specifier: 9.1.10 - version: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + version: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) tsdown: specifier: 'catalog:' version: 0.15.5(typescript@5.8.3) @@ -1262,6 +1283,9 @@ importers: react-dom: specifier: 'catalog:' version: 18.3.1(react@18.3.1) + react-phone-input-2: + specifier: ^2.15.1 + version: 2.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-popper: specifier: ^2.3.0 version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1286,34 +1310,34 @@ importers: version: link:../typescript-config '@storybook/addon-essentials': specifier: ^8.1.1 - version: 8.6.14(@types/react@18.3.11)(storybook@8.6.14(prettier@3.6.2)) + version: 8.6.14(@types/react@18.3.11)(storybook@8.6.14(prettier@3.8.0)) '@storybook/addon-interactions': specifier: ^8.1.1 - version: 8.6.14(storybook@8.6.14(prettier@3.6.2)) + version: 8.6.14(storybook@8.6.14(prettier@3.8.0)) '@storybook/addon-links': specifier: ^8.1.1 - version: 8.6.14(react@18.3.1)(storybook@8.6.14(prettier@3.6.2)) + version: 8.6.14(react@18.3.1)(storybook@8.6.14(prettier@3.8.0)) '@storybook/addon-onboarding': specifier: ^8.1.1 - version: 8.6.14(storybook@8.6.14(prettier@3.6.2)) + version: 8.6.14(storybook@8.6.14(prettier@3.8.0)) '@storybook/addon-styling-webpack': specifier: ^1.0.0 - version: 1.0.1(storybook@8.6.14(prettier@3.6.2))(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) + version: 1.0.1(storybook@8.6.14(prettier@3.8.0))(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) '@storybook/addon-webpack5-compiler-swc': specifier: ^1.0.2 version: 1.0.6(@swc/helpers@0.5.17)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) '@storybook/blocks': specifier: ^8.1.1 - version: 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2)) + version: 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0)) '@storybook/react': specifier: ^8.1.1 - version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) + version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.8.0)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0))(typescript@5.8.3) '@storybook/react-webpack5': specifier: ^8.1.1 - version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) + version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.8.0)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0))(typescript@5.8.3) '@storybook/test': specifier: ^8.1.1 - version: 8.6.14(storybook@8.6.14(prettier@3.6.2)) + version: 8.6.14(storybook@8.6.14(prettier@3.8.0)) '@types/lodash-es': specifier: 'catalog:' version: 4.17.12 @@ -1340,7 +1364,7 @@ importers: version: 6.2.0(postcss@8.5.6) storybook: specifier: ^8.1.1 - version: 8.6.14(prettier@3.6.2) + version: 8.6.14(prettier@3.8.0) tsdown: specifier: 'catalog:' version: 0.15.5(typescript@5.8.3) @@ -1495,6 +1519,7 @@ packages: '@base-ui-components/react@1.0.0-beta.3': resolution: {integrity: sha512-4sAq6zmDA9ixV2HRjjeM1+tSEw5R6nvGjXUQmFoQnC3DZLEUdwO94gWDmUDdpoDuChn27jdbaJs9F0Ih4w2UAA==} engines: {node: '>=14.0.0'} + deprecated: Package was renamed to @base-ui/react peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -1505,6 +1530,7 @@ packages: '@base-ui-components/utils@0.1.1': resolution: {integrity: sha512-HWXZA8upEKgrdL1rQqxWu1H+2tB2cXzY2jCxvgnpUv3eoWN2jldhXxMZnXIjZF7jahGxSWXfSIM/qskiTWFFxA==} + deprecated: Package was renamed to @base-ui/utils peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -2170,8 +2196,8 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@napi-rs/wasm-runtime@1.0.5': - resolution: {integrity: sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg==} + '@napi-rs/wasm-runtime@1.0.7': + resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} '@next/env@14.2.32': resolution: {integrity: sha512-n9mQdigI6iZ/DF6pCTwMKeWgF2e8lg7qgt5M7HXMLtyhZYMnf/u905M18sSpPmHL9MKp9JHo56C6jrD2EvWxng==} @@ -2461,12 +2487,8 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 - '@oxc-project/runtime@0.82.3': - resolution: {integrity: sha512-LNh5GlJvYHAnMurO+EyA8jJwN1rki7l3PSHuosDh2I7h00T6/u9rCkUjg/SvPmT1CZzvhuW0y+gf7jcqUy/Usg==} - engines: {node: '>=6.9.0'} - - '@oxc-project/types@0.82.3': - resolution: {integrity: sha512-6nCUxBnGX0c6qfZW5MaF6/fmu5dHJDMiMPaioKHKs5mi5+8/FHQ7WGjgQIz1zxpmceMYfdIXkOaLYE+ejbuOtA==} + '@oxc-project/types@0.95.0': + resolution: {integrity: sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -2738,78 +2760,91 @@ packages: '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} - '@rolldown/binding-android-arm64@1.0.0-beta.34': - resolution: {integrity: sha512-jf5GNe5jP3Sr1Tih0WKvg2bzvh5T/1TA0fn1u32xSH7ca/p5t+/QRr4VRFCV/na5vjwKEhwWrChsL2AWlY+eoA==} + '@rolldown/binding-android-arm64@1.0.0-beta.44': + resolution: {integrity: sha512-g9ejDOehJFhxC1DIXQuZQ9bKv4lRDioOTL42cJjFjqKPl1L7DVb9QQQE1FxokGEIMr6FezLipxwnzOXWe7DNPg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-beta.34': - resolution: {integrity: sha512-2F/TqH4QuJQ34tgWxqBjFL3XV1gMzeQgUO8YRtCPGBSP0GhxtoFzsp7KqmQEothsxztlv+KhhT9Dbg3HHwHViQ==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.44': + resolution: {integrity: sha512-PxAW1PXLPmCzfhfKIS53kwpjLGTUdIfX4Ht+l9mj05C3lYCGaGowcNsYi2rdxWH24vSTmeK+ajDNRmmmrK0M7g==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.34': - resolution: {integrity: sha512-E1QuFslgLWbHQ8Qli/AqUKdfg0pockQPwRxVbhNQ74SciZEZpzLaujkdmOLSccMlSXDfFCF8RPnMoRAzQ9JV8Q==} + '@rolldown/binding-darwin-x64@1.0.0-beta.44': + resolution: {integrity: sha512-/CtQqs1oO9uSb5Ju60rZvsdjE7Pzn8EK2ISAdl2jedjMzeD/4neNyCbwyJOAPzU+GIQTZVyrFZJX+t7HXR1R/g==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-beta.34': - resolution: {integrity: sha512-VS8VInNCwnkpI9WeQaWu3kVBq9ty6g7KrHdLxYMzeqz24+w9hg712TcWdqzdY6sn+24lUoMD9jTZrZ/qfVpk0g==} + '@rolldown/binding-freebsd-x64@1.0.0-beta.44': + resolution: {integrity: sha512-V5Q5W9c4+2GJ4QabmjmVV6alY97zhC/MZBaLkDtHwGy3qwzbM4DYgXUbun/0a8AH5hGhuU27tUIlYz6ZBlvgOA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.34': - resolution: {integrity: sha512-4St4emjcnULnxJYb/5ZDrH/kK/j6PcUgc3eAqH5STmTrcF+I9m/X2xvSF2a2bWv1DOQhxBewThu0KkwGHdgu5w==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.44': + resolution: {integrity: sha512-X6adjkHeFqKsTU0FXdNN9HY4LDozPqIfHcnXovE5RkYLWIjMWuc489mIZ6iyhrMbCqMUla9IOsh5dvXSGT9o9A==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.34': - resolution: {integrity: sha512-a737FTqhFUoWfnebS2SnQ2BS50p0JdukdkUBwy2J06j4hZ6Eej0zEB8vTfAqoCjn8BQKkXBy+3Sx0IRkgwz1gA==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.44': + resolution: {integrity: sha512-kRRKGZI4DXWa6ANFr3dLA85aSVkwPdgXaRjfanwY84tfc3LncDiIjyWCb042e3ckPzYhHSZ3LmisO+cdOIYL6Q==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.34': - resolution: {integrity: sha512-NH+FeQWKyuw0k+PbXqpFWNfvD8RPvfJk766B/njdaWz4TmiEcSB0Nb6guNw1rBpM1FmltQYb3fFnTumtC6pRfA==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.44': + resolution: {integrity: sha512-hMtiN9xX1NhxXBa2U3Up4XkVcsVp2h73yYtMDY59z9CDLEZLrik9RVLhBL5QtoX4zZKJ8HZKJtWuGYvtmkCbIQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.34': - resolution: {integrity: sha512-Q3RSCivp8pNadYK8ke3hLnQk08BkpZX9BmMjgwae2FWzdxhxxUiUzd9By7kneUL0vRQ4uRnhD9VkFQ+Haeqdvw==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.44': + resolution: {integrity: sha512-rd1LzbpXQuR8MTG43JB9VyXDjG7ogSJbIkBpZEHJ8oMKzL6j47kQT5BpIXrg3b5UVygW9QCI2fpFdMocT5Kudg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.34': - resolution: {integrity: sha512-wDd/HrNcVoBhWWBUW3evJHoo7GJE/RofssBy3Dsiip05YUBmokQVrYAyrboOY4dzs/lJ7HYeBtWQ9hj8wlyF0A==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.44': + resolution: {integrity: sha512-qI2IiPqmPRW25exXkuQr3TlweCDc05YvvbSDRPCuPsWkwb70dTiSoXn8iFxT4PWqTi71wWHg1Wyta9PlVhX5VA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-beta.34': - resolution: {integrity: sha512-dH3FTEV6KTNWpYSgjSXZzeX7vLty9oBYn6R3laEdhwZftQwq030LKL+5wyQdlbX5pnbh4h127hpv3Hl1+sj8dg==} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.44': + resolution: {integrity: sha512-+vHvEc1pL5iJRFlldLC8mjm6P4Qciyfh2bh5ZI6yxDQKbYhCHRKNURaKz1mFcwxhVL5YMYsLyaqM3qizVif9MQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.34': - resolution: {integrity: sha512-y5BUf+QtO0JsIDKA51FcGwvhJmv89BYjUl8AmN7jqD6k/eU55mH6RJYnxwCsODq5m7KSSTigVb6O7/GqB8wbPw==} + '@rolldown/binding-wasm32-wasi@1.0.0-beta.44': + resolution: {integrity: sha512-XSgLxRrtFj6RpTeMYmmQDAwHjKseYGKUn5LPiIdW4Cq+f5SBSStL2ToBDxkbdxKPEbCZptnLPQ/nfKcAxrC8Xg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.34': - resolution: {integrity: sha512-ga5hFhdTwpaNxEiuxZHWnD3ed0GBAzbgzS5tRHpe0ObptxM1a9Xrq6TVfNQirBLwb5Y7T/FJmJi3pmdLy95ljg==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.44': + resolution: {integrity: sha512-cF1LJdDIX02cJrFrX3wwQ6IzFM7I74BYeKFkzdcIA4QZ0+2WA7/NsKIgjvrunupepWb1Y6PFWdRlHSaz5AW1Wg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.34': - resolution: {integrity: sha512-4/MBp9T9eRnZskxWr8EXD/xHvLhdjWaeX/qY9LPRG1JdCGV3DphkLTy5AWwIQ5jhAy2ZNJR5z2fYRlpWU0sIyQ==} + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.44': + resolution: {integrity: sha512-5uaJonDafhHiMn+iEh7qUp3QQ4Gihv3lEOxKfN8Vwadpy0e+5o28DWI42DpJ9YBYMrVy4JOWJ/3etB/sptpUwA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.34': - resolution: {integrity: sha512-7O5iUBX6HSBKlQU4WykpUoEmb0wQmonb6ziKFr3dJTHud2kzDnWMqk344T0qm3uGv9Ddq6Re/94pInxo1G2d4w==} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.44': + resolution: {integrity: sha512-vsqhWAFJkkmgfBN/lkLCWTXF1PuPhMjfnAyru48KvF7mVh2+K7WkKYHezF3Fjz4X/mPScOcIv+g6cf6wnI6eWg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.34': - resolution: {integrity: sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==} + '@rolldown/pluginutils@1.0.0-beta.44': + resolution: {integrity: sha512-g6eW7Zwnr2c5RADIoqziHoVs6b3W5QTQ4+qbpfjbkMJ9x+8Og211VW/oot2dj9dVwaK/UyC6Yo+02gV+wWQVNg==} '@rollup/pluginutils@5.2.0': resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==} @@ -3986,6 +4021,19 @@ packages: cpu: [x64] os: [win32] + '@videojs/http-streaming@3.17.2': + resolution: {integrity: sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + video.js: ^8.19.0 + + '@videojs/vhs-utils@4.1.1': + resolution: {integrity: sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==} + engines: {node: '>=8', npm: '>=5'} + + '@videojs/xhr@2.7.0': + resolution: {integrity: sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==} + '@vitest/expect@2.0.5': resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} @@ -4072,6 +4120,10 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@xmldom/xmldom@0.8.11': + resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + engines: {node: '>=10.0.0'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -4106,6 +4158,13 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + + aes-decrypter@4.0.2: + resolution: {integrity: sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -4159,10 +4218,6 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - ansis@4.1.0: - resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==} - engines: {node: '>=14'} - ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -4177,6 +4232,9 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -4328,6 +4386,9 @@ packages: birpc@2.6.1: resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -4407,6 +4468,10 @@ packages: resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==} engines: {node: '>=4'} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -4491,6 +4556,10 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -4589,6 +4658,9 @@ packages: core-js@3.45.1: resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -4597,6 +4669,11 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + create-react-class@15.7.0: resolution: {integrity: sha512-QZv4sFWG9S5RUvkTYWbflxeZX+JG7Cz0Tn33rQBJ+WFQTqTfUTjMjiv9tnfXazjsO5r0KhPs+AqCjyrQX6h2ng==} @@ -4837,6 +4914,9 @@ packages: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -4866,6 +4946,9 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-walk@0.1.2: + resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} + dom4@2.1.6: resolution: {integrity: sha512-JkCVGnN4ofKGbjf5Uvc8mmxaATIErKQKSgACdBXpsQ3fY6DlIpAyWfiBSrGkttATssbDCp3psiAKWXk5gmjycA==} @@ -4913,6 +4996,9 @@ packages: oxc-resolver: optional: true + duck@0.1.12: + resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -5405,6 +5491,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -5508,6 +5598,9 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported + global@4.4.0: + resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==} + globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} @@ -5582,6 +5675,9 @@ packages: resolution: {integrity: sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==} engines: {node: '>=12.0.0'} + hls.js@1.6.15: + resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -5652,6 +5748,9 @@ packages: imagesloaded@4.1.4: resolution: {integrity: sha512-ltiBVcYpc/TYTF5nolkMNsnREHW+ICvfQ3Yla2Sgr71YFwQ86bDwV9hgpFhFtrGPuwEx5+LqOHIrdXBdoWwwsA==} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -5773,6 +5872,9 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-function@1.0.2: + resolution: {integrity: sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==} + is-generator-function@1.1.0: resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} engines: {node: '>= 0.4'} @@ -5852,6 +5954,9 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -5941,6 +6046,9 @@ packages: jsx-dom-cjs@8.1.6: resolution: {integrity: sha512-aeGqlIZ3IBKF2+B0cKXZGh10OHxxABuHD9tlS10suXDXXG0c4wMkJios9xVslduflSNEQJVlicME3EBCYgxGvA==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -5967,6 +6075,9 @@ packages: engines: {node: '>=16'} hasBin: true + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -6029,9 +6140,18 @@ packages: lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.reduce@4.6.0: + resolution: {integrity: sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==} + + lodash.startswith@4.2.1: + resolution: {integrity: sha512-XClYR1h4/fJ7H+mmCKppbiBmljN/nGs73iq2SjCT9SF4CBPoUHzLvWmH1GtZMhMBZSiRkHXfeA2RY1eIlJ75ww==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -6043,6 +6163,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lop@0.4.2: + resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -6070,6 +6193,9 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + m3u8-parser@7.2.0: + resolution: {integrity: sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==} + magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} @@ -6077,6 +6203,11 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + mammoth@1.11.0: + resolution: {integrity: sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==} + engines: {node: '>=12.0.0'} + hasBin: true + map-or-similar@1.5.0: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} @@ -6231,6 +6362,9 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + min-document@2.19.2: + resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -6286,6 +6420,10 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + mpd-parser@1.3.1: + resolution: {integrity: sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==} + hasBin: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -6296,6 +6434,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mux.js@7.1.0: + resolution: {integrity: sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==} + engines: {node: '>=8', npm: '>=5'} + hasBin: true + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -6333,6 +6476,7 @@ packages: next@14.2.32: resolution: {integrity: sha512-fg5g0GZ7/nFc09X8wLe6pNSU8cLWbLRG3TZzPJ1BJvi2s9m7eF991se67wliM9kR5yLHRkyGKU49MMx58s3LJg==} engines: {node: '>=18.17.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -6466,6 +6610,9 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + option@0.2.4: + resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -6615,6 +6762,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkcs7@1.0.4: + resolution: {integrity: sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==} + hasBin: true + pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -6836,6 +6987,11 @@ packages: engines: {node: '>=14'} hasBin: true + prettier@3.8.0: + resolution: {integrity: sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==} + engines: {node: '>=14'} + hasBin: true + pretty-error@4.0.0: resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} @@ -6847,6 +7003,9 @@ packages: resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==} engines: {node: '>= 0.8'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -7052,6 +7211,12 @@ packages: '@react-pdf/renderer': '>=3.4.4' react: '>=16' + react-phone-input-2@2.15.1: + resolution: {integrity: sha512-W03abwhXcwUoq+vUFvC6ch2+LJYMN8qSOiO889UH6S7SyMCQvox/LF3QWt+cZagZrRdi5z2ON3omnjoCUmlaYw==} + peerDependencies: + react: ^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 + react-dom: ^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 + react-popper@1.3.11: resolution: {integrity: sha512-VSA/bS+pSndSF2fiasHK/PTEEAyOpX60+H5EPAjoArr8JGm+oihu4UbrqcEBpQibJxBVCpYyjAX7abJ+7DoYVg==} peerDependencies: @@ -7118,6 +7283,9 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -7245,8 +7413,9 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-beta.34: - resolution: {integrity: sha512-Wwh7EwalMzzX3Yy3VN58VEajeR2Si8+HDNMf706jPLIqU7CxneRW+dQVfznf5O0TWTnJyu4npelwg2bzTXB1Nw==} + rolldown@1.0.0-beta.44: + resolution: {integrity: sha512-gcqgyCi3g93Fhr49PKvymE8PoaGS0sf6ajQrsYaQ8o5de6aUEbD6rJZiJbhOfpcqOnycgsAsUNPYri1h25NgsQ==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true rollup@4.52.4: @@ -7268,6 +7437,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -7338,6 +7510,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -7410,6 +7585,13 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -7480,6 +7662,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -7570,6 +7755,10 @@ packages: '@swc/core': ^1.2.147 webpack: '>=2' + swiper@12.0.3: + resolution: {integrity: sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==} + engines: {node: '>= 4.7.0'} + swr@2.2.4: resolution: {integrity: sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==} peerDependencies: @@ -7839,6 +8028,9 @@ packages: unconfig@7.3.3: resolution: {integrity: sha512-QCkQoOnJF8L107gxfHL0uavn7WD9b3dpBcFX6HtfQYmjw2YzWxGuFQ0N0J6tE9oguCBJn9KOvfqYDCMPHIZrBA==} + underscore@1.13.7: + resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} + undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} @@ -7980,6 +8172,21 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + video.js@8.23.4: + resolution: {integrity: sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==} + + videojs-contrib-quality-levels@4.1.0: + resolution: {integrity: sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==} + engines: {node: '>=16', npm: '>=8'} + peerDependencies: + video.js: ^8 + + videojs-font@4.2.0: + resolution: {integrity: sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==} + + videojs-vtt.js@0.15.5: + resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==} + vite-compatible-readable-stream@3.6.1: resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} engines: {node: '>= 6'} @@ -8106,10 +8313,18 @@ packages: resolution: {integrity: sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==} engines: {node: '>= 12.0.0'} + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -8145,6 +8360,15 @@ packages: utf-8-validate: optional: true + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + + xmlbuilder@10.1.1: + resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} + engines: {node: '>=4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -9047,7 +9271,7 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@napi-rs/wasm-runtime@1.0.5': + '@napi-rs/wasm-runtime@1.0.7': dependencies: '@emnapi/core': 1.5.0 '@emnapi/runtime': 1.5.0 @@ -9366,9 +9590,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) - '@oxc-project/runtime@0.82.3': {} - - '@oxc-project/types@0.82.3': {} + '@oxc-project/types@0.95.0': {} '@pkgjs/parseargs@0.11.0': optional: true @@ -9702,51 +9924,51 @@ snapshots: '@remirror/core-constants@3.0.0': {} - '@rolldown/binding-android-arm64@1.0.0-beta.34': + '@rolldown/binding-android-arm64@1.0.0-beta.44': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-beta.34': + '@rolldown/binding-darwin-arm64@1.0.0-beta.44': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.34': + '@rolldown/binding-darwin-x64@1.0.0-beta.44': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.34': + '@rolldown/binding-freebsd-x64@1.0.0-beta.44': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.34': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.44': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.34': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.44': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.34': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.44': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.34': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.44': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.34': + '@rolldown/binding-linux-x64-musl@1.0.0-beta.44': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.34': + '@rolldown/binding-openharmony-arm64@1.0.0-beta.44': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.34': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.44': dependencies: - '@napi-rs/wasm-runtime': 1.0.5 + '@napi-rs/wasm-runtime': 1.0.7 optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.34': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.44': optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.34': + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.44': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.34': + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.44': optional: true - '@rolldown/pluginutils@1.0.0-beta.34': {} + '@rolldown/pluginutils@1.0.0-beta.44': {} '@rollup/pluginutils@5.2.0(rollup@4.52.4)': dependencies: @@ -9903,133 +10125,133 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/addon-actions@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-actions@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: '@storybook/global': 5.0.0 '@types/uuid': 9.0.8 dequal: 2.0.3 polished: 4.3.1 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) uuid: 9.0.1 - '@storybook/addon-backgrounds@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-backgrounds@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: '@storybook/global': 5.0.0 memoizerific: 1.11.3 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) ts-dedent: 2.2.0 - '@storybook/addon-controls@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-controls@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: '@storybook/global': 5.0.0 dequal: 2.0.3 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) ts-dedent: 2.2.0 - '@storybook/addon-designs@10.0.2(@storybook/addon-docs@9.1.10(@types/react@18.3.11)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': + '@storybook/addon-designs@10.0.2(@storybook/addon-docs@9.1.10(@types/react@18.3.11)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': dependencies: '@figspec/react': 1.0.4(react@18.3.1) - storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) optionalDependencies: - '@storybook/addon-docs': 9.1.10(@types/react@18.3.11)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + '@storybook/addon-docs': 9.1.10(@types/react@18.3.11)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/addon-docs@8.6.14(@types/react@18.3.11)(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-docs@8.6.14(@types/react@18.3.11)(storybook@8.6.14(prettier@3.8.0))': dependencies: '@mdx-js/react': 3.1.0(@types/react@18.3.11)(react@18.3.1) - '@storybook/blocks': 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2)) - '@storybook/csf-plugin': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/react-dom-shim': 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2)) + '@storybook/blocks': 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0)) + '@storybook/csf-plugin': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/react-dom-shim': 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0)) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-docs@9.1.10(@types/react@18.3.11)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': + '@storybook/addon-docs@9.1.10(@types/react@18.3.11)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': dependencies: '@mdx-js/react': 3.1.0(@types/react@18.3.11)(react@18.3.1) - '@storybook/csf-plugin': 9.1.10(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + '@storybook/csf-plugin': 9.1.10(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) '@storybook/icons': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@storybook/react-dom-shim': 9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + '@storybook/react-dom-shim': 9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-essentials@8.6.14(@types/react@18.3.11)(storybook@8.6.14(prettier@3.6.2))': - dependencies: - '@storybook/addon-actions': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/addon-backgrounds': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/addon-controls': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/addon-docs': 8.6.14(@types/react@18.3.11)(storybook@8.6.14(prettier@3.6.2)) - '@storybook/addon-highlight': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/addon-measure': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/addon-outline': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/addon-toolbars': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/addon-viewport': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - storybook: 8.6.14(prettier@3.6.2) + '@storybook/addon-essentials@8.6.14(@types/react@18.3.11)(storybook@8.6.14(prettier@3.8.0))': + dependencies: + '@storybook/addon-actions': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/addon-backgrounds': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/addon-controls': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/addon-docs': 8.6.14(@types/react@18.3.11)(storybook@8.6.14(prettier@3.8.0)) + '@storybook/addon-highlight': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/addon-measure': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/addon-outline': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/addon-toolbars': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/addon-viewport': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + storybook: 8.6.14(prettier@3.8.0) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-highlight@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-highlight@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) - '@storybook/addon-interactions@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-interactions@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/test': 8.6.14(storybook@8.6.14(prettier@3.6.2)) + '@storybook/instrumenter': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/test': 8.6.14(storybook@8.6.14(prettier@3.8.0)) polished: 4.3.1 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) ts-dedent: 2.2.0 - '@storybook/addon-links@8.6.14(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-links@8.6.14(react@18.3.1)(storybook@8.6.14(prettier@3.8.0))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) ts-dedent: 2.2.0 optionalDependencies: react: 18.3.1 - '@storybook/addon-measure@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-measure@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) tiny-invariant: 1.3.3 - '@storybook/addon-onboarding@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-onboarding@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) - '@storybook/addon-outline@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-outline@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) ts-dedent: 2.2.0 - '@storybook/addon-styling-webpack@1.0.1(storybook@8.6.14(prettier@3.6.2))(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0))': + '@storybook/addon-styling-webpack@1.0.1(storybook@8.6.14(prettier@3.8.0))(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0))': dependencies: - '@storybook/node-logger': 8.6.14(storybook@8.6.14(prettier@3.6.2)) + '@storybook/node-logger': 8.6.14(storybook@8.6.14(prettier@3.8.0)) webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) transitivePeerDependencies: - storybook - '@storybook/addon-toolbars@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-toolbars@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) - '@storybook/addon-viewport@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/addon-viewport@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: memoizerific: 1.11.3 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) '@storybook/addon-webpack5-compiler-swc@1.0.6(@swc/helpers@0.5.17)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0))': dependencies: @@ -10039,25 +10261,25 @@ snapshots: - '@swc/helpers' - webpack - '@storybook/blocks@8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))': + '@storybook/blocks@8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0))': dependencies: '@storybook/icons': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) ts-dedent: 2.2.0 optionalDependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@9.1.10(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': + '@storybook/builder-vite@9.1.10(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: - '@storybook/csf-plugin': 9.1.10(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) - storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + '@storybook/csf-plugin': 9.1.10(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) ts-dedent: 2.2.0 vite: 7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) - '@storybook/builder-webpack5@8.6.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3)': + '@storybook/builder-webpack5@8.6.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(storybook@8.6.14(prettier@3.8.0))(typescript@5.8.3)': dependencies: - '@storybook/core-webpack': 8.6.14(storybook@8.6.14(prettier@3.6.2)) + '@storybook/core-webpack': 8.6.14(storybook@8.6.14(prettier@3.8.0)) '@types/semver': 7.7.1 browser-assert: 1.2.1 case-sensitive-paths-webpack-plugin: 2.4.0 @@ -10071,7 +10293,7 @@ snapshots: path-browserify: 1.0.1 process: 0.11.10 semver: 7.7.2 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) style-loader: 3.3.4(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) terser-webpack-plugin: 5.3.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) ts-dedent: 2.2.0 @@ -10091,18 +10313,18 @@ snapshots: - uglify-js - webpack-cli - '@storybook/components@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/components@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) - '@storybook/core-webpack@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/core-webpack@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) ts-dedent: 2.2.0 - '@storybook/core@8.6.14(prettier@3.6.2)(storybook@8.6.14(prettier@3.6.2))': + '@storybook/core@8.6.14(prettier@3.8.0)(storybook@8.6.14(prettier@3.8.0))': dependencies: - '@storybook/theming': 8.6.14(storybook@8.6.14(prettier@3.6.2)) + '@storybook/theming': 8.6.14(storybook@8.6.14(prettier@3.8.0)) better-opn: 3.0.2 browser-assert: 1.2.1 esbuild: 0.25.0 @@ -10114,21 +10336,21 @@ snapshots: util: 0.12.5 ws: 8.18.3 optionalDependencies: - prettier: 3.6.2 + prettier: 3.8.0 transitivePeerDependencies: - bufferutil - storybook - supports-color - utf-8-validate - '@storybook/csf-plugin@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/csf-plugin@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) unplugin: 1.16.1 - '@storybook/csf-plugin@9.1.10(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': + '@storybook/csf-plugin@9.1.10(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': dependencies: - storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) unplugin: 1.16.1 '@storybook/global@5.0.0': {} @@ -10138,24 +10360,24 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/instrumenter@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/instrumenter@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: '@storybook/global': 5.0.0 '@vitest/utils': 2.1.9 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) - '@storybook/manager-api@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/manager-api@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) - '@storybook/node-logger@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/node-logger@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) - '@storybook/preset-react-webpack@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3)': + '@storybook/preset-react-webpack@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.8.0)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0))(typescript@5.8.3)': dependencies: - '@storybook/core-webpack': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/react': 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) + '@storybook/core-webpack': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/react': 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.8.0)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0))(typescript@5.8.3) '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.8.3)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) '@types/semver': 7.7.1 find-up: 5.0.0 @@ -10165,7 +10387,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) resolve: 1.22.10 semver: 7.7.2 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) tsconfig-paths: 4.2.0 webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) optionalDependencies: @@ -10178,9 +10400,9 @@ snapshots: - uglify-js - webpack-cli - '@storybook/preview-api@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/preview-api@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.8.3)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0))': dependencies: @@ -10196,31 +10418,31 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/react-dom-shim@8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))': + '@storybook/react-dom-shim@8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0))': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) - '@storybook/react-dom-shim@9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': + '@storybook/react-dom-shim@9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) - '@storybook/react-vite@9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.52.4)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': + '@storybook/react-vite@9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.52.4)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) '@rollup/pluginutils': 5.2.0(rollup@4.52.4) - '@storybook/builder-vite': 9.1.10(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) - '@storybook/react': 9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3) + '@storybook/builder-vite': 9.1.10(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + '@storybook/react': 9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3) find-up: 7.0.0 magic-string: 0.30.19 react: 18.3.1 react-docgen: 8.0.1 react-dom: 18.3.1(react@18.3.1) resolve: 1.22.10 - storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) tsconfig-paths: 4.2.0 vite: 7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) transitivePeerDependencies: @@ -10228,14 +10450,14 @@ snapshots: - supports-color - typescript - '@storybook/react-webpack5@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3)': + '@storybook/react-webpack5@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.8.0)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0))(typescript@5.8.3)': dependencies: - '@storybook/builder-webpack5': 8.6.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) - '@storybook/preset-react-webpack': 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) - '@storybook/react': 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) + '@storybook/builder-webpack5': 8.6.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(storybook@8.6.14(prettier@3.8.0))(typescript@5.8.3) + '@storybook/preset-react-webpack': 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.8.0)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0))(typescript@5.8.3) + '@storybook/react': 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.8.0)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0))(typescript@5.8.3) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -10247,45 +10469,45 @@ snapshots: - uglify-js - webpack-cli - '@storybook/react@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3)': + '@storybook/react@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.8.0)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0))(typescript@5.8.3)': dependencies: - '@storybook/components': 8.6.14(storybook@8.6.14(prettier@3.6.2)) + '@storybook/components': 8.6.14(storybook@8.6.14(prettier@3.8.0)) '@storybook/global': 5.0.0 - '@storybook/manager-api': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/preview-api': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - '@storybook/react-dom-shim': 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2)) - '@storybook/theming': 8.6.14(storybook@8.6.14(prettier@3.6.2)) + '@storybook/manager-api': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/preview-api': 8.6.14(storybook@8.6.14(prettier@3.8.0)) + '@storybook/react-dom-shim': 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.8.0)) + '@storybook/theming': 8.6.14(storybook@8.6.14(prettier@3.8.0)) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) optionalDependencies: - '@storybook/test': 8.6.14(storybook@8.6.14(prettier@3.6.2)) + '@storybook/test': 8.6.14(storybook@8.6.14(prettier@3.8.0)) typescript: 5.8.3 - '@storybook/react@9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)': + '@storybook/react@9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + '@storybook/react-dom-shim': 9.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) optionalDependencies: typescript: 5.8.3 - '@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/test@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.6.14(storybook@8.6.14(prettier@3.6.2)) + '@storybook/instrumenter': 8.6.14(storybook@8.6.14(prettier@3.8.0)) '@testing-library/dom': 10.4.0 '@testing-library/jest-dom': 6.5.0 '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) '@vitest/expect': 2.0.5 '@vitest/spy': 2.0.5 - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) - '@storybook/theming@8.6.14(storybook@8.6.14(prettier@3.6.2))': + '@storybook/theming@8.6.14(storybook@8.6.14(prettier@3.8.0))': dependencies: - storybook: 8.6.14(prettier@3.6.2) + storybook: 8.6.14(prettier@3.8.0) '@swc/core-darwin-arm64@1.13.5': optional: true @@ -11089,6 +11311,28 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@videojs/http-streaming@3.17.2(video.js@8.23.4)': + dependencies: + '@babel/runtime': 7.26.10 + '@videojs/vhs-utils': 4.1.1 + aes-decrypter: 4.0.2 + global: 4.4.0 + m3u8-parser: 7.2.0 + mpd-parser: 1.3.1 + mux.js: 7.1.0 + video.js: 8.23.4 + + '@videojs/vhs-utils@4.1.1': + dependencies: + '@babel/runtime': 7.26.10 + global: 4.4.0 + + '@videojs/xhr@2.7.0': + dependencies: + '@babel/runtime': 7.26.10 + global: 4.4.0 + is-function: 1.0.2 + '@vitest/expect@2.0.5': dependencies: '@vitest/spy': 2.0.5 @@ -11227,6 +11471,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@xmldom/xmldom@0.8.11': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -11252,6 +11498,15 @@ snapshots: acorn@8.15.0: {} + adler-32@1.3.1: {} + + aes-decrypter@4.0.2: + dependencies: + '@babel/runtime': 7.26.10 + '@videojs/vhs-utils': 4.1.1 + global: 4.4.0 + pkcs7: 1.0.4 + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -11297,8 +11552,6 @@ snapshots: ansi-styles@6.2.3: {} - ansis@4.1.0: {} - ansis@4.2.0: {} any-promise@1.3.0: {} @@ -11310,6 +11563,10 @@ snapshots: arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -11482,6 +11739,8 @@ snapshots: birpc@2.6.1: {} + bluebird@3.4.7: {} + bluebird@3.7.2: {} body-parser@1.20.3: @@ -11575,6 +11834,11 @@ snapshots: case-sensitive-paths-webpack-plugin@2.4.0: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -11672,6 +11936,8 @@ snapshots: - '@types/react' - '@types/react-dom' + codepage@1.15.0: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -11766,6 +12032,8 @@ snapshots: core-js@3.45.1: {} + core-util-is@1.0.3: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -11779,6 +12047,8 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 + crc-32@1.2.2: {} + create-react-class@15.7.0: dependencies: loose-envify: 1.4.0 @@ -11989,6 +12259,8 @@ snapshots: diff@8.0.2: {} + dingbat-to-unicode@1.0.1: {} + dlv@1.1.3: {} doctrine@2.1.0: @@ -12024,6 +12296,8 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + dom-walk@0.1.2: {} + dom4@2.1.6: {} domelementtype@2.3.0: {} @@ -12065,6 +12339,10 @@ snapshots: dts-resolver@2.1.2: {} + duck@0.1.12: + dependencies: + underscore: 1.13.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -12421,11 +12699,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@9.1.10(eslint@8.57.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3): + eslint-plugin-storybook@9.1.10(eslint@8.57.1)(storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3): dependencies: '@typescript-eslint/utils': 8.44.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 - storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) transitivePeerDependencies: - supports-color - typescript @@ -12763,6 +13041,8 @@ snapshots: forwarded@0.2.0: {} + frac@1.1.2: {} + fraction.js@4.3.7: {} fresh@0.5.2: {} @@ -12879,6 +13159,11 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + global@4.4.0: + dependencies: + min-document: 2.19.2 + process: 0.11.10 + globals@13.24.0: dependencies: type-fest: 0.20.2 @@ -12935,6 +13220,8 @@ snapshots: highlight.js@11.8.0: {} + hls.js@1.6.15: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -13006,6 +13293,8 @@ snapshots: dependencies: ev-emitter: 1.1.1 + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -13149,6 +13438,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-function@1.0.2: {} + is-generator-function@1.1.0: dependencies: call-bound: 1.0.4 @@ -13222,6 +13513,8 @@ snapshots: dependencies: is-docker: 2.2.1 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -13308,6 +13601,13 @@ snapshots: dependencies: csstype: 3.1.3 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -13331,6 +13631,10 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lilconfig@3.1.3: {} linebreak@1.1.0: @@ -13390,8 +13694,14 @@ snapshots: lodash.isplainobject@4.0.6: {} + lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} + lodash.reduce@4.6.0: {} + + lodash.startswith@4.2.1: {} + lodash@4.17.21: {} logform@2.7.0: @@ -13407,6 +13717,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + lop@0.4.2: + dependencies: + duck: 0.1.12 + option: 0.2.4 + underscore: 1.13.7 + loupe@3.2.1: {} lower-case@2.0.2: @@ -13437,6 +13753,12 @@ snapshots: lz-string@1.5.0: {} + m3u8-parser@7.2.0: + dependencies: + '@babel/runtime': 7.26.10 + '@videojs/vhs-utils': 4.1.1 + global: 4.4.0 + magic-string@0.30.19: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -13445,6 +13767,19 @@ snapshots: dependencies: semver: 6.3.1 + mammoth@1.11.0: + dependencies: + '@xmldom/xmldom': 0.8.11 + argparse: 1.0.10 + base64-js: 1.5.1 + bluebird: 3.4.7 + dingbat-to-unicode: 1.0.1 + jszip: 3.10.1 + lop: 0.4.2 + path-is-absolute: 1.0.1 + underscore: 1.13.7 + xmlbuilder: 10.1.1 + map-or-similar@1.5.0: {} markdown-it-task-lists@2.1.1: {} @@ -13679,6 +14014,10 @@ snapshots: mimic-fn@2.1.0: {} + min-document@2.19.2: + dependencies: + dom-walk: 0.1.2 + min-indent@1.0.1: {} minimatch@3.1.2: @@ -13717,12 +14056,24 @@ snapshots: module-details-from-path@1.0.4: {} + mpd-parser@1.3.1: + dependencies: + '@babel/runtime': 7.26.10 + '@videojs/vhs-utils': 4.1.1 + '@xmldom/xmldom': 0.8.11 + global: 4.4.0 + mri@1.2.0: {} ms@2.0.0: {} ms@2.1.3: {} + mux.js@7.1.0: + dependencies: + '@babel/runtime': 7.26.10 + global: 4.4.0 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -13892,6 +14243,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + option@0.2.4: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -14026,6 +14379,10 @@ snapshots: pirates@4.0.7: {} + pkcs7@1.0.4: + dependencies: + '@babel/runtime': 7.26.10 + pkg-dir@4.2.0: dependencies: find-up: 4.1.0 @@ -14165,12 +14522,14 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-tailwindcss@0.6.14(prettier@3.6.2): + prettier-plugin-tailwindcss@0.6.14(prettier@3.8.0): dependencies: - prettier: 3.6.2 + prettier: 3.8.0 prettier@3.6.2: {} + prettier@3.8.0: {} + pretty-error@4.0.0: dependencies: lodash: 4.17.21 @@ -14184,6 +14543,8 @@ snapshots: pretty-hrtime@1.0.3: {} + process-nextick-args@2.0.1: {} + process@0.11.10: {} prop-types@15.8.1: @@ -14471,6 +14832,17 @@ snapshots: node-html-parser: 6.1.13 react: 18.3.1 + react-phone-input-2@2.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + classnames: 2.5.1 + lodash.debounce: 4.0.8 + lodash.memoize: 4.1.2 + lodash.reduce: 4.6.0 + lodash.startswith: 4.2.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-popper@1.3.11(react@18.3.1): dependencies: '@babel/runtime': 7.26.10 @@ -14547,6 +14919,16 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -14684,7 +15066,7 @@ snapshots: dependencies: glob: 7.2.3 - rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.34)(typescript@5.8.3): + rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.44)(typescript@5.8.3): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.4 @@ -14695,34 +15077,32 @@ snapshots: dts-resolver: 2.1.2 get-tsconfig: 4.10.1 magic-string: 0.30.19 - rolldown: 1.0.0-beta.34 + rolldown: 1.0.0-beta.44 optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: - oxc-resolver - supports-color - rolldown@1.0.0-beta.34: + rolldown@1.0.0-beta.44: dependencies: - '@oxc-project/runtime': 0.82.3 - '@oxc-project/types': 0.82.3 - '@rolldown/pluginutils': 1.0.0-beta.34 - ansis: 4.1.0 + '@oxc-project/types': 0.95.0 + '@rolldown/pluginutils': 1.0.0-beta.44 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.34 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.34 - '@rolldown/binding-darwin-x64': 1.0.0-beta.34 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.34 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.34 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.34 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.34 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.34 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.34 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.34 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.34 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.34 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.34 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.34 + '@rolldown/binding-android-arm64': 1.0.0-beta.44 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.44 + '@rolldown/binding-darwin-x64': 1.0.0-beta.44 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.44 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.44 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.44 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.44 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.44 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.44 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.44 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.44 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.44 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.44 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.44 rollup@4.52.4: dependencies: @@ -14770,6 +15150,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-push-apply@1.0.0: @@ -14876,6 +15258,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} sharp@0.33.5: @@ -14972,6 +15356,12 @@ snapshots: space-separated-tokens@2.0.2: {} + sprintf-js@1.0.3: {} + + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stable-hash@0.0.5: {} stack-trace@0.0.10: {} @@ -14985,17 +15375,17 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@8.6.14(prettier@3.6.2): + storybook@8.6.14(prettier@3.8.0): dependencies: - '@storybook/core': 8.6.14(prettier@3.6.2)(storybook@8.6.14(prettier@3.6.2)) + '@storybook/core': 8.6.14(prettier@3.8.0)(storybook@8.6.14(prettier@3.8.0)) optionalDependencies: - prettier: 3.6.2 + prettier: 3.8.0 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)): + storybook@9.1.10(@testing-library/dom@10.4.0)(prettier@3.8.0)(vite@7.1.11(@types/node@22.12.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)): dependencies: '@storybook/global': 5.0.0 '@testing-library/jest-dom': 6.9.1 @@ -15010,7 +15400,7 @@ snapshots: semver: 7.7.2 ws: 8.18.3 optionalDependencies: - prettier: 3.6.2 + prettier: 3.8.0 transitivePeerDependencies: - '@testing-library/dom' - bufferutil @@ -15083,6 +15473,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -15158,6 +15552,8 @@ snapshots: '@swc/counter': 0.1.3 webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) + swiper@12.0.3: {} + swr@2.2.4(react@18.3.1): dependencies: client-only: 0.0.1 @@ -15321,8 +15717,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.34 - rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.34)(typescript@5.8.3) + rolldown: 1.0.0-beta.44 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.44)(typescript@5.8.3) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -15436,6 +15832,8 @@ snapshots: jiti: 2.5.1 quansync: 0.2.11 + underscore@1.13.7: {} + undici-types@6.20.0: {} unicode-properties@1.4.1: @@ -15623,6 +16021,32 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + video.js@8.23.4: + dependencies: + '@babel/runtime': 7.26.10 + '@videojs/http-streaming': 3.17.2(video.js@8.23.4) + '@videojs/vhs-utils': 4.1.1 + '@videojs/xhr': 2.7.0 + aes-decrypter: 4.0.2 + global: 4.4.0 + m3u8-parser: 7.2.0 + mpd-parser: 1.3.1 + mux.js: 7.1.0 + videojs-contrib-quality-levels: 4.1.0(video.js@8.23.4) + videojs-font: 4.2.0 + videojs-vtt.js: 0.15.5 + + videojs-contrib-quality-levels@4.1.0(video.js@8.23.4): + dependencies: + global: 4.4.0 + video.js: 8.23.4 + + videojs-font@4.2.0: {} + + videojs-vtt.js@0.15.5: + dependencies: + global: 4.4.0 + vite-compatible-readable-stream@3.6.1: dependencies: inherits: 2.0.4 @@ -15785,8 +16209,12 @@ snapshots: triple-beam: 1.4.1 winston-transport: 4.9.0 + wmf@1.0.2: {} + word-wrap@1.2.5: {} + word@0.3.0: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -15805,6 +16233,18 @@ snapshots: ws@8.18.3: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + + xmlbuilder@10.1.1: {} + xtend@4.0.2: {} y-indexeddb@9.0.12(yjs@13.6.27): diff --git a/scripts/build-push-image.sh b/scripts/build-push-image.sh new file mode 100755 index 00000000000..6e944f1a8fd --- /dev/null +++ b/scripts/build-push-image.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +usage() { + cat <<'USAGE' +Usage: + scripts/build-push-image.sh <web|api|admin|space|live> + +Examples: + scripts/build-push-image.sh web + IMAGE_TAG=sha-abc123 scripts/build-push-image.sh api + PUSH=0 scripts/build-push-image.sh web + +Environment: + REGISTRY Docker repository namespace. Default: drakesoftware + IMAGE_TAG Docker tag. Default: manual-<utc timestamp>-<git sha> + IMAGE Full image reference override. + PLATFORM Docker platform. Default: linux/amd64 + PUSH=0 Load locally instead of pushing. + NEXT_PUBLIC_* Build-time frontend URL overrides. +USAGE +} + +service="${1:-}" +if [[ -z "${service}" || "${service}" == "-h" || "${service}" == "--help" ]]; then + usage + [[ -z "${service}" ]] && exit 2 || exit 0 +fi + +short_sha="$(git -C "${ROOT_DIR}" rev-parse --short=12 HEAD)" +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + +REGISTRY="${REGISTRY:-drakesoftware}" +IMAGE_TAG="${IMAGE_TAG:-manual-${timestamp}-${short_sha}}" +PLATFORM="${PLATFORM:-linux/amd64}" +PUSH="${PUSH:-1}" + +NEXT_PUBLIC_API_BASE_URL="${NEXT_PUBLIC_API_BASE_URL:-https://sports.kanavio.com}" +NEXT_PUBLIC_WEB_BASE_URL="${NEXT_PUBLIC_WEB_BASE_URL:-https://sports.kanavio.com}" +NEXT_PUBLIC_ADMIN_BASE_URL="${NEXT_PUBLIC_ADMIN_BASE_URL:-https://sports.kanavio.com}" +NEXT_PUBLIC_ADMIN_BASE_PATH="${NEXT_PUBLIC_ADMIN_BASE_PATH:-/god-mode}" +NEXT_PUBLIC_SPACE_BASE_URL="${NEXT_PUBLIC_SPACE_BASE_URL:-https://sports.kanavio.com}" +NEXT_PUBLIC_SPACE_BASE_PATH="${NEXT_PUBLIC_SPACE_BASE_PATH:-/spaces}" +NEXT_PUBLIC_LIVE_BASE_URL="${NEXT_PUBLIC_LIVE_BASE_URL:-https://sports.kanavio.com}" +NEXT_PUBLIC_LIVE_BASE_PATH="${NEXT_PUBLIC_LIVE_BASE_PATH:-/live}" +NEXT_PUBLIC_CP_SERVER_URL="${NEXT_PUBLIC_CP_SERVER_URL:-https://sports.kanavio.com/sports/api}" +NEXT_PUBLIC_RTMP_URL="${NEXT_PUBLIC_RTMP_URL:-rtmp://sports.kanavio.com:1935}" + +dockerfile="" +build_context="${ROOT_DIR}" +default_image="" +build_args=() + +case "${service}" in + web|plane-web) + dockerfile="apps/web/Dockerfile.web" + default_image="${REGISTRY}/plane-web-amd64:${IMAGE_TAG}" + build_args=( + --build-arg "NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}" + --build-arg "NEXT_PUBLIC_WEB_BASE_URL=${NEXT_PUBLIC_WEB_BASE_URL}" + --build-arg "NEXT_PUBLIC_ADMIN_BASE_URL=${NEXT_PUBLIC_ADMIN_BASE_URL}" + --build-arg "NEXT_PUBLIC_ADMIN_BASE_PATH=${NEXT_PUBLIC_ADMIN_BASE_PATH}" + --build-arg "NEXT_PUBLIC_SPACE_BASE_URL=${NEXT_PUBLIC_SPACE_BASE_URL}" + --build-arg "NEXT_PUBLIC_SPACE_BASE_PATH=${NEXT_PUBLIC_SPACE_BASE_PATH}" + --build-arg "NEXT_PUBLIC_LIVE_BASE_URL=${NEXT_PUBLIC_LIVE_BASE_URL}" + --build-arg "NEXT_PUBLIC_LIVE_BASE_PATH=${NEXT_PUBLIC_LIVE_BASE_PATH}" + --build-arg "NEXT_PUBLIC_CP_SERVER_URL=${NEXT_PUBLIC_CP_SERVER_URL}" + --build-arg "NEXT_PUBLIC_RTMP_URL=${NEXT_PUBLIC_RTMP_URL}" + ) + ;; + api|plane-api) + dockerfile="apps/api/Dockerfile.api" + build_context="${ROOT_DIR}/apps/api" + default_image="${REGISTRY}/plane-api-amd64:${IMAGE_TAG}" + ;; + admin|plane-admin) + dockerfile="apps/admin/Dockerfile.admin" + default_image="${REGISTRY}/plane-admin-amd64:${IMAGE_TAG}" + build_args=( + --build-arg "NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}" + --build-arg "NEXT_PUBLIC_WEB_BASE_URL=${NEXT_PUBLIC_WEB_BASE_URL}" + --build-arg "NEXT_PUBLIC_ADMIN_BASE_URL=${NEXT_PUBLIC_ADMIN_BASE_URL}" + --build-arg "NEXT_PUBLIC_ADMIN_BASE_PATH=${NEXT_PUBLIC_ADMIN_BASE_PATH}" + --build-arg "NEXT_PUBLIC_SPACE_BASE_URL=${NEXT_PUBLIC_SPACE_BASE_URL}" + --build-arg "NEXT_PUBLIC_SPACE_BASE_PATH=${NEXT_PUBLIC_SPACE_BASE_PATH}" + ) + ;; + space|plane-space) + dockerfile="apps/space/Dockerfile.space" + default_image="${REGISTRY}/plane-space-amd64:${IMAGE_TAG}" + build_args=( + --build-arg "NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}" + --build-arg "NEXT_PUBLIC_WEB_BASE_URL=${NEXT_PUBLIC_WEB_BASE_URL}" + --build-arg "NEXT_PUBLIC_ADMIN_BASE_URL=${NEXT_PUBLIC_ADMIN_BASE_URL}" + --build-arg "NEXT_PUBLIC_ADMIN_BASE_PATH=${NEXT_PUBLIC_ADMIN_BASE_PATH}" + --build-arg "NEXT_PUBLIC_SPACE_BASE_URL=${NEXT_PUBLIC_SPACE_BASE_URL}" + --build-arg "NEXT_PUBLIC_SPACE_BASE_PATH=${NEXT_PUBLIC_SPACE_BASE_PATH}" + ) + ;; + live|plane-live) + dockerfile="apps/live/Dockerfile.live" + default_image="${REGISTRY}/plane-live-amd64:${IMAGE_TAG}" + ;; + *) + echo "ERROR: unsupported Plane image target: ${service}" >&2 + usage >&2 + exit 2 + ;; +esac + +IMAGE="${IMAGE:-${default_image}}" + +output_args=(--push) +if [[ "${PUSH}" == "0" ]]; then + output_args=(--load) +fi + +echo "Building Plane image:" +echo " target=${service}" +echo " image=${IMAGE}" +echo " platform=${PLATFORM}" +echo " push=${PUSH}" + +docker buildx build \ + "${output_args[@]}" \ + --platform "${PLATFORM}" \ + -f "${ROOT_DIR}/${dockerfile}" \ + -t "${IMAGE}" \ + "${build_args[@]}" \ + "${build_context}" + +echo "IMAGE=${IMAGE}"