okay
This commit is contained in:
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -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.
|
||||
|
||||
54
backend/account/migrations/0001_initial.py
Normal file
54
backend/account/migrations/0001_initial.py
Normal file
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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.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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
13
backend/thirdparty/downloader/urls.py
vendored
13
backend/thirdparty/downloader/urls.py
vendored
@@ -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"),
|
||||
]
|
||||
|
||||
62
backend/thirdparty/downloader/views.py
vendored
62
backend/thirdparty/downloader/views.py
vendored
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
# )
|
||||
# ),
|
||||
})
|
||||
|
||||
@@ -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')),
|
||||
|
||||
]
|
||||
|
||||
649
frontend/package-lock.json
generated
649
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
BIN
frontend/public/portfolio/davo1.png
Normal file
BIN
frontend/public/portfolio/davo1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
frontend/public/portfolio/epinger.png
Normal file
BIN
frontend/public/portfolio/epinger.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/portfolio/perlica.png
Normal file
BIN
frontend/public/portfolio/perlica.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -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<FormatsResponse> {
|
||||
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<Choices> {
|
||||
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<DownloadJobResponse> {
|
||||
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<DownloadJobResponse> {
|
||||
const res = await Client.auth.get(`/api/downloader/jobs/${id}/`);
|
||||
return res.data as DownloadJobResponse;
|
||||
function contentTypeToExt(ct: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -61,46 +61,28 @@ export default function Drone() {
|
||||
|
||||
<article>
|
||||
<header>
|
||||
<h1>Letecké snímky dronem</h1>
|
||||
<h1>Letecké záběry, co zaujmou</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section>
|
||||
<h2>Oprávnění</h2>
|
||||
<p>
|
||||
A1, A2, A3 a průkaz na vysílačku!
|
||||
<br />
|
||||
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šť!
|
||||
</p>
|
||||
<p>Oprávnění A1/A2/A3 + radiostanice. Bezpečný provoz i v omezených zónách, povolení zajistím.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Cena</h2>
|
||||
<p>
|
||||
Nabízím letecké záběry dronem <br />
|
||||
za cenu <u>3 000 Kč</u>.
|
||||
</p>
|
||||
<p>
|
||||
Pokud se nacházíte v Ostravě, doprava je zdarma. Pro oblasti mimo Ostravu účtuji 10 Kč/km.
|
||||
</p>
|
||||
<p>
|
||||
Cena se může odvíjet ještě podle složitosti získaní povolení.*
|
||||
</p>
|
||||
<p>Paušál 3 000 Kč. Ostrava zdarma; mimo 10 Kč/km. Cena se může lišit dle povolení.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Výstup</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>Krátký sestřih nebo surové záběry — podle potřeby.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div>
|
||||
V případě zájmu mě neváhejte <br />
|
||||
<a href="#contacts">kontaktovat!</a>
|
||||
<a href="#contacts">Zájem?</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</span>
|
||||
|
||||
{portfolioItems.map((item, index) => (
|
||||
<article key={index} className={styles.article}>
|
||||
<article
|
||||
key={index}
|
||||
className={`${styles.article} ${item.className ?? ""}`}
|
||||
style={item.style}
|
||||
>
|
||||
<header>
|
||||
<a href={item.href} target="_blank" rel="noopener noreferrer">
|
||||
<img src={item.src} alt={item.alt} />
|
||||
<img
|
||||
src={item.src}
|
||||
alt={item.alt}
|
||||
className={item.imgClassName}
|
||||
style={item.imgStyle}
|
||||
/>
|
||||
</a>
|
||||
</header>
|
||||
<main></main>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
/* 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 */
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<nav className={styles.nav}>
|
||||
<FaBars className={styles.toggle} onClick={toggleNav} />
|
||||
<FaBars
|
||||
className={`${styles.toggle} ${navOpen ? styles.toggleRotated : ""}`}
|
||||
onClick={toggleNav}
|
||||
aria-label="Toggle navigation"
|
||||
aria-expanded={navOpen}
|
||||
/>
|
||||
|
||||
<ul className={`${styles.navList} ${navOpen ? styles.open : ""}`}>
|
||||
<li id="nav-logo" className={styles.logo}>
|
||||
@@ -21,8 +26,18 @@ export default function HomeNav() {
|
||||
<li>
|
||||
<a href="#portfolio">Portfolio</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#services">Services</a>
|
||||
<li className={styles.hasSubmenu}>
|
||||
<details>
|
||||
<summary>
|
||||
Services
|
||||
<FaChevronDown className={`${styles.caret} ml-2 inline-block`} aria-hidden="true" />
|
||||
</summary>
|
||||
<ul className={styles.submenu}>
|
||||
<li><a href="#web">Web development</a></li>
|
||||
<li><a href="#integration">Integrations</a></li>
|
||||
<li><a href="#support">Support</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#contactme-form">Contact me</a>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
@@ -19,15 +21,6 @@
|
||||
--c-other: #70A288; /*other*/
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
|
||||
28
frontend/src/layouts/Default.tsx
Normal file
28
frontend/src/layouts/Default.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Footer from "../components/Footer/footer";
|
||||
import ContactMeForm from "../components/Forms/ContactMe/ContactMeForm";
|
||||
import HomeNav from "../components/navbar/HomeNav";
|
||||
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(
|
||||
<>
|
||||
{/* Example usage of imported components, adjust as needed */}
|
||||
|
||||
<HomeNav />
|
||||
|
||||
<Home /> {/*page*/}
|
||||
<div style={{margin: "10em 0"}}>
|
||||
<Drone />
|
||||
</div>
|
||||
<Outlet />
|
||||
<Portfolio />
|
||||
<div style={{ margin: "6em auto", marginTop: "15em", maxWidth: "80vw" }}>
|
||||
<ContactMeForm />
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -10,9 +10,13 @@ export default function HomeLayout(){
|
||||
return(
|
||||
<>
|
||||
{/* Example usage of imported components, adjust as needed */}
|
||||
|
||||
<HomeNav />
|
||||
|
||||
<Home /> {/*page*/}
|
||||
<div style={{margin: "10em 0"}}>
|
||||
<Drone />
|
||||
</div>
|
||||
<Outlet />
|
||||
<Portfolio />
|
||||
<div style={{ margin: "6em auto", marginTop: "15em", maxWidth: "80vw" }}>
|
||||
|
||||
@@ -1,160 +1,140 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getChoices,
|
||||
submitDownload,
|
||||
getJobStatus,
|
||||
type Choices,
|
||||
type DownloadJobResponse,
|
||||
} from "../../api/apps/Downloader";
|
||||
import { useState } from "react";
|
||||
import { probeFormats, downloadFormat, type FormatsResponse, type FormatOption } from "../../api/apps/Downloader";
|
||||
|
||||
export default function Downloader() {
|
||||
const [choices, setChoices] = useState<Choices>({ file_types: [], qualities: [] });
|
||||
const [loadingChoices, setLoadingChoices] = useState(true);
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [fileType, setFileType] = useState<string>("");
|
||||
const [quality, setQuality] = useState<string>("");
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [job, setJob] = useState<DownloadJobResponse | null>(null);
|
||||
const [probing, setProbing] = useState(false);
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formats, setFormats] = useState<FormatsResponse | null>(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) {
|
||||
async function onProbe(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
setFormats(null);
|
||||
setProbing(true);
|
||||
try {
|
||||
const created = await submitDownload({ url, file_type: fileType, quality });
|
||||
setJob(created);
|
||||
const res = await probeFormats(url);
|
||||
setFormats(res);
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || e?.message || "Submission failed.");
|
||||
setError(e?.response?.data?.detail || e?.message || "Failed to load formats.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setProbing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
if (!job?.id) return;
|
||||
async function onDownload(fmt: FormatOption) {
|
||||
setError(null);
|
||||
setDownloadingId(fmt.format_id);
|
||||
try {
|
||||
const updated = await getJobStatus(job.id);
|
||||
setJob(updated);
|
||||
const { blob, filename } = await downloadFormat(url, fmt.format_id);
|
||||
const link = document.createElement("a");
|
||||
const href = URL.createObjectURL(blob);
|
||||
link.href = href;
|
||||
link.download = filename || "download.bin";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(href);
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || e?.message || "Failed to refresh status.");
|
||||
setError(e?.response?.data?.detail || e?.message || "Download failed.");
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1rem" }}>
|
||||
<h1>Downloader</h1>
|
||||
<div className="max-w-3xl mx-auto p-4">
|
||||
<h1 className="text-2xl font-semibold mb-4">Downloader</h1>
|
||||
|
||||
{error && (
|
||||
<div style={{ background: "#fee", color: "#900", padding: ".5rem", marginBottom: ".75rem" }}>
|
||||
<div className="mb-3 rounded border border-red-300 bg-red-50 text-red-700 p-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: ".75rem" }}>
|
||||
<label>
|
||||
URL
|
||||
<form onSubmit={onProbe} className="grid gap-3 mb-4">
|
||||
<label className="grid gap-1">
|
||||
<span className="text-sm font-medium">URL</span>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/video"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
style={{ width: "100%", padding: ".5rem" }}
|
||||
className="w-full border rounded p-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".75rem" }}>
|
||||
<label>
|
||||
File type
|
||||
<select
|
||||
value={fileType}
|
||||
onChange={(e) => setFileType(e.target.value)}
|
||||
disabled={loadingChoices}
|
||||
style={{ width: "100%", padding: ".5rem" }}
|
||||
>
|
||||
{choices.file_types.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Quality
|
||||
<select
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(e.target.value)}
|
||||
disabled={loadingChoices}
|
||||
style={{ width: "100%", padding: ".5rem" }}
|
||||
>
|
||||
{choices.qualities.map((q) => (
|
||||
<option key={q} value={q}>
|
||||
{q}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" disabled={!canSubmit} style={{ padding: ".5rem 1rem" }}>
|
||||
{submitting ? "Submitting..." : "Start download"}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!url || probing}
|
||||
className="px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||||
>
|
||||
{probing ? "Probing..." : "Find formats"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{job && (
|
||||
<div style={{ marginTop: "1rem", borderTop: "1px solid #ddd", paddingTop: "1rem" }}>
|
||||
<h2>Job</h2>
|
||||
<div>ID: {job.id}</div>
|
||||
<div>Status: {job.status}</div>
|
||||
{typeof job.progress === "number" && <div>Progress: {job.progress}%</div>}
|
||||
{job.detail && <div>Detail: {job.detail}</div>}
|
||||
{job.download_url ? (
|
||||
<div style={{ marginTop: ".5rem" }}>
|
||||
<a href={job.download_url} target="_blank" rel="noreferrer">
|
||||
Download file
|
||||
</a>
|
||||
{formats && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-700">
|
||||
<div><span className="font-medium">Title:</span> {formats.title || "-"}</div>
|
||||
<div><span className="font-medium">Duration:</span> {formats.duration ? `${Math.round(formats.duration)} s` : "-"}</div>
|
||||
<div><span className="font-medium">Max size:</span> {formatBytes(formats.max_size_bytes)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={refreshStatus} style={{ marginTop: ".5rem", padding: ".5rem 1rem" }}>
|
||||
Refresh status
|
||||
|
||||
<div className="border rounded overflow-hidden">
|
||||
<div className="grid grid-cols-6 gap-2 p-2 bg-gray-50 text-sm font-medium">
|
||||
<div>Format</div>
|
||||
<div>Resolution</div>
|
||||
<div>Type</div>
|
||||
<div>Note</div>
|
||||
<div>Est. size</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{formats.options.map((o) => (
|
||||
<div key={o.format_id} className="grid grid-cols-6 gap-2 p-2 items-center text-sm">
|
||||
<div className="truncate">{o.format_id}{o.ext ? `.${o.ext}` : ""}</div>
|
||||
<div>{o.resolution || (o.audio_only ? "audio" : "-")}</div>
|
||||
<div>{o.audio_only ? "Audio" : "Video"}</div>
|
||||
<div className="truncate">{o.format_note || "-"}</div>
|
||||
<div className={o.size_ok ? "text-gray-800" : "text-red-600"}>
|
||||
{o.estimated_size_bytes ? formatBytes(o.estimated_size_bytes) : (o.filesize || o.filesize_approx) ? "~" + formatBytes((o.filesize || o.filesize_approx)!) : "?"}
|
||||
{!o.size_ok && " (too big)"}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<button
|
||||
onClick={() => onDownload(o)}
|
||||
disabled={!o.size_ok || downloadingId === o.format_id}
|
||||
className="px-2 py-1 rounded bg-emerald-600 text-white disabled:opacity-50"
|
||||
>
|
||||
{downloadingId === o.format_id ? "Downloading..." : "Download"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!formats.options.length && (
|
||||
<div className="text-sm text-gray-600">No formats available.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number | null): string {
|
||||
if (!bytes || bytes <= 0) return "-";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let i = 0;
|
||||
let n = bytes;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user