diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 175e089..daaf368 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,7 +11,7 @@ This monorepo contains a Django backend and a Vite/React frontend, orchestrated - **frontend/**: Vite + React + TypeScript app. - Organized by `src/api/`, `components/`, `features/`, `layouts/`, `pages/`, `routes/`. - Uses React Router layouts and nested routes (see `src/layouts/`, `src/routes/`). -- **docker-compose.yml**: Orchestrates backend, frontend, Redis, and Postgres for local/dev. + - Uses Tailwind CSS for styling (configured via `src/index.css` with `@import "tailwindcss";`). Prefer utility classes over custom CSS. ## Developer Workflows - **Backend** @@ -39,6 +39,7 @@ This monorepo contains a Django backend and a Vite/React frontend, orchestrated - Route definitions and guards in `src/routes/` (`ROUTES.md`). - Use TypeScript strict mode (see `tsconfig.*.json`). - Linting: ESLint config in `eslint.config.js`. + - Styling: Tailwind CSS is present. Prefer utility classes; keep minimal component-scoped CSS. Global/base styles live in `src/index.css`. Avoid inline styles and CSS-in-JS unless necessary. ### Frontend API Client (required) All frontend API calls must use the shared client at frontend/src/api/Client.ts. diff --git a/backend/account/migrations/0001_initial.py b/backend/account/migrations/0001_initial.py new file mode 100644 index 0000000..41ba5ce --- /dev/null +++ b/backend/account/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.7 on 2025-10-28 22:28 + +import account.models +import django.contrib.auth.validators +import django.core.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('role', models.CharField(choices=[('admin', 'Admin'), ('mod', 'Moderator'), ('regular', 'Regular')], default='regular', max_length=20)), + ('phone_number', models.CharField(blank=True, max_length=16, null=True, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])), + ('email_verified', models.BooleanField(default=False)), + ('email', models.EmailField(db_index=True, max_length=254, unique=True)), + ('gdpr', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=False)), + ('create_time', models.DateTimeField(auto_now_add=True)), + ('city', models.CharField(blank=True, max_length=100, null=True)), + ('street', models.CharField(blank=True, max_length=200, null=True)), + ('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('objects', account.models.CustomUserManager()), + ('active', account.models.ActiveUserManager()), + ], + ), + ] diff --git a/backend/advertisement/migrations/__init__.py b/backend/account/migrations/__init__.py similarity index 100% rename from backend/advertisement/migrations/__init__.py rename to backend/account/migrations/__init__.py diff --git a/backend/account/models.py b/backend/account/models.py index 54e6928..a01eebe 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -1,6 +1,6 @@ import uuid from django.db import models -from django.contrib.auth.models import AbstractUser, Group, Permission +from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission from django.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator from django.conf import settings @@ -16,7 +16,13 @@ import logging logger = logging.getLogger(__name__) +class CustomUserManager(UserManager): + # Inherit get_by_natural_key and all auth behaviors + use_in_migrations = True +class ActiveUserManager(CustomUserManager): + def get_queryset(self): + return super().get_queryset().filter(is_active=True) class CustomUser(SoftDeleteModel, AbstractUser): groups = models.ManyToManyField( @@ -83,9 +89,10 @@ class CustomUser(SoftDeleteModel, AbstractUser): "email" ] - - def __str__(self): - return f"{self.email} at {self.create_time.strftime('%d-%m-%Y %H:%M:%S')}" + # Ensure default manager has get_by_natural_key + objects = CustomUserManager() + # Optional convenience manager for active users only + active = ActiveUserManager() def delete(self, *args, **kwargs): self.is_active = False @@ -93,25 +100,29 @@ class CustomUser(SoftDeleteModel, AbstractUser): return super().delete(*args, **kwargs) def save(self, *args, **kwargs): - if self.pk is None: # if newely created user - - from django.contrib.auth.models import Group - group, _ = Group.objects.get_or_create(name=self.role) - self.groups.set([group]) + is_new = self._state.adding # True if object hasn't been saved yet + # Pre-save flags for new users + if is_new: if self.is_superuser or self.role == "admin": + # ensure admin flags are consistent self.is_active = True - - if self.role == 'admin': - self.is_staff = True - self.is_superuser = True - - if self.is_superuser: - self.role = 'admin' - + self.is_staff = True + self.is_superuser = True + self.role = "admin" else: self.is_staff = False - + + # First save to obtain a primary key + super().save(*args, **kwargs) + + # Assign group after we have a PK + if is_new: + from django.contrib.auth.models import Group + group, _ = Group.objects.get_or_create(name=self.role) + # Use add/set now that PK exists + self.groups.set([group]) + return super().save(*args, **kwargs) diff --git a/backend/account/serializers.py b/backend/account/serializers.py index 1319eff..7a1ceb5 100644 --- a/backend/account/serializers.py +++ b/backend/account/serializers.py @@ -27,21 +27,16 @@ class CustomUserSerializer(serializers.ModelSerializer): "last_name", "email", "role", - "account_type", "email_verified", "phone_number", "create_time", - "var_symbol", - "bank_account", - "ICO", - "RC", "city", "street", - "PSC", - "GDPR", + "postal_code", + "gdpr", "is_active", ] - read_only_fields = ["id", "create_time", "GDPR", "username"] # <-- removed "account_type" + read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type" def update(self, instance, validated_data): user = self.context["request"].user diff --git a/backend/commerce/migrations/0001_initial.py b/backend/commerce/migrations/0001_initial.py index 28cc9c2..97b32df 100644 --- a/backend/commerce/migrations/0001_initial.py +++ b/backend/commerce/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-10-28 01:24 +# Generated by Django 5.2.7 on 2025-10-28 22:28 import django.db.models.deletion from django.db import migrations, models diff --git a/backend/requirements.txt b/backend/requirements.txt index be16d6b..97ed758 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -78,7 +78,7 @@ django-celery-beat #slouží k plánování úkolů pro Celery #opencv-python #moviepy use this better instead of pillow #moviepy -#yt-dlp +yt-dlp weasyprint #tvoření PDFek z html dokumentu + css styly diff --git a/backend/thirdparty/downloader/migrations/0001_initial.py b/backend/thirdparty/downloader/migrations/0001_initial.py deleted file mode 100644 index 16f6b26..0000000 --- a/backend/thirdparty/downloader/migrations/0001_initial.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-28 00:14 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='DownloaderModel', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('url', models.URLField()), - ('download_time', models.DateTimeField(auto_now_add=True)), - ('status', models.CharField(max_length=50)), - ('requested_format', models.CharField(blank=True, db_index=True, max_length=100, null=True)), - ('format_id', models.CharField(blank=True, db_index=True, max_length=50, null=True)), - ('ext', models.CharField(blank=True, db_index=True, max_length=20, null=True)), - ('vcodec', models.CharField(blank=True, db_index=True, max_length=50, null=True)), - ('acodec', models.CharField(blank=True, db_index=True, max_length=50, null=True)), - ('width', models.IntegerField(blank=True, null=True)), - ('height', models.IntegerField(blank=True, null=True)), - ('fps', models.FloatField(blank=True, null=True)), - ('abr', models.FloatField(blank=True, null=True)), - ('vbr', models.FloatField(blank=True, null=True)), - ('tbr', models.FloatField(blank=True, null=True)), - ('asr', models.IntegerField(blank=True, null=True)), - ('filesize', models.BigIntegerField(blank=True, null=True)), - ('duration', models.FloatField(blank=True, null=True)), - ('title', models.CharField(blank=True, max_length=512, null=True)), - ('extractor', models.CharField(blank=True, db_index=True, max_length=100, null=True)), - ('extractor_key', models.CharField(blank=True, max_length=100, null=True)), - ('video_id', models.CharField(blank=True, db_index=True, max_length=128, null=True)), - ('webpage_url', models.URLField(blank=True, null=True)), - ('is_audio_only', models.BooleanField(db_index=True, default=False)), - ('ip_address', models.GenericIPAddressField(blank=True, null=True)), - ('user_agent', models.TextField(blank=True, null=True)), - ('error_message', models.TextField(blank=True, null=True)), - ('raw_info', models.JSONField(blank=True, null=True)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'indexes': [models.Index(fields=['download_time'], name='downloader__downloa_ef522e_idx'), models.Index(fields=['ext', 'is_audio_only'], name='downloader__ext_2aa7af_idx'), models.Index(fields=['requested_format'], name='downloader__request_f4048b_idx'), models.Index(fields=['extractor'], name='downloader__extract_b39777_idx')], - }, - ), - ] diff --git a/backend/thirdparty/downloader/migrations/__init__.py b/backend/thirdparty/downloader/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/thirdparty/downloader/urls.py b/backend/thirdparty/downloader/urls.py index 542e675..d42e04a 100644 --- a/backend/thirdparty/downloader/urls.py +++ b/backend/thirdparty/downloader/urls.py @@ -1,14 +1,13 @@ from django.urls import path -from .views import DownloaderLogView, DownloaderStatsView -from .views import DownloaderFormatsView, DownloaderFileView +from .views import DownloaderFormatsView, DownloaderFileView, DownloaderStatsView urlpatterns = [ # Probe formats for a URL (size-checked) - path("api/downloader/formats/", DownloaderFormatsView.as_view(), name="downloader-formats"), + path("formats/", DownloaderFormatsView.as_view(), name="downloader-formats"), + # Download selected format (enforces size limit) - path("api/downloader/download/", DownloaderFileView.as_view(), name="downloader-download"), + path("download/", DownloaderFileView.as_view(), name="downloader-download"), + # Aggregated statistics - path("api/downloader/stats/", DownloaderStatsView.as_view(), name="downloader-stats"), - # Legacy helper - path("api/downloader/logs/", DownloaderLogView.as_view(), name="downloader-log"), + path("stats/", DownloaderStatsView.as_view(), name="downloader-stats"), ] diff --git a/backend/thirdparty/downloader/views.py b/backend/thirdparty/downloader/views.py index 719d854..907efa7 100644 --- a/backend/thirdparty/downloader/views.py +++ b/backend/thirdparty/downloader/views.py @@ -9,6 +9,7 @@ from django.http import StreamingHttpResponse, JsonResponse from django.utils.text import slugify from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator +from django.db.utils import OperationalError, ProgrammingError # docs + schema helpers from rest_framework import serializers @@ -26,6 +27,7 @@ import math import json import tempfile from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import quote as urlquote from .models import DownloaderModel from .serializers import DownloaderLogSerializer @@ -141,9 +143,30 @@ def _client_meta(request) -> Tuple[Optional[Any], Optional[str], Optional[str]]: user = getattr(request, "user", None) return user, ip, ua +# Safe logger: swallow DB errors if table is missing/not migrated yet +def _log_safely(*, info, requested_format, status: str, url: str, user, ip_address: str, user_agent: str, error_message: str | None = None): + try: + DownloaderModel.from_ydl_info( + info=info, + requested_format=requested_format, + status=status, + url=url, + user=user, + ip_address=ip_address, + user_agent=user_agent, + error_message=error_message, + ) + except (OperationalError, ProgrammingError): + # migrations not applied or table missing – ignore + pass + except Exception: + # never break the request on logging failures + pass + class DownloaderFormatsView(APIView): """Probe media URL and return available formats with estimated sizes and limit flags.""" permission_classes = [AllowAny] + authentication_classes = [] @extend_schema( tags=["downloader"], @@ -224,7 +247,7 @@ class DownloaderFormatsView(APIView): except Exception as e: # log probe error user, ip, ua = _client_meta(request) - DownloaderModel.from_ydl_info( + _log_safely( info={"webpage_url": url}, requested_format=None, status="probe_error", @@ -258,7 +281,7 @@ class DownloaderFormatsView(APIView): # Log probe user, ip, ua = _client_meta(request) - DownloaderModel.from_ydl_info( + _log_safely( info=info, requested_format=None, status="probe_ok", @@ -280,6 +303,7 @@ class DownloaderFormatsView(APIView): class DownloaderFileView(APIView): """Download selected format if under max size, then stream the file back.""" permission_classes = [AllowAny] + authentication_classes = [] @extend_schema( tags=["downloader"], @@ -288,7 +312,7 @@ class DownloaderFileView(APIView): description="Downloads with a strict max filesize guard and streams as application/octet-stream.", request=DownloadRequestSchema, responses={ - 200: OpenApiResponse(response=OpenApiTypes.BINARY, media_type="application/octet-stream"), + 200: OpenApiTypes.BINARY, # was OpenApiResponse(..., media_type="application/octet-stream") 400: OpenApiResponse(response=ErrorResponseSchema), 413: OpenApiResponse(response=ErrorResponseSchema), 500: OpenApiResponse(response=ErrorResponseSchema), @@ -334,7 +358,7 @@ class DownloaderFileView(APIView): info = ydl.extract_info(url, download=False) except Exception as e: user, ip, ua = _client_meta(request) - DownloaderModel.from_ydl_info( + _log_safely( info={"webpage_url": url}, requested_format=fmt_id, status="precheck_error", @@ -358,7 +382,7 @@ class DownloaderFileView(APIView): est_size = _estimate_size_bytes(selected, duration) if est_size is not None and est_size > max_bytes: user, ip, ua = _client_meta(request) - DownloaderModel.from_ydl_info( + _log_safely( info=selected, requested_format=fmt_id, status="blocked_by_size", @@ -400,7 +424,7 @@ class DownloaderFileView(APIView): filepath = result.get("requested_downloads", [{}])[0].get("filepath") or result.get("_filename") except Exception as e: user, ip, ua = _client_meta(request) - DownloaderModel.from_ydl_info( + _log_safely( info=selected, requested_format=fmt_id, status="download_error", @@ -425,7 +449,7 @@ class DownloaderFileView(APIView): try: selected_info = dict(selected) selected_info["filesize"] = os.path.getsize(filepath) - DownloaderModel.from_ydl_info( + _log_safely( info=selected_info, requested_format=fmt_id, status="success", @@ -451,7 +475,13 @@ class DownloaderFileView(APIView): pass resp = StreamingHttpResponse(file_generator(filepath), content_type="application/octet-stream") - resp["Content-Disposition"] = f'attachment; filename="{safe_name}"' + # Include both plain and RFC 5987 encoded filename + resp["Content-Disposition"] = ( + f'attachment; filename="{safe_name}"; filename*=UTF-8\'\'{urlquote(safe_name)}' + ) + # Expose headers so the browser can read them via XHR/fetch + resp["X-Filename"] = safe_name + resp["Access-Control-Expose-Headers"] = "Content-Disposition, X-Filename, Content-Length, Content-Type" try: resp["Content-Length"] = str(os.path.getsize(filepath)) except Exception: @@ -521,19 +551,3 @@ class DownloaderStatsView(APIView): "top_acodec": top_acodec, "audio_vs_video": audio_vs_video, }) - - -# Minimal placeholder so existing URL doesn't break; prefer using automatic logs above. -class DownloaderLogView(APIView): - permission_classes = [AllowAny] - - @extend_schema( - tags=["downloader"], - operation_id="downloader_log_helper", - summary="Deprecated helper", - description="Use /api/downloader/formats/ then /api/downloader/download/.", - responses={200: inline_serializer(name="LogHelper", fields={"detail": serializers.CharField()})}, - ) - def post(self, request): - """POST to the deprecated log helper endpoint.""" - return Response({"detail": "Use /api/downloader/formats/ then /api/downloader/download/."}, status=200) diff --git a/backend/thirdparty/gopay/migrations/__init__.py b/backend/thirdparty/gopay/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/thirdparty/stripe/migrations/0001_initial.py b/backend/thirdparty/stripe/migrations/0001_initial.py index b8d70c0..9377f3e 100644 --- a/backend/thirdparty/stripe/migrations/0001_initial.py +++ b/backend/thirdparty/stripe/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-10-28 00:13 +# Generated by Django 5.2.7 on 2025-10-28 22:28 from django.db import migrations, models diff --git a/backend/thirdparty/trading212/migrations/__init__.py b/backend/thirdparty/trading212/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/vontor_cz/asgi.py b/backend/vontor_cz/asgi.py index 44ef2fb..626be7f 100644 --- a/backend/vontor_cz/asgi.py +++ b/backend/vontor_cz/asgi.py @@ -17,9 +17,9 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings') application = ProtocolTypeRouter({ "http": get_asgi_application(), - "websocket": AuthMiddlewareStack( - URLRouter( - #myapp.routing.websocket_urlpatterns - ) - ), + # "websocket": AuthMiddlewareStack( + # URLRouter( + # #myapp.routing.websocket_urlpatterns + # ) + # ), }) diff --git a/backend/vontor_cz/urls.py b/backend/vontor_cz/urls.py index 4130a41..04bbb31 100644 --- a/backend/vontor_cz/urls.py +++ b/backend/vontor_cz/urls.py @@ -32,8 +32,11 @@ urlpatterns = [ path('admin/', admin.site.urls), path('api/account/', include('account.urls')), + #path('api/commerce/', include('commerce.urls')), + #path('api/advertisments/', include('advertisements.urls')), path('api/stripe/', include('thirdparty.stripe.urls')), path('api/trading212/', include('thirdparty.trading212.urls')), + path('api/downloader/', include('thirdparty.downloader.urls')), ] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e9b1a1..faebd73 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,14 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@tailwindcss/vite": "^4.1.16", "@types/react-router": "^5.1.20", "axios": "^1.13.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-icons": "^5.5.0", - "react-router-dom": "^7.8.1" + "react-router-dom": "^7.8.1", + "tailwindcss": "^4.1.16" }, "devDependencies": { "@eslint/js": "^9.33.0", @@ -333,7 +335,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -350,7 +351,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -367,7 +367,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -384,7 +383,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -401,7 +399,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -418,7 +415,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -435,7 +431,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -452,7 +447,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -469,7 +463,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -486,7 +479,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -503,7 +495,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -520,7 +511,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -537,7 +527,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -554,7 +543,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -571,7 +559,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -588,7 +575,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -605,7 +591,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -622,7 +607,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -639,7 +623,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -656,7 +639,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -673,7 +655,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -690,7 +671,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -707,7 +687,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -724,7 +703,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -741,7 +719,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -758,7 +735,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -992,18 +968,26 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1013,14 +997,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.30", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1079,7 +1061,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1093,7 +1074,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1107,7 +1087,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1121,7 +1100,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1135,7 +1113,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1149,7 +1126,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1163,7 +1139,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1177,7 +1152,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1191,7 +1165,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1205,7 +1178,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1219,7 +1191,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1233,7 +1204,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1247,7 +1217,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1261,7 +1230,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1275,7 +1243,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1289,7 +1256,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1303,7 +1269,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1317,7 +1282,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1331,7 +1295,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1345,13 +1308,269 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", + "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.19", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.16" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz", + "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-x64": "4.1.16", + "@tailwindcss/oxide-freebsd-x64": "4.1.16", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-x64-musl": "4.1.16", + "@tailwindcss/oxide-wasm32-wasi": "4.1.16", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz", + "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz", + "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz", + "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz", + "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz", + "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz", + "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz", + "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz", + "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz", + "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz", + "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz", + "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz", + "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.16.tgz", + "integrity": "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.16", + "@tailwindcss/oxide": "4.1.16", + "tailwindcss": "4.1.16" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, "node_modules/@types/axios": { "version": "0.9.36", "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.9.36.tgz", @@ -1408,7 +1627,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/history": { @@ -2060,6 +2278,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2081,6 +2308,19 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2130,7 +2370,6 @@ "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -2534,7 +2773,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2639,6 +2877,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2772,6 +3016,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2863,6 +3116,255 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2896,6 +3398,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2974,7 +3485,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -3090,7 +3600,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -3110,7 +3619,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3285,7 +3793,6 @@ "version": "4.46.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -3394,7 +3901,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3426,11 +3932,29 @@ "node": ">=8" } }, + "node_modules/tailwindcss": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", + "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3447,7 +3971,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3465,7 +3988,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3596,7 +4118,6 @@ "version": "7.1.12", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -3671,7 +4192,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3689,7 +4209,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" diff --git a/frontend/package.json b/frontend/package.json index f9c435c..e328aec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,12 +10,14 @@ "preview": "vite preview" }, "dependencies": { + "@tailwindcss/vite": "^4.1.16", "@types/react-router": "^5.1.20", "axios": "^1.13.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-icons": "^5.5.0", - "react-router-dom": "^7.8.1" + "react-router-dom": "^7.8.1", + "tailwindcss": "^4.1.16" }, "devDependencies": { "@eslint/js": "^9.33.0", diff --git a/frontend/public/portfolio/davo1.png b/frontend/public/portfolio/davo1.png new file mode 100644 index 0000000..5f2f654 Binary files /dev/null and b/frontend/public/portfolio/davo1.png differ diff --git a/frontend/public/portfolio/epinger.png b/frontend/public/portfolio/epinger.png new file mode 100644 index 0000000..e6cf9bc Binary files /dev/null and b/frontend/public/portfolio/epinger.png differ diff --git a/frontend/public/portfolio/perlica.png b/frontend/public/portfolio/perlica.png new file mode 100644 index 0000000..fdfee8a Binary files /dev/null and b/frontend/public/portfolio/perlica.png differ diff --git a/frontend/src/api/apps/Downloader.ts b/frontend/src/api/apps/Downloader.ts index ab72e11..d1e3f55 100644 --- a/frontend/src/api/apps/Downloader.ts +++ b/frontend/src/api/apps/Downloader.ts @@ -1,57 +1,104 @@ import Client from "../Client"; -export type Choices = { - file_types: string[]; - qualities: string[]; +export type FormatOption = { + format_id: string; + ext: string | null; + vcodec: string | null; + acodec: string | null; + fps: number | null; + tbr: number | null; + abr: number | null; + vbr: number | null; + asr: number | null; + filesize: number | null; + filesize_approx: number | null; + estimated_size_bytes: number | null; + size_ok: boolean; + format_note: string | null; + resolution: string | null; // e.g. "1920x1080" + audio_only: boolean; }; -export type DownloadPayload = { - url: string; - file_type?: string; - quality?: string; +export type FormatsResponse = { + title: string | null; + duration: number | null; + extractor: string | null; + video_id: string | null; + max_size_bytes: number; + options: FormatOption[]; }; +// Probe available formats for a URL (no auth required) +export async function probeFormats(url: string): Promise { + const res = await Client.public.post("/api/downloader/formats/", { url }); + return res.data as FormatsResponse; +} + +// Download selected format as a Blob and resolve filename from headers +export async function downloadFormat(url: string, format_id: string): Promise<{ blob: Blob; filename: string }> { + const res = await Client.public.post( + "/api/downloader/download/", + { url, format_id }, + { responseType: "blob" } + ); + + // Try to parse Content-Disposition filename first, then X-Filename (exposed by backend) + const cd = res.headers?.["content-disposition"] as string | undefined; + const xfn = res.headers?.["x-filename"] as string | undefined; + const filename = + parseContentDispositionFilename(cd) || + (xfn && xfn.trim()) || + inferFilenameFromUrl(url, (res.headers?.["content-type"] as string | undefined)) || + "download.bin"; + + return { blob: res.data as Blob, filename }; +} + +// Deprecated types kept for compatibility if referenced elsewhere +export type Choices = { file_types: string[]; qualities: string[] }; export type DownloadJobResponse = { id: string; status: "pending" | "running" | "finished" | "failed"; detail?: string; download_url?: string; - progress?: number; // 0-100 + progress?: number; }; -// Fallback when choices endpoint is unavailable or models are hardcoded -const FALLBACK_CHOICES: Choices = { - file_types: ["auto", "video", "audio"], - qualities: ["best", "good", "worst"], -}; +// Helpers +function parseContentDispositionFilename(cd?: string): string | null { + if (!cd) return null; + // filename*=UTF-8''encoded or filename="plain" + const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); + if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]); + const plainMatch = cd.match(/filename\s*=\s*"([^"]+)"/i) || cd.match(/filename\s*=\s*([^;]+)/i); + return plainMatch?.[1]?.trim() || null; +} -/** - * Fetch dropdown choices from backend (adjust path to match your Django views). - * Expected response shape: - * { file_types: string[], qualities: string[] } - */ -export async function getChoices(): Promise { +function inferFilenameFromUrl(url: string, contentType?: string): string { try { - const res = await Client.auth.get("/api/downloader/choices/"); - return res.data as Choices; + const u = new URL(url); + const last = u.pathname.split("/").filter(Boolean).pop(); + if (last) return last; } catch { - return FALLBACK_CHOICES; + // ignore } + if (contentType) { + const ext = contentTypeToExt(contentType); + return `download${ext ? `.${ext}` : ""}`; + } + return "download.bin"; } -/** - * Submit a new download job (adjust path/body to your viewset). - * Example payload: { url, file_type, quality } - */ -export async function submitDownload(payload: DownloadPayload): Promise { - const res = await Client.auth.post("/api/downloader/jobs/", payload); - return res.data as DownloadJobResponse; -} - -/** - * Get job status by ID. Returns progress, status, and download_url when finished. - */ -export async function getJobStatus(id: string): Promise { - const res = await Client.auth.get(`/api/downloader/jobs/${id}/`); - return res.data as DownloadJobResponse; +function contentTypeToExt(ct: string): string | null { + const map: Record = { + "video/mp4": "mp4", + "audio/mpeg": "mp3", + "audio/mp4": "m4a", + "audio/aac": "aac", + "audio/ogg": "ogg", + "video/webm": "webm", + "audio/webm": "webm", + "application/octet-stream": "bin", + }; + return map[ct] || null; } diff --git a/frontend/src/components/Footer/footer.module.css b/frontend/src/components/Footer/footer.module.css index 0be6ec2..54d7228 100644 --- a/frontend/src/components/Footer/footer.module.css +++ b/frontend/src/components/Footer/footer.module.css @@ -6,6 +6,10 @@ footer a i{ color: white; text-decoration: none; } +footer a:hover i{ + color: var(--c-text); + text-decoration: none; +} footer{ font-family: "Roboto Mono", monospace; diff --git a/frontend/src/components/ads/Drone/Drone.tsx b/frontend/src/components/ads/Drone/Drone.tsx index eb99062..02846a8 100644 --- a/frontend/src/components/ads/Drone/Drone.tsx +++ b/frontend/src/components/ads/Drone/Drone.tsx @@ -61,46 +61,28 @@ export default function Drone() {
-

Letecké snímky dronem

+

Letecké záběry, co zaujmou

Oprávnění

-

- A1, A2, A3 a průkaz na vysílačku! -
- Mohu garantovat bezpečný provoz dronu i ve složitějších podmínkách. - Mám také možnost žádat o povolení k letu v blízkosti letišť! -

+

Oprávnění A1/A2/A3 + radiostanice. Bezpečný provoz i v omezených zónách, povolení zajistím.

Cena

-

- Nabízím letecké záběry dronem
- za cenu 3 000 Kč. -

-

- Pokud se nacházíte v Ostravě, doprava je zdarma. Pro oblasti mimo Ostravu účtuji 10 Kč/km. -

-

- Cena se může odvíjet ještě podle složitosti získaní povolení.* -

+

Paušál 3 000 Kč. Ostrava zdarma; mimo 10 Kč/km. Cena se může lišit dle povolení.

Výstup

-

- Rád Vám připravím jednoduchý sestřih videa, který můžete rychle použít, - nebo Vám mohu poskytnout samotné záběry k vlastní editaci. -

+

Krátký sestřih nebo surové záběry — podle potřeby.

- V případě zájmu mě neváhejte
- kontaktovat! + Zájem?
diff --git a/frontend/src/components/ads/Portfolio/Portfolio.module.css b/frontend/src/components/ads/Portfolio/Portfolio.module.css index fdde5e2..ee6d871 100644 --- a/frontend/src/components/ads/Portfolio/Portfolio.module.css +++ b/frontend/src/components/ads/Portfolio/Portfolio.module.css @@ -27,8 +27,11 @@ transform-origin: bottom; transition: transform 0.5s ease-in-out; + transform: skew(-5deg); z-index: 3; + box-shadow: #000000 5px 5px 15px; + } .portfolio div span svg{ font-size: 5em; @@ -56,7 +59,7 @@ } .portfolio .door-open{ - transform: rotateX(180deg); + transform: rotateX(90deg) skew(-2deg) !important; } .portfolio>header { @@ -131,6 +134,8 @@ border-radius: 1em; border-top-left-radius: 0; + + aspect-ratio: 16 / 9; } .portfolio div article { @@ -145,7 +150,6 @@ } .portfolio div article header a img { - padding: 2em 0; width: 80%; margin: auto; } diff --git a/frontend/src/components/ads/Portfolio/Portfolio.tsx b/frontend/src/components/ads/Portfolio/Portfolio.tsx index be2589a..d9eb3b9 100644 --- a/frontend/src/components/ads/Portfolio/Portfolio.tsx +++ b/frontend/src/components/ads/Portfolio/Portfolio.tsx @@ -6,23 +6,34 @@ interface PortfolioItem { href: string src: string alt: string + // Optional per-item styling (prefer Tailwind utility classes in className/imgClassName) + className?: string + imgClassName?: string + style?: React.CSSProperties + imgStyle?: React.CSSProperties } const portfolioItems: PortfolioItem[] = [ { href: "https://davo1.cz", - src: "/home/img/portfolio/DAVO_logo_2024_bile.png", + src: "/portfolio/davo1.png", alt: "davo1.cz logo", + imgClassName: "bg-black rounded-lg p-4", + //className: "bg-white/5 rounded-lg p-4", }, { href: "https://perlica.cz", - src: "/home/img/portfolio/perlica-3.webp", + src: "/portfolio/perlica.png", alt: "Perlica logo", + imgClassName: "rounded-lg", + // imgClassName: "max-h-12", }, { href: "http://epinger2.cz", - src: "/home/img/portfolio/logo_epinger.svg", + src: "/portfolio/epinger.png", alt: "Epinger2 logo", + imgClassName: "bg-white rounded-lg", + // imgClassName: "max-h-12", }, ] @@ -51,10 +62,19 @@ export default function Portfolio() { {portfolioItems.map((item, index) => ( -
+
- {item.alt} + {item.alt}
diff --git a/frontend/src/components/navbar/HomeNav.module.css b/frontend/src/components/navbar/HomeNav.module.css index a65eb83..7ddbfa4 100644 --- a/frontend/src/components/navbar/HomeNav.module.css +++ b/frontend/src/components/navbar/HomeNav.module.css @@ -36,6 +36,10 @@ nav.isSticky-nav{ nav ul #nav-logo{ border-right: 0.2em solid var(--c-lines); } +/* Add class alias for logo used in TSX */ +.logo { + border-right: 0.2em solid var(--c-lines); +} nav ul #nav-logo span{ line-height: 0.75; font-size: 1.5em; @@ -47,10 +51,21 @@ nav a{ position: relative; text-decoration: none; } - nav a:hover{ color: #fff; } +/* Unify link/summary layout to prevent distortion */ +nav a, +nav summary { + color: #fff; + transition: color 1s; + position: relative; + text-decoration: none; + cursor: pointer; + display: inline-block; /* ensure consistent inline sizing */ + vertical-align: middle; /* align with neighbors */ + padding: 0; /* keep padding controlled by li */ +} nav a::before { content: ""; @@ -67,7 +82,125 @@ nav a::before { nav a:hover::before { transform: scaleX(1); } +nav summary:hover { + color: #fff; +} +/* underline effect shared for links and summary */ +nav a::before, +nav summary::before { + content: ""; + position: absolute; + display: block; + width: 100%; + height: 2px; + bottom: 0; + left: 0; + background-color: #fff; + transform: scaleX(0); + transition: transform 0.3s ease; +} +nav a:hover::before, +nav summary:hover::before { + transform: scaleX(1); +} + +/* Submenu support */ +.hasSubmenu { + position: relative; + vertical-align: middle; /* align with other inline items */ +} + +/* Keep details inline to avoid breaking the first row flow */ +.hasSubmenu details { + display: inline-block; + margin: 0; + padding: 0; +} + +/* Ensure "Services" and caret stay on the same line */ +.hasSubmenu details > summary { + display: inline-flex; /* horizontal layout */ + align-items: center; /* vertical alignment */ + gap: 0.5em; /* space between text and icon */ + white-space: nowrap; /* prevent wrapping */ +} + +/* Hide native disclosure icon/marker on summary */ +.hasSubmenu details > summary { + list-style: none; + outline: none; +} +.hasSubmenu details > summary::-webkit-details-marker { + display: none; +} +.hasSubmenu details > summary::marker { + content: ""; +} + +/* Reusable caret for submenu triggers */ +.caret { + transition: transform 0.2s ease-in-out; +} + +/* Rotate caret when submenu is open */ +.hasSubmenu details[open] .caret { + transform: rotate(180deg); +} + +/* Submenu box: place directly under nav with a tiny gap (no overlap) */ +.submenu { + list-style: none; + margin: 1em 0; + padding: 0.5em 0; + position: absolute; + left: 0; + top: calc(100% + 0.25em); + display: none; + background: var(--c-background-light); + border: 1px solid var(--c-lines); + border-radius: 0.75em; + min-width: max-content; + text-align: left; + z-index: 10; +} +.submenu li { + display: block; + padding: 0; +} +.submenu a { + display: inline-block; + padding: 0; /* remove padding so underline equals text width */ + margin: 0.35em 0; /* spacing without affecting underline width */ +} + +/* Show submenu when open */ +.hasSubmenu details[open] .submenu { + display: flex; + flex-direction: column; +} + +/* Hamburger toggle class (used by TSX) */ +.toggle { + display: none; + transition: transform 0.5s ease; +} +.toggleRotated { + transform: rotate(180deg); +} + +/* Bridge TSX classnames to existing rules */ +.navList { + list-style: none; + padding: 0; +} +.navList li { + display: inline; + padding: 0 3em; +} +.navList li a { + text-decoration: none; +} nav ul { list-style: none; @@ -129,6 +262,11 @@ nav ul li a { max-height: 2em; } + /* When TSX adds styles.open to the UL, expand it */ + .open { + max-height: 20em; + } + nav ul:last-child{ padding-bottom: 1em; } @@ -139,4 +277,28 @@ nav ul li a { border-bottom: 0.2em solid var(--c-lines); border-right: none; } - } \ No newline at end of file + /* Show hamburger on mobile */ + .toggle { + margin-top: 0.25em; + margin-left: 0.75em; + position: absolute; + left: 0; + display: block; + font-size: 2em; + } + + /* Submenu stacks inline under the parent item on mobile */ + .submenu { + position: static; + border: none; + border-radius: 0; + background: transparent; + padding: 0 0 0.5em 0.5em; + min-width: unset; + } + .submenu a { + display: inline-block; + padding: 0; /* keep no padding on mobile too */ + margin: 0.25em 0.5em; /* spacing via margin */ + } +} \ No newline at end of file diff --git a/frontend/src/components/navbar/HomeNav.tsx b/frontend/src/components/navbar/HomeNav.tsx index 3d9965c..1d45f04 100644 --- a/frontend/src/components/navbar/HomeNav.tsx +++ b/frontend/src/components/navbar/HomeNav.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react" import styles from "./HomeNav.module.css" -import { FaBars } from "react-icons/fa"; +import { FaBars, FaChevronDown } from "react-icons/fa"; export default function HomeNav() { const [navOpen, setNavOpen] = useState(false) @@ -9,7 +9,12 @@ export default function HomeNav() { return (