-
+
+
-
+
Nabídka tvorby webových stránek
-
+
- Jsme malý tým , který se snaží prorazit a přinášet moderní řešení za férové
- ceny.
+ Jsme malý tým , který se snaží prorazit a přinášet moderní řešení za férové ceny.
Nabízíme také levný hosting a SSL zabezpečení zdarma .
@@ -23,115 +21,108 @@
-
+
-
+
Balíčky
-
+
-
+
BASIC
Jednoduchá prezentační webová stránka
Moderní a responzivní design (PC, tablety, mobily)
- Maximalní počet stránek: 5
- Použítí vlastní domény a SSL certifikát zdarma
+ Max. počet stránek: 5
+ Seřízení vlastní domény, a k tomuSSL certifikát zdarma
-
- Cena: 5 000 Kč
- (jednorázově) + 100 Kč / měsíc
+
+ Cena: 5 000 Kč (jednorázově) + 100 Kč / měsíc
+
-
-
+
STANDARD
Vše z balíčku BASIC
- Kontaktní formulář, který posílá pobídky na váš email
- Větší priorita při řešení problémů a rychlejší vývoj (cca 2 týdny)
+ Kontaktní formulář (přijde vám poptávka na e-mail)
+ Priorita při vývoji (cca 2 týdny)
Základní SEO
- Maximální počet stránek: 10
+ Max. počet stránek: 10
-
- Cena: 7 500 Kč (jednorázově) + 250 Kč / měsíc
+
+ Cena: 7 500 Kč (jednorázově) + 250 Kč / měsíc
+
-
-
+
PREMIUM
Vše z balíčku STANDARD
- Registrace firmy do Google Business Profile
- Pokročilé SEO (klíčová slova, podpora pro slepce, čtečky)
- Měsíční report návštěvnosti webu
- Možnost drobných úprav (texty, fotky)
+ Vaše firma na Google Maps díky plně nastavenému Google Business Profile
+ Pokročilé SEO
+ Měsíční report návštěvnosti
+ Možnost drobných úprav
Neomezený počet stránek
-
- Cena: od 9 500 Kč (jednorázově) + 400 Kč / měsíc
+
+ Cena: od 9 500 Kč (jednorázově) + 400 Kč / měsíc
+
-
-
+
CUSTOM
- Kompletně na míru podle potřeb
- Možnost e-shopu, rezervačního systému, managment
- Integrace jakéhokoliv API
- Integrace platební brány (např. Stripe, Platba QR kódem, atd.)
- Pokročilé SEO
- Marketing skrz Google Ads
+ Kompletně na míru
+ Možnost e-shopu a rezervačních systémů
+ Integrace API a platební brány
+ Pokročilé SEO a marketing
-
- Cena: dohodou
+
+ Cena: dohodou
+
-
\ No newline at end of file
+
+
+
+
+
diff --git a/backend/account/views.py b/backend/account/views.py
index 9744196..ae9d49a 100644
--- a/backend/account/views.py
+++ b/backend/account/views.py
@@ -160,7 +160,7 @@ class CookieTokenRefreshView(APIView):
except TokenError:
return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED)
-#---------------------------------------------LOGIN/LOGOUT------------------------------------------------
+#---------------------------------------------LOGOUT------------------------------------------------
@extend_schema(
tags=["Authentication"],
diff --git a/backend/commerce/admin.py b/backend/commerce/admin.py
index d76afc4..8093833 100644
--- a/backend/commerce/admin.py
+++ b/backend/commerce/admin.py
@@ -1,17 +1,14 @@
from django.contrib import admin
-from .models import Carrier, Order
+from .models import Carrier, Product
# Register your models here.
@admin.register(Carrier)
class CarrierAdmin(admin.ModelAdmin):
- list_display = ("name", "price", "api_id")
- search_fields = ("name", "api_id")
+ list_display = ("name", "base_price", "is_active")
-@admin.register(Order)
-class OrderAdmin(admin.ModelAdmin):
- list_display = ("id", "product", "carrier", "quantity", "total_price", "status", "created_at")
- list_filter = ("status", "created_at")
- search_fields = ("stripe_session_id",)
- readonly_fields = ("total_price", "status", "stripe_session_id", "created_at", "updated_at")
+@admin.register(Product)
+class ProductAdmin(admin.ModelAdmin):
+ list_display = ("name", "price", "currency", "stock", "is_active")
+ search_fields = ("name", "description")
diff --git a/backend/commerce/migrations/0001_initial.py b/backend/commerce/migrations/0001_initial.py
new file mode 100644
index 0000000..28cc9c2
--- /dev/null
+++ b/backend/commerce/migrations/0001_initial.py
@@ -0,0 +1,41 @@
+# Generated by Django 5.2.7 on 2025-10-28 01:24
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Carrier',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100)),
+ ('base_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
+ ('delivery_time', models.CharField(blank=True, max_length=100)),
+ ('is_active', models.BooleanField(default=True)),
+ ('logo', models.ImageField(blank=True, null=True, upload_to='carriers/')),
+ ('external_id', models.CharField(blank=True, max_length=50, null=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Product',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=200)),
+ ('description', models.TextField(blank=True)),
+ ('price', models.DecimalField(decimal_places=2, max_digits=10)),
+ ('currency', models.CharField(default='czk', max_length=10)),
+ ('stock', models.PositiveIntegerField(default=0)),
+ ('is_active', models.BooleanField(default=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('default_carrier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_products', to='commerce.carrier')),
+ ],
+ ),
+ ]
diff --git a/backend/account/migrations/__init__.py b/backend/thirdparty/downloader/__init__.py
similarity index 100%
rename from backend/account/migrations/__init__.py
rename to backend/thirdparty/downloader/__init__.py
diff --git a/backend/thirdparty/downloader/admin.py b/backend/thirdparty/downloader/admin.py
new file mode 100644
index 0000000..1d24b47
--- /dev/null
+++ b/backend/thirdparty/downloader/admin.py
@@ -0,0 +1,9 @@
+from django.contrib import admin
+from .models import DownloaderModel
+
+@admin.register(DownloaderModel)
+class DownloaderModelAdmin(admin.ModelAdmin):
+ list_display = ("id", "status", "ext", "requested_format", "vcodec", "acodec", "is_audio_only", "download_time")
+ list_filter = ("status", "ext", "vcodec", "acodec", "is_audio_only", "extractor")
+ search_fields = ("title", "video_id", "url")
+ readonly_fields = ("download_time",)
diff --git a/backend/thirdparty/downloader/apps.py b/backend/thirdparty/downloader/apps.py
new file mode 100644
index 0000000..25fcc01
--- /dev/null
+++ b/backend/thirdparty/downloader/apps.py
@@ -0,0 +1,10 @@
+from django.apps import AppConfig
+
+
+class DownloaderConfig(AppConfig):
+ # Ensure stable default primary key type
+ default_auto_field = "django.db.models.BigAutoField"
+ # Must be the full dotted path of this app
+ name = "thirdparty.downloader"
+ # Keep a short, stable label (used in migrations/admin)
+ label = "downloader"
diff --git a/backend/thirdparty/downloader/migrations/0001_initial.py b/backend/thirdparty/downloader/migrations/0001_initial.py
new file mode 100644
index 0000000..16f6b26
--- /dev/null
+++ b/backend/thirdparty/downloader/migrations/0001_initial.py
@@ -0,0 +1,54 @@
+# 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/frontend/src/features/auth/LogOut.tsx b/backend/thirdparty/downloader/migrations/__init__.py
similarity index 100%
rename from frontend/src/features/auth/LogOut.tsx
rename to backend/thirdparty/downloader/migrations/__init__.py
diff --git a/backend/thirdparty/downloader/models.py b/backend/thirdparty/downloader/models.py
new file mode 100644
index 0000000..b1052a9
--- /dev/null
+++ b/backend/thirdparty/downloader/models.py
@@ -0,0 +1,92 @@
+from django.db import models
+from django.conf import settings
+
+# 7áznamy pro donwloader, co lidé nejvíc stahujou a v jakém formátu
+class DownloaderModel(models.Model):
+ url = models.URLField()
+ download_time = models.DateTimeField(auto_now_add=True)
+ status = models.CharField(max_length=50)
+
+ # yt-dlp metadata (flattened for stats)
+ requested_format = models.CharField(max_length=100, blank=True, null=True, db_index=True)
+ format_id = models.CharField(max_length=50, blank=True, null=True, db_index=True)
+ ext = models.CharField(max_length=20, blank=True, null=True, db_index=True)
+ vcodec = models.CharField(max_length=50, blank=True, null=True, db_index=True)
+ acodec = models.CharField(max_length=50, blank=True, null=True, db_index=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) # audio bitrate
+ vbr = models.FloatField(blank=True, null=True) # video bitrate
+ tbr = models.FloatField(blank=True, null=True) # total bitrate
+ asr = models.IntegerField(blank=True, null=True) # audio sample rate
+ filesize = models.BigIntegerField(blank=True, null=True)
+ duration = models.FloatField(blank=True, null=True)
+ title = models.CharField(max_length=512, blank=True, null=True)
+ extractor = models.CharField(max_length=100, blank=True, null=True, db_index=True)
+ extractor_key = models.CharField(max_length=100, blank=True, null=True)
+ video_id = models.CharField(max_length=128, blank=True, null=True, db_index=True)
+ webpage_url = models.URLField(blank=True, null=True)
+ is_audio_only = models.BooleanField(default=False, db_index=True)
+
+ # client/context
+ user = models.ForeignKey(getattr(settings, "AUTH_USER_MODEL", "auth.User"), on_delete=models.SET_NULL, null=True, blank=True)
+ ip_address = models.GenericIPAddressField(blank=True, null=True)
+ user_agent = models.TextField(blank=True, null=True)
+
+ # diagnostics
+ error_message = models.TextField(blank=True, null=True)
+
+ # full raw yt-dlp info for future analysis
+ raw_info = models.JSONField(blank=True, null=True)
+
+ class Meta:
+ indexes = [
+ models.Index(fields=["download_time"]),
+ models.Index(fields=["ext", "is_audio_only"]),
+ models.Index(fields=["requested_format"]),
+ models.Index(fields=["extractor"]),
+ ]
+
+ def __str__(self):
+ return f"DownloaderModel {self.id} - {self.status} at {self.download_time.strftime('%d-%m-%Y %H:%M:%S')}"
+
+ @classmethod
+ def from_ydl_info(cls, *, info: dict, requested_format: str | None = None, status: str = "success",
+ url: str | None = None, user=None, ip_address: str | None = None,
+ user_agent: str | None = None, error_message: str | None = None):
+ # Safe getters
+ def g(k, default=None):
+ return info.get(k, default)
+
+ instance = cls(
+ url=url or g("webpage_url") or g("original_url"),
+ status=status,
+ requested_format=requested_format,
+ format_id=g("format_id"),
+ ext=g("ext"),
+ vcodec=g("vcodec"),
+ acodec=g("acodec"),
+ width=g("width") if isinstance(g("width"), int) else None,
+ height=g("height") if isinstance(g("height"), int) else None,
+ fps=g("fps"),
+ abr=g("abr"),
+ vbr=g("vbr"),
+ tbr=g("tbr"),
+ asr=g("asr"),
+ filesize=g("filesize") or g("filesize_approx"),
+ duration=g("duration"),
+ title=g("title"),
+ extractor=g("extractor"),
+ extractor_key=g("extractor_key"),
+ video_id=g("id"),
+ webpage_url=g("webpage_url"),
+ is_audio_only=(g("vcodec") in (None, "none")),
+ user=user if getattr(user, "is_authenticated", False) else None,
+ ip_address=ip_address,
+ user_agent=user_agent,
+ error_message=error_message,
+ raw_info=info,
+ )
+ instance.save()
+ return instance
\ No newline at end of file
diff --git a/backend/thirdparty/downloader/serializers.py b/backend/thirdparty/downloader/serializers.py
new file mode 100644
index 0000000..e2ba20f
--- /dev/null
+++ b/backend/thirdparty/downloader/serializers.py
@@ -0,0 +1,69 @@
+from rest_framework import serializers
+from .models import DownloaderModel
+
+class DownloaderLogSerializer(serializers.ModelSerializer):
+ # Optional raw yt-dlp info dict
+ info = serializers.DictField(required=False)
+
+ class Meta:
+ model = DownloaderModel
+ fields = (
+ "id",
+ "url",
+ "status",
+ "requested_format",
+ "format_id",
+ "ext",
+ "vcodec",
+ "acodec",
+ "width",
+ "height",
+ "fps",
+ "abr",
+ "vbr",
+ "tbr",
+ "asr",
+ "filesize",
+ "duration",
+ "title",
+ "extractor",
+ "extractor_key",
+ "video_id",
+ "webpage_url",
+ "is_audio_only",
+ "error_message",
+ "raw_info",
+ "info", # virtual input
+ )
+ read_only_fields = ("id", "raw_info")
+
+ def create(self, validated_data):
+ info = validated_data.pop("info", None)
+ request = self.context.get("request")
+ user = getattr(request, "user", None) if request else None
+ ip_address = None
+ user_agent = None
+ if request:
+ xff = request.META.get("HTTP_X_FORWARDED_FOR")
+ ip_address = (xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR"))
+ user_agent = request.META.get("HTTP_USER_AGENT")
+
+ if info:
+ return DownloaderModel.from_ydl_info(
+ info=info,
+ requested_format=validated_data.get("requested_format"),
+ status=validated_data.get("status", "success"),
+ url=validated_data.get("url"),
+ user=user,
+ ip_address=ip_address,
+ user_agent=user_agent,
+ error_message=validated_data.get("error_message"),
+ )
+ # Fallback: create from flattened fields only
+ return DownloaderModel.objects.create(
+ user=user,
+ ip_address=ip_address,
+ user_agent=user_agent,
+ raw_info=None,
+ **validated_data
+ )
diff --git a/backend/thirdparty/downloader/tests.py b/backend/thirdparty/downloader/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/backend/thirdparty/downloader/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/backend/thirdparty/downloader/urls.py b/backend/thirdparty/downloader/urls.py
new file mode 100644
index 0000000..542e675
--- /dev/null
+++ b/backend/thirdparty/downloader/urls.py
@@ -0,0 +1,14 @@
+from django.urls import path
+from .views import DownloaderLogView, DownloaderStatsView
+from .views import DownloaderFormatsView, DownloaderFileView
+
+urlpatterns = [
+ # Probe formats for a URL (size-checked)
+ path("api/downloader/formats/", DownloaderFormatsView.as_view(), name="downloader-formats"),
+ # Download selected format (enforces size limit)
+ path("api/downloader/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"),
+]
diff --git a/backend/thirdparty/downloader/views.py b/backend/thirdparty/downloader/views.py
new file mode 100644
index 0000000..719d854
--- /dev/null
+++ b/backend/thirdparty/downloader/views.py
@@ -0,0 +1,539 @@
+from django.shortcuts import render
+from django.db.models import Count
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework.permissions import AllowAny
+from rest_framework import status
+from django.conf import settings
+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
+
+# docs + schema helpers
+from rest_framework import serializers
+from drf_spectacular.utils import (
+ extend_schema,
+ OpenApiExample,
+ OpenApiParameter,
+ OpenApiTypes,
+ OpenApiResponse,
+ inline_serializer,
+)
+
+import os
+import math
+import json
+import tempfile
+from typing import Any, Dict, List, Optional, Tuple
+
+from .models import DownloaderModel
+from .serializers import DownloaderLogSerializer
+
+# ---------------------- Inline serializers for documentation only ----------------------
+# Using inline_serializer to avoid creating new files.
+
+FormatOptionSchema = inline_serializer(
+ name="FormatOption",
+ fields={
+ "format_id": serializers.CharField(allow_null=True),
+ "ext": serializers.CharField(allow_null=True),
+ "vcodec": serializers.CharField(allow_null=True),
+ "acodec": serializers.CharField(allow_null=True),
+ "fps": serializers.FloatField(allow_null=True),
+ "tbr": serializers.FloatField(allow_null=True),
+ "abr": serializers.FloatField(allow_null=True),
+ "vbr": serializers.FloatField(allow_null=True),
+ "asr": serializers.IntegerField(allow_null=True),
+ "filesize": serializers.IntegerField(allow_null=True),
+ "filesize_approx": serializers.IntegerField(allow_null=True),
+ "estimated_size_bytes": serializers.IntegerField(allow_null=True),
+ "size_ok": serializers.BooleanField(),
+ "format_note": serializers.CharField(allow_null=True),
+ "resolution": serializers.CharField(allow_null=True),
+ "audio_only": serializers.BooleanField(),
+ },
+)
+
+FormatsRequestSchema = inline_serializer(
+ name="FormatsRequest",
+ fields={"url": serializers.URLField()},
+)
+
+FormatsResponseSchema = inline_serializer(
+ name="FormatsResponse",
+ fields={
+ "title": serializers.CharField(allow_null=True),
+ "duration": serializers.FloatField(allow_null=True),
+ "extractor": serializers.CharField(allow_null=True),
+ "video_id": serializers.CharField(allow_null=True),
+ "max_size_bytes": serializers.IntegerField(),
+ "options": serializers.ListField(child=FormatOptionSchema),
+ },
+)
+
+DownloadRequestSchema = inline_serializer(
+ name="DownloadRequest",
+ fields={
+ "url": serializers.URLField(),
+ "format_id": serializers.CharField(),
+ },
+)
+
+ErrorResponseSchema = inline_serializer(
+ name="ErrorResponse",
+ fields={
+ "detail": serializers.CharField(),
+ "error": serializers.CharField(required=False),
+ "estimated_size_bytes": serializers.IntegerField(required=False),
+ "max_bytes": serializers.IntegerField(required=False),
+ },
+)
+# ---------------------------------------------------------------------------------------
+
+
+def _estimate_size_bytes(fmt: Dict[str, Any], duration: Optional[float]) -> Optional[int]:
+ """Estimate (or return exact) size in bytes for a yt-dlp format."""
+ # Prefer exact sizes from yt-dlp
+ if fmt.get("filesize"):
+ return int(fmt["filesize"])
+ if fmt.get("filesize_approx"):
+ return int(fmt["filesize_approx"])
+ # Estimate via total bitrate (tbr is in Kbps)
+ if duration and fmt.get("tbr"):
+ try:
+ kbps = float(fmt["tbr"])
+ return int((kbps * 1000 / 8) * float(duration))
+ except Exception:
+ return None
+ return None
+
+def _format_option(fmt: Dict[str, Any], duration: Optional[float], max_bytes: int) -> Dict[str, Any]:
+ """Project yt-dlp format dict to a compact option object suitable for UI."""
+ est = _estimate_size_bytes(fmt, duration)
+ w = fmt.get("width")
+ h = fmt.get("height")
+ resolution = f"{w}x{h}" if w and h else None
+ return {
+ "format_id": fmt.get("format_id"),
+ "ext": fmt.get("ext"),
+ "vcodec": fmt.get("vcodec"),
+ "acodec": fmt.get("acodec"),
+ "fps": fmt.get("fps"),
+ "tbr": fmt.get("tbr"),
+ "abr": fmt.get("abr"),
+ "vbr": fmt.get("vbr"),
+ "asr": fmt.get("asr"),
+ "filesize": fmt.get("filesize"),
+ "filesize_approx": fmt.get("filesize_approx"),
+ "estimated_size_bytes": est,
+ "size_ok": (est is not None and est <= max_bytes),
+ "format_note": fmt.get("format_note"),
+ "resolution": resolution,
+ "audio_only": (fmt.get("vcodec") in (None, "none")),
+ }
+
+def _client_meta(request) -> Tuple[Optional[Any], Optional[str], Optional[str]]:
+ """Extract current user, client IP and User-Agent."""
+ xff = request.META.get("HTTP_X_FORWARDED_FOR")
+ ip = (xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR"))
+ ua = request.META.get("HTTP_USER_AGENT")
+ user = getattr(request, "user", None)
+ return user, ip, ua
+
+class DownloaderFormatsView(APIView):
+ """Probe media URL and return available formats with estimated sizes and limit flags."""
+ permission_classes = [AllowAny]
+
+ @extend_schema(
+ tags=["downloader"],
+ operation_id="downloader_formats",
+ summary="List available formats for a media URL",
+ description="Uses yt-dlp to extract formats and estimates size. Applies max size policy.",
+ request=FormatsRequestSchema,
+ responses={
+ 200: FormatsResponseSchema,
+ 400: OpenApiResponse(response=ErrorResponseSchema),
+ 500: OpenApiResponse(response=ErrorResponseSchema),
+ },
+ examples=[
+ OpenApiExample(
+ "Formats request",
+ value={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
+ request_only=True,
+ ),
+ OpenApiExample(
+ "Formats response (excerpt)",
+ value={
+ "title": "Example Title",
+ "duration": 213.0,
+ "extractor": "youtube",
+ "video_id": "dQw4w9WgXcQ",
+ "max_size_bytes": 209715200,
+ "options": [
+ {
+ "format_id": "140",
+ "ext": "m4a",
+ "vcodec": "none",
+ "acodec": "mp4a.40.2",
+ "fps": None,
+ "tbr": 128.0,
+ "abr": 128.0,
+ "vbr": None,
+ "asr": 44100,
+ "filesize": None,
+ "filesize_approx": 3342334,
+ "estimated_size_bytes": 3400000,
+ "size_ok": True,
+ "format_note": "tiny",
+ "resolution": None,
+ "audio_only": True,
+ }
+ ],
+ },
+ response_only=True,
+ ),
+ ],
+ )
+ def post(self, request):
+ """POST to probe a media URL and list available formats."""
+ try:
+ import yt_dlp
+ except Exception:
+ return Response({"detail": "yt-dlp not installed. pip install yt-dlp"}, status=500)
+
+ url = request.data.get("url")
+ if not url:
+ return Response({"detail": "Missing 'url'."}, status=400)
+
+ max_bytes = getattr(settings, "DOWNLOADER_MAX_SIZE_BYTES", 200 * 1024 * 1024)
+ ydl_opts = {
+ "skip_download": True,
+ "quiet": True,
+ "no_warnings": True,
+ "noprogress": True,
+ "ignoreerrors": True,
+ "socket_timeout": getattr(settings, "DOWNLOADER_TIMEOUT", 120),
+ "extract_flat": False,
+ "allow_playlist": False,
+ }
+
+ try:
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+ info = ydl.extract_info(url, download=False)
+ except Exception as e:
+ # log probe error
+ user, ip, ua = _client_meta(request)
+ DownloaderModel.from_ydl_info(
+ info={"webpage_url": url},
+ requested_format=None,
+ status="probe_error",
+ url=url,
+ user=user,
+ ip_address=ip,
+ user_agent=ua,
+ error_message=str(e),
+ )
+ return Response({"detail": "Failed to extract formats", "error": str(e)}, status=400)
+
+ duration = info.get("duration")
+ formats = info.get("formats") or []
+ options: List[Dict[str, Any]] = []
+ for f in formats:
+ options.append(_format_option(f, duration, max_bytes))
+
+ # optional: sort by size then by resolution desc
+ def sort_key(o):
+ size = o["estimated_size_bytes"] if o["estimated_size_bytes"] is not None else math.inf
+ res = 0
+ if o["resolution"]:
+ try:
+ w, h = o["resolution"].split("x")
+ res = int(w) * int(h)
+ except Exception:
+ res = 0
+ return (size, -res)
+
+ options_sorted = sorted(options, key=sort_key)[:50]
+
+ # Log probe
+ user, ip, ua = _client_meta(request)
+ DownloaderModel.from_ydl_info(
+ info=info,
+ requested_format=None,
+ status="probe_ok",
+ url=url,
+ user=user,
+ ip_address=ip,
+ user_agent=ua,
+ )
+
+ return Response({
+ "title": info.get("title"),
+ "duration": duration,
+ "extractor": info.get("extractor"),
+ "video_id": info.get("id"),
+ "max_size_bytes": max_bytes,
+ "options": options_sorted,
+ })
+
+class DownloaderFileView(APIView):
+ """Download selected format if under max size, then stream the file back."""
+ permission_classes = [AllowAny]
+
+ @extend_schema(
+ tags=["downloader"],
+ operation_id="downloader_download",
+ summary="Download a selected format and stream file",
+ 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"),
+ 400: OpenApiResponse(response=ErrorResponseSchema),
+ 413: OpenApiResponse(response=ErrorResponseSchema),
+ 500: OpenApiResponse(response=ErrorResponseSchema),
+ },
+ examples=[
+ OpenApiExample(
+ "Download request",
+ value={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "format_id": "140"},
+ request_only=True,
+ ),
+ ],
+ )
+ def post(self, request):
+ """POST to download a media URL in the selected format."""
+ try:
+ import yt_dlp
+ except Exception:
+ return Response({"detail": "yt-dlp not installed. pip install yt-dlp"}, status=500)
+
+ url = request.data.get("url")
+ fmt_id = request.data.get("format_id")
+ if not url or not fmt_id:
+ return Response({"detail": "Missing 'url' or 'format_id'."}, status=400)
+
+ max_bytes = getattr(settings, "DOWNLOADER_MAX_SIZE_BYTES", 200 * 1024 * 1024)
+ timeout = getattr(settings, "DOWNLOADER_TIMEOUT", 120)
+ tmp_dir = getattr(settings, "DOWNLOADER_TMP_DIR", os.path.join(settings.BASE_DIR, "tmp", "downloader"))
+ os.makedirs(tmp_dir, exist_ok=True)
+
+ # First, extract info to check/estimate size
+ probe_opts = {
+ "skip_download": True,
+ "quiet": True,
+ "no_warnings": True,
+ "noprogress": True,
+ "ignoreerrors": True,
+ "socket_timeout": timeout,
+ "extract_flat": False,
+ "allow_playlist": False,
+ }
+ try:
+ with yt_dlp.YoutubeDL(probe_opts) as ydl:
+ info = ydl.extract_info(url, download=False)
+ except Exception as e:
+ user, ip, ua = _client_meta(request)
+ DownloaderModel.from_ydl_info(
+ info={"webpage_url": url},
+ requested_format=fmt_id,
+ status="precheck_error",
+ url=url,
+ user=user,
+ ip_address=ip,
+ user_agent=ua,
+ error_message=str(e),
+ )
+ return Response({"detail": "Failed to analyze media", "error": str(e)}, status=400)
+
+ duration = info.get("duration")
+ selected = None
+ for f in (info.get("formats") or []):
+ if str(f.get("format_id")) == str(fmt_id):
+ selected = f
+ break
+ if not selected:
+ return Response({"detail": f"format_id '{fmt_id}' not found"}, status=400)
+ # Enforce size policy
+ 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(
+ info=selected,
+ requested_format=fmt_id,
+ status="blocked_by_size",
+ url=url,
+ user=user,
+ ip_address=ip,
+ user_agent=ua,
+ error_message=f"Estimated size {est_size} > max {max_bytes}",
+ )
+ return Response(
+ {"detail": "File too large for this server", "estimated_size_bytes": est_size, "max_bytes": max_bytes},
+ status=413,
+ )
+
+ # Now download with strict max_filesize guard
+ ydl_opts = {
+ "format": str(fmt_id),
+ "quiet": True,
+ "no_warnings": True,
+ "noprogress": True,
+ "socket_timeout": timeout,
+ "retries": 3,
+ "outtmpl": os.path.join(tmp_dir, "%(id)s.%(ext)s"),
+ "max_filesize": max_bytes, # hard cap during download
+ "concurrent_fragment_downloads": 1,
+ "http_chunk_size": 1024 * 1024, # 1MB chunks to reduce memory
+ }
+
+ try:
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+ # Will raise if max_filesize exceeded during transfer
+ result = ydl.extract_info(url, download=True)
+ # yt-dlp returns result for entries or single; get final info
+ if "requested_downloads" in result and result["requested_downloads"]:
+ rd = result["requested_downloads"][0]
+ filepath = rd.get("filepath") or rd.get("__final_filename")
+ else:
+ # fallback
+ 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(
+ info=selected,
+ requested_format=fmt_id,
+ status="download_error",
+ url=url,
+ user=user,
+ ip_address=ip,
+ user_agent=ua,
+ error_message=str(e),
+ )
+ return Response({"detail": "Download failed", "error": str(e)}, status=400)
+
+ if not filepath or not os.path.exists(filepath):
+ return Response({"detail": "Downloaded file not found"}, status=500)
+
+ # Build a safe filename
+ base_title = info.get("title") or "video"
+ ext = os.path.splitext(filepath)[1].lstrip(".") or (selected.get("ext") or "bin")
+ safe_name = f"{slugify(base_title)[:80]}.{ext}"
+
+ # Log success
+ user, ip, ua = _client_meta(request)
+ try:
+ selected_info = dict(selected)
+ selected_info["filesize"] = os.path.getsize(filepath)
+ DownloaderModel.from_ydl_info(
+ info=selected_info,
+ requested_format=fmt_id,
+ status="success",
+ url=url,
+ user=user,
+ ip_address=ip,
+ user_agent=ua,
+ )
+ except Exception:
+ pass
+
+ # Stream file and remove after sending
+ def file_generator(path: str):
+ with open(path, "rb") as f:
+ while True:
+ chunk = f.read(8192)
+ if not chunk:
+ break
+ yield chunk
+ try:
+ os.remove(path)
+ except Exception:
+ pass
+
+ resp = StreamingHttpResponse(file_generator(filepath), content_type="application/octet-stream")
+ resp["Content-Disposition"] = f'attachment; filename="{safe_name}"'
+ try:
+ resp["Content-Length"] = str(os.path.getsize(filepath))
+ except Exception:
+ pass
+ resp["X-Content-Type-Options"] = "nosniff"
+ return resp
+
+
+# Simple stats view (aggregations for UI charts)
+class DownloaderStatsView(APIView):
+ permission_classes = [AllowAny]
+
+ @extend_schema(
+ tags=["downloader"],
+ operation_id="downloader_stats",
+ summary="Aggregated downloader statistics",
+ description="Returns top extensions, requested formats, codecs and audio/video split.",
+ responses={
+ 200: inline_serializer(
+ name="DownloaderStats",
+ fields={
+ "top_ext": serializers.ListField(
+ child=inline_serializer(name="ExtCount", fields={
+ "ext": serializers.CharField(allow_null=True),
+ "count": serializers.IntegerField(),
+ })
+ ),
+ "top_requested_format": serializers.ListField(
+ child=inline_serializer(name="RequestedFormatCount", fields={
+ "requested_format": serializers.CharField(allow_null=True),
+ "count": serializers.IntegerField(),
+ })
+ ),
+ "top_vcodec": serializers.ListField(
+ child=inline_serializer(name="VCodecCount", fields={
+ "vcodec": serializers.CharField(allow_null=True),
+ "count": serializers.IntegerField(),
+ })
+ ),
+ "top_acodec": serializers.ListField(
+ child=inline_serializer(name="ACodecCount", fields={
+ "acodec": serializers.CharField(allow_null=True),
+ "count": serializers.IntegerField(),
+ })
+ ),
+ "audio_vs_video": serializers.ListField(
+ child=inline_serializer(name="AudioVsVideo", fields={
+ "is_audio_only": serializers.BooleanField(),
+ "count": serializers.IntegerField(),
+ })
+ ),
+ },
+ )
+ },
+ )
+ def get(self, request):
+ """GET to retrieve aggregated downloader statistics."""
+ top_ext = list(DownloaderModel.objects.values("ext").annotate(count=Count("id")).order_by("-count")[:10])
+ top_formats = list(DownloaderModel.objects.values("requested_format").annotate(count=Count("id")).order_by("-count")[:10])
+ top_vcodec = list(DownloaderModel.objects.values("vcodec").annotate(count=Count("id")).order_by("-count")[:10])
+ top_acodec = list(DownloaderModel.objects.values("acodec").annotate(count=Count("id")).order_by("-count")[:10])
+ audio_vs_video = list(DownloaderModel.objects.values("is_audio_only").annotate(count=Count("id")).order_by("-count"))
+ return Response({
+ "top_ext": top_ext,
+ "top_requested_format": top_formats,
+ "top_vcodec": top_vcodec,
+ "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/stripe/apps.py b/backend/thirdparty/stripe/apps.py
index 793395d..43baef9 100644
--- a/backend/thirdparty/stripe/apps.py
+++ b/backend/thirdparty/stripe/apps.py
@@ -3,4 +3,5 @@ from django.apps import AppConfig
class StripeConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
- name = 'stripe'
+ name = 'thirdparty.stripe'
+ label = "stripe"
diff --git a/backend/thirdparty/stripe/migrations/0001_initial.py b/backend/thirdparty/stripe/migrations/0001_initial.py
new file mode 100644
index 0000000..b8d70c0
--- /dev/null
+++ b/backend/thirdparty/stripe/migrations/0001_initial.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.2.7 on 2025-10-28 00:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Order',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('amount', models.DecimalField(decimal_places=2, max_digits=10)),
+ ('currency', models.CharField(default='czk', max_length=10)),
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
+ ('stripe_session_id', models.CharField(blank=True, max_length=255, null=True)),
+ ('stripe_payment_intent', models.CharField(blank=True, max_length=255, null=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ ),
+ ]
diff --git a/backend/thirdparty/stripe/serializers.py b/backend/thirdparty/stripe/serializers.py
index f88ce06..b14fd0b 100644
--- a/backend/thirdparty/stripe/serializers.py
+++ b/backend/thirdparty/stripe/serializers.py
@@ -1,54 +1,29 @@
from rest_framework import serializers
-
-from rest_framework import serializers
-from .models import Product, Carrier, Order
-
-from ...commerce.serializers import ProductSerializer, CarrierSerializer
+from .models import Order
class OrderSerializer(serializers.ModelSerializer):
- product = ProductSerializer(read_only=True)
- product_id = serializers.PrimaryKeyRelatedField(
- queryset=Product.objects.all(), source="product", write_only=True
- )
- carrier = CarrierSerializer(read_only=True)
- carrier_id = serializers.PrimaryKeyRelatedField(
- queryset=Carrier.objects.all(), source="carrier", write_only=True
- )
+ # Nested read-only representations
+ # product = ProductSerializer(read_only=True)
+ # carrier = CarrierSerializer(read_only=True)
- class Meta:
- model = Order
- fields = [
- "id",
- "product", "product_id",
- "carrier", "carrier_id",
- "quantity",
- "total_price",
- "status",
- "stripe_session_id",
- "created_at",
- "updated_at",
- ]
- read_only_fields = ("total_price", "status", "stripe_session_id", "created_at", "updated_at")
+ # Write-only foreign keys
+ # product_id = serializers.PrimaryKeyRelatedField(
+ # queryset=Product.objects.all(), source="product", write_only=True
+ # )
+ # carrier_id = serializers.PrimaryKeyRelatedField(
+ # queryset=Carrier.objects.all(), source="carrier", write_only=True
+ # )
- queryset=Product.objects.all(), source="product", write_only=True
-
- carrier = CarrierSerializer(read_only=True)
- carrier_id = serializers.PrimaryKeyRelatedField(
- queryset=Carrier.objects.all(), source="carrier", write_only=True
- )
-
- class Meta:
- model = Order
- fields = [
- "id",
- "product", "product_id",
- "carrier", "carrier_id",
- "quantity",
- "total_price",
- "status",
- "stripe_session_id",
- "created_at",
- "updated_at",
- ]
- read_only_fields = ("total_price", "status", "stripe_session_id", "created_at", "updated_at")
+ class Meta:
+ model = Order
+ fields = [
+ "id",
+ "amount",
+ "currency",
+ "status",
+ "stripe_session_id",
+ "stripe_payment_intent",
+ "created_at",
+ ]
+ read_only_fields = ("created_at",)
diff --git a/backend/thirdparty/stripe/views.py b/backend/thirdparty/stripe/views.py
index 278bf0c..f51939b 100644
--- a/backend/thirdparty/stripe/views.py
+++ b/backend/thirdparty/stripe/views.py
@@ -8,9 +8,10 @@ from rest_framework.views import APIView
from .models import Order
from .serializers import OrderSerializer
+import os
import stripe
-stripe.api_key = settings.STRIPE_SECRET_KEY # uložený v .env
+stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
class CreateCheckoutSessionView(APIView):
def post(self, request):
diff --git a/backend/thirdparty/trading212/apps.py b/backend/thirdparty/trading212/apps.py
index 6e47900..833ba03 100644
--- a/backend/thirdparty/trading212/apps.py
+++ b/backend/thirdparty/trading212/apps.py
@@ -3,4 +3,5 @@ from django.apps import AppConfig
class Trading212Config(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
- name = 'trading212'
+ name = 'thirdparty.trading212'
+ label = "trading212"
diff --git a/backend/thirdparty/trading212/urls.py b/backend/thirdparty/trading212/urls.py
index 4475ca9..07a8468 100644
--- a/backend/thirdparty/trading212/urls.py
+++ b/backend/thirdparty/trading212/urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from .views import YourTrading212View # Replace with actual view class
+from .views import Trading212AccountCashView # Replace with actual view class
urlpatterns = [
- path('your-endpoint/', YourTrading212View.as_view(), name='trading212-endpoint'),
+ path('your-endpoint/', Trading212AccountCashView.as_view(), name='trading212-endpoint'),
]
\ No newline at end of file
diff --git a/backend/thirdparty/trading212/views.py b/backend/thirdparty/trading212/views.py
index 67580be..2071929 100644
--- a/backend/thirdparty/trading212/views.py
+++ b/backend/thirdparty/trading212/views.py
@@ -1,7 +1,6 @@
# thirdparty/trading212/views.py
import os
import requests
-from decouple import config
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
@@ -18,6 +17,7 @@ class Trading212AccountCashView(APIView):
)
def get(self, request):
api_key = os.getenv("API_KEY_TRADING212")
+
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
diff --git a/backend/vontor_cz/settings.py b/backend/vontor_cz/settings.py
index 316c223..1e389bc 100644
--- a/backend/vontor_cz/settings.py
+++ b/backend/vontor_cz/settings.py
@@ -264,6 +264,11 @@ REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
+
+ 'DEFAULT_THROTTLE_RATES': {
+ 'anon': '100/hour', # unauthenticated
+ 'user': '2000/hour', # authenticated
+ }
}
#--------------------------------END REST FRAMEWORK 🛠️-------------------------------------
@@ -273,6 +278,11 @@ REST_FRAMEWORK = {
#-------------------------------------APPS 📦------------------------------------
MY_CREATED_APPS = [
'account',
+ 'commerce',
+
+ 'thirdparty.downloader',
+ 'thirdparty.stripe', # register Stripe app so its models are recognized
+ 'thirdparty.trading212',
]
INSTALLED_APPS = [
@@ -893,3 +903,10 @@ SPECTACULAR_DEFAULTS: Dict[str, Any] = {
'OAUTH2_REFRESH_URL': None,
'OAUTH2_SCOPES': None,
}
+
+# -------------------------------------DOWNLOADER LIMITS------------------------------------
+DOWNLOADER_MAX_SIZE_MB = int(os.getenv("DOWNLOADER_MAX_SIZE_MB", "200")) # Raspberry Pi safe cap
+DOWNLOADER_MAX_SIZE_BYTES = DOWNLOADER_MAX_SIZE_MB * 1024 * 1024
+DOWNLOADER_TIMEOUT = int(os.getenv("DOWNLOADER_TIMEOUT", "120")) # seconds
+DOWNLOADER_TMP_DIR = os.getenv("DOWNLOADER_TMP_DIR", str(BASE_DIR / "tmp" / "downloader"))
+# -------------------------------------END DOWNLOADER LIMITS--------------------------------
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e2e50a5..8e9b1a1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@types/react-router": "^5.1.20",
+ "axios": "^1.13.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
@@ -1807,6 +1808,23 @@
"dev": true,
"license": "Python-2.0"
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.0.tgz",
+ "integrity": "sha512-zt40Pz4zcRXra9CVV31KeyofwiNvAbJ5B6YPz9pMJ+yOSLikvPT4Yi5LjfgjRa9CawVYBaD1JQzIVcIvBejKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1871,6 +1889,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1939,6 +1970,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2008,6 +2051,29 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.200",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz",
@@ -2015,6 +2081,51 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/esbuild": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
@@ -2383,6 +2494,42 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2398,6 +2545,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -2408,6 +2564,43 @@
"node": ">=6.9.0"
}
},
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2434,6 +2627,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -2451,6 +2656,45 @@
"node": ">=8"
}
},
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2652,6 +2896,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2676,6 +2929,27 @@
"node": ">=8.6"
}
},
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2871,6 +3145,12 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3313,9 +3593,9 @@
}
},
"node_modules/vite": {
- "version": "7.1.7",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
- "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
+ "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": {
diff --git a/frontend/package.json b/frontend/package.json
index 8351fea..f9c435c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,6 +11,7 @@
},
"dependencies": {
"@types/react-router": "^5.1.20",
+ "axios": "^1.13.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0f8ddc0..4dbb4f0 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,19 +1,19 @@
import { BrowserRouter as Router, Routes, Route, Link, Outlet } from "react-router-dom"
import Home from "./pages/home/home";
import HomeLayout from "./layouts/HomeLayout";
+import Downloader from "./pages/downloader/Downloader";
-function App() {
+export default function App() {
return (
{/* Layout route */}
}>
} />
+ } />
)
-}
-
-export default App
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/frontend/src/api/Client.ts b/frontend/src/api/Client.ts
new file mode 100644
index 0000000..7fd36a2
--- /dev/null
+++ b/frontend/src/api/Client.ts
@@ -0,0 +1,268 @@
+import axios from "axios";
+
+// --- ENV CONFIG ---
+const API_BASE_URL =
+ import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
+const REFRESH_URL =
+ import.meta.env.VITE_API_REFRESH_URL || "/api/token/refresh/";
+const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH || "/login";
+
+
+// --- ERROR EVENT BUS ---
+const ERROR_EVENT = "api:error";
+type ApiErrorDetail = {
+ message: string;
+ status?: number;
+ url?: string;
+ data?: unknown;
+};
+
+// Use interface instead of arrow function types for readability
+interface ApiErrorHandler {
+ (e: CustomEvent): void;
+}
+
+function notifyError(detail: ApiErrorDetail) {
+ window.dispatchEvent(new CustomEvent(ERROR_EVENT, { detail }));
+ // eslint-disable-next-line no-console
+ console.error("[API ERROR]", detail);
+}
+function onError(handler: ApiErrorHandler) {
+ const wrapped = handler as EventListener;
+ window.addEventListener(ERROR_EVENT, wrapped as EventListener);
+ return () => window.removeEventListener(ERROR_EVENT, wrapped);
+}
+
+// --- AXIOS INSTANCES ---
+// Always send cookies. Django will set auth cookies; browser will include them automatically.
+function createAxios(baseURL: string): any {
+ const instance = axios.create({
+ baseURL,
+ withCredentials: true, // <-- always true
+ headers: {
+ "Content-Type": "application/json",
+ },
+ timeout: 20000,
+ });
+ return instance;
+}
+
+// Use a single behavior for both: cookies are always sent
+const apiPublic = createAxios(API_BASE_URL);
+const apiAuth = createAxios(API_BASE_URL);
+
+// --- REQUEST INTERCEPTOR (PUBLIC) ---
+// Ensure no Authorization header is ever sent by the public client
+apiPublic.interceptors.request.use(function (config: any) {
+ if (config?.headers && (config.headers as any).Authorization) {
+ delete (config.headers as any).Authorization;
+ }
+ return config;
+});
+
+// --- REQUEST INTERCEPTOR (AUTH) ---
+// Do not attach Authorization header; rely on cookies set by Django.
+apiAuth.interceptors.request.use(function (config: any) {
+ (config as any)._retryCount = (config as any)._retryCount || 0;
+ return config;
+});
+
+// --- RESPONSE INTERCEPTOR (AUTH) ---
+// Simplified: on 401, redirect to login. Server manages refresh via cookies.
+apiAuth.interceptors.response.use(
+ function (response: any) {
+ return response;
+ },
+ async function (error: any) {
+ if (!error.response) {
+ alert("Backend connection is unavailable. Please check your network.");
+ notifyError({
+ message: "Network error or backend unavailable",
+ url: error.config?.url,
+ });
+ return Promise.reject(error);
+ }
+
+ const status = error.response.status;
+ if (status === 401) {
+ clearTokens(); // optional: clear cookies client-side
+ window.location.assign(LOGIN_PATH);
+ return Promise.reject(error);
+ }
+
+ notifyError({
+ message:
+ (error.response.data as any)?.detail ||
+ (error.response.data as any)?.message ||
+ `Request failed with status ${status}`,
+ status,
+ url: error.config?.url,
+ data: error.response.data,
+ });
+ return Promise.reject(error);
+ }
+);
+
+// --- PUBLIC CLIENT: still emits errors and alerts on network failure ---
+apiPublic.interceptors.response.use(
+ function (response: any) {
+ return response;
+ },
+ async function (error: any) {
+ if (!error.response) {
+ alert("Backend connection is unavailable. Please check your network.");
+ notifyError({
+ message: "Network error or backend unavailable",
+ url: error.config?.url,
+ });
+ return Promise.reject(error);
+ }
+ notifyError({
+ message:
+ (error.response.data as any)?.detail ||
+ (error.response.data as any)?.message ||
+ `Request failed with status ${error.response.status}`,
+ status: error.response.status,
+ url: error.config?.url,
+ data: error.response.data,
+ });
+ return Promise.reject(error);
+ }
+);
+
+// --- TOKEN HELPERS (NO-OPS) ---
+// Django sets/rotates cookies server-side. Keep API surface to avoid breaking imports.
+function setTokens(_access?: string, _refresh?: string) {
+ // no-op: cookies are managed by Django
+}
+function clearTokens() {
+ // optional: try to clear auth cookies client-side; server should also clear on logout
+ try {
+ document.cookie = "access_token=; Max-Age=0; path=/";
+ document.cookie = "refresh_token=; Max-Age=0; path=/";
+ } catch {
+ // ignore
+ }
+}
+function getAccessToken(): string | null {
+ // no Authorization header is used; rely purely on cookies
+ return null;
+}
+
+// --- EXPORT DEFAULT API WRAPPER ---
+const Client = {
+ // Axios instances
+ auth: apiAuth,
+ public: apiPublic,
+
+ // Token helpers (kept for compatibility; now no-ops)
+ setTokens,
+ clearTokens,
+ getAccessToken,
+
+ // Error subscription
+ onError,
+};
+
+export default Client;
+
+/**
+USAGE EXAMPLES (TypeScript/React)
+
+Import the client
+--------------------------------------------------
+import Client from "@/api/Client";
+
+
+Login: obtain tokens and persist to cookies
+--------------------------------------------------
+async function login(username: string, password: string) {
+ // SimpleJWT default login endpoint (adjust if your backend differs)
+ // Example backend endpoint: POST /api/token/ -> { access, refresh }
+ const res = await Client.public.post("/api/token/", { username, password });
+ const { access, refresh } = res.data;
+ Client.setTokens(access, refresh);
+ // After this, Client.auth will automatically attach Authorization header
+ // and refresh when receiving a 401 (up to 2 retries).
+}
+
+
+Public request (no cookies, no Authorization)
+--------------------------------------------------
+// The public client does NOT send cookies or Authorization.
+async function listPublicItems() {
+ const res = await Client.public.get("/api/public/items/");
+ return res.data;
+}
+
+
+Authenticated requests (auto Bearer header + refresh on 401)
+--------------------------------------------------
+async function fetchProfile() {
+ const res = await Client.auth.get("/api/users/me/");
+ return res.data;
+}
+
+async function updateProfile(payload: { first_name?: string; last_name?: string }) {
+ const res = await Client.auth.patch("/api/users/me/", payload);
+ return res.data;
+}
+
+
+Global error handling (UI notifications)
+--------------------------------------------------
+import { useEffect } from "react";
+
+function useApiErrors(showToast: (msg: string) => void) {
+ useEffect(function () {
+ const unsubscribe = Client.onError(function (e) {
+ const { message, status } = e.detail;
+ showToast(status ? String(status) + ": " + message : message);
+ });
+ return unsubscribe;
+ }, [showToast]);
+}
+
+// Note: Network connectivity issues trigger an alert and also dispatch api:error.
+// All errors are logged to console for developers.
+
+
+Logout
+--------------------------------------------------
+function logout() {
+ Client.clearTokens();
+ window.location.assign("/login");
+}
+
+
+Route protection (PrivateRoute)
+--------------------------------------------------
+// If you created src/routes/PrivateRoute.tsx, wrap your protected routes with it.
+// PrivateRoute checks for "access_token" cookie presence and redirects to /login if missing.
+
+// Example:
+//
+// } >
+// }>
+// } />
+// } />
+//
+//
+// } />
+//
+
+
+Refresh and retry flow (what happens on 401)
+--------------------------------------------------
+// 1) Client.auth request receives 401 from backend
+// 2) Client tries to refresh access token using refresh_token cookie
+// 3) If refresh succeeds, original request is retried (max 2 times)
+// 4) If still 401 (or no refresh token), tokens are cleared and user is redirected to /login
+
+
+Environment variables (optional overrides)
+--------------------------------------------------
+// VITE_API_BASE_URL default: "http://localhost:8000"
+// VITE_API_REFRESH_URL default: "/api/token/refresh/"
+// VITE_LOGIN_PATH default: "/login"
+*/
diff --git a/frontend/src/api/apps/Downloader.ts b/frontend/src/api/apps/Downloader.ts
new file mode 100644
index 0000000..ab72e11
--- /dev/null
+++ b/frontend/src/api/apps/Downloader.ts
@@ -0,0 +1,57 @@
+import Client from "../Client";
+
+export type Choices = {
+ file_types: string[];
+ qualities: string[];
+};
+
+export type DownloadPayload = {
+ url: string;
+ file_type?: string;
+ quality?: string;
+};
+
+export type DownloadJobResponse = {
+ id: string;
+ status: "pending" | "running" | "finished" | "failed";
+ detail?: string;
+ download_url?: string;
+ progress?: number; // 0-100
+};
+
+// Fallback when choices endpoint is unavailable or models are hardcoded
+const FALLBACK_CHOICES: Choices = {
+ file_types: ["auto", "video", "audio"],
+ qualities: ["best", "good", "worst"],
+};
+
+/**
+ * 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 {
+ try {
+ const res = await Client.auth.get("/api/downloader/choices/");
+ return res.data as Choices;
+ } catch {
+ return FALLBACK_CHOICES;
+ }
+}
+
+/**
+ * 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;
+}
diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts
deleted file mode 100644
index f7cd6be..0000000
--- a/frontend/src/api/axios.ts
+++ /dev/null
@@ -1,202 +0,0 @@
-import axios from "axios";
-
-const API_URL: string = `${import.meta.env.VITE_BACKEND_URL}/api`;
-
-// Axios instance, můžeme používat místo globálního axios
-const axios_instance = axios.create({
- baseURL: API_URL,
- withCredentials: true, // potřebné pro cookies
-});
-axios_instance.defaults.xsrfCookieName = "csrftoken";
-axios_instance.defaults.xsrfHeaderName = "X-CSRFToken";
-
-export default axios_instance;
-
-// 🔐 Axios response interceptor: automatická obnova při 401
-axios_instance.interceptors.request.use((config) => {
- const getCookie = (name: string): string | null => {
- let cookieValue: string | null = null;
- if (document.cookie && document.cookie !== "") {
- const cookies = document.cookie.split(";");
- for (let cookie of cookies) {
- cookie = cookie.trim();
- if (cookie.startsWith(name + "=")) {
- cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
- break;
- }
- }
- }
- return cookieValue;
- };
-
- const csrfToken = getCookie("csrftoken");
- if (csrfToken && config.method && ["post", "put", "patch", "delete"].includes(config.method)) {
- if (!config.headers) config.headers = {};
- config.headers["X-CSRFToken"] = csrfToken;
- }
-
- return config;
-});
-
-// Přidej globální response interceptor pro redirect na login při 401 s detail hláškou
-axios_instance.interceptors.response.use(
- (response) => response,
- (error) => {
- if (
- error.response &&
- error.response.status === 401 &&
- error.response.data &&
- error.response.data.detail === "Nebyly zadány přihlašovací údaje."
- ) {
- window.location.href = "/login";
- }
- return Promise.reject(error);
- }
-);
-
-// 🔄 Obnova access tokenu pomocí refresh cookie
-export const refreshAccessToken = async (): Promise<{ access: string; refresh: string } | null> => {
- try {
- const res = await axios_instance.post(`/account/token/refresh/`);
- return res.data as { access: string; refresh: string };
- } catch (err) {
- console.error("Token refresh failed", err);
- await logout();
- return null;
- }
-};
-
-
-// ✅ Přihlášení
-export const login = async (username: string, password: string): Promise => {
- await logout();
- try {
- const response = await axios_instance.post(`/account/token/`, { username, password });
- return response.data;
- } catch (err: any) {
- if (err.response) {
- // Server responded with a status code outside 2xx
- console.log('Login error status:', err.response.status);
- } else if (err.request) {
- // Request was made but no response received
- console.log('Login network error:', err.request);
- } else {
- // Something else happened
- console.log('Login setup error:', err.message);
- }
- throw err;
- }
-};
-
-
-// ❌ Odhlášení s CSRF tokenem
-export const logout = async (): Promise => {
- try {
- const getCookie = (name: string): string | null => {
- let cookieValue: string | null = null;
- if (document.cookie && document.cookie !== "") {
- const cookies = document.cookie.split(";");
- for (let cookie of cookies) {
- cookie = cookie.trim();
- if (cookie.startsWith(name + "=")) {
- cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
- break;
- }
- }
- }
- return cookieValue;
- };
-
- const csrfToken = getCookie("csrftoken");
-
- const response = await axios_instance.post(
- "/account/logout/",
- {},
- {
- headers: {
- "X-CSRFToken": csrfToken,
- },
- }
- );
- console.log(response.data);
- return response.data; // např. { detail: "Logout successful" }
- } catch (err) {
- console.error("Logout failed", err);
- throw err;
- }
-};
-
-
-/**
- * 📡 Obecný request pro API
- *
- * @param method - HTTP metoda (např. "get", "post", "put", "patch", "delete")
- * @param endpoint - API endpoint (např. "/api/service-tickets/")
- * @param data - data pro POST/PUT/DELETE requesty
- * @param config - další konfigurace pro axios request
- * @returns Promise - vrací data z odpovědi
- */
-export const apiRequest = async (
- method: string,
- endpoint: string,
- data: Record = {},
- config: Record = {}
-): Promise => {
- const url = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
-
- try {
- const response = await axios_instance({
- method,
- url,
- data: ["post", "put", "patch"].includes(method.toLowerCase()) ? data : undefined,
- params: ["get", "delete"].includes(method.toLowerCase()) ? data : undefined,
- ...config,
- });
-
- return response.data;
-
- } catch (err: any) {
- if (err.response) {
- // Server odpověděl s kódem mimo rozsah 2xx
- console.error("API Error:", {
- status: err.response.status,
- data: err.response.data,
- headers: err.response.headers,
- });
- } else if (err.request) {
- // Request byl odeslán, ale nedošla odpověď
- console.error("No response received:", err.request);
- } else {
- // Něco jiného se pokazilo při sestavování requestu
- console.error("Request setup error:", err.message);
- }
-
- throw err;
- }
-};
-
-
-
-
-
-
-
-// 👤 Funkce pro získání aktuálně přihlášeného uživatele
-export async function getCurrentUser(): Promise {
- const response = await axios_instance.get(`${API_URL}/account/user/me/`);
- return response.data; // vrací data uživatele
-}
-
-// 🔒 ✔️ Jednoduchá funkce, která kontroluje přihlášení - můžeš to upravit dle potřeby
-export async function isAuthenticated(): Promise {
- try {
- const user = await getCurrentUser();
- return user != null;
- } catch (err) {
- return false; // pokud padne 401, není přihlášen
- }
-}
-
-
-
-export { axios_instance, API_URL };
\ No newline at end of file
diff --git a/frontend/src/api/external.ts b/frontend/src/api/external.ts
deleted file mode 100644
index cc4face..0000000
--- a/frontend/src/api/external.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
-
-/**
- * Makes a general external API call using axios.
- *
- * @param url - The full URL of the external API endpoint.
- * @param method - HTTP method (GET, POST, PUT, PATCH, DELETE, etc.).
- * @param data - Request body data (for POST, PUT, PATCH). Optional.
- * @param config - Additional Axios request config (headers, params, etc.). Optional.
- * @returns Promise resolving to AxiosResponse.
- *
- * @example externalApiCall("https://api.example.com/data", "post", { foo: "bar" }, { headers: { Authorization: "Bearer token" } })
- */
-export async function externalApiCall(
- url: string,
- method: AxiosRequestConfig["method"],
- data?: any,
- config?: AxiosRequestConfig
-): Promise> {
- return axios({
- url,
- method,
- data,
- ...config,
- });
-}
diff --git a/frontend/src/api/get_chocies.ts b/frontend/src/api/get_chocies.ts
index 8e014e3..5a7dd69 100644
--- a/frontend/src/api/get_chocies.ts
+++ b/frontend/src/api/get_chocies.ts
@@ -1,4 +1,4 @@
-import { apiRequest } from "./axios";
+import Client from "./Client";
/**
* Loads enum values from an OpenAPI schema for a given path, method, and field (e.g., category).
@@ -16,7 +16,7 @@ export async function fetchEnumFromSchemaJson(
schemaUrl: string = "/schema/?format=json"
): Promise> {
try {
- const schema = await apiRequest("get", schemaUrl);
+ const schema = await Client.public.get(schemaUrl);
const methodDef = schema.paths?.[path]?.[method];
if (!methodDef) {
diff --git a/frontend/src/features/ads/Drone/Drone.tsx b/frontend/src/components/ads/Drone/Drone.tsx
similarity index 100%
rename from frontend/src/features/ads/Drone/Drone.tsx
rename to frontend/src/components/ads/Drone/Drone.tsx
diff --git a/frontend/src/features/ads/Drone/drone.module.css b/frontend/src/components/ads/Drone/drone.module.css
similarity index 100%
rename from frontend/src/features/ads/Drone/drone.module.css
rename to frontend/src/components/ads/Drone/drone.module.css
diff --git a/frontend/src/features/ads/Drone/readme.png b/frontend/src/components/ads/Drone/readme.png
similarity index 100%
rename from frontend/src/features/ads/Drone/readme.png
rename to frontend/src/components/ads/Drone/readme.png
diff --git a/frontend/src/features/ads/Portfolio/Portfolio.module.css b/frontend/src/components/ads/Portfolio/Portfolio.module.css
similarity index 100%
rename from frontend/src/features/ads/Portfolio/Portfolio.module.css
rename to frontend/src/components/ads/Portfolio/Portfolio.module.css
diff --git a/frontend/src/features/ads/Portfolio/Portfolio.tsx b/frontend/src/components/ads/Portfolio/Portfolio.tsx
similarity index 100%
rename from frontend/src/features/ads/Portfolio/Portfolio.tsx
rename to frontend/src/components/ads/Portfolio/Portfolio.tsx
diff --git a/frontend/src/features/ads/Portfolio/readme.png b/frontend/src/components/ads/Portfolio/readme.png
similarity index 100%
rename from frontend/src/features/ads/Portfolio/readme.png
rename to frontend/src/components/ads/Portfolio/readme.png
diff --git a/frontend/src/features/auth/LoginForm.tsx b/frontend/src/components/auth/LogOut.tsx
similarity index 100%
rename from frontend/src/features/auth/LoginForm.tsx
rename to frontend/src/components/auth/LogOut.tsx
diff --git a/frontend/src/routes/AuthenticatedRoute.tsx b/frontend/src/components/auth/LoginForm.tsx
similarity index 100%
rename from frontend/src/routes/AuthenticatedRoute.tsx
rename to frontend/src/components/auth/LoginForm.tsx
diff --git a/frontend/src/layouts/HomeLayout.tsx b/frontend/src/layouts/HomeLayout.tsx
index 2a9836b..2d428ab 100644
--- a/frontend/src/layouts/HomeLayout.tsx
+++ b/frontend/src/layouts/HomeLayout.tsx
@@ -1,9 +1,10 @@
import Footer from "../components/Footer/footer";
import ContactMeForm from "../components/Forms/ContactMe/ContactMeForm";
import HomeNav from "../components/navbar/HomeNav";
-import Drone from "../features/ads/Drone/Drone";
-import Portfolio from "../features/ads/Portfolio/Portfolio";
+import Drone from "../components/ads/Drone/Drone";
+import Portfolio from "../components/ads/Portfolio/Portfolio";
import Home from "../pages/home/home";
+import { Outlet } from "react-router";
export default function HomeLayout(){
return(
@@ -12,6 +13,7 @@ export default function HomeLayout(){
{/*page*/}
+
diff --git a/frontend/src/pages/downloader/Downloader.tsx b/frontend/src/pages/downloader/Downloader.tsx
new file mode 100644
index 0000000..5d19c29
--- /dev/null
+++ b/frontend/src/pages/downloader/Downloader.tsx
@@ -0,0 +1,160 @@
+import { useEffect, useMemo, useState } from "react";
+import {
+ getChoices,
+ submitDownload,
+ getJobStatus,
+ type Choices,
+ type DownloadJobResponse,
+} from "../../api/apps/Downloader";
+
+export default function Downloader() {
+ const [choices, setChoices] = useState
({ file_types: [], qualities: [] });
+ const [loadingChoices, setLoadingChoices] = useState(true);
+
+ const [url, setUrl] = useState("");
+ const [fileType, setFileType] = useState("");
+ const [quality, setQuality] = useState("");
+
+ const [submitting, setSubmitting] = useState(false);
+ const [job, setJob] = useState(null);
+ const [error, setError] = useState(null);
+
+ // Load dropdown choices once
+ useEffect(() => {
+ let mounted = true;
+ (async () => {
+ setLoadingChoices(true);
+ try {
+ const data = await getChoices();
+ if (!mounted) return;
+ setChoices(data);
+ // preselect first option
+ if (!fileType && data.file_types.length > 0) setFileType(data.file_types[0]);
+ if (!quality && data.qualities.length > 0) setQuality(data.qualities[0]);
+ } catch (e: any) {
+ setError(e?.message || "Failed to load choices.");
+ } finally {
+ setLoadingChoices(false);
+ }
+ })();
+ return () => {
+ mounted = false;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const canSubmit = useMemo(() => {
+ return !!url && !!fileType && !!quality && !submitting;
+ }, [url, fileType, quality, submitting]);
+
+ async function onSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setSubmitting(true);
+ try {
+ const created = await submitDownload({ url, file_type: fileType, quality });
+ setJob(created);
+ } catch (e: any) {
+ setError(e?.response?.data?.detail || e?.message || "Submission failed.");
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ async function refreshStatus() {
+ if (!job?.id) return;
+ try {
+ const updated = await getJobStatus(job.id);
+ setJob(updated);
+ } catch (e: any) {
+ setError(e?.response?.data?.detail || e?.message || "Failed to refresh status.");
+ }
+ }
+
+ return (
+
+
Downloader
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {job && (
+
+
Job
+
ID: {job.id}
+
Status: {job.status}
+ {typeof job.progress === "number" &&
Progress: {job.progress}%
}
+ {job.detail &&
Detail: {job.detail}
}
+ {job.download_url ? (
+
+ ) : (
+
+ Refresh status
+
+ )}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/routes/PrivateRoute.tsx b/frontend/src/routes/PrivateRoute.tsx
new file mode 100644
index 0000000..1b3a3c5
--- /dev/null
+++ b/frontend/src/routes/PrivateRoute.tsx
@@ -0,0 +1,22 @@
+import { Navigate, Outlet, useLocation } from "react-router-dom";
+
+function getCookie(name: string): string | null {
+ const nameEQ = name + "=";
+ const ca = document.cookie.split(";").map((c) => c.trim());
+ for (const c of ca) {
+ if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length));
+ }
+ return null;
+}
+
+const ACCESS_COOKIE = "access_token";
+
+export default function PrivateRoute() {
+ const location = useLocation();
+ const isLoggedIn = !!getCookie(ACCESS_COOKIE);
+
+ if (!isLoggedIn) {
+ return ;
+ }
+ return ;
+}