diff --git a/.gitignore b/.gitignore
index c5ba5117..fb1fed7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+### Server
+
# Byte-compiled / optimized / DLL files
#User added
TEKDB/TEKDB/local_settings.py
@@ -98,4 +100,33 @@ ENV/
.ropeproject
#vscode settings
-.vscode/
\ No newline at end of file
+.vscode/
+
+### Client
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# client related files
+.react-router/
\ No newline at end of file
diff --git a/TEKDB/TEKDB/models.py b/TEKDB/TEKDB/models.py
index a4dd8d3a..84040d6c 100644
--- a/TEKDB/TEKDB/models.py
+++ b/TEKDB/TEKDB/models.py
@@ -2833,7 +2833,7 @@ def image(self):
return settings.RECORD_ICONS["media"]
def subtitle(self):
- return self.mediatype
+ return str(self.mediatype)
def link(self):
return "/explore/media/%d/" % self.pk
diff --git a/TEKDB/TEKDB/settings.py b/TEKDB/TEKDB/settings.py
index f6f4e8c0..e219d4ca 100644
--- a/TEKDB/TEKDB/settings.py
+++ b/TEKDB/TEKDB/settings.py
@@ -56,6 +56,7 @@
"django.contrib.staticfiles",
"django.contrib.gis",
"colorfield",
+ "corsheaders",
# 'registration',
"leaflet",
"nested_admin",
@@ -70,11 +71,13 @@
"Relationships",
"reversion",
"django.contrib.sites",
+ "rest_framework",
# 'moderation.apps.SimpleModerationConfig',
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
+ "corsheaders.middleware.CorsMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
@@ -86,6 +89,30 @@
ROOT_URLCONF = "TEKDB.urls"
+# Cross Origin Resource Sharing (CORS)
+CORS_ALLOW_CREDENTIALS = True
+
+CORS_ALLOWED_ORIGINS = [
+ "http://localhost:5173",
+]
+
+# CSRF trusted origins must include scheme and port for cross-site requests
+CSRF_TRUSTED_ORIGINS = [
+ "http://localhost:5173",
+]
+
+# CSRF Cookie settings for cross-origin requests
+CSRF_COOKIE_SAMESITE = "Lax"
+CSRF_COOKIE_HTTPONLY = False # Allow JavaScript to read the cookie
+SESSION_COOKIE_SAMESITE = "Lax"
+
+REST_FRAMEWORK = {
+ "DEFAULT_AUTHENTICATION_CLASSES": [
+ "rest_framework.authentication.BasicAuthentication",
+ "rest_framework.authentication.SessionAuthentication",
+ ],
+}
+
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
diff --git a/TEKDB/entrypoint.sh b/TEKDB/entrypoint.sh
index 166c5378..5829e0c4 100644
--- a/TEKDB/entrypoint.sh
+++ b/TEKDB/entrypoint.sh
@@ -34,4 +34,5 @@ echo "Starting uWSGI (HTTP) on :8000"
# Use HTTP socket so direct HTTP clients (browsers) can connect to the container port.
# If you proxy with nginx using the uwsgi protocol, switch back to --socket and use
# uwsgi_pass in nginx configuration.
-uwsgi --http :8000 --master --enable-threads --module TEKDB.wsgi
\ No newline at end of file
+# uwsgi --http :8000 --master --enable-threads --module TEKDB.wsgi
+python manage.py runserver 0.0.0.0:8000
\ No newline at end of file
diff --git a/TEKDB/explore/API/serializers.py b/TEKDB/explore/API/serializers.py
new file mode 100644
index 00000000..29ab2763
--- /dev/null
+++ b/TEKDB/explore/API/serializers.py
@@ -0,0 +1,23 @@
+from ..models import PageContent
+from rest_framework import serializers
+
+
+class PageContentSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = PageContent
+ fields = ["page", "content", "is_html", "html_content"]
+ read_only_fields = ["page"] # Make 'page' read-only to prevent changes
+
+
+class SiteConfigurationSerializer(serializers.Serializer):
+ proj_logo_text = serializers.CharField(max_length=100)
+ proj_text_placement = serializers.CharField(max_length=50)
+ proj_css = serializers.DictField(child=serializers.CharField(max_length=100))
+ proj_icons = serializers.DictField(child=serializers.CharField(max_length=100))
+ proj_image_select = serializers.CharField(max_length=100)
+ home_image_attribution = serializers.CharField(max_length=255, allow_blank=True)
+ home_font_color = serializers.CharField(max_length=7)
+ homepage_left_background = serializers.CharField(max_length=7)
+ homepage_right_background = serializers.CharField(max_length=7)
+ map_pin = serializers.CharField(max_length=100)
+ map_pin_selected = serializers.CharField(max_length=100)
diff --git a/TEKDB/explore/API/views.py b/TEKDB/explore/API/views.py
new file mode 100644
index 00000000..d0f82d3b
--- /dev/null
+++ b/TEKDB/explore/API/views.py
@@ -0,0 +1,726 @@
+from django.http import Http404, HttpResponse
+from django.middleware.csrf import get_token
+from rest_framework import permissions, viewsets, status
+from rest_framework.response import Response
+from rest_framework.views import APIView
+# from rest_framework.generics import GenericAPIView
+
+from .serializers import PageContentSerializer, SiteConfigurationSerializer
+from ..models import PageContent
+
+
+class PageContentViewSet(viewsets.ModelViewSet):
+ """
+ CRUD for PageContent via DRF.
+ """
+
+ serializer_class = PageContentSerializer
+ queryset = PageContent.objects.all()
+ permission_classes = [permissions.AllowAny]
+
+
+class SiteConfigurationAPIView(APIView):
+ permission_classes = [permissions.AllowAny]
+
+ def get(self, request):
+ from ..context_processors import explore_context
+
+ # Get configuration data from context processor
+ config_data = explore_context(request)
+
+ serializer = SiteConfigurationSerializer(config_data)
+ return Response(serializer.data)
+
+
+class CsrfTokenAPIView(APIView):
+ """
+ Return CSRF token for the client to use in subsequent requests.
+ """
+
+ permission_classes = [permissions.AllowAny]
+
+ def get(self, request):
+ csrf_token = get_token(request)
+ response = Response({"csrfToken": csrf_token})
+ # Ensure the CSRF cookie is set in the response
+ response.set_cookie(
+ key="csrftoken",
+ value=csrf_token,
+ max_age=86400, # 1 day
+ httponly=False, # Allow JavaScript to read it
+ samesite="Lax",
+ )
+ return response
+
+
+# ----- Utility functions ported from template views -----
+def _get_project_geography():
+ from TEKDB.settings import DATABASE_GEOGRAPHY
+
+ return DATABASE_GEOGRAPHY
+
+
+def _get_model_by_type(model_type):
+ from TEKDB import models as tekmodels
+
+ searchable_models = {
+ "resources": [tekmodels.Resources],
+ "places": [tekmodels.Places],
+ "locality": [tekmodels.Locality],
+ "sources": [tekmodels.Citations],
+ "citations": [tekmodels.Citations],
+ "media": [tekmodels.Media],
+ "activities": [tekmodels.ResourcesActivityEvents],
+ "relationships": [
+ tekmodels.LocalityPlaceResourceEvent,
+ tekmodels.MediaCitationEvents,
+ tekmodels.PlacesCitationEvents,
+ tekmodels.PlacesMediaEvents,
+ tekmodels.PlacesResourceCitationEvents,
+ tekmodels.PlacesResourceEvents,
+ tekmodels.PlacesResourceMediaEvents,
+ tekmodels.ResourceActivityCitationEvents,
+ tekmodels.ResourceActivityMediaEvents,
+ tekmodels.ResourceResourceEvents,
+ tekmodels.ResourcesCitationEvents,
+ tekmodels.ResourcesMediaEvents,
+ ],
+ "localityplaceresourceevents": [tekmodels.LocalityPlaceResourceEvent],
+ "mediacitationevents": [tekmodels.MediaCitationEvents],
+ "placescitationevents": [tekmodels.PlacesCitationEvents],
+ "placesmediaevents": [tekmodels.PlacesMediaEvents],
+ "placesresourcecitationevents": [tekmodels.PlacesResourceCitationEvents],
+ "placesresourceevents": [tekmodels.PlacesResourceEvents],
+ "placesresourcemediaevents": [tekmodels.PlacesResourceMediaEvents],
+ "resourceactivitycitationevents": [tekmodels.ResourceActivityCitationEvents],
+ "resourceactivitymediaevents": [tekmodels.ResourceActivityMediaEvents],
+ "resourceresourceevents": [tekmodels.ResourceResourceEvents],
+ "resourcesactivityevents": [tekmodels.ResourcesActivityEvents],
+ "resourcescitationevents": [tekmodels.ResourcesCitationEvents],
+ "resourcesmediaevents": [tekmodels.ResourcesMediaEvents],
+ "people": [tekmodels.People],
+ }
+
+ if model_type.lower() in searchable_models.keys():
+ return searchable_models[model_type.lower()]
+ elif model_type.lower() == "all":
+ return sum(
+ [
+ searchable_models[key]
+ for key in ["resources", "places", "sources", "media", "activities"]
+ ],
+ [],
+ )
+ else:
+ return []
+
+
+# ----- Page content endpoints -----
+class PageContentSingle(APIView):
+ """Return single page content by name (Home/About/Help)."""
+
+ permission_classes = [permissions.AllowAny]
+
+ def get(self, request, name: str):
+ try:
+ page_content_obj = PageContent.objects.get(page=name)
+ page_content = (
+ page_content_obj.html_content
+ if page_content_obj.is_html
+ else page_content_obj.content
+ )
+ except Exception:
+ page_content = f"
{name}
Set {name} Page Content In Admin
"
+
+ return Response(
+ {
+ "page": name.lower(),
+ "pageTitle": name,
+ "pageContent": page_content,
+ }
+ )
+
+
+class ExploreByType(APIView):
+ """Explore for a specific model type."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get(self, request, model_type):
+ return Response(
+ {
+ "query": "",
+ "category": model_type,
+ "page": "Results",
+ "pageTitle": "Results",
+ "pageContent": "Your search results:
",
+ "user": str(request.user),
+ }
+ )
+
+
+# ----- Explore/search endpoints -----
+class ExploreSearch(APIView):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_verbose_field_name(self, model, field_path):
+ parts = field_path.split("__")
+ field = None
+ current_model = model
+ for part in parts:
+ field = current_model._meta.get_field(part)
+ if hasattr(field, "related_model") and field.related_model:
+ current_model = field.related_model
+ return (
+ field.verbose_name.title()
+ if field
+ else field_path.replace("_", " ").title()
+ )
+
+ def remove_match_prefix(self, string):
+ if string and string.startswith("match_"):
+ return string[6:]
+ return string
+
+ def find_match_attributes(self, obj):
+ return [attr for attr in dir(obj) if "match" in attr]
+
+ def get_greatest_similarity_attribute(self, result, pks):
+ greatest_similarity_attribute = None
+ matching_attributes = []
+ for match_attr in self.find_match_attributes(result):
+ match_value = getattr(result, match_attr)
+ if match_value is not None and match_value == result.similarity:
+ matching_attributes.append(match_attr)
+ if len(matching_attributes) == 1:
+ greatest_similarity_attribute = matching_attributes[0]
+ elif len(matching_attributes) > 0:
+ num_same_id = pks.get(result.pk, 1)
+ if num_same_id - 1 < len(matching_attributes):
+ greatest_similarity_attribute = matching_attributes[num_same_id - 1]
+ pks[result.pk] = max(0, num_same_id - 1)
+ else:
+ greatest_similarity_attribute = matching_attributes[0]
+ return greatest_similarity_attribute
+
+ def get_results(self, keyword_string, categories):
+ if keyword_string is None:
+ keyword_string = ""
+ resultlist = []
+ for category in categories:
+ query_models = _get_model_by_type(category)
+ for model in query_models:
+ model_results = model.keyword_search(keyword_string)
+ pks = {}
+ for result in model_results:
+ if hasattr(result, "pk"):
+ pks[result.pk] = pks.get(result.pk, 0) + 1
+ for result in model_results:
+ greatest_attr = self.get_greatest_similarity_attribute(result, pks)
+ actual_attribute = (
+ self.remove_match_prefix(greatest_attr)
+ if greatest_attr
+ else None
+ )
+ verbose_name = (
+ self.get_verbose_field_name(model, actual_attribute)
+ if actual_attribute
+ else None
+ )
+ headline_key = (
+ f"headline_{actual_attribute}" if actual_attribute else None
+ )
+ headline_value = (
+ getattr(result, headline_key)
+ if headline_key and hasattr(result, headline_key)
+ else None
+ )
+ result_json = result.get_response_format()
+ if keyword_string != "":
+ result_json["rank"] = result.rank
+ result_json["similarity"] = result.similarity
+ result_json["headline"] = (
+ f"{verbose_name}: {headline_value}
"
+ if headline_value and verbose_name
+ else None
+ )
+ else:
+ result_json["rank"] = 0
+ result_json["similarity"] = 0
+ result_json["headline"] = None
+ resultlist.append(result_json)
+ return sorted(
+ resultlist, key=lambda res: (res["rank"], res["similarity"]), reverse=True
+ )
+
+ def get(self, request):
+ all_categories = ["places", "resources", "activities", "sources", "media"]
+ query_string = request.GET.get("query")
+ categories = (
+ request.GET.getlist("category") or [request.GET.get("category")]
+ if request.GET.get("category")
+ else []
+ )
+ if categories == []:
+ # derive from flags or default to all
+ for key in all_categories:
+ if request.GET.get(key) == "true":
+ categories.append(key)
+ if categories == []:
+ categories = ["all"]
+ # sanitize categories
+ # Zero tolerance for mispelled or 'all' categories. if it's not perfect, fail to 'all'
+ for category in categories:
+ if category not in all_categories:
+ categories = all_categories
+ break
+ results = self.get_results(query_string, categories)
+
+ # cap results by config
+ try:
+ from configuration.models import Configuration
+
+ max_results = Configuration.objects.all()[0].max_results_returned
+ except Exception:
+ from TEKDB.settings import DEFAULT_MAXIMUM_RESULTS
+
+ max_results = DEFAULT_MAXIMUM_RESULTS
+
+ too_many = len(results) > max_results
+ if too_many:
+ results = results[:max_results]
+
+ geo = _get_project_geography()
+ return Response(
+ {
+ "results": results,
+ "categories": categories,
+ "query": query_string,
+ "too_many_results": too_many,
+ "map": {
+ "default_lon": geo["default_lon"],
+ "default_lat": geo["default_lat"],
+ "default_zoom": geo["default_zoom"],
+ "min_zoom": geo["min_zoom"],
+ "max_zoom": geo["max_zoom"],
+ "extent": geo["map_extent"],
+ },
+ }
+ )
+
+
+class ExploreById(APIView):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get(self, request, model_type, id):
+ models = _get_model_by_type(model_type)
+ if len(models) != 1:
+ return Response(
+ {"error": f"Incorrect number of models for {model_type}"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ model = models[0]
+ try:
+ obj = model.objects.get(pk=int(id))
+ record_dict = obj.get_record_dict(request.user, 3857)
+ except Exception:
+ raise Http404
+ geo_info_needed = False
+ if ("map" in record_dict and record_dict["map"] is not None) or any(
+ rel.get("key") == "Place-Resource Events"
+ and any((val.get("map") is not None) for val in rel.get("value", []))
+ for rel in record_dict.get("relationships", [])
+ ):
+ geo_info_needed = True
+
+ payload = {"record": record_dict, "model": model_type, "id": id}
+ if geo_info_needed:
+ geo = _get_project_geography()
+ payload["map"] = {
+ "default_lon": geo["default_lon"],
+ "default_lat": geo["default_lat"],
+ "default_zoom": geo["default_zoom"],
+ "min_zoom": geo["min_zoom"],
+ "max_zoom": geo["max_zoom"],
+ "extent": geo["map_extent"],
+ }
+ return Response(payload)
+
+
+class ExportRecord(APIView):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get(self, request, model_type, id, format):
+ models = _get_model_by_type(model_type)
+ if len(models) != 1:
+ return Response(
+ {"error": f"Incorrect number of models for {model_type}"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ model = models[0]
+ try:
+ obj = model.objects.get(pk=id)
+ record_dict = obj.get_record_dict(request.user, 4326)
+ except Exception as e:
+ return Response(
+ {"error": "unknown error", "code": f"{e}"},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+
+ filename = f"{model_type}_{id}_{record_dict.get('name', 'record')}"
+ if format == "xls":
+ # XLSX export
+ import io
+ from xlsxwriter.workbook import Workbook
+
+ output = io.BytesIO()
+ workbook = Workbook(output, {"in_membory": True})
+ worksheet = workbook.add_worksheet()
+ workbook.add_format({"bold": True})
+ row = 0
+
+ # helper for ordered keys
+ def get_sorted_keys(keys):
+ ordered = []
+ for key in [
+ "name",
+ "image",
+ "subtitle",
+ "data",
+ "relationships",
+ "map",
+ "link",
+ "enteredbyname",
+ "enteredbydate",
+ "modifiedbyname",
+ "modifiedbydate",
+ ]:
+ if key in keys:
+ keys.remove(key)
+ ordered.append(key)
+ return ordered + keys
+
+ for key in get_sorted_keys(list(record_dict.keys())):
+ field = record_dict[key]
+ if (
+ isinstance(field, list)
+ and len(field) > 0
+ and isinstance(field[0], dict)
+ ):
+ for item in field:
+ if "key" in item and "value" in item and len(item.keys()) == 2:
+ if (
+ isinstance(item["value"], list)
+ and len(item["value"]) > 0
+ ):
+ for sub_item in item["value"]:
+ worksheet.write(row, 0, f"{key} - {item['key']}")
+ worksheet.write(
+ row, 1, sub_item.get("name", str(sub_item))
+ )
+ row += 1
+ else:
+ worksheet.write(row, 0, f"{key} - {item['key']}")
+ try:
+ worksheet.write(row, 1, str(item["value"]))
+ except Exception:
+ pass
+ row += 1
+ else:
+ for list_key in item.keys():
+ worksheet.write(row, 0, f"{key} - {list_key}")
+ worksheet.write(row, 1, item[list_key])
+ row += 1
+ else:
+ worksheet.write(row, 0, key)
+ worksheet.write(row, 1, str(field))
+ row += 1
+ workbook.close()
+ output.seek(0)
+ resp = HttpResponse(
+ output.read(),
+ content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ )
+ resp["Content-Disposition"] = f'attachment; filename="{filename}.xlsx"'
+ return resp
+ else:
+ # CSV export
+ import csv
+
+ csv_response = HttpResponse(content_type="text/csv")
+ csv_response["Content-Disposition"] = (
+ f'attachment; filename="{filename}.csv"'
+ )
+ writer = csv.writer(csv_response)
+
+ def get_sorted_keys(keys):
+ ordered = []
+ for key in [
+ "name",
+ "image",
+ "subtitle",
+ "data",
+ "relationships",
+ "map",
+ "link",
+ "enteredbyname",
+ "enteredbydate",
+ "modifiedbyname",
+ "modifiedbydate",
+ ]:
+ if key in keys:
+ keys.remove(key)
+ ordered.append(key)
+ return ordered + keys
+
+ for key in get_sorted_keys(list(record_dict.keys())):
+ field = record_dict[key]
+ if (
+ isinstance(field, list)
+ and len(field) > 0
+ and isinstance(field[0], dict)
+ ):
+ for item in field:
+ if "key" in item and "value" in item and len(item.keys()) == 2:
+ if (
+ isinstance(item["value"], list)
+ and len(item["value"]) > 0
+ ):
+ for sub_item in item["value"]:
+ if (
+ isinstance(sub_item, dict)
+ and "name" in sub_item
+ ):
+ writer.writerow(
+ [f"{key} - {item['key']}", sub_item["name"]]
+ )
+ else:
+ writer.writerow(
+ [f"{key} - {item['key']}", str(sub_item)]
+ )
+ else:
+ writer.writerow(
+ [f"{key} - {item['key']}", item["value"]]
+ )
+ else:
+ for list_key in item.keys():
+ writer.writerow([f"{key} - {list_key}", item[list_key]])
+ else:
+ writer.writerow([key, str(field)])
+ return csv_response
+
+
+class DownloadMediaFile(APIView):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get(self, request, model_type, id):
+ models = _get_model_by_type(model_type)
+ if len(models) != 1:
+ raise Http404
+ model = models[0]
+ try:
+ obj = model.objects.get(pk=id)
+ except Exception:
+ raise Http404
+ media = obj.media()
+ import os
+ from TEKDB.settings import MEDIA_ROOT
+
+ file_path = os.path.join(MEDIA_ROOT, media["file"])
+ if os.path.exists(file_path):
+ with open(file_path, "rb") as fh:
+ response = HttpResponse(
+ fh.read(), content_type="application/force-download"
+ )
+ response["Content-Disposition"] = (
+ f"attachment; filename={os.path.basename(file_path)}"
+ )
+ return response
+ raise Http404
+
+
+# ----- Export endpoints -----
+class Download(APIView):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_category_list(self, request):
+ categories = []
+ for category in ["places", "resources", "activities", "sources", "media"]:
+ if request.GET.get(category) == "true":
+ categories.append(category)
+ return categories
+
+ def find_match_attributes(self, obj):
+ match_attributes = [attr for attr in dir(obj) if "match" in attr]
+ return match_attributes
+
+ def get_greatest_similarity_attribute(self, result, pks):
+ greatest_similarity_attribute = None
+ matching_attributes = []
+
+ # get headline and similarity results
+ for match_attr in self.find_match_attributes(result):
+ match_value = getattr(result, match_attr)
+ if match_value is not None:
+ if match_value == result.similarity:
+ matching_attributes.append(match_attr)
+
+ # If multiple attributes have the same similarity, choose based on number of matching IDs
+ if len(matching_attributes) == 1:
+ greatest_similarity_attribute = matching_attributes[0]
+ elif len(matching_attributes) > 0:
+ num_same_id = pks[result.pk]
+ if num_same_id - 1 < len(matching_attributes):
+ greatest_similarity_attribute = matching_attributes[num_same_id - 1]
+ pks[result.pk] -= 1
+ else:
+ greatest_similarity_attribute = matching_attributes[0]
+ else:
+ greatest_similarity_attribute = None
+
+ return greatest_similarity_attribute
+
+ def remove_match_prefix(self, string):
+ if string.startswith("match_"):
+ return string[6:]
+ return string
+
+ def get_verbose_field_name(self, model, field_path):
+ parts = field_path.split("__")
+ field = None
+ current_model = model
+
+ for part in parts:
+ field = current_model._meta.get_field(part)
+
+ # If it's a related field, follow to the related model
+ if hasattr(field, "related_model") and field.related_model:
+ current_model = field.related_model
+
+ return (
+ field.verbose_name.title()
+ if field
+ else field_path.replace("_", " ").title()
+ )
+
+ def get_results(self, keyword_string, categories):
+ if keyword_string is None:
+ keyword_string = ""
+
+ resultlist = []
+
+ for category in categories:
+ query_models = _get_model_by_type(category)
+ for model in query_models:
+ # Find all results matching keyword in this model
+ model_results = model.keyword_search(keyword_string)
+ pks = {}
+ # Count number of times each pk appears in results
+ for result in model_results:
+ if hasattr(result, "pk"):
+ if result.pk not in pks:
+ pks[result.pk] = 1
+ else:
+ pks[result.pk] += 1
+
+ for result in model_results:
+ actual_attribute = None
+ headline_value = None
+
+ greatest_similarity_attribute = (
+ self.get_greatest_similarity_attribute(result, pks)
+ )
+
+ actual_attribute = (
+ self.remove_match_prefix(greatest_similarity_attribute)
+ if greatest_similarity_attribute
+ else None
+ )
+ verbose_name = (
+ self.get_verbose_field_name(model, actual_attribute)
+ if actual_attribute
+ else None
+ )
+
+ headline_key = f"headline_{actual_attribute}"
+ if hasattr(result, headline_key):
+ headline_value = getattr(result, headline_key)
+
+ # Create JSON object to be returned
+ result_json = result.get_response_format()
+ if keyword_string != "":
+ result_json["rank"] = result.rank
+ result_json["similarity"] = result.similarity
+ result_json["headline"] = (
+ f"{verbose_name}: {headline_value}
"
+ if headline_value and verbose_name
+ else None
+ )
+ else:
+ result_json["rank"] = 0
+ result_json["similarity"] = 0
+ result_json["headline"] = None
+
+ resultlist.append(result_json)
+ # Sort results from all models by rank, then similarity (descending)
+ return sorted(
+ resultlist, key=lambda res: (res["rank"], res["similarity"]), reverse=True
+ )
+
+ def get(self, request):
+ categories = self.get_category_list(request)
+ results = self.get_results(request.GET.get("query"), categories)
+ format_type = request.GET.get("format")
+ filename = "TEK_RESULTS"
+ fieldnames = ["id", "name", "description", "type"]
+ rows = []
+ for row in results:
+ row_dict = {}
+ for field in fieldnames:
+ row_dict[field] = row[field] if row[field] else " "
+ rows.append(row_dict)
+
+ if format_type == "xlsx":
+ import io
+ from xlsxwriter.workbook import Workbook
+
+ output = io.BytesIO()
+ workbook = Workbook(output, {"in_membory": True})
+ worksheet = workbook.add_worksheet()
+ bold = workbook.add_format({"bold": True})
+ rows.insert(0, fieldnames)
+ row = 0
+ col = 0
+ for entry in rows:
+ for field in fieldnames:
+ if row == 0:
+ worksheet.write(0, col, field, bold)
+ else:
+ worksheet.write(row, col, entry[field])
+ col += 1
+ row += 1
+ col = 0
+ workbook.close()
+ output.seek(0)
+ xls_response = HttpResponse(
+ output.read(),
+ content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ )
+ xls_response["Content-Disposition"] = (
+ "attachment; filename=%s.xlsx" % filename
+ )
+ return xls_response
+
+ else:
+ # if format_type == 'csv':
+ import csv
+
+ csv_response = HttpResponse(content_type="text/csv")
+ csv_response["Content-Disposition"] = (
+ 'attachment; filename="%s.csv"' % filename
+ )
+ writer = csv.DictWriter(csv_response, fieldnames=fieldnames)
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(row)
+ return csv_response
diff --git a/TEKDB/explore/tests/test_api_views.py b/TEKDB/explore/tests/test_api_views.py
new file mode 100644
index 00000000..df8f36cb
--- /dev/null
+++ b/TEKDB/explore/tests/test_api_views.py
@@ -0,0 +1,497 @@
+from base64 import b64encode
+
+from django.conf import settings
+from django.test import TestCase
+from django.test.client import RequestFactory
+from os.path import join
+from unittest.mock import patch, MagicMock
+
+from TEKDB.tests.test_views import import_fixture_file
+
+
+class HomeViewTest(TestCase):
+ def test_home_page_api_html_content(self):
+ with patch("explore.API.views.PageContent.objects.get") as mock_get:
+ mock_obj = MagicMock()
+ mock_obj.is_html = True
+ mock_obj.html_content = "Test HTML Home"
+ mock_get.return_value = mock_obj
+ response = self.client.get("/api/page/Welcome/")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["page"], "welcome")
+ self.assertIn("Test HTML Home", data["pageContent"])
+
+ def test_home_page_api_not_html_content(self):
+ with patch("explore.API.views.PageContent.objects.get") as mock_get:
+ mock_obj = MagicMock()
+ mock_obj.is_html = False
+ mock_obj.content = "Test Text Home"
+ mock_get.return_value = mock_obj
+ response = self.client.get("/api/page/Welcome/")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["pageTitle"], "Welcome")
+ self.assertIn("Test Text Home", data["pageContent"])
+
+
+class AboutViewTest(TestCase):
+ def test_about_page_api_html_content(self):
+ with patch("explore.API.views.PageContent.objects.get") as mock_get:
+ mock_obj = MagicMock()
+ mock_obj.is_html = True
+ mock_obj.html_content = "Test HTML About"
+ mock_get.return_value = mock_obj
+ response = self.client.get("/api/page/About/")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Test HTML About", response.json()["pageContent"])
+
+ def test_about_page_api_not_html_content(self):
+ with patch("explore.API.views.PageContent.objects.get") as mock_get:
+ mock_obj = MagicMock()
+ mock_obj.is_html = False
+ mock_obj.content = "Test Text About"
+ mock_get.return_value = mock_obj
+ response = self.client.get("/api/page/About/")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Test Text About", response.json()["pageContent"])
+
+
+class HelpViewTest(TestCase):
+ def test_help_page_api_html_content(self):
+ with patch("explore.API.views.PageContent.objects.get") as mock_get:
+ mock_obj = MagicMock()
+ mock_obj.is_html = True
+ mock_obj.html_content = "Test HTML Help"
+ mock_get.return_value = mock_obj
+ response = self.client.get("/api/page/Help/")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Test HTML Help", response.json()["pageContent"])
+
+ def test_help_page_api_not_html_content(self):
+ with patch("explore.API.views.PageContent.objects.get") as mock_get:
+ mock_obj = MagicMock()
+ mock_obj.is_html = False
+ mock_obj.content = "Test Text Help"
+ mock_get.return_value = mock_obj
+ response = self.client.get("/api/page/Help/")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Test Text Help", response.json()["pageContent"])
+
+
+class ExploreViewTest(TestCase):
+ def setUp(self):
+ import_fixture_file(
+ join(settings.BASE_DIR, "TEKDB", "fixtures", "all_dummy_data.json")
+ )
+
+ self.factory = RequestFactory()
+ self.credentials = b64encode(b"admin:admin").decode("ascii")
+
+ def test_explore_api(self):
+ from TEKDB.models import Users
+
+ user = Users.objects.get(username="admin")
+ self.client.force_login(user)
+ url = "/api/search/?query=test&category=places"
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["categories"], ["places"])
+
+
+class GetByModelTypeTest(TestCase):
+ def setUp(self):
+ import_fixture_file(
+ join(settings.BASE_DIR, "TEKDB", "fixtures", "all_dummy_data.json")
+ )
+
+ self.factory = RequestFactory()
+ self.credentials = b64encode(b"admin:admin").decode("ascii")
+
+ def test_get_by_model_type(self):
+ # Test that a valid model_type returns the expected records
+ from TEKDB.models import Users
+
+ user = Users.objects.get(username="admin")
+ self.client.force_login(user)
+ url = "/api/explore/Places/"
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["category"], "Places")
+
+
+class SearchTest(TestCase):
+ # fixtures = ['TEKDB/fixtures/all_dummy_data.json',]
+
+ def setUp(self):
+ import_fixture_file(
+ join(settings.BASE_DIR, "TEKDB", "fixtures", "all_dummy_data.json")
+ )
+
+ self.factory = RequestFactory()
+ self.credentials = b64encode(b"admin:admin").decode("ascii")
+
+ def test_multi_word_search(self):
+ # Test that the query string submitted matches the query string returned to the client/user
+ from TEKDB.models import Users
+
+ query_string = "A multi word search"
+ self.client.force_login(Users.objects.get(username="admin"))
+ response = self.client.get(f"/api/search/?query={query_string}")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["query"], query_string)
+
+
+class GetByModelIdTest(TestCase):
+ def setUp(self):
+ import_fixture_file(
+ join(settings.BASE_DIR, "TEKDB", "fixtures", "all_dummy_data.json")
+ )
+
+ self.factory = RequestFactory()
+ self.credentials = b64encode(b"admin:admin").decode("ascii")
+
+ def test_get_by_model_id(self):
+ # Test that a valid model_type and id returns the expected record
+ from TEKDB.models import (
+ Users,
+ Places,
+ Resources,
+ Media,
+ PlacesResourceEvents,
+ Citations,
+ ResourcesActivityEvents,
+ )
+
+ user = Users.objects.get(username="admin")
+ self.client.force_login(user)
+
+ place = Places.objects.first()
+ url = f"/api/explore/Places/{place.pk}/"
+ place_response = self.client.get(url)
+ self.assertEqual(place_response.json()["model"].lower(), "places")
+ self.assertEqual(place_response.status_code, 200)
+
+ resource = Resources.objects.first()
+ url = f"/api/explore/resources/{resource.pk}/"
+ resource_response = self.client.get(url)
+ self.assertEqual(resource_response.json()["model"].lower(), "resources")
+ self.assertEqual(resource_response.status_code, 200)
+
+ rae = ResourcesActivityEvents.objects.first()
+ url = f"/api/explore/resourcesactivityevents/{rae.pk}/"
+ rae_response = self.client.get(url)
+ self.assertEqual(
+ rae_response.json()["model"].lower(), "resourcesactivityevents"
+ )
+ self.assertEqual(rae_response.status_code, 200)
+
+ media = Media.objects.first()
+ url = f"/api/explore/media/{media.pk}/"
+ media_response = self.client.get(url)
+ self.assertEqual(media_response.json()["model"].lower(), "media")
+ self.assertEqual(media_response.status_code, 200)
+
+ pre = PlacesResourceEvents.objects.first()
+ url = f"/api/explore/placesresourceevents/{pre.pk}/"
+ pre_response = self.client.get(url)
+ self.assertEqual(pre_response.json()["model"].lower(), "placesresourceevents")
+ self.assertEqual(pre_response.status_code, 200)
+
+ citation = Citations.objects.first()
+ url = f"/api/explore/citations/{citation.pk}/"
+ citation_response = self.client.get(url)
+ self.assertEqual(citation_response.json()["model"].lower(), "citations")
+ self.assertEqual(citation_response.status_code, 200)
+
+ def test_get_by_model_id_invalid_model(self):
+ # Test that an invalid model_type returns an error message
+ from TEKDB.models import Users
+
+ user = Users.objects.get(username="admin")
+ self.client.force_login(user)
+ url = "/api/explore/InvalidModel/1/"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("Incorrect number of models", response.content.decode())
+
+ def test_get_by_model_id_invalid_id(self):
+ # Test that an invalid id returns an error message
+ from TEKDB.models import Users
+
+ user = Users.objects.get(username="admin")
+ self.client.force_login(user)
+ url = "/api/explore/Places/999999/"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_get_by_model_id_with_map(self):
+ # Test that a valid model_type and id returns the expected record with map context
+ from TEKDB.models import Users, Places
+
+ place = Places.objects.first()
+ user = Users.objects.get(username="admin")
+ self.client.force_login(user)
+ url = f"/api/explore/Places/{place.pk}/"
+ response = self.client.get(url)
+ data = response.json().get("map", {})
+ map_keys = [
+ "default_lon",
+ "default_lat",
+ "default_zoom",
+ "min_zoom",
+ "max_zoom",
+ "extent",
+ ]
+ for key in map_keys:
+ self.assertIn(key, data)
+
+
+class DownloadMediaFileTest(TestCase):
+ def setUp(self):
+ import_fixture_file(
+ join(settings.BASE_DIR, "TEKDB", "fixtures", "all_dummy_data.json")
+ )
+
+ self.factory = RequestFactory()
+ self.credentials = b64encode(b"admin:admin").decode("ascii")
+
+ def test_download_media_file(self):
+ from TEKDB.models import Users, Media
+
+ user = Users.objects.get(username="admin")
+ self.client.force_login(user)
+
+ media = Media.objects.first()
+ url = f"/api/explore/Media/{media.pk}/download"
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response["Content-Disposition"],
+ f"attachment; filename={media.mediafile}",
+ )
+ self.assertEqual(response["Content-Type"], "application/force-download")
+
+ def test_download_media_file_invalid_id(self):
+ from TEKDB.models import Users
+
+ user = Users.objects.get(username="admin")
+ self.client.force_login(user)
+
+ url = "/api/explore/Media/999999/download"
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 404)
+
+ def test_invalid_model_type(self):
+ from TEKDB.models import Users
+
+ user = Users.objects.get(username="admin")
+ self.client.force_login(user)
+
+ url = "/api/explore/InvalidModel/1/download"
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 404)
+
+
+class GetSortedKeysTest(TestCase):
+ def setUp(self):
+ from os.path import join
+ from django.conf import settings
+ from TEKDB.tests.test_views import import_fixture_file
+
+ import_fixture_file(
+ join(settings.BASE_DIR, "TEKDB", "fixtures", "all_dummy_data.json")
+ )
+ from TEKDB.models import Users
+
+ self.user = Users.objects.get(username="admin")
+ self.client.force_login(self.user)
+
+ # curl -u admin -H 'Accept: application/json; indent=4' http://localhost:8000/api/export/Places/31/csv/
+ def test_sorted_keys_via_export_csv(self):
+ # Indirectly verify ordering through CSV export payload
+ from TEKDB.models import Places
+
+ place = Places.objects.get(pk=31)
+ url = f"/api/export/Places/{place.pk}/csv/"
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ content = response.content.decode()
+ self.assertIn("name", content)
+ self.assertIn("map", content)
+
+
+class ExportRecordCsvTest(TestCase):
+ def test_export_record_csv(self):
+ from os.path import join
+ from django.conf import settings
+ from TEKDB.tests.test_views import import_fixture_file
+ from TEKDB.models import Users
+
+ import_fixture_file(
+ join(settings.BASE_DIR, "TEKDB", "fixtures", "all_dummy_data.json")
+ )
+
+ self.user = Users.objects.get(username="admin")
+ self.client.force_login(self.user)
+ from TEKDB.models import Places
+
+ place = Places.objects.get(pk=31)
+ url = f"/api/export/Places/{place.pk}/csv/"
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response["Content-Type"], "text/csv")
+
+ self.assertIn(
+ f'attachment; filename="Places_{place.pk}_{str(place)}.csv"',
+ response["Content-Disposition"],
+ )
+ self.assertIn("id", response.content.decode())
+ self.assertIn("name", response.content.decode())
+
+
+class ExportRecordXlsTest(TestCase):
+ def setUp(self):
+ from os.path import join
+ from django.conf import settings
+ from TEKDB.tests.test_views import import_fixture_file
+
+ import_fixture_file(
+ join(settings.BASE_DIR, "TEKDB", "fixtures", "all_dummy_data.json")
+ )
+ from TEKDB.models import Users
+
+ self.user = Users.objects.get(username="admin")
+ self.client.force_login(self.user)
+
+ def test_export_record_xls(self):
+ from TEKDB.models import Places
+
+ place = Places.objects.get(pk=31)
+ url = f"/api/export/Places/{place.pk}/xls/"
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response["Content-Type"],
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ )
+
+ self.assertIn(
+ f'attachment; filename="Places_{place.pk}_{str(place)}.xlsx"',
+ response["Content-Disposition"],
+ )
+ self.assertEqual(
+ response.content[:2], b"PK"
+ ) # XLSX files start with 'PK' (zip signature)
+
+
+class SearchViewTest(TestCase):
+ def setUp(self):
+ from os.path import join
+ from django.conf import settings
+ from TEKDB.tests.test_views import import_fixture_file
+ from TEKDB.models import Users
+
+ import_fixture_file(
+ join(settings.BASE_DIR, "TEKDB", "fixtures", "all_dummy_data.json")
+ )
+ self.user = Users.objects.get(username="admin")
+ self.client.force_login(self.user)
+
+ def test_search_api_get_request(self):
+ url = "/api/search/?query=test&category=places"
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["query"], "test")
+ self.assertEqual(data["categories"], ["places"])
+
+ def test_search_api_misspelled_category(self):
+ url = "/api/search/?query=test&category=placess,resourcess"
+ response = self.client.get(url)
+ # should resort to all
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["query"], "test")
+ self.assertEqual(
+ data["categories"],
+ ["places", "resources", "activities", "sources", "media"],
+ )
+
+ def test_search_api_no_category(self):
+ url = "/api/search/?query=test"
+ response = self.client.get(url)
+ # should resort to all
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["query"], "test")
+ self.assertEqual(
+ data["categories"],
+ ["places", "resources", "activities", "sources", "media"],
+ )
+
+ def test_search_api_post_request(self):
+ url = "/api/search/"
+ data = {
+ "query": "test",
+ "activities": "on",
+ "citations": "on",
+ "media": "on",
+ }
+ response = self.client.post(url, data)
+ # DRF ExploreSearch currently implements GET; POST returns 405
+ self.assertIn(response.status_code, [200, 405])
+
+
+class DownloadViewTest(TestCase):
+ def setUp(self):
+ from os.path import join
+ from django.conf import settings
+ from TEKDB.tests.test_views import import_fixture_file
+
+ import_fixture_file(
+ join(settings.BASE_DIR, "TEKDB", "fixtures", "all_dummy_data.json")
+ )
+ from TEKDB.models import Users
+
+ self.user = Users.objects.get(username="admin")
+ self.client.force_login(self.user)
+
+ def test_download_csv(self):
+ url = "/api/export?query=test&places=true&format=csv"
+ response = self.client.get(url, follow=True)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response["Content-Type"], "text/csv")
+ self.assertIn(
+ 'attachment; filename="TEK_RESULTS.csv"', response["Content-Disposition"]
+ )
+ self.assertIn("id", response.content.decode())
+ self.assertIn("name", response.content.decode())
+
+ def test_download_xlsx(self):
+ url = "/api/export?query=test&places=true&format=xlsx"
+ url = "/api/export?query=test&places=true&format=csv"
+ response = self.client.get(url, follow=True)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response["Content-Type"],
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ )
+ self.assertIn(
+ "attachment; filename=TEK_RESULTS.xlsx", response["Content-Disposition"]
+ )
+ self.assertEqual(
+ response.content[:2], b"PK"
+ ) # XLSX files start with 'PK' (zip signature)
diff --git a/TEKDB/explore/urls.py b/TEKDB/explore/urls.py
index d66626f3..58a2b8e8 100644
--- a/TEKDB/explore/urls.py
+++ b/TEKDB/explore/urls.py
@@ -1,4 +1,5 @@
from django.urls import include, path, re_path
+from .API import views as api_views
from . import views
@@ -9,6 +10,12 @@
re_path(r"^(?P\w+)/(?P\w+)/download$", views.download_media_file),
]
+api_explore_patterns = [
+ path("/", api_views.ExploreByType.as_view()),
+ path("//", api_views.ExploreById.as_view()),
+ path("//download", api_views.DownloadMediaFile.as_view()),
+]
+
export_patterns = [
path("", views.download),
re_path(
@@ -25,5 +32,18 @@
path("export", views.download),
path("export/", include(export_patterns)),
path("", views.home),
+ # API endpoints (DRF)
+ path("api/explore/", include(api_explore_patterns)),
+ path("api/page//", api_views.PageContentSingle.as_view()),
+ path("api/search/", api_views.ExploreSearch.as_view()),
+ path("api/export/", api_views.Download.as_view()),
+ path(
+ "api/export////",
+ api_views.ExportRecord.as_view(),
+ ),
+ path(
+ "api/site-info/", api_views.SiteConfigurationAPIView.as_view(), name="site-info"
+ ),
+ path("api/csrf/", api_views.CsrfTokenAPIView.as_view(), name="csrf-token"),
]
# url(r'^logout$', views.logout, name='logout'),
diff --git a/TEKDB/media/__init__.py b/TEKDB/media/__init__.py
old mode 100755
new mode 100644
diff --git a/TEKDB/requirements.txt b/TEKDB/requirements.txt
index 276851e8..463b225c 100644
--- a/TEKDB/requirements.txt
+++ b/TEKDB/requirements.txt
@@ -10,10 +10,12 @@ django-nested-admin
django-registration
django-reversion
django-tinymce
+django-cors-headers
pillow
psycopg2-binary
psutil
django-filebrowser-no-grappelli>=4.0.0,<5.0.0
+djangorestframework
XlsxWriter
#-e git+https://github.com/dominno/django-moderation.git@master#egg=moderation
diff --git a/client/README.md b/client/README.md
new file mode 100644
index 00000000..4dcad1f9
--- /dev/null
+++ b/client/README.md
@@ -0,0 +1,73 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## React Compiler
+
+The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress.
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+
+```js
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+
+ // Remove tseslint.configs.recommended and replace with this
+ tseslint.configs.recommendedTypeChecked,
+ // Alternatively, use this for stricter rules
+ tseslint.configs.strictTypeChecked,
+ // Optionally, add this for stylistic rules
+ tseslint.configs.stylisticTypeChecked,
+
+ // Other configs...
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
+
+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+
+```js
+// eslint.config.js
+import reactX from 'eslint-plugin-react-x'
+import reactDom from 'eslint-plugin-react-dom'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+ // Enable lint rules for React
+ reactX.configs['recommended-typescript'],
+ // Enable lint rules for React DOM
+ reactDom.configs.recommended,
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
diff --git a/client/app/App.css b/client/app/App.css
new file mode 100644
index 00000000..1852faca
--- /dev/null
+++ b/client/app/App.css
@@ -0,0 +1,366 @@
+#root {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ width: 100%;
+}
+
+:root {
+ --header_height: 6rem;
+ --black: #000000;
+ --white: #ffffff;
+ /* PRIMARY A: Vivid, unique. ORIGINAL: SIENNA */
+ --current_pg_index: var(--primary_a);
+ --highlight_underline: var(--primary_a);
+ --highlight_font: var(--primary_a);
+ /* PRIMARY B VERY faint. ORIGINAL: OFF-WHITE */
+ --light_font: var(--primary_b);
+ --accent_bg: var(--primary_b);
+ /* PRIMARY C VERY dark, bold. ORIGINAL: OFF-BLACK */
+ --dark_font: var(--primary_c);
+ /* PRIMARY D neutral. ORIGINAL: GRAY */
+ --result_highlight: var(--primary_d);
+ --alt_bg: var(--primary_d);
+ --light_border: var(--primary_d);
+ /* SECONDARY A Themed, dark. ORIGINAL: FOREST-GREEN */
+ --link_color: var(--secondary_a);
+ --alt_field_bg: var(--secondary_a);
+ /* SECONDARY B Themed, moderate. ORIGINAL: OLIVE-GREEN */
+ /* SECONDARY C Themed, vivid. ORIGINAL: GREEN */
+ --ok_button_bg: var(--secondary_c);
+ --placeholder_font: var(--secondary_c);
+ /* SECONDARY D Vivid, unique, accent. ORIGINAL: FUCHSIA*/
+ /* PALETTE OVERRIDES */
+ --default_background: var(--white);
+ --default_alt_font: var(--white);
+ --default_font: var(--black);
+ --default_border: var(--black);
+ --btn_border: 2px solid var(--white);
+ --btn_color: var(--white);
+ /* Theme Colors */
+ --theme_bg_color: var(--black);
+ --theme_color: var(--white);
+ /* FONT OVERRIDES */
+ --bs-font-sans-serif: "Open Sans Regular", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ --font-sans-serif: var(--bs-font-sans-serif);
+ --font-sans-serif-bold: "Open Sans Bold", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ --font-sans-serif-extrabold: "Open Sans ExtraBold", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ --small_font_size: 0.7rem;
+
+ /* Spacing */
+ --margin_bottom: 1.5em;
+ --margin_top: 1.5em;
+ --margin: 1.5em;
+ --letter_spacing: 0.05em;
+
+ /* Borders */
+ --default_border: 1px solid var(--default_font);
+ --default_border_radius: 0.3rem;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
+main {
+ background: var(--white);
+}
+
+.min-full-height {
+ min-height: calc(100vh - 96px);
+}
+
+h1 {
+ display: block;
+ /* margin: var(--margin_top) 0 calc(var(--margin_bottom) / 2); */
+ margin-bottom: 0;
+ position: relative;
+ text-align: center;
+}
+
+h2 {
+ font-size: 2em;
+ margin-top: var(--margin_top);
+}
+
+p {
+ font-size: 1.25em;
+ letter-spacing: .05em;
+ margin: calc(var(--margin_top) / 2) 0 calc(var(--margin_bottom) * 2)
+}
+
+.explore-form-row {
+ align-items: stretch;
+ flex-direction: row;
+}
+
+.explore-form-row > div {
+ /* width: 100%; */
+}
+
+.explore-form {
+ margin-top: calc(var(--margin_top) * 2);
+}
+
+.explore-input-group {
+ /* margin: var(--margin_top) 0 calc(var(--margin_bottom) * 2); */
+ padding: 0 10%;
+}
+
+hr {
+ font-size: 1.25em;
+ margin: calc(var(--margin_top) * 2) 0 calc(var(--margin_bottom) * 2);
+}
+
+.explore-form .btn {
+ /* border: 1px solid var(--primary_d): */
+ /* position: relative; */
+}
+
+.button-wrapper {
+ /* justify-content: ; */
+ margin: calc(var(--margin_top) * 2) 0;
+ position: relative;
+ text-align: center;
+}
+
+button.btn {
+ background: var(--white);
+ border-color: var(--black);
+ border-radius: 0;
+ color: var(--black);
+ font-family: var(--font-sans-serif);
+ font-size: 1.0375em;
+}
+
+button.btn:hover {
+ background: var(--primary_a);
+ border-color: var(--black);
+ color: var(--white);
+}
+
+.arrow-right {
+ display: inline-block;
+ position: relative;
+}
+
+button.btn .arrow-right,
+button.btn .arrow-right::before,
+button.btn .arrow-right::after {
+ background: var(--primary_a);
+}
+
+button.btn:hover .arrow-right,
+button.btn:hover .arrow-right::before,
+button.btn:hover .arrow-right::after {
+ background: var(--white);
+}
+
+.form-wrapper {
+ margin-top: calc(var(--margin_top) * 2);
+ text-align: center;
+}
+
+
+.form-wrapper .explore-form h1:nth-of-type(2)::after {
+ background: var(--secondary_b);
+ border-radius: 50%;
+ color: var(--white);
+ content: "OR";
+ font-size: .3125em;
+ height: 4em;
+ left: 50%;
+ line-height: 4em;
+ position: absolute;
+ text-align: center;
+ top: -5em;
+ transform: translateX(-50%);
+ width: 4em;
+ white-space: nowrap;
+}
+
+#filter-checkboxes {
+ flex-direction: row;
+ padding-left: 40%;
+ text-transform: uppercase;
+ text-align: left;
+}
+
+@media (min-width: 64em) {
+ #filter-checkboxes {
+ padding-left: 0%;
+ }
+}
+
+#filter-checkboxes div {
+ letter-spacing: .1em;
+}
+
+#filter-checkboxes p {
+ margin: 0;
+}
+
+main.content-wrapper div.container {
+ min-width: 100vw;
+ max-width: 100vw;
+ margin: 0;
+}
+
+@media screen and (min-width: 992px) {
+ div.homepage-column {
+ align-self: stretch;
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ min-height: calc(100vh - var(--header_height));
+ /* max-height: calc(100vh - var(--header_height)); */
+ }
+}
+
+div#homepage-left-column {
+ background-color: var(--homepage_left_background);
+ overflow-y: auto;
+}
+
+.welcome-content-wrapper {
+ color: var(--home_font_color);
+ margin: 3rem 2rem;
+}
+
+.welcome-content-wrapper p {
+ font-family: var(--font-sans-serif-bold);
+}
+
+div.welcome-login-button-wrapper {
+ margin: 0 2rem;
+}
+
+div.welcome-login-button-wrapper a.btn.welcome-login-btn {
+ border-color: var(--home_font_color);
+ color: var(--home_font_color);
+}
+
+div.welcome-login-button-wrapper a.btn.welcome-login-btn span.arrow-right::before,
+div.welcome-login-button-wrapper a.btn.welcome-login-btn span.arrow-right,
+div.welcome-login-button-wrapper a.btn.welcome-login-btn span.arrow-right::after {
+ background: var(--home_font_color);
+}
+
+div#homepage-right-column {
+ background-color: var(--homepage_right_background);
+ display: flex;
+ flex-direction: column;
+ max-height: calc(100vh - var(--header_height));
+}
+
+div#homepage-right-column::after {
+ background: var(--homepage_right_background);
+ bottom: 0;
+ content: "";
+ left: 40%;
+ position: fixed;
+ right: 0;
+ top: 0;
+ z-index: -1;
+}
+
+div.proj-image-select-wrapper {
+ max-height: 100%;
+ display: flex;
+ flex-grow: 1;
+ justify-content: center;
+}
+
+.proj-image-select-wrapper img {
+ object-fit: contain;
+}
+
+div#proj-image-attribution {
+ color: var(--dark_font);
+ background-color: var(--accent_bg);
+ /* background-color: var(--white); */
+ border: var(--default_border);
+ border-radius: var(--default_border_radius);
+ bottom: .5rem;
+ opacity: 80%;
+ /* padding: .375rem .75rem; */
+ display: flex;
+ position: absolute;
+ right: .5rem;
+}
+
+div#proj-image-attribution .attribution-toggle {
+ color: #000;
+ font-family: serif;
+ font-size: 1.25rem;
+ line-height: 1;
+ padding: .375rem .75rem;
+ text-decoration: none;
+}
+
+div#proj-image-attribution .attribution-toggle.collapsed {
+ margin-right: 0;
+}
+
+div#proj-image-attribution p {
+ font-size: var(--small_font_size);
+ margin: 5px 5px 0 5px;
+}
+
+#collapseAttribution.show:before {
+ background-color: var(--dark_font);
+ border: 1px solid #fff;
+ color: #fff;
+ content: "X";
+ font-family: Tahoma, sans-serif;
+ font-size: .875rem;
+ font-weight: bold;
+ height: 100%;
+ left: 0;
+ padding: .25rem .6125rem;
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ z-index: 2;
+}
\ No newline at end of file
diff --git a/client/app/api/pageContent.ts b/client/app/api/pageContent.ts
new file mode 100644
index 00000000..63451b40
--- /dev/null
+++ b/client/app/api/pageContent.ts
@@ -0,0 +1,71 @@
+import { useOutletContext } from "react-router";
+
+export type PageContentResponse = {
+ page: string;
+ pageTitle: string;
+ pageContent: string;
+};
+
+export type SiteInfoResponse = {
+ proj_logo_text: string;
+ proj_text_placement: string;
+ proj_css: Record;
+ proj_icons: {
+ logo: string;
+ place_icon: string;
+ resource_icon: string;
+ activity_icon: string;
+ source_icon: string;
+ media_icon: string;
+ };
+ proj_image_select: string;
+ home_image_attribution: string;
+ home_font_color: string;
+ homepage_right_background: string;
+ homepage_left_background: string;
+ map_pin: string;
+ map_pin_selected: string;
+};
+
+export type PageContentAndSiteInfo = {
+ pageContent: PageContentResponse;
+ siteInfo: SiteInfoResponse;
+};
+
+// TODO: fix baseUrl to use env variable
+const baseUrl = "http://localhost:8000";
+
+export async function fetchPageContent(
+ path: string
+): Promise {
+ const url = `${baseUrl}/api/page${path}/`;
+ const res = await fetch(url, { credentials: "include" });
+ if (!res.ok) {
+ throw new Error(`Failed to fetch page content: ${res.status}`);
+ }
+ return res.json();
+}
+
+export async function fetchSiteInfo(): Promise {
+ const url = `${baseUrl}/api/site-info/`;
+ const res = await fetch(url, { credentials: "include" });
+ if (!res.ok) {
+ throw new Error(`Failed to fetch site info: ${res.status}`);
+ }
+ return await res.json();
+}
+
+export async function fetchPageContentAndSiteInfo(path: string): Promise<{
+ pageContent: PageContentResponse;
+ siteInfo: SiteInfoResponse;
+}> {
+ const [pageContent, siteInfo] = await Promise.all([
+ fetchPageContent(path),
+ fetchSiteInfo(),
+ ]);
+ return { pageContent, siteInfo };
+}
+
+export const usePageContentAndSiteInfo = () => {
+ return useOutletContext();
+};
diff --git a/client/app/api/tekdbApi.ts b/client/app/api/tekdbApi.ts
new file mode 100644
index 00000000..6b43ccdd
--- /dev/null
+++ b/client/app/api/tekdbApi.ts
@@ -0,0 +1,50 @@
+import axios, {
+ AxiosError,
+ type AxiosResponse,
+ type InternalAxiosRequestConfig,
+} from "axios";
+import { redirect } from "react-router";
+
+const baseURL =
+ (import.meta.env.VITE_API_BASE_URL as string) || `http://localhost:8000/`;
+
+export const tekdbApi = axios.create({
+ baseURL,
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ xsrfCookieName: "csrftoken",
+ xsrfHeaderName: "X-CSRFToken",
+ withCredentials: true,
+});
+
+// interceptor to apply auth token to axios requests
+tekdbApi.interceptors.request.use(
+ (config: InternalAxiosRequestConfig) => {
+ config.headers = config.headers ?? {};
+ return config;
+ },
+ (error) => {
+ return Promise.reject(error);
+ }
+);
+
+export const returnOnSuccess = (response: AxiosResponse) => response;
+
+export const redirectOnUnauthorized = (error: AxiosError) => {
+ if (error.response?.status === 401) {
+ redirect("/login_async/");
+ }
+ return Promise.reject(error);
+};
+
+tekdbApi.interceptors.response.use(returnOnSuccess, redirectOnUnauthorized);
+
+// Helper to prime CSRF cookie and token
+export async function primeCsrf(): Promise {
+ const resp = await tekdbApi.get("/api/csrf/");
+ const token = (resp.data?.csrfToken as string) || "";
+
+ return token;
+}
diff --git a/client/app/components/header.css b/client/app/components/header.css
new file mode 100644
index 00000000..1ec1ee2f
--- /dev/null
+++ b/client/app/components/header.css
@@ -0,0 +1,117 @@
+nav.navbar {
+ align-items: stretch;
+ background-color: var(--default_background);
+ border-bottom: 1px solid var(--black);
+ height: var(--header_height);
+}
+
+.navbar-brand {
+ margin-right: auto;
+}
+
+/* Wrapper for logo */
+/* Logo value is a django variable in navbar.html */
+/* Logo is assigned as the background-image for .navbar-brand-wrapper */
+.navbar-brand-wrapper {
+ align-content: center;
+ align-items: center;
+ background-repeat: no-repeat;
+ background-size: contain;
+ background-position: center;
+ display: flex;
+ font-family: var(--font-sans-serif-extrabold);
+ font-size: 1.06375em;
+ letter-spacing: 0.025em;
+ flex-wrap: wrap;
+ height: 79px;
+ line-height: normal;
+ padding: 0;
+ text-align: center;
+ min-width: 120px;
+}
+
+/*
+ * Customizable Logo and text position
+ *
+ * The text position can be set to left, right, or center.
+ * The background image will be positioned accordingly.
+ * For default styles see .navbar-brand-wrapper
+*/
+
+.navbar-brand-wrapper.text-before {
+ background-position: right center;
+ text-align: left;
+}
+
+.navbar-brand-wrapper.text-after {
+ background-position: left center;
+ text-align: right;
+}
+
+.navbar-brand-text {
+ width: 100%;
+}
+
+/*
+ * Navbar Toggler
+ *
+*/
+
+ul.navbar-nav {
+ width: 100%;
+ margin-left: 10%;
+}
+
+.navbar-toggler {
+ margin-bottom: calc(var(--header_height)/4);
+}
+
+ul.navbar-nav li.nav-item {
+ flex-grow: 3;
+ text-align: center;
+}
+
+ul.navbar-nav li.nav-item a.nav-link {
+ border-bottom: 3px solid transparent;
+ color: var(--black);
+ display: inline-block;
+ font-size: 1.125em;
+ font-family: var(--font-sans-serif-bold);
+ letter-spacing: 0.125em;
+ transition: border-bottom 0.2s ease-in-out;
+}
+
+ul.navbar-nav li.nav-item a.nav-link.disabled {
+ color: rgba(0, 0, 0, .3);
+}
+
+ul.navbar-nav li.nav-item a.nav-link:hover {
+ border-bottom: 3px solid var(--highlight_underline);
+ color: var(--black);
+}
+
+ul.navbar-nav li.nav-item.active a.nav-link {
+ border-bottom: 3px solid var(--highlight_underline);
+}
+
+ul.navbar-nav li.nav-item.user-menu {
+ text-align: right;
+}
+
+ul.navbar-nav li.nav-item.user-menu a.header-button,
+ul.navbar-nav li.nav-item.user-menu a#userDropdown {
+ /* background-color: var(--alt_bg); */
+ font-family: var(--font-sans-serif);
+ font-size: .9375em;
+ padding: .6375em 1.375em;
+}
+
+/* ul.navbar-nav li.nav-item.user-menu a.header-button,
+ul.navbar-nav li.nav-item.user-menu a#userDropdown:hover {
+ border: 1px solid var(--default_border);
+} */
+
+ul.navbar-nav li.nav-item.user-menu ul.dropdown-menu[data-bs-popper] {
+ left: unset;
+ right: 2rem;
+}
\ No newline at end of file
diff --git a/client/app/components/header.tsx b/client/app/components/header.tsx
new file mode 100644
index 00000000..1e037eb8
--- /dev/null
+++ b/client/app/components/header.tsx
@@ -0,0 +1,136 @@
+import React, { useActionState, useEffect } from "react";
+import { Link } from "react-router";
+import LoginModal from "./login-modal";
+import "./header.css";
+import { useLogin } from "../context/loginContext";
+
+export type HeaderProps = {
+ projIcons: {
+ logo: string;
+ };
+ projTextPlacement: string;
+ projLogoText: string;
+ pageTitle: string;
+ isAuthenticated?: boolean;
+};
+
+const Header: React.FC = ({
+ projIcons,
+ projTextPlacement,
+ projLogoText,
+ pageTitle,
+}) => {
+ const { loginForm, showLoginModal, setShowLoginModal } = useLogin();
+
+ const logoUrl = projIcons.logo
+ ? // @ts-ignore
+ `${import.meta.env.VITE_API_BASE_URL}${projIcons.logo}`
+ : "";
+
+ return (
+
+
+ setShowLoginModal(false)}
+ handleSubmit={loginForm.formAction}
+ isPending={loginForm.isPending}
+ error={loginForm.error}
+ />
+
+ );
+};
+
+export default Header;
diff --git a/client/app/components/login-modal.css b/client/app/components/login-modal.css
new file mode 100644
index 00000000..12d7d066
--- /dev/null
+++ b/client/app/components/login-modal.css
@@ -0,0 +1,23 @@
+.modal-content {
+ background-color: var(--black);
+ border: 1px solid var(--white);
+ color: var(--white);
+}
+.form-center {
+ text-align: center;
+}
+
+.registration-form .helptext {
+ visibility: hidden;
+}
+
+input[type="text"],
+.form-control,
+input[type="password"] {
+ background: var(--white);
+ border-color: var(--secondary_a);
+ border-radius: 2em;
+ border-width: 2px;
+ font-size: 1.125em;
+ line-height: 2;
+}
\ No newline at end of file
diff --git a/client/app/components/login-modal.tsx b/client/app/components/login-modal.tsx
new file mode 100644
index 00000000..78373511
--- /dev/null
+++ b/client/app/components/login-modal.tsx
@@ -0,0 +1,62 @@
+import Button from "react-bootstrap/Button";
+import Modal from "react-bootstrap/Modal";
+import Form from "react-bootstrap/Form";
+import React from "react";
+
+type LoginModalProps = {
+ show: boolean;
+ handleClose: () => void;
+ handleSubmit: (formData: FormData) => void;
+ isPending: boolean;
+ error: string | null;
+};
+
+const LoginModal: React.FC = ({
+ show,
+ handleClose,
+ handleSubmit,
+ isPending,
+ error,
+}) => {
+ return (
+
+
+ Please Log In
+
+
+
+ Username
+
+
+
+ Password
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+};
+
+export default LoginModal;
diff --git a/client/app/context/action.ts b/client/app/context/action.ts
new file mode 100644
index 00000000..eaeddbde
--- /dev/null
+++ b/client/app/context/action.ts
@@ -0,0 +1,49 @@
+import { primeCsrf, tekdbApi } from "../api/tekdbApi";
+
+export type LoginState = {
+ success?: boolean;
+ error?: string | null;
+};
+
+export const submitAction = async (
+ prevState: LoginState,
+ formData: FormData
+): Promise => {
+ const username = String(formData.get("username") ?? "");
+ const password = String(formData.get("password") ?? "");
+
+ // Prime CSRF cookie/token first
+ const csrfToken = await primeCsrf();
+
+ // Use form-encoded body
+ const body = new URLSearchParams();
+ body.append("username", username);
+ body.append("password", password);
+
+ const login = await tekdbApi.post("/login_async/", body, {
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "X-CSRFToken": csrfToken,
+ },
+ withCredentials: true,
+ });
+
+ if (
+ login.status !== 200 ||
+ login.statusText !== "OK" ||
+ !login.data.success
+ ) {
+ return {
+ ...prevState,
+ success: false,
+ error: login.data.error || "Invalid username or password",
+ };
+ } else if (login.status == 200) {
+ return { ...prevState, success: true, error: null };
+ }
+ return {
+ ...prevState,
+ success: false,
+ error: "Invalid username or password",
+ };
+};
diff --git a/client/app/context/loginContext.tsx b/client/app/context/loginContext.tsx
new file mode 100644
index 00000000..124943b7
--- /dev/null
+++ b/client/app/context/loginContext.tsx
@@ -0,0 +1,93 @@
+import React, {
+ useActionState,
+ useEffect,
+ createContext,
+ useContext,
+} from "react";
+import { LoginState, submitAction } from "./action";
+
+type UseLoginFormReturn = {
+ isAuthenticated: boolean;
+ formAction: (formData: FormData) => void;
+ isPending: boolean;
+ error: string | null;
+};
+
+type useLogin = {
+ loginForm: UseLoginFormReturn;
+ showLoginModal: boolean;
+ setShowLoginModal: React.Dispatch>;
+};
+
+const useLoginForm = (
+ action: (prevState: LoginState, formData: FormData) => Promise,
+ onSuccess: () => void,
+ onError: () => void
+): UseLoginFormReturn => {
+ const [state, formAction, isPending] = useActionState(
+ action,
+ {
+ success: false,
+ error: null,
+ }
+ );
+ const [isAuthenticated, setIsAuthenticated] = React.useState(false);
+ const [error, setError] = React.useState(null);
+
+ useEffect(() => {
+ if (state?.success) {
+ setIsAuthenticated(true);
+ setError(null);
+ onSuccess();
+ } else {
+ setIsAuthenticated(false);
+ if (state?.error) {
+ setError(state.error);
+ onError();
+ }
+ }
+ }, [state?.success, state?.error, onSuccess, onError]);
+
+ return {
+ isAuthenticated,
+ formAction,
+ isPending,
+ error,
+ };
+};
+
+const LoginContext = createContext(null);
+
+export const LoginProvider: React.FC> = ({
+ children,
+}) => {
+ const [showLoginModal, setShowLoginModal] = React.useState(false);
+
+ const loginForm = useLoginForm(
+ async (prevState, formData) => {
+ return submitAction(prevState, formData);
+ },
+ () => setShowLoginModal(false),
+ () => {
+ console.log("Login failed");
+ }
+ );
+
+ const value = {
+ loginForm,
+ showLoginModal,
+ setShowLoginModal,
+ };
+
+ return (
+ {children}
+ );
+};
+
+export const useLogin = () => {
+ const context = useContext(LoginContext);
+ if (!context) {
+ throw new Error("useLogin must be used within a LoginProvider");
+ }
+ return context as useLogin;
+};
diff --git a/client/app/index.css b/client/app/index.css
new file mode 100644
index 00000000..5fd97966
--- /dev/null
+++ b/client/app/index.css
@@ -0,0 +1,652 @@
+:root {
+ --header_height: 6rem;
+ --black: #000000;
+ --white: #ffffff;
+ /* PRIMARY A: Vivid, unique. ORIGINAL: SIENNA */
+ --current_pg_index: var(--primary_a);
+ --highlight_underline: var(--primary_a);
+ --highlight_font: var(--primary_a);
+ /* PRIMARY B VERY faint. ORIGINAL: OFF-WHITE */
+ --light_font: var(--primary_b);
+ --accent_bg: var(--primary_b);
+ /* PRIMARY C VERY dark, bold. ORIGINAL: OFF-BLACK */
+ --dark_font: var(--primary_c);
+ /* PRIMARY D neutral. ORIGINAL: GRAY */
+ --result_highlight: var(--primary_d);
+ --alt_bg: var(--primary_d);
+ --light_border: var(--primary_d);
+ /* SECONDARY A Themed, dark. ORIGINAL: FOREST-GREEN */
+ --link_color: var(--secondary_a);
+ --alt_field_bg: var(--secondary_a);
+ /* SECONDARY B Themed, moderate. ORIGINAL: OLIVE-GREEN */
+ /* SECONDARY C Themed, vivid. ORIGINAL: GREEN */
+ --ok_button_bg: var(--secondary_c);
+ --placeholder_font: var(--secondary_c);
+ /* SECONDARY D Vivid, unique, accent. ORIGINAL: FUCHSIA*/
+ /* PALETTE OVERRIDES */
+ --default_background: var(--white);
+ --default_alt_font: var(--white);
+ --default_font: var(--black);
+ --default_border: var(--black);
+ --btn_border: 2px solid var(--white);
+ --btn_color: var(--white);
+ /* Theme Colors */
+ --theme_bg_color: var(--black);
+ --theme_color: var(--white);
+ /* FONT OVERRIDES */
+ --bs-font-sans-serif: "Open Sans Regular", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ --font-sans-serif: var(--bs-font-sans-serif);
+ --font-sans-serif-bold: "Open Sans Bold", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ --font-sans-serif-extrabold: "Open Sans ExtraBold", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ --small_font_size: 0.7rem;
+
+ /* Spacing */
+ --margin_bottom: 1.5em;
+ --margin_top: 1.5em;
+ --margin: 1.5em;
+ --letter_spacing: 0.05em;
+
+ /* Borders */
+ --default_border: 1px solid var(--default_font);
+ --default_border_radius: 0.3rem;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
+main {
+ background: var(--white);
+}
+
+.min-full-height {
+ min-height: calc(100vh - 96px);
+}
+
+h1 {
+ display: block;
+ /* margin: var(--margin_top) 0 calc(var(--margin_bottom) / 2); */
+ margin-bottom: 0;
+ position: relative;
+ text-align: center;
+}
+
+h2 {
+ font-size: 2em;
+ margin-top: var(--margin_top);
+}
+
+p {
+ font-size: 1.25em;
+ letter-spacing: .05em;
+ margin: calc(var(--margin_top) / 2) 0 calc(var(--margin_bottom) * 2)
+}
+
+.explore-form-row {
+ align-items: stretch;
+ flex-direction: row;
+}
+
+.explore-form-row > div {
+ /* width: 100%; */
+}
+
+.explore-form {
+ margin-top: calc(var(--margin_top) * 2);
+}
+
+.explore-input-group {
+ /* margin: var(--margin_top) 0 calc(var(--margin_bottom) * 2); */
+ padding: 0 10%;
+}
+
+hr {
+ font-size: 1.25em;
+ margin: calc(var(--margin_top) * 2) 0 calc(var(--margin_bottom) * 2);
+}
+
+.explore-form .btn {
+ /* border: 1px solid var(--primary_d): */
+ /* position: relative; */
+}
+
+.button-wrapper {
+ /* justify-content: ; */
+ margin: calc(var(--margin_top) * 2) 0;
+ position: relative;
+ text-align: center;
+}
+
+button.btn {
+ background: var(--white);
+ border-color: var(--black);
+ border-radius: 0;
+ color: var(--black);
+ font-family: var(--font-sans-serif);
+ font-size: 1.0375em;
+}
+
+button.btn:hover {
+ background: var(--primary_a);
+ border-color: var(--black);
+ color: var(--white);
+}
+
+.arrow-right {
+ display: inline-block;
+ position: relative;
+}
+
+button.btn .arrow-right,
+button.btn .arrow-right::before,
+button.btn .arrow-right::after {
+ background: var(--primary_a);
+}
+
+button.btn:hover .arrow-right,
+button.btn:hover .arrow-right::before,
+button.btn:hover .arrow-right::after {
+ background: var(--white);
+}
+
+.form-wrapper {
+ margin-top: calc(var(--margin_top) * 2);
+ text-align: center;
+}
+
+
+.form-wrapper .explore-form h1:nth-of-type(2)::after {
+ background: var(--secondary_b);
+ border-radius: 50%;
+ color: var(--white);
+ content: "OR";
+ font-size: .3125em;
+ height: 4em;
+ left: 50%;
+ line-height: 4em;
+ position: absolute;
+ text-align: center;
+ top: -5em;
+ transform: translateX(-50%);
+ width: 4em;
+ white-space: nowrap;
+}
+
+#filter-checkboxes {
+ flex-direction: row;
+ padding-left: 40%;
+ text-transform: uppercase;
+ text-align: left;
+}
+
+@media (min-width: 64em) {
+ #filter-checkboxes {
+ padding-left: 0%;
+ }
+}
+
+#filter-checkboxes div {
+ letter-spacing: .1em;
+}
+
+#filter-checkboxes p {
+ margin: 0;
+}
+
+main.content-wrapper div.container {
+ min-width: 100vw;
+ max-width: 100vw;
+ margin: 0;
+}
+
+@media screen and (min-width: 992px) {
+ div.homepage-column {
+ align-self: stretch;
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ min-height: calc(100vh - var(--header_height));
+ /* max-height: calc(100vh - var(--header_height)); */
+ }
+}
+
+div#homepage-left-column {
+ background-color: var(--homepage_left_background);
+ overflow-y: auto;
+}
+
+.welcome-content-wrapper {
+ color: var(--home_font_color);
+ margin: 3rem 2rem;
+}
+
+.welcome-content-wrapper p {
+ font-family: var(--font-sans-serif-bold);
+}
+
+div.welcome-login-button-wrapper {
+ margin: 0 2rem;
+}
+
+div.welcome-login-button-wrapper a.btn.welcome-login-btn {
+ border-color: var(--home_font_color);
+ color: var(--home_font_color);
+}
+
+div.welcome-login-button-wrapper a.btn.welcome-login-btn span.arrow-right::before,
+div.welcome-login-button-wrapper a.btn.welcome-login-btn span.arrow-right,
+div.welcome-login-button-wrapper a.btn.welcome-login-btn span.arrow-right::after {
+ background: var(--home_font_color);
+}
+
+div#homepage-right-column {
+ background-color: var(--homepage_right_background);
+ display: flex;
+ flex-direction: column;
+ max-height: calc(100vh - var(--header_height));
+}
+
+div#homepage-right-column::after {
+ background: var(--homepage_right_background);
+ bottom: 0;
+ content: "";
+ left: 40%;
+ position: fixed;
+ right: 0;
+ top: 0;
+ z-index: -1;
+}
+
+div.proj-image-select-wrapper {
+ max-height: 100%;
+ display: flex;
+ flex-grow: 1;
+ justify-content: center;
+}
+
+.proj-image-select-wrapper img {
+ object-fit: contain;
+}
+
+div#proj-image-attribution {
+ color: var(--dark_font);
+ background-color: var(--accent_bg);
+ /* background-color: var(--white); */
+ border: var(--default_border);
+ border-radius: var(--default_border_radius);
+ bottom: .5rem;
+ opacity: 80%;
+ /* padding: .375rem .75rem; */
+ display: flex;
+ position: absolute;
+ right: .5rem;
+}
+
+div#proj-image-attribution .attribution-toggle {
+ color: #000;
+ font-family: serif;
+ font-size: 1.25rem;
+ line-height: 1;
+ padding: .375rem .75rem;
+ text-decoration: none;
+}
+
+div#proj-image-attribution .attribution-toggle.collapsed {
+ margin-right: 0;
+}
+
+div#proj-image-attribution p {
+ font-size: var(--small_font_size);
+ margin: 5px 5px 0 5px;
+}
+
+#collapseAttribution.show:before {
+ background-color: var(--dark_font);
+ border: 1px solid #fff;
+ color: #fff;
+ content: "X";
+ font-family: Tahoma, sans-serif;
+ font-size: .875rem;
+ font-weight: bold;
+ height: 100%;
+ left: 0;
+ padding: .25rem .6125rem;
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ z-index: 2;
+}
+
+/* Main */
+main {
+ min-height: calc(100vh - 96px);
+}
+
+.min-full-height {
+ min-height: calc(100vh - 96px);
+}
+
+
+main[data-theme="dark"] {
+ /* background: var(--black); */
+}
+
+main[data-theme="light"] {
+ background: var(--white);
+ --btn_border: 2px solid var(--black);
+ --btn_color: var(--black);
+ --theme_bg_color: var(--black);
+ --theme_color: var(--black);
+}
+
+/* a { color: #336633; } */
+a:hover {
+ cursor: pointer;
+ /* color: #336633; */
+ /* font-weight: bold; */
+}
+
+.btn,
+.btn-style {
+ background: transparent;
+ border: var(--btn_border);
+ border-radius: var(--default_border_radius);
+ color: var(--btn_color);
+ font-size: .9375em;
+ letter-spacing: 0.125em;
+ padding: .6375em 1.375em;
+}
+
+ .btn:hover {
+ background: var(--primary_a);
+ color: var(--btn_color);
+ }
+
+.arrow-right,
+.arrow-left {
+ background: var(--theme_color);
+ display: inline-flex;
+ height: 2px;
+ position: relative;
+ vertical-align: middle;
+ width: 24px;
+}
+
+.arrow-right {
+ margin-left: 4px;
+}
+
+.arrow-left {
+ margin-right: 4px;
+}
+
+ .arrow-right::before,
+ .arrow-right::after,
+ .arrow-left::before,
+ .arrow-left::after {
+ background: var(--theme_color);
+ content: "";
+ height: 2px;
+ position: absolute;
+ transform-origin: 50% 50%;
+ width: 10px;
+ }
+
+ .arrow-right::before,
+ .arrow-right::after {
+ right: -3px;
+ }
+
+ .arrow-left::before,
+ .arrow-left::after {
+ left: -3px;
+ }
+
+ .arrow-right::before,
+ .arrow-left::before {
+ top: -3px;
+ }
+
+ .arrow-right::before {
+ transform: rotate(45deg);
+ }
+
+ .arrow-left::before {
+ transform: rotate(-45deg);
+ }
+
+ .arrow-right::after,
+ .arrow-left::after {
+ bottom: -3px;
+ }
+
+ .arrow-right::after {
+ transform: rotate(-45deg);
+ }
+
+ .arrow-left::after {
+ transform: rotate(45deg);
+ }
+
+img {
+ height: auto;
+ max-width: 100%;
+}
+
+p {
+ font-size: 1.125em;
+ letter-spacing: 0.025em;
+ line-height: 1.75;
+ margin-bottom: var(--margin_bottom);
+}
+
+hr {
+ margin: var(--margin_top) 0 var(--margin_bottom);
+}
+
+h1 {
+ font-size: 2.5em;
+ font-family: var(--font-sans-serif-bold);
+ letter-spacing: var(--letter_spacing);
+ line-height: 1.4375;
+ margin-bottom: var(--margin_bottom);
+ text-align: left;
+}
+
+h2 {
+ font-size: 2.5em;
+ font-family: var(--font-sans-serif-bold);
+ letter-spacing: var(--letter_spacing);
+ line-height: 1.4375;
+}
+
+h3 {
+ color: var(--secondary_a);
+ font-family: var(--font-sans-serif-extrabold);
+ font-size: 1em;
+ letter-spacing: var(--letter_spacing);
+ margin: var(--margin_top) 0 var(--margin_bottom);
+ text-transform: uppercase;
+}
+
+h2 span {
+ font-size: .5em;
+ font-family: var(--font-sans-serif);
+}
+
+h4 {
+ font-size: 2em;
+ font-family: var(--font-sans-serif-bold);
+ letter-spacing: var(--letter_spacing);
+ margin: 0 0 var(--margin_bottom);
+}
+
+h5 {
+ font-size: 1.25em;
+}
+
+.accent-bg {
+ background: var(--accent_bg);
+}
+
+.white-bg {
+ background: var(--white);
+}
+
+#search-icon-label {
+ border-color: var(--secondary_a);
+ border-radius: 2em 0 0 2em;
+ border-right: none;
+ border-width: 2px;
+ background: var(--white);
+ padding: .375rem 1.125rem;
+}
+
+#search-text {
+ border-radius: 0 2em 2em 0;
+ border-left: none;
+}
+
+
+#export-links {
+ list-style-type: none;
+}
+#export-links li a,
+#export-links a,
+#export-links li span{
+ color: var(--link_color);
+}
+
+.text-right {
+ text-align: right;
+}
+
+.query-input-group::after {
+ background: var(--secondary_a);
+ content: '';
+ height: 2em;
+ position: absolute;
+ right: 6em;
+ top: .625em;
+ width: 1px;
+}
+
+.arrow-right.search-arrow,
+.arrow-right.search-arrow::before,
+.arrow-right.search-arrow::after {
+ background: var(--primary_a);
+}
+
+.arrow-right.search-arrow {
+ position: absolute;
+ right: 2.5em;
+ top: 1.5625em;
+ pointer-events: none;
+}
+
+.query-input-group {
+ position: relative;
+}
+
+#query-form-submit {
+ height: 100%;
+ opacity: 0;
+ pointer-events: all;
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 5em;
+ z-index: 9;
+}
+
+/* Filter Category Checkboxs */
+#filter-checkboxes {
+ margin: var(--margin_top) auto;
+}
+
+#filter-checkboxes div {
+ font-family: var(--font-sans-serif-extrabold);
+ letter-spacing: 0.05em;
+ position: relative;
+}
+
+#filter-checkboxes div p {
+ font-family: var(--font-sans-serif);
+ font-size: .875em;
+}
+
+#filter-checkboxes input[type=checkbox] {
+ height: 80%;
+ opacity: 0;
+ position: absolute;
+ width: 80%;
+ z-index: 9;
+}
+
+#filter-checkboxes input[type=checkbox]:checked ~ label {
+ background-color: var(--secondary_a);
+}
+
+#filter-checkboxes input[type=checkbox]:focus ~ label,
+#filter-checkboxes input[type=checkbox]:active ~ label {
+ outline: 2px solid var(--secondary_c);
+}
+
+#filter-checkboxes label {
+ background: var(--white);
+ border: 2px solid var(--secondary_a);
+ display: inline-block;
+ margin-bottom: 2px;
+ margin-right: 4px;
+ padding: 6px;
+ vertical-align: middle;
+}
\ No newline at end of file
diff --git a/client/app/layouts/explore.tsx b/client/app/layouts/explore.tsx
new file mode 100644
index 00000000..a3ad756b
--- /dev/null
+++ b/client/app/layouts/explore.tsx
@@ -0,0 +1,26 @@
+import type React from "react";
+import { Outlet } from "react-router";
+import Header from "../components/header";
+import { usePageContentAndSiteInfo } from "../api/pageContent";
+
+const ExploreLayout: React.FC = () => {
+ const { pageContent, siteInfo } = usePageContentAndSiteInfo();
+ const isAuthenticated = false; // TODO: Replace with actual authentication logic
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export default ExploreLayout;
diff --git a/client/app/layouts/private-route.tsx b/client/app/layouts/private-route.tsx
new file mode 100644
index 00000000..ab9fb5ae
--- /dev/null
+++ b/client/app/layouts/private-route.tsx
@@ -0,0 +1,15 @@
+import React, { ReactElement } from "react";
+import { Navigate, Outlet, useLocation } from "react-router";
+import { useLogin } from "../context/loginContext";
+
+/** Redirect to the login if the user is not authenticated*/
+export const PrivateRoute = (): ReactElement => {
+ const location = useLocation();
+ const { loginForm } = useLogin();
+
+ return loginForm.isAuthenticated ? (
+
+ ) : (
+
+ );
+};
diff --git a/client/app/root.tsx b/client/app/root.tsx
new file mode 100644
index 00000000..cd512e7a
--- /dev/null
+++ b/client/app/root.tsx
@@ -0,0 +1,120 @@
+import {
+ isRouteErrorResponse,
+ Links,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useLoaderData,
+} from "react-router";
+
+import type { Route } from "./+types/root";
+import "./App.css";
+import "bootstrap/dist/css/bootstrap.min.css";
+import {
+ fetchPageContentAndSiteInfo,
+ type PageContentAndSiteInfo,
+} from "./api/pageContent";
+import { LoginProvider } from "./context/loginContext";
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export const RouteToPath: Record = {
+ about: "/About",
+ help: "/Help",
+ welcome: "/Welcome",
+};
+
+export async function loader({ request }: Route.LoaderArgs) {
+ const url = new URL(request.url);
+ const [, path] = url.pathname.split("/");
+ const pageContent = await fetchPageContentAndSiteInfo(
+ RouteToPath[path as keyof typeof RouteToPath] || "/Welcome"
+ );
+ return pageContent;
+}
+
+export default function Root() {
+ const { pageContent, siteInfo } = useLoaderData();
+
+ return (
+ <>
+