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 ( + <> +