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 = `
-
-
- ${ComponentUtils.escapeHtml(message)}
-
-
- `;
-
- 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 = `
-
-
- ${ComponentUtils.escapeHtml(message)}
-
-
- `;
-
- 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 = `
+
+
+ ${ComponentUtils.escapeHtml(message)}
+
+
`;
+ setTimeout(() => {
+ const alert = container.querySelector(".alert");
+ if (alert) alert.remove();
+ }, 5000);
+ },
+ showError(message) {
+ writeAriaLive(message);
+ const container = ensureAlertContainer();
+ container.innerHTML = `
+
+
+ ${ComponentUtils.escapeHtml(message)}
+
+
`;
+ 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