diff --git a/gateway/config/settings/base.py b/gateway/config/settings/base.py index b75d875f..3cf3c21e 100644 --- a/gateway/config/settings/base.py +++ b/gateway/config/settings/base.py @@ -502,3 +502,16 @@ def __get_random_token(length: int) -> str: "SDS_NEW_USERS_APPROVED_ON_CREATION", default=False, ) + +# File upload limits +# ------------------------------------------------------------------------------ +# Maximum number of files that can be uploaded at once +DATA_UPLOAD_MAX_NUMBER_FILES: int = env.int( + "DATA_UPLOAD_MAX_NUMBER_FILES", default=1000 +) + +# Maximum memory size for file uploads (default: 2.5MB, increased to 100MB) +DATA_UPLOAD_MAX_MEMORY_SIZE: int = env.int( + "DATA_UPLOAD_MAX_MEMORY_SIZE", + default=104857600, # 100MB +) diff --git a/gateway/sds_gateway/api_methods/helpers/file_helpers.py b/gateway/sds_gateway/api_methods/helpers/file_helpers.py new file mode 100644 index 00000000..098a0fd6 --- /dev/null +++ b/gateway/sds_gateway/api_methods/helpers/file_helpers.py @@ -0,0 +1,146 @@ +from http import HTTPStatus + +from rest_framework import status +from rest_framework.parsers import MultiPartParser +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory + +from sds_gateway.api_methods.views.capture_endpoints import CaptureViewSet +from sds_gateway.api_methods.views.file_endpoints import CheckFileContentsExistView +from sds_gateway.api_methods.views.file_endpoints import FileViewSet + + +def upload_file_helper_simple(request, file_data): + """Upload a single file using FileViewSet.create. + + file_data should contain all required fields: name, directory, file, + media_type, etc. Returns ([response], []) for success, ([], [error]) for + error, and handles 409 as a warning. + """ + factory = APIRequestFactory() + django_request = factory.post( + request.path, + file_data, + format="multipart", + ) + django_request.user = request.user + drf_request = Request(django_request, parsers=[MultiPartParser()]) + drf_request.user = request.user + view = FileViewSet() + view.request = drf_request + view.action = "create" + view.format_kwarg = None + view.args = () + view.kwargs = {} + try: + response = view.create(drf_request) + except (ValueError, TypeError, AttributeError, KeyError) as e: + return [], [f"Data validation error: {e}"] + else: + responses = [] + errors = [] + + if not hasattr(response, "status_code"): + errors.append(getattr(response, "data", str(response))) + else: + http_status = HTTPStatus(response.status_code) + response_data = getattr(response, "data", str(response)) + + if http_status.is_success: + responses.append(response) + elif response.status_code == status.HTTP_409_CONFLICT: + # Already exists, treat as warning + errors.append(response_data) + elif http_status.is_server_error: + # Handle 500 and other server errors + errors.append("Internal server error") + elif http_status.is_client_error: + # Handle 4xx client errors + errors.append(f"Client error ({response.status_code}): {response_data}") + else: + # Handle any other status codes + errors.append(response_data) + + return responses, errors + + +# TODO: Use this helper method when implementing the file upload mode multiplexer. +def check_file_contents_exist_helper(request, check_data): + """Call the post method of CheckFileContentsExistView with the given data. + + check_data should contain the required fields: directory, name, sum_blake3, + etc. + """ + factory = APIRequestFactory() + django_request = factory.post( + request.path, # or a specific path for the check endpoint + check_data, + format="multipart", + ) + django_request.user = request.user + drf_request = Request(django_request, parsers=[MultiPartParser()]) + drf_request.user = request.user + view = CheckFileContentsExistView() + view.request = drf_request + view.action = None + view.format_kwarg = None + view.args = () + view.kwargs = {} + return view.post(drf_request) + + +def create_capture_helper_simple(request, capture_data): + """Create a capture using CaptureViewSet.create. + + capture_data should contain all required fields for capture creation: + owner, top_level_dir, capture_type, channel, index_name, etc. + Returns ([response], []) for success, ([], [error]) for error, and handles + 409 as a warning. + """ + factory = APIRequestFactory() + django_request = factory.post( + request.path, + capture_data, + format="multipart", + ) + django_request.user = request.user + drf_request = Request(django_request, parsers=[MultiPartParser()]) + drf_request.user = request.user + view = CaptureViewSet() + view.request = drf_request + view.action = "create" + view.format_kwarg = None + view.args = () + view.kwargs = {} + # Set the context for the serializer + view.get_serializer_context = lambda: {"request_user": request.user} + try: + response = view.create(drf_request) + except (ValueError, TypeError, AttributeError, KeyError) as e: + return [], [f"Data validation error: {e}"] + else: + responses = [] + errors = [] + + if not hasattr(response, "status_code"): + errors.append(getattr(response, "data", str(response))) + else: + http_status = HTTPStatus(response.status_code) + response_data = getattr(response, "data", str(response)) + + if http_status.is_success: + responses.append(response) + elif response.status_code == status.HTTP_409_CONFLICT: + # Already exists, treat as warning + errors.append(response_data) + elif http_status.is_server_error: + # Handle 500 and other server errors + errors.append(f"Server error ({response.status_code}): {response_data}") + elif http_status.is_client_error: + # Handle 4xx client errors + errors.append(f"Client error ({response.status_code}): {response_data}") + else: + # Handle any other status codes + errors.append(response_data) + + return responses, errors diff --git a/gateway/sds_gateway/api_methods/models.py b/gateway/sds_gateway/api_methods/models.py index dfe57415..1b6dcb40 100644 --- a/gateway/sds_gateway/api_methods/models.py +++ b/gateway/sds_gateway/api_methods/models.py @@ -20,6 +20,7 @@ from django.dispatch import receiver from django_cog.models import Pipeline +from .utils.metadata_schemas import infer_index_name from .utils.opensearch_client import get_opensearch_client log = logging.getLogger(__name__) @@ -285,6 +286,13 @@ def save(self, *args, **kwargs): if not self.name and self.top_level_dir: # Extract the last part of the path as the default name self.name = Path(self.top_level_dir).name or self.top_level_dir.strip("/") + + # Set the index_name if not provided + if not self.index_name and self.capture_type: + # Convert string to CaptureType enum + capture_type_enum = CaptureType(self.capture_type) + self.index_name = infer_index_name(capture_type_enum) + super().save(*args, **kwargs) @property diff --git a/gateway/sds_gateway/api_methods/serializers/file_serializers.py b/gateway/sds_gateway/api_methods/serializers/file_serializers.py index 17f7ace0..dfb18ef9 100644 --- a/gateway/sds_gateway/api_methods/serializers/file_serializers.py +++ b/gateway/sds_gateway/api_methods/serializers/file_serializers.py @@ -217,7 +217,6 @@ def check_file_contents_exist( user=user, ) - log.debug(f"Checking file contents for user in directory: {safe_dir}") identical_file: File | None = identical_user_owned_file.filter( directory=safe_dir, name=name, @@ -240,14 +239,12 @@ def check_file_contents_exist( user_mutable_attributes_differ = True break - payload = { + return { "file_exists_in_tree": identical_file is not None, "file_contents_exist_for_user": file_contents_exist_for_user, "user_mutable_attributes_differ": user_mutable_attributes_differ, "asset_id": asset.uuid if asset else None, } - log.debug(payload) - return payload class FileCheckResponseSerializer(serializers.Serializer[File]): diff --git a/gateway/sds_gateway/api_methods/utils/metadata_schemas.py b/gateway/sds_gateway/api_methods/utils/metadata_schemas.py index cb3570d1..646abb5c 100644 --- a/gateway/sds_gateway/api_methods/utils/metadata_schemas.py +++ b/gateway/sds_gateway/api_methods/utils/metadata_schemas.py @@ -3,9 +3,11 @@ # the mapping below is used for drf capture metadata parsing in extract_drf_metadata.py import logging +from typing import TYPE_CHECKING from typing import Any -from sds_gateway.api_methods.models import CaptureType +if TYPE_CHECKING: + from sds_gateway.api_methods.models import CaptureType log = logging.getLogger(__name__) @@ -357,10 +359,9 @@ "capture_props", ] -capture_index_mapping_by_type: dict[CaptureType, dict[str, dict[str, Any]]] = { - CaptureType.DigitalRF: drf_capture_index_mapping, - CaptureType.RadioHound: rh_capture_index_mapping, -} +# Initialize empty dictionary to avoid circular import issues +# Will be populated dynamically in get_mapping_for_capture_type() +capture_index_mapping_by_type: dict[Any, dict[str, dict[str, Any]]] = {} base_properties = { "channel": {"type": "keyword"}, @@ -387,9 +388,20 @@ def get_mapping_by_capture_type( - capture_type: CaptureType, + capture_type: Any, ) -> dict[str, str | dict[str, Any]]: """Get the mapping for a given capture type.""" + # Local import to avoid circular dependency + from sds_gateway.api_methods.models import CaptureType # noqa: PLC0415 + + # Initialize mapping if not already done + if not capture_index_mapping_by_type: + capture_index_mapping_by_type.update( + { + CaptureType.DigitalRF: drf_capture_index_mapping, + CaptureType.RadioHound: rh_capture_index_mapping, + } + ) return { "properties": { @@ -406,14 +418,17 @@ def get_mapping_by_capture_type( } -def infer_index_name(capture_type: CaptureType) -> str: +def infer_index_name(capture_type: "CaptureType") -> str: """Infer the index name for a given capture.""" - # Populate index_name based on capture type + # Local import to avoid circular dependency + from sds_gateway.api_methods.models import CaptureType # noqa: PLC0415 + + # Handle enum inputs (strings match fine against StrEnum) match capture_type: case CaptureType.DigitalRF: - return f"captures-{CaptureType.DigitalRF}" + return f"captures-{capture_type.value}" case CaptureType.RadioHound: - return f"captures-{CaptureType.RadioHound}" + return f"captures-{capture_type.value}" case _: msg = f"Invalid capture type: {capture_type}" log.error(msg) diff --git a/gateway/sds_gateway/api_methods/views/capture_endpoints.py b/gateway/sds_gateway/api_methods/views/capture_endpoints.py index 0c78b7ec..01652e7b 100644 --- a/gateway/sds_gateway/api_methods/views/capture_endpoints.py +++ b/gateway/sds_gateway/api_methods/views/capture_endpoints.py @@ -158,7 +158,7 @@ def ingest_capture( capture_type=capture.capture_type, drf_channel=drf_channel, rh_scan_group=rh_scan_group, - verbose=True, + verbose=False, ) # try to validate and index metadata before connecting files @@ -244,11 +244,14 @@ def _trigger_post_processing(self, capture: Capture) -> None: ), summary="Create Capture", ) - def create(self, request: Request) -> Response: # noqa: PLR0911 - """Create a capture object, connecting files and indexing the metadata.""" + def _validate_create_request( + self, request: Request + ) -> tuple[Response | None, dict[str, Any] | None]: + """Validate the create request and return response or validated data.""" drf_channel = request.data.get("channel", None) rh_scan_group = request.data.get("scan_group", None) capture_type = request.data.get("capture_type", None) + log.debug("POST request to create capture:") log.debug(f"\tcapture_type: '{capture_type}' {type(capture_type)}") log.debug(f"\tchannel: '{drf_channel}' {type(drf_channel)}") @@ -258,11 +261,9 @@ def create(self, request: Request) -> Response: # noqa: PLR0911 return Response( {"detail": "The `capture_type` field is required."}, status=status.HTTP_400_BAD_REQUEST, - ) + ), None unsafe_top_level_dir = request.data.get("top_level_dir", "") - - # sanitize top_level_dir requested_top_level_dir = sanitize_path_rel_to_user( unsafe_path=unsafe_top_level_dir, request=request, @@ -271,18 +272,19 @@ def create(self, request: Request) -> Response: # noqa: PLR0911 return Response( {"detail": "The provided `top_level_dir` is invalid."}, status=status.HTTP_400_BAD_REQUEST, - ) - if capture_type == CaptureType.DigitalRF and not drf_channel: + ), None + + if capture_type == "drf" and not drf_channel: return Response( {"detail": "The `channel` field is required for DigitalRF captures."}, status=status.HTTP_400_BAD_REQUEST, - ) + ), None requester = cast("User", request.user) - - # Populate index_name based on capture type request_data = request.data.copy() - request_data["index_name"] = infer_index_name(capture_type) + # Convert string to CaptureType enum + capture_type_enum = CaptureType(capture_type) + request_data["index_name"] = infer_index_name(capture_type_enum) post_serializer = CapturePostSerializer( data=request_data, @@ -294,10 +296,10 @@ def create(self, request: Request) -> Response: # noqa: PLR0911 return Response( {"detail": errors}, status=status.HTTP_400_BAD_REQUEST, - ) + ), None + capture_candidate: dict[str, Any] = post_serializer.validated_data - # check capture creation constraints and form error message to end-user try: _check_capture_creation_constraints(capture_candidate, owner=requester) except ValueError as err: @@ -308,44 +310,59 @@ def create(self, request: Request) -> Response: # noqa: PLR0911 return Response( {"detail": msg}, status=status.HTTP_400_BAD_REQUEST, - ) + ), None - capture = post_serializer.save() + return None, { + "drf_channel": drf_channel, + "rh_scan_group": rh_scan_group, + "requested_top_level_dir": requested_top_level_dir, + "requester": requester, + "capture_candidate": capture_candidate, + } - try: - self.ingest_capture( - capture=capture, - drf_channel=drf_channel, - rh_scan_group=rh_scan_group, - requester=requester, - top_level_dir=requested_top_level_dir, + def create(self, request: Request) -> Response: + """Create a capture object, connecting files and indexing the metadata.""" + # Validate request + response, validated_data = self._validate_create_request(request) + if response is not None: + return response + + if validated_data is None: + return Response( + {"detail": "Validation failed but no specific error was returned."}, + status=status.HTTP_400_BAD_REQUEST, ) - # Use transaction.on_commit to ensure the task is queued after the - # transaction is committed - transaction.on_commit(lambda: self._trigger_post_processing(capture)) + drf_channel = validated_data["drf_channel"] + rh_scan_group = validated_data["rh_scan_group"] + requested_top_level_dir = validated_data["requested_top_level_dir"] + requester = validated_data["requester"] - except UnknownIndexError as e: - user_msg = f"Unknown index: '{e}'. Try recreating this capture." - server_msg = ( - f"Unknown index: '{e}'. Try running the init_indices " - "subcommand if this is index should exist." - ) - log.error(server_msg) - capture.soft_delete() - return Response({"detail": user_msg}, status=status.HTTP_400_BAD_REQUEST) - except ValueError as e: - user_msg = f"Error handling metadata for capture '{capture.uuid}': {e}" - capture.soft_delete() - return Response({"detail": user_msg}, status=status.HTTP_400_BAD_REQUEST) - except os_exceptions.ConnectionError as e: - user_msg = f"Error connecting to OpenSearch: {e}" - log.error(user_msg) - capture.soft_delete() - return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE) + # Create the capture within a transaction + try: + with transaction.atomic(): + post_serializer = CapturePostSerializer( + data=request.data.copy(), + context={"request_user": request.user}, + ) + post_serializer.is_valid() + capture = post_serializer.save() + + self.ingest_capture( + capture=capture, + drf_channel=drf_channel, + rh_scan_group=rh_scan_group, + requester=requester, + top_level_dir=requested_top_level_dir, + ) - get_serializer = CaptureGetSerializer(capture) - return Response(get_serializer.data, status=status.HTTP_201_CREATED) + # If we get here, the transaction was successful + get_serializer = CaptureGetSerializer(capture) + return Response(get_serializer.data, status=status.HTTP_201_CREATED) + + except (UnknownIndexError, ValueError, os_exceptions.ConnectionError) as e: + # Transaction will auto-rollback, no manual deletion needed + return self._handle_capture_creation_errors(capture, e) @extend_schema( parameters=[ @@ -1024,6 +1041,10 @@ def _check_capture_creation_constraints( AssertionError: If an internal assertion fails. """ + log.debug( + "No channel and top_level_dir conflictsfor current user's DigitalRF captures." + ) + capture_type = capture_candidate.get("capture_type") top_level_dir = capture_candidate.get("top_level_dir") _errors: dict[str, str] = {} @@ -1075,11 +1096,6 @@ def _check_capture_creation_constraints( f"another capture: {conflicting_capture.pk}", }, ) - else: - log.debug( - "No `channel` and `top_level_dir` conflicts for current user's " - "DigitalRF captures.", - ) # CONSTRAINT: RadioHound captures must have unique scan group if capture_type == CaptureType.RadioHound: @@ -1091,9 +1107,8 @@ def _check_capture_creation_constraints( owner=owner, ) if scan_group is None: - log.debug( - "No scan group provided for RadioHound capture.", - ) + # No scan group provided for RadioHound capture + pass elif cap_qs.exists(): conflicting_capture = cap_qs.first() assert conflicting_capture is not None, "QuerySet should not be empty here." diff --git a/gateway/sds_gateway/api_methods/views/file_endpoints.py b/gateway/sds_gateway/api_methods/views/file_endpoints.py index fcd597b0..7d594859 100644 --- a/gateway/sds_gateway/api_methods/views/file_endpoints.py +++ b/gateway/sds_gateway/api_methods/views/file_endpoints.py @@ -236,7 +236,6 @@ def list(self, request: Request) -> Response: unsafe_path=unsafe_path, request=request, ) - log.debug(f"Listing for '{user_rel_path}'") if user_rel_path is None: return Response( {"detail": "The provided path must be in the user's files directory."}, @@ -273,7 +272,6 @@ def list(self, request: Request) -> Response: # despite being a single result, we return it paginated for consistency return paginator.get_paginated_response(serializer.data) - log.debug( "No exact match found for " f"{inferred_user_rel_path!s} and name {basename}", @@ -301,11 +299,6 @@ def list(self, request: Request) -> Response: paginated_files = paginator.paginate_queryset(latest_files, request=request) serializer = FileGetSerializer(paginated_files, many=True) - first_file = all_valid_user_owned_files.first() - if first_file: - log.debug(f"First file directory: {first_file.directory}") - log.debug(f"First file name: {first_file.name}") - log.debug( f"Matched {latest_files.count()} / {all_valid_user_owned_files.count()} " f"user files for path {user_rel_path!s} - returning {len(serializer.data)}", diff --git a/gateway/sds_gateway/static/css/file-list.css b/gateway/sds_gateway/static/css/file-list.css index 3a7526ce..82922b0f 100644 --- a/gateway/sds_gateway/static/css/file-list.css +++ b/gateway/sds_gateway/static/css/file-list.css @@ -481,3 +481,25 @@ body { .items-per-page-select { width: auto; } + +/* ================================ + Upload Modal Input Groups + ================================ */ +.hidden-input-group { + display: none; +} + +/* ================================ + Progress Bar Styles + ================================ */ +.progress-section-hidden { + display: none; +} + +.progress-bar-custom { + height: 8px; +} + +.progress-bar-width-0 { + width: 0%; +} diff --git a/gateway/sds_gateway/static/css/file-manager.css b/gateway/sds_gateway/static/css/file-manager.css new file mode 100644 index 00000000..c09f5ac7 --- /dev/null +++ b/gateway/sds_gateway/static/css/file-manager.css @@ -0,0 +1,962 @@ +:root { + --color-text-primary: #3c4043; + --color-text-secondary: #5f6368; + --color-border: #dadce0; + --color-background: #fff; + --color-background-hover: #f8f9fa; + --color-primary: #1a73e8; + --color-primary-hover: #1557b0; + --font-family: "Google Sans", sans-serif; + --shadow-small: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 1px 3px 1px + rgba(60, 64, 67, 0.15); + --shadow-medium: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 2px 6px 2px + rgba(60, 64, 67, 0.15); + --transition-default: background-color 0.2s, box-shadow 0.2s, color 0.2s; + + /* Spacing and sizing variables */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 16px; + --spacing-xl: 24px; + --spacing-2xl: 32px; + --spacing-3xl: 48px; + --spacing-4xl: 96px; + --spacing-5xl: 120px; + + /* Component sizing */ + --file-list-max-height: 320px; + --modal-max-width: 800px; + --button-height: 36px; + --header-height: 40px; + --breadcrumb-height: 32px; + --file-card-min-height: 40px; + --z-index-sticky: 10; +} + +/* Base Styles */ +body { + font-family: var(--font-family); + background-color: var(--color-background-hover); + margin: 0; + padding: 0; +} + +/* Accessibility */ +/* Focus styles for keyboard navigation */ +.file-card:focus-visible, +.breadcrumb-item:focus-visible, +.new-button:focus-visible, +.upload-zone:focus-visible, +.dropdown-item:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: 4px; +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* Layout */ +/* Main container */ +.files-container { + display: flex; + flex-direction: column; + height: auto; + min-height: 200px; /* Minimum height to prevent empty state from looking too small */ + max-height: calc(100vh - 250px); /* Maximum height based on viewport */ + overflow: auto; +} + +/* Files grid */ +.files-grid { + display: flex; + flex-direction: column; + padding: 0; + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + margin: 0 16px; + height: auto; /* Let it grow based on content */ + min-height: 100px; /* Minimum height for empty state */ +} + +/* Individual file card */ +.file-card { + border: none; + padding: 8px 16px; + cursor: pointer; + transition: var(--transition-default); + display: flex; + align-items: center; + text-align: left; + position: relative; + background-color: var(--color-background); + min-height: var(--file-card-min-height); + gap: 12px; + border-bottom: 1px solid var(--color-border); +} + +.file-card:last-child { + border-bottom: none; +} + +.file-card:hover { + background-color: var(--color-background-hover); +} + +/* Clickable directory styling */ +.clickable-directory { + cursor: pointer; +} + +.clickable-directory:hover { + background-color: var(--color-background-hover); + box-shadow: var(--shadow-small); +} + +.file-card-content { + display: grid; + grid-template-columns: 32px 1fr 150px 120px 100px; /* icon, name, modified, shared, actions */ + align-items: center; + width: 100%; + gap: 16px; +} + +/* File/Folder Names */ +.file-name { + font-size: 14px; + color: var(--color-text-primary); + margin: 0; + font-weight: 400; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 6px; +} + +/* Icons */ +.file-icon, +.folder-icon, +.dataset-icon.bi { + font-size: 20px; + color: var(--color-text-secondary); + margin-right: 12px; + flex-shrink: 0; + display: inline-block; + vertical-align: middle; +} + +/* Dataset icon styles are consolidated with other icons and hover rules */ + +/* Hover effects for all items */ +.file-card:hover .file-name, +.file-card:hover .folder-icon, +.file-card:hover .file-icon { + color: #005a9c; /* SpectrumX blue */ +} + +/* No hover effects for H5 files */ +.file-card[data-type="file"]:not([data-h5-file="true"]):hover .file-name, +.file-card[data-type="file"]:not([data-h5-file="true"]):hover .file-icon { + color: #005a9c; /* SpectrumX blue */ +} + +/* Directory hover should also use the same blue */ +.file-card[data-type="directory"]:hover .file-name { + color: #005a9c; +} + +.file-meta { + font-size: 13px; + color: var(--color-text-secondary); + white-space: nowrap; + text-align: center; /* Center align the modified date */ + padding: 0 8px; /* Small padding for spacing */ +} + +/* Keep the shared-with icon from shifting the centered date */ + +.file-shared { + font-size: 13px; + color: var(--color-text-secondary); + white-space: nowrap; + text-align: center; /* Center align shared by */ + padding: 0 8px; /* Small padding for spacing */ +} + +/* Actions column */ +.file-actions { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 0 8px; /* Small padding for spacing */ +} + +/* Directory Specific */ +.file-card[data-type="directory"] { + font-weight: 500; +} + +/* Header row */ +.file-card.header { + padding: 12px 16px; + height: var(--header-height); + border-bottom: 1px solid var(--color-border); + font-weight: 500; + color: var(--color-text-secondary); + cursor: default; + position: sticky; + top: 0; + z-index: var(--z-index-sticky); + background-color: #f8f9fa; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +/* Header has different grid layout (no icon column) */ +.file-card.header .file-card-content { + display: grid; + grid-template-columns: 1fr 150px 120px 100px; /* name, modified, shared, actions */ + align-items: center; + width: 100%; + gap: 16px; +} + +.file-card.header:hover { + background-color: #f8f9fa; +} + +.file-card.header .file-name { + font-weight: 500; + color: var(--color-text-secondary); + font-size: 13px; + text-transform: none; + letter-spacing: normal; + padding-left: 48px; /* Account for icon space (32px icon + 16px gap) */ +} + +.file-card.header .file-meta, +.file-card.header .file-shared, +.file-card.header .file-actions { + font-size: 13px; + text-transform: none; + letter-spacing: normal; + color: var(--color-text-secondary); + text-align: center; /* Center align header text */ +} + +/* Breadcrumb Navigation */ +.breadcrumb { + display: flex; + align-items: center; + list-style: none; + margin: 0; + padding: 0; + font-size: 14px; + height: var(--breadcrumb-height); + font-family: var(--font-family); + font-weight: 400; /* Add normal font weight */ +} + +.breadcrumb-item { + color: var(--color-text-secondary); + text-decoration: none; + cursor: pointer; + display: flex; + align-items: center; + height: var(--breadcrumb-height); + padding: 0 4px; + border-radius: 4px; + font-family: var(--font-family); + font-weight: 400; /* Add normal font weight */ +} + +.breadcrumb-item:hover { + background-color: var(--color-background-hover); +} + +.breadcrumb-item a { + color: var(--color-text-secondary); + text-decoration: none; + padding: 0 4px; + font-family: var(--font-family); + font-weight: 400; /* Add normal font weight */ +} + +.breadcrumb-item:hover a { + color: var(--color-text-primary); + text-decoration: none; +} + +/* Empty State */ +.no-files { + text-align: center; + padding: var(--spacing-5xl) 20px; + color: var(--color-text-secondary); + background-color: var(--color-background); + margin: 0; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.empty-folder-icon { + font-size: var(--spacing-4xl); + margin-bottom: 24px; + color: var(--color-border); +} + +/* Upload Zone */ +.upload-zone { + border: 2px dashed #ccc; + border-radius: 8px; + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + user-select: none; +} + +.upload-zone:hover, +.upload-zone.drag-over { + border-color: #0d6efd; + background-color: rgba(13, 110, 253, 0.05); +} + +.upload-zone-icon { + font-size: 3rem; + color: #6c757d; + margin-bottom: 1rem; +} + +.upload-zone-text { + font-size: 1.1rem; + margin-bottom: 0.5rem; +} + +.upload-zone-subtext { + color: #6c757d; + font-size: 0.9rem; +} + +.browse-button { + color: #0d6efd; + text-decoration: underline; + cursor: pointer; +} + +.browse-button:hover { + text-decoration: none; +} + +/* Selected Files */ +.selected-files { + margin-top: 1rem; + display: none; +} + +.selected-files.has-files { + display: block; +} + +.selected-files-header { + font-weight: 500; + margin-bottom: 0.5rem; +} + +.selected-files-list { + list-style: none; + padding: 0; + margin: 0; + max-height: var(--file-list-max-height); /* taller list for large folder previews */ + overflow-y: auto; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 0.5rem; +} + +.selected-files-list li { + display: flex; + align-items: center; + padding: 0.25rem 0; +} + +.selected-files-list li i { + margin-right: 0.5rem; + color: #6c757d; +} + +.selected-files-list li ul { + list-style: none; + padding-left: 1.5rem; + margin: 0; +} + +.upload-spinner { + display: inline-flex; + align-items: center; +} + +/* Modal Styles */ +.modal-content { + border-radius: 8px; + border: none; +} + +.modal-header { + border-bottom: 1px solid var(--color-border); + padding: 16px 24px; +} + +.modal-title { + font-family: var(--font-family); + font-size: 16px; + font-weight: 500; + color: var(--color-text-primary); +} + +.modal-body { + padding: 24px; +} + +.modal-footer { + border-top: 1px solid var(--color-border); + padding: 16px 24px; +} + +/* Button Styles (scoped to files page content only to avoid modal conflicts) */ +/* Scope button tweaks to the files page action bar only to avoid bleed into modals */ +.files-actions-bar .btn-primary, +.files-actions-bar .btn-secondary { + font-family: var(--font-family); + font-weight: 500; +} + +.files-actions-bar .btn-primary { + background-color: var(--color-primary); + border-color: var(--color-primary); +} + +.files-actions-bar .btn-primary:hover { + background-color: var(--color-primary-hover); + border-color: var(--color-primary-hover); +} + +.files-actions-bar .btn-secondary { + color: var(--color-primary); + background-color: transparent; + border-color: transparent; +} + +.files-actions-bar .btn-secondary:hover { + color: var(--color-primary-hover); + background-color: var(--color-background-hover); + border-color: transparent; +} + +/* Form Styles */ +.form-control { + border-radius: 4px; + border-color: var(--color-border); + padding: 8px 12px; +} + +.form-control:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2); +} + +.form-label { + font-size: 14px; + color: var(--color-text-secondary); + margin-bottom: 8px; +} + +.form-text { + font-size: 12px; + color: var(--color-text-secondary); +} + +.file-card[data-type="directory"] .folder-icon { + margin-left: 0; +} + +/* Adjust size of Bootstrap Icons */ +.bi { + font-size: 20px; + line-height: 1; + vertical-align: middle; +} + +.preview-content { + max-height: 500px; + overflow: auto; + border: 1px solid #e9ecef; + border-radius: 4px; + background: #f8f9fa; +} + +.preview-text { + margin: 0; + padding: 1rem; + white-space: pre-wrap; + word-wrap: break-word; + font-family: monospace; + font-size: 14px; + line-height: 1.5; + color: #212529; +} + +/* Enhanced syntax highlighting styles */ +.syntax-highlighted { + margin: 0; + padding: 0; + border-radius: 6px; + overflow: hidden; + background: #f8f9fa; + border: 1px solid #e9ecef; +} + +.syntax-highlighted code { + font-family: "Fira Code", "Monaco", "Consolas", "Liberation Mono", + "Courier New", monospace; + font-size: 13px; + line-height: 1.6; + padding: 1rem; + display: block; + overflow-x: auto; + white-space: pre; + background: transparent; +} + +/* Custom Prism.js theme overrides for better readability */ +.syntax-highlighted .token.comment, +.syntax-highlighted .token.prolog, +.syntax-highlighted .token.doctype, +.syntax-highlighted .token.cdata { + color: #6a737d; + font-style: italic; +} + +.syntax-highlighted .token.string, +.syntax-highlighted .token.attr-value { + color: #032f62; +} + +.syntax-highlighted .token.keyword, +.syntax-highlighted .token.operator { + color: #d73a49; +} + +.syntax-highlighted .token.function, +.syntax-highlighted .token.class-name { + color: #6f42c1; +} + +.syntax-highlighted .token.number, +.syntax-highlighted .token.boolean { + color: #005cc5; +} + +.syntax-highlighted .token.variable { + color: #24292e; +} + +.syntax-highlighted .token.property { + color: #005cc5; +} + +.syntax-highlighted .token.tag { + color: #22863a; +} + +.syntax-highlighted .token.attr-name { + color: #6f42c1; +} + +/* File preview modal enhancements */ +#filePreviewModal .modal-body { + padding: 0; +} + +#filePreviewModal .preview-content { + max-height: 70vh; + overflow: auto; +} + +/* Jupyter Notebook Preview Styles */ +.jupyter-notebook-preview { + font-family: var(--font-family); + background: #fff; + border-radius: 8px; + overflow: hidden; +} + +.notebook-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px; + border-bottom: 1px solid #e9ecef; +} + +.notebook-title { + font-size: 24px; + font-weight: 600; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 12px; +} + +.notebook-title i { + font-size: 28px; + color: #ffd700; +} + +.notebook-info { + display: flex; + gap: 20px; + font-size: 14px; + opacity: 0.9; +} + +.notebook-kernel { + background: rgba(255, 255, 255, 0.2); + padding: 4px 12px; + border-radius: 16px; + font-weight: 500; +} + +.notebook-cells { + background: rgba(255, 255, 255, 0.2); + padding: 4px 12px; + border-radius: 16px; + font-weight: 500; +} + +.notebook-cell { + margin: 0; + border-bottom: 1px solid #f0f0f0; +} + +.notebook-cell:last-child { + border-bottom: none; +} + +.cell-header { + background: #f8f9fa; + padding: 8px 16px; + border-left: 4px solid transparent; + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + font-weight: 500; +} + +.notebook-cell.code .cell-header { + border-left-color: #007acc; +} + +.notebook-cell.markdown .cell-header { + border-left-color: #28a745; +} + +.cell-type { + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.cell-type.code { + background: #e3f2fd; + color: #1976d2; +} + +.cell-type.markdown { + background: #e8f5e8; + color: #2e7d32; +} + +.execution-count { + color: #666; + font-family: "Monaco", "Consolas", monospace; +} + +.cell-content { + padding: 16px; +} + +.cell-content pre { + margin: 0; + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 12px; + font-size: 13px; + line-height: 1.5; + overflow-x: auto; +} + +.cell-content code { + background: transparent; + padding: 0; + border: none; + font-size: inherit; +} + +.markdown-content { + line-height: 1.6; + color: #333; +} + +.cell-output { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e9ecef; +} + +.output-label { + font-size: 12px; + font-weight: 600; + color: #666; + margin-bottom: 8px; + display: block; +} + +.output-stream { + background: #f5f5f5; + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 8px; + font-family: "Monaco", "Consolas", monospace; + font-size: 12px; + color: #333; +} + +.output-result { + background: #e8f5e8; + border: 1px solid #c8e6c9; + border-radius: 4px; + padding: 8px; + font-family: "Monaco", "Consolas", monospace; + font-size: 12px; + color: #2e7d32; +} + +/* Make modal larger for previews */ +#filePreviewModal .modal-dialog { + max-width: var(--modal-max-width); +} + +/* Main Navigation styles are handled by base.html */ + +/* Actions Bar (scoped) */ +.files-actions-bar { + margin: 24px 16px; /* Keep the margin */ + display: flex; + justify-content: flex-start; /* Align to the left */ + align-items: center; +} + +/* New Button & Menu */ +.new-button { + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text-primary); + cursor: pointer; + font-family: var(--font-family); + font-size: 14px; + font-weight: 500; + height: var(--button-height); + padding: 0 16px; + display: flex; + align-items: center; + gap: 8px; + transition: var(--transition-default); + min-width: 100px; /* Give the button a minimum width */ + justify-content: center; /* Center the button content */ +} + +.new-button:hover { + background-color: var(--color-background-hover); + box-shadow: var(--shadow-small); +} + +.new-button i { + font-size: 20px; + color: var(--color-text-primary); +} + +.new-menu { + border-radius: 8px; + box-shadow: var(--shadow-medium); + padding: 8px 0; + min-width: 200px; + border: 1px solid var(--color-border); + margin-top: 4px; + background-color: var(--color-background); +} + +.new-menu .dropdown-item { + color: var(--color-text-primary); + font-size: 14px; + padding: 8px 16px; + display: flex; + align-items: center; + gap: 12px; + font-family: var(--font-family); +} + +.new-menu .dropdown-item:hover { + background-color: var(--color-background-hover); +} + +.new-menu .dropdown-item i { + font-size: 20px; + color: var(--color-text-secondary); + width: 20px; + text-align: center; + margin-right: 12px; + flex-shrink: 0; + display: inline-block; + vertical-align: middle; +} + +.new-menu .dropdown-item:hover i { + color: var(--color-text-primary); +} + +/* Ensure Material Icons align properly in dropdown */ +.new-menu .dropdown-item .material-icons { + font-size: 20px; + line-height: 1; + vertical-align: middle; + color: var(--color-text-secondary); +} + +/* Responsive adjustments */ +@media (max-width: 767.98px) { + /* Containers and spacing */ + .files-actions-bar { + margin: 16px 8px; + } + .files-grid { + margin: 0 8px; + } + + /* Compact file rows */ + .file-card { + padding: 8px 12px; + gap: 8px; + } + .file-name { + padding-right: 0; + } + + /* Hide secondary columns to prevent overflow */ + .file-meta, + .file-shared { + display: none; + } + .file-card.header .file-meta, + .file-card.header .file-shared { + display: none; + } + + /* Upload area sizing */ + .upload-zone { + padding: 1rem; + } + .upload-zone-icon { + font-size: 2.4rem; + } + .selected-files-list { + max-height: 220px; + } + + /* Breadcrumbs wrap nicely */ + .breadcrumb { + flex-wrap: wrap; + height: auto; + row-gap: 4px; + } + .breadcrumb-item { + height: auto; + } +} + +@media (max-width: 575.98px) { + /* Modal paddings on very small screens */ + .modal-header, + .modal-footer { + padding: 12px 16px; + } + .modal-body { + padding: 16px; + } + .modal-dialog { + margin: 0.5rem; + } +} + +/* Global Drop Zone Overlay */ +.global-drop-zone { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 123, 255, 0.1); + border: 3px dashed #007bff; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(2px); +} + +.global-drop-zone-content { + text-align: center; + background: white; + padding: 3rem; + border-radius: 1rem; + box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.15); +} + +.global-drop-zone-icon { + font-size: 4rem; + color: #007bff; + margin-bottom: 1rem; +} + +.global-drop-zone-text { + font-size: 1.5rem; + font-weight: 600; + color: #333; + margin-bottom: 0.5rem; +} + +.global-drop-zone-subtext { + font-size: 1rem; + color: #666; +} diff --git a/gateway/sds_gateway/static/js/components.js b/gateway/sds_gateway/static/js/components.js index 8d560dea..804200b8 100644 --- a/gateway/sds_gateway/static/js/components.js +++ b/gateway/sds_gateway/static/js/components.js @@ -358,50 +358,10 @@ class CapturesTableManager extends TableManager { // Close modal first this.closeCustomModal("downloadModal"); - // Show loading state - const originalContent = button.innerHTML; - button.innerHTML = ' Processing...'; - button.disabled = true; - - // Make API request using the unified download endpoint - fetch(`/users/download-item/capture/${captureUuid}/`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": this.getCSRFToken(), - }, - }) - .then((response) => response.json()) - .then((data) => { - if (data.success === true) { - button.innerHTML = - ' Download Requested'; - this.showDownloadSuccessMessage(data.message); - } else { - button.innerHTML = - ' Request Failed'; - this.showDownloadErrorMessage( - data.detail || - data.message || - "Download request failed. Please try again.", - ); - } - }) - .catch((error) => { - console.error("Download error:", error); - button.innerHTML = - ' Request Failed'; - this.showDownloadErrorMessage( - "An error occurred while processing your request.", - ); - }) - .finally(() => { - // Reset button after 3 seconds - setTimeout(() => { - button.innerHTML = originalContent; - button.disabled = false; - }, 3000); - }); + // Use unified download handler + window.components.handleDownload("capture", captureUuid, button, { + alertHandler: this, // Use this class's showDownloadSuccessMessage/showDownloadErrorMessage methods + }); }; } @@ -409,68 +369,20 @@ class CapturesTableManager extends TableManager { * Show download success message */ showDownloadSuccessMessage(message) { - // Try to find an existing alert container or create one - let alertContainer = document.querySelector(".alert-container"); - if (!alertContainer) { - alertContainer = document.createElement("div"); - alertContainer.className = "alert-container"; - // Insert at the top of the main content area - const mainContent = - document.querySelector(".container-fluid") || document.body; - mainContent.insertBefore(alertContainer, mainContent.firstChild); + // Use the global components helper for consistency + if (window.components?.showSuccess) { + window.components.showSuccess(message); } - - const alertHtml = ` - - `; - - alertContainer.innerHTML = alertHtml; - - // Auto-dismiss after 5 seconds - setTimeout(() => { - const alert = alertContainer.querySelector(".alert"); - if (alert) { - alert.remove(); - } - }, 5000); } /** * Show download error message */ showDownloadErrorMessage(message) { - // Try to find an existing alert container or create one - let alertContainer = document.querySelector(".alert-container"); - if (!alertContainer) { - alertContainer = document.createElement("div"); - alertContainer.className = "alert-container"; - // Insert at the top of the main content area - const mainContent = - document.querySelector(".container-fluid") || document.body; - mainContent.insertBefore(alertContainer, mainContent.firstChild); + // Use the global components helper for consistency + if (window.components?.showError) { + window.components.showError(message); } - - const alertHtml = ` - - `; - - alertContainer.innerHTML = alertHtml; - - // Auto-dismiss after 8 seconds (longer for error messages) - setTimeout(() => { - const alert = alertContainer.querySelector(".alert"); - if (alert) { - alert.remove(); - } - }, 8000); } renderRow(capture, index) { @@ -1891,6 +1803,166 @@ window.SearchManager = SearchManager; window.ModalManager = ModalManager; window.PaginationManager = PaginationManager; +// Lightweight global helpers for inline alerts and basic modals used by other scripts +(function initGlobalComponentsHelpers() { + if (window.components) return; + + function ensureAlertContainer() { + let alertContainer = document.querySelector(".alert-container"); + if (!alertContainer) { + alertContainer = document.createElement("div"); + alertContainer.className = "alert-container"; + const mainContent = + document.querySelector(".container-fluid") || document.body; + mainContent.insertBefore(alertContainer, mainContent.firstChild); + } + return alertContainer; + } + + function writeAriaLive(message) { + const live = document.getElementById("aria-live-region"); + if (live) live.textContent = message; + } + + function getCSRFToken() { + return document.querySelector("[name=csrfmiddlewaretoken]")?.value || ""; + } + + // Unified download handler to eliminate duplication + function handleDownload(itemType, itemUuid, button, options = {}) { + const { + successIcon = ' Download Requested', + errorIcon = ' Request Failed', + loadingIcon = ' Processing...', + resetText = "Download", + resetDelay = 3000, + alertHandler = null, // Custom alert handler, defaults to window.components + } = options; + + const originalContent = button.innerHTML; + + // Show loading state + button.innerHTML = loadingIcon; + button.disabled = true; + + // Make API request + fetch(`/users/download-item/${itemType}/${itemUuid}/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": getCSRFToken(), + }, + }) + .then(async (response) => { + // Handle both JSON and non-JSON responses + const contentType = response.headers.get("content-type"); + if (contentType?.includes("application/json")) { + return response.json(); + } + const text = await response.text(); + throw new Error(`Server returned non-JSON response: ${text}`); + }) + .then((data) => { + if (data.success === true) { + button.innerHTML = successIcon; + const message = + data.message || + "Download request submitted successfully! You will receive an email when ready."; + + if (alertHandler?.showSuccess) { + alertHandler.showSuccess(message); + } else if (window.components?.showSuccess) { + window.components.showSuccess(message); + } else if (window.showAlert) { + window.showAlert(message, "success"); + } + } else { + button.innerHTML = errorIcon; + const message = + data.detail || + data.message || + "Download request failed. Please try again."; + + if (alertHandler?.showError) { + alertHandler.showError(message); + } else if (window.components?.showError) { + window.components.showError(message); + } else if (window.showAlert) { + window.showAlert(message, "error"); + } + } + }) + .catch((error) => { + console.error("Download error:", error); + button.innerHTML = errorIcon; + const message = + error.message || "An error occurred while processing your request."; + + if (alertHandler?.showError) { + alertHandler.showError(message); + } else if (window.components?.showError) { + window.components.showError(message); + } else if (window.showAlert) { + window.showAlert(message, "error"); + } + }) + .finally(() => { + // Reset button after delay + setTimeout(() => { + button.innerHTML = resetText || originalContent; + button.disabled = false; + }, resetDelay); + }); + } + + window.components = { + showSuccess(message) { + writeAriaLive(message); + const container = ensureAlertContainer(); + container.innerHTML = ` + `; + setTimeout(() => { + const alert = container.querySelector(".alert"); + if (alert) alert.remove(); + }, 5000); + }, + showError(message) { + writeAriaLive(message); + const container = ensureAlertContainer(); + container.innerHTML = ` + `; + setTimeout(() => { + const alert = container.querySelector(".alert"); + if (alert) alert.remove(); + }, 8000); + }, + openCustomModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = "block"; + document.body.style.overflow = "hidden"; + } + }, + closeCustomModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = "none"; + document.body.style.overflow = "auto"; + } + }, + // Expose unified download handler + handleDownload, + }; +})(); + // Export classes for module use if (typeof module !== "undefined" && module.exports) { module.exports = { diff --git a/gateway/sds_gateway/static/js/dataset-list.js b/gateway/sds_gateway/static/js/dataset-list.js index 44423221..c5550498 100644 --- a/gateway/sds_gateway/static/js/dataset-list.js +++ b/gateway/sds_gateway/static/js/dataset-list.js @@ -80,73 +80,25 @@ document.addEventListener("DOMContentLoaded", () => { document.getElementById("downloadDatasetName").textContent = datasetName; // Show the modal - openCustomModal("downloadModal"); + if (window.components?.openCustomModal) { + window.components.openCustomModal("downloadModal"); + } else { + openCustomModal("downloadModal"); + } // Handle confirm download document.getElementById("confirmDownloadBtn").onclick = () => { // Close modal first - closeCustomModal("downloadModal"); - - // Show loading state - button.innerHTML = - ' Processing...'; - button.disabled = true; - - // Make API request - fetch(`/users/download-item/dataset/${datasetUuid}/`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]") - .value, - }, - }) - .then((response) => { - // Check if response is JSON - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - return response.json(); - } - // If not JSON, throw an error with the response text - return response.text().then((text) => { - throw new Error(`Server returned non-JSON response: ${text}`); - }); - }) - .then((data) => { - if (data.success === true) { - button.innerHTML = - ' Download Requested'; - showAlert( - data.message || - "Download request submitted successfully! You will receive an email when ready.", - "success", - ); - } else { - button.innerHTML = - ' Request Failed'; - showAlert( - data.message || "Download request failed. Please try again.", - "error", - ); - } - }) - .catch((error) => { - console.error("Download error:", error); - button.innerHTML = - ' Request Failed'; - showAlert( - error.message || - "An error occurred while processing your request.", - "error", - ); - }) - .finally(() => { - // Reset button after 3 seconds - setTimeout(() => { - button.innerHTML = "Download"; - button.disabled = false; - }, 3000); - }); + if (window.components?.closeCustomModal) { + window.components.closeCustomModal("downloadModal"); + } else { + closeCustomModal("downloadModal"); + } + + // Use unified download handler + if (window.components?.handleDownload) { + window.components.handleDownload("dataset", datasetUuid, button); + } }; }); } diff --git a/gateway/sds_gateway/static/js/file-manager.js b/gateway/sds_gateway/static/js/file-manager.js new file mode 100644 index 00000000..cbe928cd --- /dev/null +++ b/gateway/sds_gateway/static/js/file-manager.js @@ -0,0 +1,1625 @@ +class FileManager { + constructor() { + // Check browser compatibility before proceeding + if (!this.checkBrowserSupport()) { + this.showError( + "Your browser doesn't support required features. Please use a modern browser.", + null, + "browser-compatibility", + ); + return; + } + + this.droppedFiles = null; + this.boundHandlers = new Map(); // Track bound event handlers for cleanup + this.activeModals = new Set(); // Track active modals + + // Prevent browser from navigating away when user drags files over the whole window + this.addGlobalDropGuards(); + this.init(); + } + + addGlobalDropGuards() { + // Prevent browser navigation on any drop event + document.addEventListener( + "dragover", + (e) => { + e.preventDefault(); + }, + false, + ); + + document.addEventListener( + "drop", + (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Always handle global drops for testing + this.handleGlobalDrop(e); + }, + false, + ); + } + + async handleGlobalDrop(e) { + const dt = e.dataTransfer; + if (!dt) { + console.warn("No dataTransfer in global drop"); + return; + } + + const files = await this.collectFilesFromDataTransfer(dt); + + if (!files.length) { + console.warn("No files collected from global drop"); + return; + } + + // Store the dropped files globally + window.selectedFiles = files; + + // Open the upload modal + const uploadModalEl = document.getElementById("uploadCaptureModal"); + if (!uploadModalEl) { + console.error("Upload modal element not found"); + return; + } + + const uploadModal = new bootstrap.Modal(uploadModalEl); + uploadModal.show(); + + // Wait a bit for modal to fully open, then trigger file selection + setTimeout(() => { + this.handleGlobalFilesInModal(files); + }, 200); + } + + handleGlobalFilesInModal(files) { + // Update the file input to show selected files + const fileInput = document.getElementById("captureFileInput"); + if (fileInput) { + // Create a new FileList-like object + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + fileInput.files = dataTransfer.files; + } + + // Update the selected files display + this.handleFileSelection(files); + + // Make sure the selected files section is visible + const selectedFilesSection = document.getElementById("selectedFiles"); + if (selectedFilesSection) { + selectedFilesSection.classList.add("has-files"); + } + + // Update the file input label to show selected files + const fileInputLabel = fileInput?.nextElementSibling; + if (fileInputLabel?.classList.contains("form-control")) { + const fileNames = files + .map((f) => f.webkitRelativePath || f.name) + .join(", "); + fileInputLabel.textContent = fileNames || "No directory selected."; + } + } + + convertToFiles(itemsOrFiles) { + if (!itemsOrFiles) return []; + // DataTransferItemList detection: items have getAsFile() + const first = itemsOrFiles[0]; + if (first && typeof first.getAsFile === "function") { + return Array.from(itemsOrFiles) + .map((item) => item.getAsFile()) + .filter((f) => !!f); + } + return Array.from(itemsOrFiles); + } + + // Collect files from a directory or mixed drop using the File System API (Chrome/WebKit) + async collectFilesFromDataTransfer(dataTransfer) { + const items = Array.from(dataTransfer.items || []); + const supportsEntries = + items.length > 0 && typeof items[0].webkitGetAsEntry === "function"; + if (!supportsEntries) { + return this.convertToFiles( + dataTransfer.files?.length ? dataTransfer.files : dataTransfer.items, + ); + } + + const allFiles = []; + for (const item of items) { + if (item.kind !== "file") continue; + const entry = item.webkitGetAsEntry(); + if (!entry) continue; + const files = await this.traverseEntry(entry); + allFiles.push(...files); + } + return allFiles; + } + + async traverseEntry(entry) { + if (entry.isFile) { + return new Promise((resolve) => { + entry.file((file) => { + // Preserve folder structure on drop by injecting webkitRelativePath + try { + const relative = (entry.fullPath || file.name).replace(/^\//, ""); + Object.defineProperty(file, "webkitRelativePath", { + value: relative, + configurable: true, + }); + } catch (_) {} + resolve([file]); + }); + }); + } + + if (entry.isDirectory) { + const reader = entry.createReader(); + const entries = await this.readAllEntries(reader); + const nestedFiles = []; + for (const child of entries) { + const files = await this.traverseEntry(child); + nestedFiles.push(...files); + } + return nestedFiles; + } + + return []; + } + + readAllEntries(reader) { + return new Promise((resolve) => { + const entries = []; + const readChunk = () => { + reader.readEntries((results) => { + if (!results.length) { + resolve(entries); + return; + } + entries.push(...results); + readChunk(); + }); + }; + readChunk(); + }); + } + + stripHtml(html) { + if (!html) return ""; + const div = document.createElement("div"); + div.innerHTML = html; + return (div.textContent || div.innerText || "").trim(); + } + + init() { + // Get container and data + this.container = document.querySelector(".files-container"); + if (!this.container) { + this.showError("Files container not found", null, "initialization"); + return; + } + + // Get data attributes + this.currentDir = this.container.dataset.currentDir; + this.userEmail = this.container.dataset.userEmail; + + // Get all file cards for data + const fileCards = document.querySelectorAll(".file-card:not(.header)"); + const items = Array.from(fileCards).map((card) => ({ + type: card.dataset.type, + name: card.querySelector(".file-name").textContent, + path: card.dataset.path, + uuid: card.dataset.uuid, + is_capture: card.dataset.isCapture === "true", + + is_shared: card.dataset.isShared === "true", + capture_uuid: card.dataset.captureUuid, + description: card.dataset.description, + modified_at: card.querySelector(".file-meta").textContent.trim(), + shared_by: card.querySelector(".file-shared").textContent.trim(), + })); + + // Get dataset options + const datasetSelect = document.getElementById("datasetSelect"); + const datasets = datasetSelect + ? Array.from(datasetSelect.options) + .slice(1) + .map((opt) => ({ + name: opt.text, + uuid: opt.value, + })) + : []; + + // Initialize all handlers + this.initializeEventListeners(); + this.initializeUploadHandlers(); + this.initializeFileClicks(); + } + + initializeEventListeners() { + const fileCards = document.querySelectorAll(".file-card"); + + for (const card of fileCards) { + if (!card.classList.contains("header")) { + const type = card.dataset.type; + // Add click handlers to directories and files + card.addEventListener("click", (e) => + this.handleFileCardClick(e, card), + ); + // Basic keyboard accessibility + card.setAttribute("tabindex", "0"); + card.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + this.handleFileCardClick(e, card); + } + }); + + if (type === "directory") { + card.style.cursor = "pointer"; + card.classList.add("clickable-directory"); + } else if (type === "file") { + card.style.cursor = "pointer"; + card.classList.add("clickable-file"); + } + } + } + } + + initializeUploadHandlers() { + // Initialize capture upload + const captureElements = { + uploadZone: document.getElementById("uploadZone"), + fileInput: document.getElementById("captureFileInput"), + // browseButton is optional in Files modal styling + browseButton: document.querySelector( + "#uploadCaptureModal .browse-button", + ), + selectedFilesList: document.getElementById("selectedFilesList"), + uploadForm: document.getElementById("uploadCaptureForm"), + }; + + // Only require the essentials; browseButton may be missing + const essentials = [ + captureElements.uploadZone, + captureElements.fileInput, + captureElements.selectedFilesList, + captureElements.uploadForm, + ]; + // Skip initialization if we're on the files page which has its own custom handler + const isFilesPage = window.location.pathname.includes("/users/files/"); + if (essentials.every((el) => el) && !isFilesPage) { + this.initializeCaptureUpload(captureElements); + } + + // Initialize text file upload + const textUploadForm = document.getElementById("uploadFileForm"); + if (textUploadForm) { + this.initializeTextFileUpload(textUploadForm); + } + } + + initializeCaptureUpload(elements) { + const { + uploadZone, + fileInput, + browseButton, + selectedFilesList, + uploadForm, + } = elements; + + if (!uploadZone || !fileInput || !selectedFilesList || !uploadForm) { + this.showError( + "Upload elements not found", + null, + "upload-initialization", + ); + return; + } + + // Handle browse button click (if present) + if (browseButton) { + browseButton.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + fileInput.click(); + }); + } + + // Handle drag and drop + uploadZone.addEventListener("dragover", (e) => { + e.preventDefault(); + uploadZone.classList.add("drag-over"); + }); + + uploadZone.addEventListener("dragleave", () => { + uploadZone.classList.remove("drag-over"); + }); + + uploadZone.addEventListener("drop", async (e) => { + e.preventDefault(); + uploadZone.classList.remove("drag-over"); + const dt = e.dataTransfer; + if (dt) { + const files = await this.collectFilesFromDataTransfer(dt); + this.droppedFiles = files; + // Clear any existing input selection so we rely on dropped files on submit + try { + fileInput.value = ""; + } catch (_) {} + this.handleFileSelection(files); + } + }); + + // Handle file input change + fileInput.addEventListener("change", (e) => { + this.droppedFiles = null; // prefer explicit file input selection + this.handleFileSelection(this.convertToFiles(e.target.files)); + }); + + // Toggle DRF/RH input groups + const typeSelect = document.getElementById("captureTypeSelect"); + const channelGroup = document.getElementById("channelInputGroup"); + const scanGroup = document.getElementById("scanGroupInputGroup"); + const channelInput = document.getElementById("captureChannelsInput"); + + if (typeSelect) { + typeSelect.addEventListener("change", () => { + const v = typeSelect.value; + + // Use Bootstrap classes instead of inline styles + if (channelGroup) { + if (v === "drf") { + channelGroup.classList.remove("d-none"); + channelGroup.style.display = ""; + } else { + channelGroup.classList.add("d-none"); + } + } + + if (scanGroup) { + if (v === "rh") { + scanGroup.classList.remove("d-none"); + scanGroup.style.display = ""; + } else { + scanGroup.classList.add("d-none"); + } + } + + if (channelInput) { + if (v === "drf") { + channelInput.setAttribute("required", "required"); + } else { + channelInput.removeAttribute("required"); + } + } + }); + + // Trigger change event to set initial state + typeSelect.dispatchEvent(new Event("change")); + } + + // Check for globally dropped files when modal opens + if (window.selectedFiles?.length) { + this.handleFileSelection(window.selectedFiles); + } + + // Handle form submission + uploadForm.addEventListener("submit", async (e) => { + e.preventDefault(); + + const formData = new FormData(); + const submitBtn = uploadForm.querySelector('button[type="submit"]'); + const uploadText = submitBtn.querySelector(".upload-text"); + const uploadSpinner = submitBtn.querySelector(".upload-spinner"); + + try { + submitBtn.disabled = true; + uploadText.classList.add("d-none"); + uploadSpinner.classList.remove("d-none"); + + // Get CSRF token and add it when present + const csrfToken = this.getCsrfToken(); + if (csrfToken) { + formData.append("csrfmiddlewaretoken", csrfToken); + } + + // Add capture type and channels from the form + const captureType = document.getElementById("captureTypeSelect").value; + const channels = + document.getElementById("captureChannelsInput")?.value || ""; + const scanGroupVal = + document.getElementById("captureScanGroupInput")?.value || ""; + formData.append("capture_type", captureType); + formData.append("channels", channels); + if (captureType === "rh" && scanGroupVal) { + formData.append("scan_group", scanGroupVal); + } + + // Add files and their relative paths - check for globally dropped files first + const files = window.selectedFiles?.length + ? Array.from(window.selectedFiles) + : this.droppedFiles?.length + ? Array.from(this.droppedFiles) + : Array.from(fileInput.files); + + // Create an array of relative paths in the same order as files + const relativePaths = files.map( + (file) => file.webkitRelativePath || file.name, + ); + + // Add each file + for (const [index, file] of files.entries()) { + formData.append("files", file); + formData.append("relative_paths", relativePaths[index]); + } + + await this.handleUpload(formData, submitBtn, "uploadCaptureModal", { + files, + }); + } catch (error) { + const userMessage = this.getUserFriendlyErrorMessage( + error, + "capture-upload", + ); + this.showError( + `Upload failed: ${userMessage}`, + error, + "capture-upload", + ); + } finally { + submitBtn.disabled = false; + uploadText.classList.remove("d-none"); + uploadSpinner.classList.add("d-none"); + this.droppedFiles = null; + window.selectedFiles = null; // Clear global files after upload + } + }); + } + + initializeTextFileUpload(uploadForm) { + uploadForm.addEventListener("submit", async (e) => { + e.preventDefault(); + + const formData = new FormData(uploadForm); + const submitBtn = uploadForm.querySelector('button[type="submit"]'); + const uploadText = submitBtn.querySelector(".upload-text"); + const uploadSpinner = submitBtn.querySelector(".upload-spinner"); + + try { + submitBtn.disabled = true; + uploadText.classList.add("d-none"); + uploadSpinner.classList.remove("d-none"); + + // CSRF token attached in handleUpload + + await this.handleUpload(formData, submitBtn, "uploadFileModal"); + } catch (error) { + const userMessage = this.getUserFriendlyErrorMessage( + error, + "text-upload", + ); + this.showError(`Upload failed: ${userMessage}`, error, "text-upload"); + } finally { + submitBtn.disabled = false; + uploadText.classList.remove("d-none"); + uploadSpinner.classList.add("d-none"); + } + }); + } + + initializeFileClicks() { + // Wire up download confirmation for dataset and capture buttons + document.addEventListener("click", (e) => { + if ( + e.target.matches(".download-capture-btn") || + e.target.closest(".download-capture-btn") + ) { + e.preventDefault(); + e.stopPropagation(); + const btn = e.target.matches(".download-capture-btn") + ? e.target + : e.target.closest(".download-capture-btn"); + const captureUuid = btn.dataset.captureUuid; + const captureName = btn.dataset.captureName || captureUuid; + + // Validate UUID before proceeding + if (!this.isValidUuid(captureUuid)) { + console.warn("Invalid capture UUID:", captureUuid); + this.showError("Invalid capture identifier", null, "download"); + return; + } + + // Update modal text + const nameEl = document.getElementById("downloadCaptureName"); + if (nameEl) nameEl.textContent = captureName; + + // Show modal using helper method + this.openModal("downloadModal"); + + // Confirm handler + const confirmBtn = document.getElementById("confirmDownloadBtn"); + if (confirmBtn) { + const onConfirm = () => { + this.closeModal("downloadModal"); + + // Use unified download handler if available + if (window.components?.handleDownload) { + const dummyButton = document.createElement("button"); + dummyButton.style.display = "none"; + window.components.handleDownload( + "capture", + captureUuid, + dummyButton, + ); + } + }; + confirmBtn.addEventListener("click", onConfirm, { once: true }); + } + } + + if ( + e.target.matches(".download-dataset-btn") || + e.target.closest(".download-dataset-btn") + ) { + e.preventDefault(); + e.stopPropagation(); + const btn = e.target.matches(".download-dataset-btn") + ? e.target + : e.target.closest(".download-dataset-btn"); + const datasetUuid = btn.dataset.datasetUuid; + + // Validate UUID before proceeding + if (!this.isValidUuid(datasetUuid)) { + console.warn("Invalid dataset UUID:", datasetUuid); + this.showError("Invalid dataset identifier", null, "download"); + return; + } + // Show modal using helper method + this.openModal("downloadModal"); + const confirmBtn = document.getElementById("confirmDownloadBtn"); + if (confirmBtn) { + const onConfirm = () => { + this.closeModal("downloadModal"); + fetch( + `/users/download-item/dataset/${encodeURIComponent(datasetUuid)}/`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCsrfToken(), + }, + }, + ) + .then(async (r) => { + try { + return await r.json(); + } catch (_) { + return {}; + } + }) + .catch(() => {}); + }; + confirmBtn.addEventListener("click", onConfirm, { once: true }); + } + } + + // Single file direct download link (GET) + const fileDownloadLink = + e.target.closest( + 'a.dropdown-item[href^="/users/files/"][href$="/download/"]', + ) || + e.target.closest( + '.dropdown-menu a[href^="/users/files/"][href$="/download/"]', + ) || + e.target.closest('a[href^="/users/files/"][href$="/download/"]'); + if (fileDownloadLink) { + e.preventDefault(); + e.stopPropagation(); + const card = fileDownloadLink.closest(".file-card"); + const fileName = + card?.querySelector(".file-name")?.textContent?.trim() || "File"; + // Use helper method to show success message + this.showSuccessMessage(`Download starting: ${fileName}`); + const href = fileDownloadLink.getAttribute("href"); + try { + window.open(href, "_blank"); + } catch (_) { + window.location.href = href; + } + return; + } + }); + } + + async handleUpload(formData, submitBtn, modalId, options = {}) { + const uploadText = submitBtn.querySelector(".upload-text"); + const uploadSpinner = submitBtn.querySelector(".upload-spinner"); + + try { + // Update UI + submitBtn.disabled = true; + uploadText.classList.add("d-none"); + uploadSpinner.classList.remove("d-none"); + + // Make request with progress (XHR for upload progress events) + const response = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/users/upload-files/"); + xhr.withCredentials = true; + xhr.setRequestHeader("X-CSRFToken", this.getCsrfToken()); + xhr.setRequestHeader("Accept", "application/json"); + + // Progress UI elements + smoothing state + const wrap = document.getElementById("captureUploadProgressWrap"); + const bar = document.getElementById("captureUploadProgressBar"); + const text = document.getElementById("captureUploadProgressText"); + if (wrap) wrap.classList.remove("d-none"); + if (bar) { + bar.classList.add("progress-bar-striped", "progress-bar-animated"); + bar.style.width = "100%"; + bar.setAttribute("aria-valuenow", "100"); + bar.textContent = ""; + } + if (text) text.textContent = "Uploading…"; + + xhr.upload.onprogress = () => { + // Keep indeterminate to match button spinner timing (no file count) + if (text) text.textContent = "Uploading…"; + }; + + xhr.onerror = () => reject(new Error("Network error during upload")); + xhr.upload.onloadstart = () => { + if (text) text.textContent = "Starting upload…"; + }; + xhr.upload.onloadend = () => { + if (bar) { + bar.classList.add("progress-bar-striped", "progress-bar-animated"); + bar.style.width = "100%"; + bar.setAttribute("aria-valuenow", "100"); + bar.textContent = ""; + } + if (text) text.textContent = "Processing on server…"; + }; + xhr.onload = () => { + // Build a Response-like object compatible with existing code + const status = xhr.status; + const headers = new Headers({ + "content-type": xhr.getResponseHeader("content-type") || "", + }); + const bodyText = xhr.responseText || ""; + const responseLike = { + ok: status >= 200 && status < 300, + status, + headers, + json: async () => { + try { + return JSON.parse(bodyText); + } catch { + return {}; + } + }, + text: async () => bodyText, + }; + resolve(responseLike); + }; + + xhr.send(formData); + }); + + let result = null; + let fallbackText = ""; + try { + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + result = await response.json(); + } else { + fallbackText = await response.text(); + } + } catch (_) {} + + if (response.ok) { + // Build a concise success message for inline banner + let successMessage = "Upload complete."; + if (result && (result.files_uploaded || result.total_files)) { + const uploaded = result.files_uploaded ?? result.total_uploaded ?? 0; + const total = result.total_files ?? result.total_uploaded ?? 0; + successMessage = `Upload complete: ${uploaded} / ${total} file${total === 1 ? "" : "s"} uploaded.`; + if (Array.isArray(result.errors) && result.errors.length) { + successMessage += " Some items were skipped or failed."; + } + } + try { + sessionStorage.setItem( + "filesAlert", + JSON.stringify({ + message: successMessage, + type: "success", + }), + ); + } catch (_) {} + // Reload to show inline banner on main page + window.location.reload(); + } else { + let message = ""; + if (result && (result.detail || result.error || result.message)) { + message = result.detail || result.error || result.message; + } else if (fallbackText) { + message = this.stripHtml(fallbackText) + .split("\n") + .slice(0, 3) + .join(" "); + } + if (!message) message = `Upload failed (${response.status})`; + // Friendly mapping for common statuses + if (response.status === 409) { + message = + "Upload skipped: a file with the same checksum already exists. Use PATCH to replace, or change the file."; + } + throw new Error(message); + } + } catch (error) { + const userMessage = this.getUserFriendlyErrorMessage( + error, + "upload-handler", + ); + try { + sessionStorage.setItem( + "filesAlert", + JSON.stringify({ + message: `Upload failed: ${userMessage}`, + type: "error", + }), + ); + // Reload to display the banner via template startup script + window.location.reload(); + } catch (_) { + this.showError( + `Upload failed: ${userMessage}`, + error, + "upload-handler", + ); + } + } finally { + // Reset UI + submitBtn.disabled = false; + uploadText.classList.remove("d-none"); + uploadSpinner.classList.add("d-none"); + } + } + + showUploadSuccess(result, modalId) { + const resultModal = new bootstrap.Modal( + document.getElementById("uploadResultModal"), + ); + const resultBody = document.getElementById("uploadResultModalBody"); + + resultBody.innerHTML = ` +
+
Upload Complete!
+ ${ + result.files_uploaded + ? `

Files uploaded: ${result.files_uploaded} / ${result.total_files}

` + : "

File uploaded successfully!

" + } + ${result.errors ? `

Errors: ${result.errors.join("
")}

` : ""} +
+ `; + + // Close upload modal and show result (guard instance) + const uploadModalEl = document.getElementById(modalId); + const uploadModalInstance = uploadModalEl + ? bootstrap.Modal.getInstance(uploadModalEl) + : null; + if (uploadModalInstance) { + uploadModalInstance.hide(); + } + resultModal.show(); + } + + // File preview methods + async showTextFilePreview(fileUuid, fileName) { + try { + // Check if this is a file we should preview + if (!this.shouldPreviewFile(fileName)) { + this.showError("This file type cannot be previewed"); + return; + } + + const content = await this.fetchFileContent(fileUuid); + this.showPreviewModal(fileName, content); + } catch (error) { + if (error.message === "File too large to preview") { + this.showError( + "File is too large to preview. Please download it instead.", + error, + "file-preview", + ); + } else { + const userMessage = this.getUserFriendlyErrorMessage( + error, + "file-preview", + ); + this.showError(userMessage, error, "file-preview"); + } + } + } + + shouldPreviewFile(fileName) { + const extension = this.getFileExtension(fileName); + return this.isPreviewableFileType(extension); + } + + async fetchFileContent(fileUuid) { + const response = await fetch(`/users/files/${fileUuid}/content/`); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to fetch file content"); + } + + return response.text(); + } + + showPreviewModal(fileName, content) { + const modal = document.getElementById("filePreviewModal"); + const modalTitle = modal.querySelector(".modal-title"); + const previewContent = modal.querySelector(".preview-content"); + + // Enhanced accessibility + modal.setAttribute("aria-label", `Preview of ${fileName}`); + modal.setAttribute("aria-describedby", "preview-content"); + modal.setAttribute("role", "dialog"); + + modalTitle.textContent = fileName; + modalTitle.setAttribute("id", "preview-modal-title"); + + // Clear previous content + previewContent.innerHTML = ""; + previewContent.setAttribute("id", "preview-content"); + previewContent.setAttribute("aria-label", `Content of ${fileName}`); + + // Check if we should use syntax highlighting + if (this.shouldUseSyntaxHighlighting(fileName)) { + this.showSyntaxHighlightedContent(previewContent, content, fileName); + } else { + // Basic text display + const preElement = this.createElement("pre", "preview-text", content); + preElement.setAttribute("aria-label", `Text content of ${fileName}`); + previewContent.appendChild(preElement); + } + + new bootstrap.Modal(modal).show(); + } + + // Helper methods for syntax highlighting + getFileExtension(fileName) { + return fileName.split(".").pop().toLowerCase(); + } + + // Helper method to open modal with fallbacks + openModal(modalId) { + this.activeModals.add(modalId); + + if (window.components?.openCustomModal) { + window.components.openCustomModal(modalId); + } else if (typeof openCustomModal === "function") { + openCustomModal(modalId); + } else if (window.openCustomModal) { + window.openCustomModal(modalId); + } else { + const modal = document.getElementById(modalId); + if (modal) modal.style.display = "block"; + } + } + + // Helper method to close modal with fallbacks + closeModal(modalId) { + this.activeModals.delete(modalId); + + if (window.components?.closeCustomModal) { + window.components.closeCustomModal(modalId); + } else if (typeof closeCustomModal === "function") { + closeCustomModal(modalId); + } else if (window.closeCustomModal) { + window.closeCustomModal(modalId); + } else { + const modal = document.getElementById(modalId); + if (modal) modal.style.display = "none"; + } + } + + // Helper method to show success message with fallbacks + showSuccessMessage(message) { + if (window.components?.showSuccess) { + window.components.showSuccess(message); + } else { + const live = document.getElementById("aria-live-region"); + if (live) live.textContent = message; + } + } + + // Helper method to get CSRF token + getCsrfToken() { + return document.querySelector("[name=csrfmiddlewaretoken]")?.value || ""; + } + + // Helper method to check if file has extension + hasFileExtension(fileName) { + return /\.[^./]+$/.test(fileName); + } + + // Input validation methods + isValidUuid(uuid) { + if (!uuid || typeof uuid !== "string") return false; + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(uuid); + } + + isValidFileName(fileName) { + if (!fileName || typeof fileName !== "string") return false; + // Check for invalid characters and length + const invalidChars = /[<>:"/\\|?*]/; + return !invalidChars.test(fileName) && fileName.length <= 255; + } + + isValidPath(path) { + if (!path || typeof path !== "string") return false; + // Check for path traversal attempts and invalid characters + const invalidPathPatterns = /\.\.|[<>:"|?*]/; + return !invalidPathPatterns.test(path) && path.length <= 4096; + } + + sanitizeFileName(fileName) { + if (!fileName) return ""; + // Remove or replace invalid characters + return fileName.replace(/[<>:"/\\|?*]/g, "_"); + } + + // Helper method to create DOM element with attributes + createElement(tag, className, innerHTML) { + const element = document.createElement(tag); + if (className) element.className = className; + if (innerHTML) element.innerHTML = innerHTML; + return element; + } + + // Helper method to check if file type is previewable + isPreviewableFileType(extension) { + const nonPreviewableExtensions = [ + "7z", + "a", + "accdb", + "ai", + "avi", + "bak", + "bin", + "bz2", + "class", + "dll", + "dmg", + "doc", + "docx", + "ear", + "eps", + "exe", + "flv", + "gz", + "h5", + "hdf", + "hdf5", + "img", + "iso", + "jar", + "lib", + "log", + "mat", + "mdb", + "mov", + "mp3", + "mp4", + "nc", + "netcdf", + "obj", + "odp", + "ods", + "odt", + "o", + "out", + "pdf", + "pdb", + "pkg", + "ppt", + "pptx", + "psd", + "r", + "rar", + "rdata", + "rds", + "raw", + "rpm", + "sav", + "so", + "sqlite", + "svg", + "tar", + "temp", + "tmp", + "war", + "wmv", + "xls", + "xlsx", + "zip", + ]; + return !nonPreviewableExtensions.includes(extension); + } + + // Helper method to extract text from notebook cell source + extractCellSourceText(source) { + if (Array.isArray(source)) { + return source.join("") || ""; + } + if (typeof source === "string") { + return source; + } + return String(source || ""); + } + + // Helper method to extract text from notebook cell output + extractCellOutputText(output) { + if (output.output_type === "stream") { + return Array.isArray(output.text) + ? output.text.join("") + : String(output.text || ""); + } + if (output.output_type === "execute_result") { + return output.data?.["text/plain"] + ? Array.isArray(output.data["text/plain"]) + ? output.data["text/plain"].join("") + : String(output.data["text/plain"]) + : ""; + } + return ""; + } + + getLanguageFromExtension(extension) { + const languageMap = { + js: "javascript", + jsx: "javascript", + ts: "typescript", + tsx: "typescript", + py: "python", + pyw: "python", + ipynb: "json", // Jupyter notebooks are JSON + json: "json", + xml: "markup", + html: "markup", + htm: "markup", + css: "css", + scss: "css", + sass: "css", + sh: "bash", + bash: "bash", + zsh: "bash", + fish: "bash", + c: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + h: "c", + hpp: "cpp", + java: "java", + php: "php", + rb: "ruby", + go: "go", + rs: "rust", + swift: "swift", + kt: "kotlin", + scala: "scala", + clj: "clojure", + hs: "haskell", + ml: "ocaml", + fs: "fsharp", + cs: "csharp", + vb: "vbnet", + sql: "sql", + r: "r", + m: "matlab", + pl: "perl", + tcl: "tcl", + lua: "lua", + vim: "vim", + yaml: "yaml", + yml: "yaml", + toml: "toml", + ini: "ini", + cfg: "ini", + conf: "ini", + md: "markdown", + markdown: "markdown", + txt: "text", + log: "text", + }; + return languageMap[extension] || "text"; + } + + shouldUseSyntaxHighlighting(fileName) { + const extension = this.getFileExtension(fileName); + const highlightableExtensions = [ + "js", + "jsx", + "ts", + "tsx", + "py", + "pyw", + "ipynb", + "json", + "xml", + "html", + "htm", + "css", + "scss", + "sass", + "sh", + "bash", + "zsh", + "fish", + "c", + "cpp", + "cc", + "cxx", + "h", + "hpp", + "java", + "php", + "rb", + "go", + "rs", + "swift", + "kt", + "scala", + "clj", + "hs", + "ml", + "fs", + "cs", + "vb", + "sql", + "r", + "m", + "pl", + "tcl", + "lua", + "vim", + "yaml", + "yml", + "toml", + "ini", + "cfg", + "conf", + "md", + "markdown", + ]; + return highlightableExtensions.includes(extension); + } + + showSyntaxHighlightedContent(container, content, fileName) { + const extension = this.getFileExtension(fileName); + const language = this.getLanguageFromExtension(extension); + + // Special handling for Jupyter notebooks + if (extension === "ipynb") { + this.showJupyterNotebookPreview(container, content, fileName); + return; + } + + // Create code element with language class + const codeElement = this.createElement("code", `language-${language}`); + codeElement.textContent = content; + + // Create pre element + const preElement = this.createElement("pre", "syntax-highlighted"); + preElement.appendChild(codeElement); + + // Add to container + container.appendChild(preElement); + + // Apply Prism.js highlighting + if (window.Prism) { + window.Prism.highlightElement(codeElement); + } + } + + showJupyterNotebookPreview(container, content, fileName) { + try { + // Parse the JSON content + const notebook = JSON.parse(content); + + // Create a container for the notebook preview + const notebookContainer = this.createElement( + "div", + "jupyter-notebook-preview", + ); + + // Add notebook metadata header + const header = this.createElement("div", "notebook-header"); + header.innerHTML = ` +
+ + ${notebook.metadata?.title || fileName} +
+
+ ${notebook.metadata?.kernelspec?.display_name || "Python"} + ${notebook.cells?.length || 0} cells +
+ `; + notebookContainer.appendChild(header); + + // Process each cell + if (notebook.cells && Array.isArray(notebook.cells)) { + notebook.cells.forEach((cell, index) => { + const cellElement = this.createNotebookCell(cell, index); + notebookContainer.appendChild(cellElement); + }); + } + + container.appendChild(notebookContainer); + } catch (error) { + // Fallback to JSON display if parsing fails + console.warn( + "Failed to parse Jupyter notebook, falling back to JSON:", + error, + ); + this.showSyntaxHighlightedContent(container, content, "fallback.json"); + } + } + + createNotebookCell(cell, index) { + const cellContainer = this.createElement( + "div", + `notebook-cell ${cell.cell_type}`, + ); + const cellHeader = this.createElement("div", "cell-header"); + + let headerContent = ""; + if (cell.cell_type === "code") { + const execCount = + cell.execution_count !== null ? cell.execution_count : " "; + headerContent = ` + Code + In [${execCount}]: + `; + } else { + headerContent = `Markdown`; + } + + cellHeader.innerHTML = headerContent; + cellContainer.appendChild(cellHeader); + + // Cell content + const cellContent = this.createElement("div", "cell-content"); + + if (cell.cell_type === "code") { + // Code cell with syntax highlighting + const codeElement = this.createElement("code", "language-python"); + const sourceText = this.extractCellSourceText(cell.source); + codeElement.textContent = sourceText; + + const preElement = this.createElement("pre"); + preElement.appendChild(codeElement); + cellContent.appendChild(preElement); + + // Apply syntax highlighting + if (window.Prism) { + window.Prism.highlightElement(codeElement); + } + + // Add output if present + if (cell.outputs && cell.outputs.length > 0) { + const outputContainer = this.createElement("div", "cell-output"); + outputContainer.innerHTML = `Out [${cell.execution_count}]:`; + + for (const output of cell.outputs) { + const outputText = this.extractCellOutputText(output); + if (outputText) { + const outputElement = this.createElement( + "pre", + output.output_type === "stream" + ? "output-stream" + : "output-result", + ); + outputElement.textContent = outputText; + outputContainer.appendChild(outputElement); + } + } + + cellContent.appendChild(outputContainer); + } + } else { + // Markdown cell + const markdownElement = this.createElement("div", "markdown-content"); + const sourceText = this.extractCellSourceText(cell.source); + markdownElement.textContent = sourceText; + cellContent.appendChild(markdownElement); + } + + cellContainer.appendChild(cellContent); + return cellContainer; + } + + showError(message, error = null, context = "") { + // Log error details for debugging + if (error) { + console.error(`FileManager Error [${context}]:`, { + message: error.message, + stack: error.stack, + userMessage: message, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + }); + } else { + console.warn(`FileManager Warning [${context}]:`, message); + } + + // Show user-friendly error message + if (window.components?.showError) { + window.components.showError(message); + return; + } + const live = document.getElementById("aria-live-region"); + if (live) { + live.textContent = message; + return; + } + // Final fallback: inline banner near top + const container = + document.querySelector(".container-fluid") || document.body; + const div = this.createElement( + "div", + "alert alert-danger alert-dismissible fade show", + `${message}`, + ); + container.insertBefore(div, container.firstChild); + } + + // Enhanced error message formatting + getUserFriendlyErrorMessage(error, context = "") { + if (!error) return "An unexpected error occurred"; + + // Handle common error types + if (error.name === "NetworkError" || error.message.includes("fetch")) { + return "Network error: Please check your connection and try again"; + } + if (error.name === "TypeError" && error.message.includes("JSON")) { + return "Invalid response format: Please try again or contact support"; + } + if (error.message.includes("403") || error.message.includes("Forbidden")) { + return "Access denied: You don't have permission to perform this action"; + } + if (error.message.includes("404") || error.message.includes("Not Found")) { + return "Resource not found: The requested file or directory may have been moved or deleted"; + } + if ( + error.message.includes("500") || + error.message.includes("Internal Server Error") + ) { + return "Server error: Please try again later or contact support"; + } + + // Default user-friendly message + return error.message || "An unexpected error occurred"; + } + + escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + // Memory management and cleanup + cleanup() { + // Remove all bound event handlers + for (const [element, handler] of this.boundHandlers) { + if (element?.removeEventListener) { + element.removeEventListener("click", handler); + } + } + this.boundHandlers.clear(); + + // Close all active modals + for (const modalId of this.activeModals) { + this.closeModal(modalId); + } + this.activeModals.clear(); + + // Clear file references + this.droppedFiles = null; + window.selectedFiles = null; + + console.log("FileManager cleanup completed"); + } + + // Browser compatibility check + checkBrowserSupport() { + const requiredFeatures = { + "File API": "File" in window, + FileReader: "FileReader" in window, + FormData: "FormData" in window, + "Fetch API": "fetch" in window, + Promise: "Promise" in window, + Map: "Map" in window, + Set: "Set" in window, + }; + + const missingFeatures = Object.entries(requiredFeatures) + .filter(([name, supported]) => !supported) + .map(([name]) => name); + + if (missingFeatures.length > 0) { + console.warn("Missing browser features:", missingFeatures); + return false; + } + + return true; + } + + // Track event handler for cleanup + bindEventHandler(element, event, handler) { + this.boundHandlers.set(element, handler); + element.addEventListener(event, handler); + } + + handleFileSelection(files) { + const selectedFilesList = document.getElementById("selectedFilesList"); + const selectedFiles = document.getElementById("selectedFiles"); + if (!selectedFilesList || !selectedFiles) return; + selectedFilesList.innerHTML = ""; + + const allFiles = Array.from(files || []); + // Filter out likely directory placeholders that some browsers expose on drop + const realFiles = allFiles.filter((f) => { + // Keep if size > 0 or has a known extension or MIME type + const hasExtension = this.hasFileExtension(f.name); + return f.size > 0 || hasExtension || (f.type && f.type.length > 0); + }); + + // If selection came from the file input (webkitdirectory browse), show all files. + // If it came from drag-and-drop, we may have limited UI space; still show all for clarity. + for (const file of realFiles) { + const li = this.createElement( + "li", + "", + ` + + ${file.webkitRelativePath || file.name} + `, + ); + selectedFilesList.appendChild(li); + } + + if (realFiles.length > 0) { + selectedFiles.classList.add("has-files"); + } else { + selectedFiles.classList.remove("has-files"); + } + } + + renderFileTree(node, container, path = "") { + for (const [name, value] of Object.entries(node)) { + let li; + if (value instanceof File) { + // Render file + li = this.createElement( + "li", + "", + ` + + ${name} + `, + ); + } else { + // Render directory + li = this.createElement( + "li", + "", + ` + + ${name} + + `, + ); + this.renderFileTree(value, li.querySelector("ul"), `${path + name}/`); + } + container.appendChild(li); + } + } + + handleFileCardClick(e, card) { + // Ignore clicks originating from the actions area (dropdown/buttons) + if (e.target.closest(".file-actions")) { + return; + } + + const type = card.dataset.type; + const path = card.dataset.path; + const uuid = card.dataset.uuid; + + if (type === "directory") { + this.handleDirectoryClick(path); + } else if (type === "dataset") { + this.handleDatasetClick(uuid); + } else if (type === "file") { + this.handleFileClick(card, uuid); + } + } + + handleDirectoryClick(path) { + if (path && this.isValidPath(path)) { + // Remove any duplicate slashes and ensure proper path format + const cleanPath = path.replace(/\/+/g, "/").replace(/\/$/, ""); + // Build the navigation URL + const navUrl = `/users/files/?dir=${encodeURIComponent(cleanPath)}`; + // Navigate to the directory using the dir query parameter + window.location.href = navUrl; + } else { + console.warn("Invalid directory path:", path); + this.showError("Invalid directory path", null, "navigation"); + } + } + + handleDatasetClick(uuid) { + if (uuid && this.isValidUuid(uuid)) { + const datasetUrl = `/users/files/?dir=/datasets/${encodeURIComponent(uuid)}`; + window.location.href = datasetUrl; + } else { + console.warn("Invalid dataset UUID:", uuid); + this.showError("Invalid dataset identifier", null, "navigation"); + } + } + + handleFileClick(card, uuid) { + if (uuid && this.isValidUuid(uuid)) { + // Prefer the exact text node for the filename and trim whitespace + const rawName = + card.querySelector(".file-name-text")?.textContent || + card.querySelector(".file-name")?.textContent || + ""; + const name = rawName.trim(); + + // Validate and sanitize filename + if (!this.isValidFileName(name)) { + console.warn("Invalid filename:", name); + this.showError("Invalid filename", null, "file-preview"); + return; + } + + const sanitizedName = this.sanitizeFileName(name); + const lower = sanitizedName.toLowerCase(); + + if (this.shouldPreviewFile(sanitizedName)) { + this.showTextFilePreview(uuid, sanitizedName); + } else if (lower.endsWith(".h5") || lower.endsWith(".hdf5")) { + // H5 files - no preview, no action + } else { + const detailUrl = `/users/file-detail/${uuid}/`; + window.location.href = detailUrl; + } + } else { + console.warn("Invalid file UUID:", uuid); + this.showError("Invalid file identifier", null, "file-preview"); + } + } +} + +// Initialize file manager when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + new FileManager(); +}); diff --git a/gateway/sds_gateway/static/js/files-ui.js b/gateway/sds_gateway/static/js/files-ui.js new file mode 100644 index 00000000..acb25ed1 --- /dev/null +++ b/gateway/sds_gateway/static/js/files-ui.js @@ -0,0 +1,581 @@ +/** + * Files UI Components + * Manages capture type selection and page initialization + */ + +// Error handling utilities +const ErrorHandler = { + showError(message, context = "", error = null) { + // Log error details for debugging + if (error) { + console.error(`FilesUI Error [${context}]:`, { + message: error.message, + stack: error.stack, + userMessage: message, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + }); + } else { + console.warn(`FilesUI Warning [${context}]:`, message); + } + + // Show user-friendly error message + if (window.components?.showError) { + window.components.showError(message); + } else { + // Fallback: show in console and try to display on page + this.showFallbackError(message); + } + }, + + showFallbackError(message) { + // Try to find an error display area + const errorContainer = document.querySelector( + ".error-container, .alert-container, .files-container", + ); + if (errorContainer) { + const errorDiv = document.createElement("div"); + errorDiv.className = "alert alert-danger alert-dismissible fade show"; + errorDiv.innerHTML = ` + ${message} + + `; + errorContainer.insertBefore(errorDiv, errorContainer.firstChild); + } + }, + + getUserFriendlyErrorMessage(error, context = "") { + if (!error) return "An unexpected error occurred"; + + // Handle common error types + if (error.name === "TypeError" && error.message.includes("Cannot read")) { + return "Configuration error: Some components are not properly loaded"; + } + if (error.name === "ReferenceError") { + return "Component error: Required functionality is not available"; + } + + // Default user-friendly message + return error.message || "An unexpected error occurred"; + }, +}; + +// Browser compatibility checker +const BrowserCompatibility = { + checkRequiredFeatures() { + const requiredFeatures = { + "DOM API": "document" in window && "addEventListener" in document, + "Console API": "console" in window && "log" in console, + Map: "Map" in window, + Set: "Set" in window, + "Template Literals": (() => { + try { + // Test template literal support without eval + const test = `test${1}`; + return test === "test1"; + } catch { + return false; + } + })(), + }; + + const missingFeatures = Object.entries(requiredFeatures) + .filter(([name, supported]) => !supported) + .map(([name]) => name); + + if (missingFeatures.length > 0) { + console.warn("Missing browser features:", missingFeatures); + return false; + } + + return true; + }, + + checkBootstrapSupport() { + return ( + "bootstrap" in window || + typeof bootstrap !== "undefined" || + document.querySelector("[data-bs-toggle]") !== null + ); + }, +}; + +/** + * Capture Type Selection Handler + * Manages capture type dropdown and conditional form fields + */ +class CaptureTypeSelector { + constructor() { + this.boundHandlers = new Map(); // Track event handlers for cleanup + this.initializeElements(); + this.setupEventListeners(); + } + + initializeElements() { + this.captureTypeSelect = document.getElementById("captureTypeSelect"); + this.channelInputGroup = document.getElementById("channelInputGroup"); + this.scanGroupInputGroup = document.getElementById("scanGroupInputGroup"); + this.captureChannelsInput = document.getElementById("captureChannelsInput"); + this.captureScanGroupInput = document.getElementById( + "captureScanGroupInput", + ); + this.uploadModal = document.getElementById("uploadCaptureModal"); + + // Log which elements were found for debugging + console.log("CaptureTypeSelector elements found:", { + captureTypeSelect: !!this.captureTypeSelect, + channelInputGroup: !!this.channelInputGroup, + scanGroupInputGroup: !!this.scanGroupInputGroup, + captureChannelsInput: !!this.captureChannelsInput, + captureScanGroupInput: !!this.captureScanGroupInput, + uploadModal: !!this.uploadModal, + }); + } + + setupEventListeners() { + // Ensure boundHandlers is initialized + if (!this.boundHandlers) { + this.boundHandlers = new Map(); + } + + if (this.captureTypeSelect) { + const changeHandler = (e) => this.handleTypeChange(e); + this.boundHandlers.set(this.captureTypeSelect, changeHandler); + this.captureTypeSelect.addEventListener("change", changeHandler); + } + + if (this.uploadModal) { + const hiddenHandler = () => this.resetForm(); + this.boundHandlers.set(this.uploadModal, hiddenHandler); + this.uploadModal.addEventListener("hidden.bs.modal", hiddenHandler); + } + } + + handleTypeChange(event) { + const selectedType = event.target.value; + + // Validate capture type + if (!this.validateCaptureType(selectedType)) { + ErrorHandler.showError( + "Invalid capture type selected", + "capture-type-validation", + ); + return; + } + + // Hide both input groups initially + this.hideInputGroups(); + + // Clear required attributes + this.clearRequiredAttributes(); + + // Show appropriate input group based on selection + if (selectedType === "drf") { + this.showChannelInput(); + } else if (selectedType === "rh") { + this.showScanGroupInput(); + } + } + + hideInputGroups() { + if (this.channelInputGroup) { + this.channelInputGroup.classList.add("hidden-input-group"); + } + if (this.scanGroupInputGroup) { + this.scanGroupInputGroup.classList.add("hidden-input-group"); + } + } + + clearRequiredAttributes() { + if (this.captureChannelsInput) { + this.captureChannelsInput.removeAttribute("required"); + } + if (this.captureScanGroupInput) { + this.captureScanGroupInput.removeAttribute("required"); + } + } + + showChannelInput() { + if (this.channelInputGroup) { + this.channelInputGroup.classList.remove("hidden-input-group"); + } + if (this.captureChannelsInput) { + this.captureChannelsInput.setAttribute("required", "required"); + } + } + + showScanGroupInput() { + if (this.scanGroupInputGroup) { + this.scanGroupInputGroup.classList.remove("hidden-input-group"); + } + // scan_group is optional for RadioHound captures, so no required attribute + } + + // Input validation methods + validateCaptureType(type) { + const validTypes = ["drf", "rh"]; + return validTypes.includes(type); + } + + validateChannelInput(channels) { + if (!channels || typeof channels !== "string") return false; + // Basic validation for channel input (can be enhanced based on requirements) + return channels.trim().length > 0 && channels.length <= 1000; + } + + validateScanGroupInput(scanGroup) { + if (!scanGroup || typeof scanGroup !== "string") return false; + // Basic validation for scan group input + return scanGroup.trim().length > 0 && scanGroup.length <= 255; + } + + sanitizeInput(input) { + if (!input || typeof input !== "string") return ""; + // Remove potentially dangerous characters + return input.replace(/[<>:"/\\|?*]/g, "_").trim(); + } + + // Memory management and cleanup + cleanup() { + // Remove all bound event handlers + for (const [element, handler] of this.boundHandlers) { + if (element?.removeEventListener) { + element.removeEventListener("change", handler); + element.removeEventListener("hidden.bs.modal", handler); + } + } + this.boundHandlers.clear(); + console.log("CaptureTypeSelector cleanup completed"); + } + + resetForm() { + // Reset the form + const form = document.getElementById("uploadCaptureForm"); + if (form) { + form.reset(); + } + + // Hide input groups + this.hideInputGroups(); + + // Clear required attributes + this.clearRequiredAttributes(); + + // Clear global variables if they exist + this.cleanupGlobalState(); + } + + // Better global state management + cleanupGlobalState() { + const globalVars = ["filesToSkip", "fileCheckResults", "selectedFiles"]; + + for (const varName of globalVars) { + if (window[varName]) { + if (typeof window[varName].clear === "function") { + window[varName].clear(); + } else if (Array.isArray(window[varName])) { + window[varName].length = 0; + } else { + window[varName] = null; + } + console.log(`Cleaned up global variable: ${varName}`); + } + } + } +} + +/** + * Files Page Initialization + * Initializes modal managers, capture handlers, and user search components + */ +class FilesPageInitializer { + constructor() { + this.boundHandlers = new Map(); // Track event handlers for cleanup + this.activeHandlers = new Set(); // Track active component handlers + this.initializeComponents(); + } + + initializeComponents() { + try { + this.initializeModalManager(); + this.initializeCapturesTableManager(); + this.initializeUserSearchHandlers(); + } catch (error) { + ErrorHandler.showError( + "Failed to initialize page components", + "component-initialization", + error, + ); + } + } + + initializeModalManager() { + // Initialize ModalManager for capture modal + let modalManager = null; + try { + if (window.ModalManager) { + modalManager = new window.ModalManager({ + modalId: "capture-modal", + modalBodyId: "capture-modal-body", + modalTitleId: "capture-modal-label", + }); + + this.modalManager = modalManager; + console.log("ModalManager initialized successfully"); + } else { + ErrorHandler.showError( + "Modal functionality is not available. Some features may be limited.", + "modal-initialization", + ); + } + } catch (error) { + ErrorHandler.showError( + "Failed to initialize modal functionality", + "modal-initialization", + error, + ); + } + } + + initializeCapturesTableManager() { + // Initialize CapturesTableManager for capture edit/download functionality + try { + if (window.CapturesTableManager) { + window.capturesTableManager = new window.CapturesTableManager({ + modalHandler: this.modalManager, + }); + console.log("CapturesTableManager initialized successfully"); + } else { + ErrorHandler.showError( + "Table management functionality is not available. Some features may be limited.", + "table-initialization", + ); + } + } catch (error) { + ErrorHandler.showError( + "Failed to initialize table management functionality", + "table-initialization", + error, + ); + } + } + + initializeUserSearchHandlers() { + // Create a UserSearchHandler for each share modal + const shareModals = document.querySelectorAll(".modal[data-item-uuid]"); + + for (const modal of shareModals) { + this.setupUserSearchHandler(modal); + } + } + + setupUserSearchHandler(modal) { + try { + // Ensure boundHandlers and activeHandlers are initialized + if (!this.boundHandlers) { + this.boundHandlers = new Map(); + } + if (!this.activeHandlers) { + this.activeHandlers = new Set(); + } + + // Validate modal attributes + const itemUuid = modal.getAttribute("data-item-uuid"); + const itemType = modal.getAttribute("data-item-type"); + + if (!this.validateModalAttributes(itemUuid, itemType)) { + ErrorHandler.showError( + "Invalid modal configuration", + "user-search-setup", + ); + return; + } + + if (!window.UserSearchHandler) { + ErrorHandler.showError( + "User search functionality is not available", + "user-search-setup", + ); + return; + } + + const handler = new window.UserSearchHandler(); + // Store the handler on the modal element + modal.userSearchHandler = handler; + this.activeHandlers.add(handler); + + // Create bound event handlers for cleanup + const showHandler = () => { + if (modal.userSearchHandler) { + modal.userSearchHandler.setItemInfo(itemUuid, itemType); + modal.userSearchHandler.init(); + } + }; + + const hideHandler = () => { + if (modal.userSearchHandler) { + modal.userSearchHandler.resetAll(); + } + }; + + // Store handlers for cleanup + this.boundHandlers.set(modal, { show: showHandler, hide: hideHandler }); + + // On modal show, set the item info and call init() + modal.addEventListener("show.bs.modal", showHandler); + + // On modal hide, reset all selections and entered data + modal.addEventListener("hidden.bs.modal", hideHandler); + + console.log(`UserSearchHandler initialized for ${itemType}: ${itemUuid}`); + } catch (error) { + ErrorHandler.showError( + "Failed to setup user search functionality", + "user-search-setup", + error, + ); + } + } + + /** + * Get initialized modal manager + * @returns {Object|null} - The modal manager instance + */ + getModalManager() { + return this.modalManager; + } + + /** + * Get captures table manager + * @returns {Object|null} - The captures table manager instance + */ + getCapturesTableManager() { + return window.capturesTableManager; + } + + // Validation methods + validateModalAttributes(uuid, type) { + if (!uuid || typeof uuid !== "string") { + console.warn("Invalid UUID in modal attributes:", uuid); + return false; + } + + if (!type || typeof type !== "string") { + console.warn("Invalid type in modal attributes:", type); + return false; + } + + // Validate UUID format (basic check) + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(uuid)) { + console.warn("Invalid UUID format in modal attributes:", uuid); + return false; + } + + // Validate type + const validTypes = ["capture", "dataset", "file"]; + if (!validTypes.includes(type)) { + console.warn("Invalid type in modal attributes:", type); + return false; + } + + return true; + } + + // Memory management and cleanup + cleanup() { + // Remove all bound event handlers + for (const [element, handlers] of this.boundHandlers) { + if (element?.removeEventListener) { + if (handlers.show) { + element.removeEventListener("show.bs.modal", handlers.show); + } + if (handlers.hide) { + element.removeEventListener("hidden.bs.modal", handlers.hide); + } + } + } + this.boundHandlers.clear(); + + // Cleanup active handlers + for (const handler of this.activeHandlers) { + if (handler && typeof handler.cleanup === "function") { + try { + handler.cleanup(); + } catch (error) { + console.warn("Error during handler cleanup:", error); + } + } + } + this.activeHandlers.clear(); + + console.log("FilesPageInitializer cleanup completed"); + } +} + +// Initialize when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + // Check browser compatibility before proceeding + if (!BrowserCompatibility.checkRequiredFeatures()) { + ErrorHandler.showError( + "Your browser doesn't support required features. Please use a modern browser.", + "browser-compatibility", + ); + return; + } + + // Check Bootstrap support + if (!BrowserCompatibility.checkBootstrapSupport()) { + console.warn( + "Bootstrap not detected. Some UI features may not work properly.", + ); + } + + try { + // Check if we're on a page that needs these components + const needsCaptureSelector = + document.getElementById("captureTypeSelect") || + document.getElementById("uploadCaptureModal"); + const needsPageInitializer = + document.querySelector(".modal[data-item-uuid]") || + document.getElementById("capture-modal"); + + // Initialize capture type selector only if needed + let captureSelector = null; + if (needsCaptureSelector) { + captureSelector = new CaptureTypeSelector(); + } + + // Initialize page components only if needed + let filesPageInitializer = null; + if (needsPageInitializer) { + filesPageInitializer = new FilesPageInitializer(); + window.filesPageInitializer = filesPageInitializer; + } + + // Store references for cleanup + window.filesUICleanup = () => { + if (captureSelector && typeof captureSelector.cleanup === "function") { + captureSelector.cleanup(); + } + if ( + filesPageInitializer && + typeof filesPageInitializer.cleanup === "function" + ) { + filesPageInitializer.cleanup(); + } + }; + + console.log("Files UI initialized successfully", { + captureSelector: !!captureSelector, + pageInitializer: !!filesPageInitializer, + }); + } catch (error) { + ErrorHandler.showError( + "Failed to initialize Files UI components", + "initialization", + error, + ); + } +}); diff --git a/gateway/sds_gateway/static/js/files-upload.js b/gateway/sds_gateway/static/js/files-upload.js new file mode 100644 index 00000000..d6c53a52 --- /dev/null +++ b/gateway/sds_gateway/static/js/files-upload.js @@ -0,0 +1,755 @@ +/** + * Files Upload Handler + * Manages file upload functionality, BLAKE3 hashing, and progress tracking + */ + +/** + * BLAKE3 File Handler + * Manages file selection and BLAKE3 hash calculation for deduplication + */ +class Blake3FileHandler { + constructor() { + // Initialize global variables for file tracking + this.initializeGlobalVariables(); + this.setupEventListeners(); + } + + initializeGlobalVariables() { + // Global variables to track files that should be skipped + window.filesToSkip = new Set(); + window.fileCheckResults = new Map(); // Store detailed results for each file + } + + setupEventListeners() { + const modal = document.getElementById("uploadCaptureModal"); + if (!modal) { + console.warn("uploadCaptureModal not found"); + return; + } + + modal.addEventListener("shown.bs.modal", () => { + this.setupFileInputHandler(); + }); + } + + setupFileInputHandler() { + const fileInput = document.getElementById("captureFileInput"); + if (!fileInput) { + console.warn("captureFileInput not found"); + return; + } + + // Remove any previous handler to avoid duplicates + if (window._blake3CaptureHandler) { + fileInput.removeEventListener("change", window._blake3CaptureHandler); + } + + // Create file handler that stores selected files + window._blake3CaptureHandler = async (event) => { + await this.handleFileSelection(event); + }; + + fileInput.addEventListener("change", window._blake3CaptureHandler); + } + + async handleFileSelection(event) { + const files = event.target.files; + if (!files || files.length === 0) { + return; + } + + // Store the selected files for later processing + window.selectedFiles = Array.from(files); + + console.log(`Selected ${files.length} files for upload`); + } + + /** + * Calculate BLAKE3 hash for a file + * @param {File} file - The file to hash + * @returns {Promise} - The BLAKE3 hash in hex format + */ + async calculateBlake3Hash(file) { + try { + const buffer = await file.arrayBuffer(); + const hasher = await hashwasm.createBLAKE3(); + hasher.init(); + hasher.update(new Uint8Array(buffer)); + return hasher.digest("hex"); + } catch (error) { + console.error("Error calculating BLAKE3 hash:", error); + throw error; + } + } + + /** + * Get directory path from webkitRelativePath + * @param {File} file - The file to get directory for + * @returns {string} - The directory path + */ + getDirectoryPath(file) { + if (!file.webkitRelativePath) { + return "/"; + } + + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); // Remove filename + return `/${pathParts.join("/")}`; + } + + return "/"; + } + + /** + * Check if a file exists on the server + * @param {File} file - The file to check + * @param {string} hash - The BLAKE3 hash of the file + * @returns {Promise} - The server response + */ + async checkFileExists(file, hash) { + const directory = this.getDirectoryPath(file); + + const checkData = { + directory: directory, + filename: file.name, + checksum: hash, + }; + + try { + const response = await fetch(window.checkFileExistsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": window.csrfToken, + }, + body: JSON.stringify(checkData), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error("Error checking file existence:", error); + throw error; + } + } + + /** + * Process a single file for duplicate checking + * @param {File} file - The file to process + * @returns {Promise} - Processing result + */ + async processFileForDuplicateCheck(file) { + try { + // Calculate hash + const hash = await this.calculateBlake3Hash(file); + + // Check if file exists + const checkResult = await this.checkFileExists(file, hash); + + // Store results + const directory = this.getDirectoryPath(file); + const fileKey = `${directory}/${file.name}`; + + const result = { + file: file, + directory: directory, + filename: file.name, + checksum: hash, + data: checkResult.data, + }; + + window.fileCheckResults.set(fileKey, result); + + // Mark for skipping if file exists + if (checkResult.data && checkResult.data.file_exists_in_tree === true) { + window.filesToSkip.add(fileKey); + } + + return result; + } catch (error) { + console.error("Error processing file for duplicate check:", error); + return null; + } + } +} + +/** + * Files Upload Modal Handler + * Manages file upload functionality, progress tracking, and chunked uploads + */ +class FilesUploadModal { + constructor() { + this.isProcessing = false; + this.uploadInProgress = false; + this.cancelRequested = false; + this.currentAbortController = null; + + this.initializeElements(); + this.setupEventListeners(); + this.clearExistingModals(); + } + + initializeElements() { + this.cancelButton = document.querySelector( + "#uploadCaptureModal .btn-secondary", + ); + this.submitButton = document.getElementById("uploadSubmitBtn"); + this.uploadModal = document.getElementById("uploadCaptureModal"); + this.fileInput = document.getElementById("captureFileInput"); + this.uploadForm = document.getElementById("uploadCaptureForm"); + } + + setupEventListeners() { + // Modal event listeners + if (this.uploadModal) { + this.uploadModal.addEventListener("show.bs.modal", () => + this.resetState(), + ); + this.uploadModal.addEventListener("hidden.bs.modal", () => + this.resetState(), + ); + } + + // File input change listener + if (this.fileInput) { + this.fileInput.addEventListener("change", () => this.resetState()); + } + + // Cancel button listener + if (this.cancelButton) { + this.cancelButton.addEventListener("click", () => this.handleCancel()); + } + + // Form submit listener + if (this.uploadForm) { + this.uploadForm.addEventListener("submit", (e) => this.handleSubmit(e)); + } + } + + clearExistingModals() { + const existingResultModal = document.getElementById("uploadResultModal"); + if (existingResultModal) { + const modalInstance = bootstrap.Modal.getInstance(existingResultModal); + if (modalInstance) { + modalInstance.hide(); + } + } + } + + resetState() { + this.isProcessing = false; + this.currentAbortController = null; + this.cancelRequested = false; + } + + handleCancel() { + if (this.isProcessing) { + this.cancelRequested = true; + + if (this.currentAbortController) { + this.currentAbortController.abort(); + } + + this.cancelButton.textContent = "Cancelling..."; + this.cancelButton.disabled = true; + + const progressMessage = document.getElementById("progressMessage"); + if (progressMessage) { + progressMessage.textContent = "Cancelling upload..."; + } + + setTimeout(() => { + if (this.cancelRequested) { + this.resetUIState(); + } + }, 500); + } + } + + async handleSubmit(e) { + e.preventDefault(); + + this.isProcessing = true; + this.uploadInProgress = true; + this.cancelRequested = false; + + // Check if files are selected + if (!window.selectedFiles || window.selectedFiles.length === 0) { + alert("Please select files to upload."); + return; + } + + try { + this.showProgressSection(); + await this.checkFilesForDuplicates(); + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + + await this.uploadFiles(); + } catch (error) { + this.handleError(error); + } finally { + this.resetUIState(); + } + } + + showProgressSection() { + const progressSection = document.getElementById("checkingProgressSection"); + const progressMessage = document.getElementById("progressMessage"); + + if (progressSection) { + progressSection.style.display = "block"; + } + if (progressMessage) { + progressMessage.textContent = "Checking files for duplicates..."; + } + + this.cancelButton.textContent = "Cancel Processing"; + this.cancelButton.classList.add("btn-warning"); + this.submitButton.disabled = true; + } + + async checkFilesForDuplicates() { + window.filesToSkip = new Set(); + window.fileCheckResults = new Map(); + const files = window.selectedFiles; + const totalFiles = files.length; + + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + + for (let i = 0; i < files.length; i++) { + if (this.cancelRequested) break; + + const file = files[i]; + const progress = Math.round(((i + 1) / totalFiles) * 100); + + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `${progress}%`; + + await this.processFile(file); + } + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + } + + async processFile(file) { + // Calculate BLAKE3 hash + const buffer = await file.arrayBuffer(); + const hasher = await hashwasm.createBLAKE3(); + hasher.init(); + hasher.update(new Uint8Array(buffer)); + const hashHex = hasher.digest("hex"); + + // Calculate directory path + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + + // Check if file exists + const checkData = { + directory: directory, + filename: file.name, + checksum: hashHex, + }; + + try { + const response = await fetch(window.checkFileExistsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": window.csrfToken, + }, + body: JSON.stringify(checkData), + }); + const data = await response.json(); + + const fileKey = `${directory}/${file.name}`; + window.fileCheckResults.set(fileKey, { + file: file, + directory: directory, + filename: file.name, + checksum: hashHex, + data: data.data, + }); + + if (data.data && data.data.file_exists_in_tree === true) { + window.filesToSkip.add(fileKey); + } + } catch (error) { + console.error("Error checking file:", error); + } + } + + async uploadFiles() { + const progressMessage = document.getElementById("progressMessage"); + const progressSection = document.getElementById("checkingProgressSection"); + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + + if (progressMessage) { + progressMessage.textContent = "Uploading files and creating captures..."; + } + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + + const files = window.selectedFiles; + const filesToUpload = []; + const relativePathsToUpload = []; + const allRelativePaths = []; + + // Process files for upload + for (const file of files) { + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + const fileKey = `${directory}/${file.name}`; + const relativePath = file.webkitRelativePath || file.name; + + console.debug( + `Processing file: ${file.name}, webkitRelativePath: '${file.webkitRelativePath}', relativePath: '${relativePath}', directory: '${directory}'`, + ); + allRelativePaths.push(relativePath); + + if (!window.filesToSkip.has(fileKey)) { + filesToUpload.push(file); + relativePathsToUpload.push(relativePath); + } + } + + console.debug( + "All relative paths being sent:", + allRelativePaths.slice(0, 5), + ); + console.debug( + "Relative paths to upload:", + relativePathsToUpload.slice(0, 5), + ); + + if (filesToUpload.length > 0 && progressSection) { + progressSection.style.display = "block"; + } + + this.currentAbortController = new AbortController(); + + let result; + if (filesToUpload.length === 0) { + result = await this.uploadSkippedFiles(allRelativePaths); + } else { + result = await this.uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + ); + } + + this.currentAbortController = null; + this.showUploadResults(result, result.saved_files_count, files.length); + } + + async uploadSkippedFiles(allRelativePaths) { + const formData = new FormData(); + + console.debug( + "uploadSkippedFiles - allRelativePaths:", + allRelativePaths.slice(0, 5), + ); + for (const path of allRelativePaths) { + formData.append("all_relative_paths", path); + } + + this.addCaptureTypeData(formData); + + const response = await fetch(window.uploadFilesUrl, { + method: "POST", + body: formData, + signal: this.currentAbortController.signal, + }); + + return await response.json(); + } + + async uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + ) { + const CHUNK_SIZE = 5; + const totalFiles = filesToUpload.length; + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + const allResults = { + file_upload_status: "success", + saved_files_count: 0, + captures: [], + errors: [], + message: "", + }; + + for (let i = 0; i < filesToUpload.length; i += CHUNK_SIZE) { + if (this.cancelRequested) break; + + const chunk = filesToUpload.slice(i, i + CHUNK_SIZE); + const chunkPaths = relativePathsToUpload.slice(i, i + CHUNK_SIZE); + + const totalChunks = Math.ceil(filesToUpload.length / CHUNK_SIZE); + const currentChunk = Math.floor(i / CHUNK_SIZE) + 1; + const isFinalChunk = currentChunk === totalChunks; + + // Update progress + const progress = Math.round(((i + chunk.length) / totalFiles) * 100); + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `${progress}%`; + if (progressMessage) { + progressMessage.textContent = `Uploading files ${i + 1}-${Math.min(i + CHUNK_SIZE, totalFiles)} of ${totalFiles} (chunk ${currentChunk}/${totalChunks})...`; + } + + const chunkResult = await this.uploadChunk( + chunk, + chunkPaths, + allRelativePaths, + currentChunk, + totalChunks, + ); + + // Merge results + if (chunkResult.saved_files_count !== undefined) { + allResults.saved_files_count += chunkResult.saved_files_count; + } + if (chunkResult.captures && isFinalChunk) { + allResults.captures = allResults.captures.concat(chunkResult.captures); + } + if (chunkResult.errors) { + allResults.errors = allResults.errors.concat(chunkResult.errors); + } + + if (chunkResult.file_upload_status === "error") { + allResults.file_upload_status = "error"; + allResults.message = chunkResult.message || "Upload failed"; + break; + } + + if (chunkResult.file_upload_status === "success" && isFinalChunk) { + allResults.file_upload_status = "success"; + } + } + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + + return allResults; + } + + async uploadChunk( + chunk, + chunkPaths, + allRelativePaths, + currentChunk, + totalChunks, + ) { + const formData = new FormData(); + + console.debug( + `uploadChunk ${currentChunk}/${totalChunks} - chunkPaths:`, + chunkPaths, + ); + console.debug( + `uploadChunk ${currentChunk}/${totalChunks} - allRelativePaths (first 5):`, + allRelativePaths.slice(0, 5), + ); + + for (const file of chunk) { + formData.append("files", file); + } + for (const path of chunkPaths) { + formData.append("relative_paths", path); + } + for (const path of allRelativePaths) { + formData.append("all_relative_paths", path); + } + + this.addCaptureTypeData(formData); + + formData.append("is_chunk", "true"); + formData.append("chunk_number", currentChunk.toString()); + formData.append("total_chunks", totalChunks.toString()); + + const controller = new AbortController(); + this.currentAbortController = controller; + const timeoutId = setTimeout(() => controller.abort(), 300000); + + try { + const response = await fetch(window.uploadFilesUrl, { + method: "POST", + body: formData, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error.name === "AbortError") { + throw new Error("Upload timeout - connection may be lost"); + } + throw error; + } + } + + addCaptureTypeData(formData) { + const captureType = document.getElementById("captureTypeSelect").value; + formData.append("capture_type", captureType); + + if (captureType === "drf") { + const channels = document.getElementById("captureChannelsInput").value; + formData.append("channels", channels); + } else if (captureType === "rh") { + const scanGroup = document.getElementById("captureScanGroupInput").value; + formData.append("scan_group", scanGroup); + } + } + + handleError(error) { + if (this.cancelRequested) { + alert( + "Upload cancelled. Any files uploaded before cancellation have been saved.", + ); + setTimeout(() => window.location.reload(), 1000); + } else if (error.name === "AbortError") { + alert( + "Upload was interrupted. Any files uploaded before the interruption have been saved.", + ); + setTimeout(() => window.location.reload(), 1000); + } else if (error.name === "TypeError" && error.message.includes("fetch")) { + alert( + "Network error during upload. Please check your connection and try again.", + ); + } else { + alert(`Upload failed: ${error.message}`); + setTimeout(() => window.location.reload(), 1000); + } + } + + resetUIState() { + this.submitButton.disabled = false; + + const progressSection = document.getElementById("checkingProgressSection"); + if (progressSection) { + progressSection.style.display = "none"; + } + + this.cancelButton.textContent = "Cancel"; + this.cancelButton.classList.remove("btn-warning"); + this.cancelButton.disabled = false; + + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + if (progressMessage) progressMessage.textContent = ""; + + this.isProcessing = false; + this.uploadInProgress = false; + this.cancelRequested = false; + this.currentAbortController = null; + } + + showUploadResults(result, uploadedCount, totalCount) { + const uploadModal = bootstrap.Modal.getInstance(this.uploadModal); + if (uploadModal) { + uploadModal.hide(); + } + + if (result.file_upload_status === "success") { + const uploaded = uploadedCount ?? 0; + const message = `Upload complete: ${uploaded} / ${totalCount} file${totalCount === 1 ? "" : "s"} uploaded.`; + try { + sessionStorage.setItem( + "filesAlert", + JSON.stringify({ message: message, type: "success" }), + ); + } catch (_) {} + setTimeout(() => window.location.reload(), 500); + } else { + this.showErrorModal(result); + } + } + + showErrorModal(result) { + const modalBody = document.getElementById("uploadResultModalBody"); + const resultModalEl = document.getElementById("uploadResultModal"); + const modal = new bootstrap.Modal(resultModalEl); + + let msg = "Upload Failed
"; + if (result.message) { + msg += `${result.message}

`; + } + msg += "Please remove the problematic files and try again."; + + if (result.errors && result.errors.length > 0) { + const errs = result.errors.map((e) => `
  • ${e}
  • `).join(""); + msg += `

    Error Details:
      ${errs}
    `; + } + + modalBody.innerHTML = msg; + modal.show(); + } +} + +// Initialize when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + // Set up session storage alert handling + const key = "filesAlert"; + const stored = sessionStorage.getItem(key); + if (stored) { + try { + const data = JSON.parse(stored); + if ( + window.components && + typeof window.components.showError === "function" && + data?.type === "error" + ) { + window.components.showError(data.message || "An error occurred."); + } else if ( + window.components && + typeof window.components.showSuccess === "function" && + data?.type === "success" + ) { + window.components.showSuccess(data.message || "Success"); + } + } catch (e) {} + sessionStorage.removeItem(key); + } + + // Initialize BLAKE3 handler first, then upload modal + new Blake3FileHandler(); + new FilesUploadModal(); +}); diff --git a/gateway/sds_gateway/static/js/userSearchComponent.js b/gateway/sds_gateway/static/js/userSearchComponent.js index 8d47564d..ed152d69 100644 --- a/gateway/sds_gateway/static/js/userSearchComponent.js +++ b/gateway/sds_gateway/static/js/userSearchComponent.js @@ -1068,66 +1068,10 @@ class UserSearchHandler { // Close modal first closeCustomModal("downloadModal"); - // Show loading state - button.innerHTML = - ' Processing...'; - button.disabled = true; - - // Make API request - fetch(`/users/download-item/dataset/${datasetUuid}/`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": this.getCSRFToken(), - }, - }) - .then((response) => { - // Check if response is JSON - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - return response.json(); - } - // If not JSON, throw an error with the response text - return response.text().then((text) => { - throw new Error(`Server returned non-JSON response: ${text}`); - }); - }) - .then((data) => { - if (data.success === true) { - button.innerHTML = - ' Download Requested'; - showAlert( - data.message || - "Download request submitted successfully! You will receive an email when ready.", - "success", - ); - } else { - button.innerHTML = - ' Request Failed'; - showAlert( - data.message || - "Download request failed. Please try again.", - "error", - ); - } - }) - .catch((error) => { - console.error("Download error:", error); - button.innerHTML = - ' Request Failed'; - showAlert( - error.message || - "An error occurred while processing your request.", - "error", - ); - }) - .finally(() => { - // Reset button after 3 seconds - setTimeout(() => { - button.innerHTML = "Download"; - button.disabled = false; - }, 3000); - }); + // Use unified download handler + if (window.components?.handleDownload) { + window.components.handleDownload("dataset", datasetUuid, button); + } }; }); } diff --git a/gateway/sds_gateway/templates/base.html b/gateway/sds_gateway/templates/base.html index 2f48de01..8bf61995 100644 --- a/gateway/sds_gateway/templates/base.html +++ b/gateway/sds_gateway/templates/base.html @@ -83,6 +83,9 @@ + diff --git a/gateway/sds_gateway/templates/users/file_list.html b/gateway/sds_gateway/templates/users/file_list.html index 0befd0f7..53902477 100644 --- a/gateway/sds_gateway/templates/users/file_list.html +++ b/gateway/sds_gateway/templates/users/file_list.html @@ -13,6 +13,8 @@ + +