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.
|
- **frontend/**: Vite + React + TypeScript app.
|
||||||
- Organized by `src/api/`, `components/`, `features/`, `layouts/`, `pages/`, `routes/`.
|
- Organized by `src/api/`, `components/`, `features/`, `layouts/`, `pages/`, `routes/`.
|
||||||
- Uses React Router layouts and nested routes (see `src/layouts/`, `src/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
|
## Developer Workflows
|
||||||
- **Backend**
|
- **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`).
|
- Route definitions and guards in `src/routes/` (`ROUTES.md`).
|
||||||
- Use TypeScript strict mode (see `tsconfig.*.json`).
|
- Use TypeScript strict mode (see `tsconfig.*.json`).
|
||||||
- Linting: ESLint config in `eslint.config.js`.
|
- 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)
|
### Frontend API Client (required)
|
||||||
All frontend API calls must use the shared client at frontend/src/api/Client.ts.
|
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
|
import uuid
|
||||||
from django.db import models
|
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.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -16,7 +16,13 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class CustomUser(SoftDeleteModel, AbstractUser):
|
||||||
groups = models.ManyToManyField(
|
groups = models.ManyToManyField(
|
||||||
@@ -83,9 +89,10 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
|||||||
"email"
|
"email"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Ensure default manager has get_by_natural_key
|
||||||
def __str__(self):
|
objects = CustomUserManager()
|
||||||
return f"{self.email} at {self.create_time.strftime('%d-%m-%Y %H:%M:%S')}"
|
# Optional convenience manager for active users only
|
||||||
|
active = ActiveUserManager()
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
self.is_active = False
|
self.is_active = False
|
||||||
@@ -93,25 +100,29 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
|||||||
return super().delete(*args, **kwargs)
|
return super().delete(*args, **kwargs)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.pk is None: # if newely created user
|
is_new = self._state.adding # True if object hasn't been saved yet
|
||||||
|
|
||||||
from django.contrib.auth.models import Group
|
|
||||||
group, _ = Group.objects.get_or_create(name=self.role)
|
|
||||||
self.groups.set([group])
|
|
||||||
|
|
||||||
|
# Pre-save flags for new users
|
||||||
|
if is_new:
|
||||||
if self.is_superuser or self.role == "admin":
|
if self.is_superuser or self.role == "admin":
|
||||||
|
# ensure admin flags are consistent
|
||||||
self.is_active = True
|
self.is_active = True
|
||||||
|
self.is_staff = True
|
||||||
if self.role == 'admin':
|
self.is_superuser = True
|
||||||
self.is_staff = True
|
self.role = "admin"
|
||||||
self.is_superuser = True
|
|
||||||
|
|
||||||
if self.is_superuser:
|
|
||||||
self.role = 'admin'
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.is_staff = False
|
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)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,21 +27,16 @@ class CustomUserSerializer(serializers.ModelSerializer):
|
|||||||
"last_name",
|
"last_name",
|
||||||
"email",
|
"email",
|
||||||
"role",
|
"role",
|
||||||
"account_type",
|
|
||||||
"email_verified",
|
"email_verified",
|
||||||
"phone_number",
|
"phone_number",
|
||||||
"create_time",
|
"create_time",
|
||||||
"var_symbol",
|
|
||||||
"bank_account",
|
|
||||||
"ICO",
|
|
||||||
"RC",
|
|
||||||
"city",
|
"city",
|
||||||
"street",
|
"street",
|
||||||
"PSC",
|
"postal_code",
|
||||||
"GDPR",
|
"gdpr",
|
||||||
"is_active",
|
"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):
|
def update(self, instance, validated_data):
|
||||||
user = self.context["request"].user
|
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
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
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
|
#opencv-python #moviepy use this better instead of pillow
|
||||||
#moviepy
|
#moviepy
|
||||||
|
|
||||||
#yt-dlp
|
yt-dlp
|
||||||
|
|
||||||
weasyprint #tvoření PDFek z html dokumentu + css styly
|
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 django.urls import path
|
||||||
from .views import DownloaderLogView, DownloaderStatsView
|
from .views import DownloaderFormatsView, DownloaderFileView, DownloaderStatsView
|
||||||
from .views import DownloaderFormatsView, DownloaderFileView
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Probe formats for a URL (size-checked)
|
# 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)
|
# 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
|
# Aggregated statistics
|
||||||
path("api/downloader/stats/", DownloaderStatsView.as_view(), name="downloader-stats"),
|
path("stats/", DownloaderStatsView.as_view(), name="downloader-stats"),
|
||||||
# Legacy helper
|
|
||||||
path("api/downloader/logs/", DownloaderLogView.as_view(), name="downloader-log"),
|
|
||||||
]
|
]
|
||||||
|
|||||||
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.utils.text import slugify
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
|
|
||||||
# docs + schema helpers
|
# docs + schema helpers
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@@ -26,6 +27,7 @@ import math
|
|||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from urllib.parse import quote as urlquote
|
||||||
|
|
||||||
from .models import DownloaderModel
|
from .models import DownloaderModel
|
||||||
from .serializers import DownloaderLogSerializer
|
from .serializers import DownloaderLogSerializer
|
||||||
@@ -141,9 +143,30 @@ def _client_meta(request) -> Tuple[Optional[Any], Optional[str], Optional[str]]:
|
|||||||
user = getattr(request, "user", None)
|
user = getattr(request, "user", None)
|
||||||
return user, ip, ua
|
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):
|
class DownloaderFormatsView(APIView):
|
||||||
"""Probe media URL and return available formats with estimated sizes and limit flags."""
|
"""Probe media URL and return available formats with estimated sizes and limit flags."""
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["downloader"],
|
tags=["downloader"],
|
||||||
@@ -224,7 +247,7 @@ class DownloaderFormatsView(APIView):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# log probe error
|
# log probe error
|
||||||
user, ip, ua = _client_meta(request)
|
user, ip, ua = _client_meta(request)
|
||||||
DownloaderModel.from_ydl_info(
|
_log_safely(
|
||||||
info={"webpage_url": url},
|
info={"webpage_url": url},
|
||||||
requested_format=None,
|
requested_format=None,
|
||||||
status="probe_error",
|
status="probe_error",
|
||||||
@@ -258,7 +281,7 @@ class DownloaderFormatsView(APIView):
|
|||||||
|
|
||||||
# Log probe
|
# Log probe
|
||||||
user, ip, ua = _client_meta(request)
|
user, ip, ua = _client_meta(request)
|
||||||
DownloaderModel.from_ydl_info(
|
_log_safely(
|
||||||
info=info,
|
info=info,
|
||||||
requested_format=None,
|
requested_format=None,
|
||||||
status="probe_ok",
|
status="probe_ok",
|
||||||
@@ -280,6 +303,7 @@ class DownloaderFormatsView(APIView):
|
|||||||
class DownloaderFileView(APIView):
|
class DownloaderFileView(APIView):
|
||||||
"""Download selected format if under max size, then stream the file back."""
|
"""Download selected format if under max size, then stream the file back."""
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["downloader"],
|
tags=["downloader"],
|
||||||
@@ -288,7 +312,7 @@ class DownloaderFileView(APIView):
|
|||||||
description="Downloads with a strict max filesize guard and streams as application/octet-stream.",
|
description="Downloads with a strict max filesize guard and streams as application/octet-stream.",
|
||||||
request=DownloadRequestSchema,
|
request=DownloadRequestSchema,
|
||||||
responses={
|
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),
|
400: OpenApiResponse(response=ErrorResponseSchema),
|
||||||
413: OpenApiResponse(response=ErrorResponseSchema),
|
413: OpenApiResponse(response=ErrorResponseSchema),
|
||||||
500: OpenApiResponse(response=ErrorResponseSchema),
|
500: OpenApiResponse(response=ErrorResponseSchema),
|
||||||
@@ -334,7 +358,7 @@ class DownloaderFileView(APIView):
|
|||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
user, ip, ua = _client_meta(request)
|
user, ip, ua = _client_meta(request)
|
||||||
DownloaderModel.from_ydl_info(
|
_log_safely(
|
||||||
info={"webpage_url": url},
|
info={"webpage_url": url},
|
||||||
requested_format=fmt_id,
|
requested_format=fmt_id,
|
||||||
status="precheck_error",
|
status="precheck_error",
|
||||||
@@ -358,7 +382,7 @@ class DownloaderFileView(APIView):
|
|||||||
est_size = _estimate_size_bytes(selected, duration)
|
est_size = _estimate_size_bytes(selected, duration)
|
||||||
if est_size is not None and est_size > max_bytes:
|
if est_size is not None and est_size > max_bytes:
|
||||||
user, ip, ua = _client_meta(request)
|
user, ip, ua = _client_meta(request)
|
||||||
DownloaderModel.from_ydl_info(
|
_log_safely(
|
||||||
info=selected,
|
info=selected,
|
||||||
requested_format=fmt_id,
|
requested_format=fmt_id,
|
||||||
status="blocked_by_size",
|
status="blocked_by_size",
|
||||||
@@ -400,7 +424,7 @@ class DownloaderFileView(APIView):
|
|||||||
filepath = result.get("requested_downloads", [{}])[0].get("filepath") or result.get("_filename")
|
filepath = result.get("requested_downloads", [{}])[0].get("filepath") or result.get("_filename")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
user, ip, ua = _client_meta(request)
|
user, ip, ua = _client_meta(request)
|
||||||
DownloaderModel.from_ydl_info(
|
_log_safely(
|
||||||
info=selected,
|
info=selected,
|
||||||
requested_format=fmt_id,
|
requested_format=fmt_id,
|
||||||
status="download_error",
|
status="download_error",
|
||||||
@@ -425,7 +449,7 @@ class DownloaderFileView(APIView):
|
|||||||
try:
|
try:
|
||||||
selected_info = dict(selected)
|
selected_info = dict(selected)
|
||||||
selected_info["filesize"] = os.path.getsize(filepath)
|
selected_info["filesize"] = os.path.getsize(filepath)
|
||||||
DownloaderModel.from_ydl_info(
|
_log_safely(
|
||||||
info=selected_info,
|
info=selected_info,
|
||||||
requested_format=fmt_id,
|
requested_format=fmt_id,
|
||||||
status="success",
|
status="success",
|
||||||
@@ -451,7 +475,13 @@ class DownloaderFileView(APIView):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
resp = StreamingHttpResponse(file_generator(filepath), content_type="application/octet-stream")
|
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:
|
try:
|
||||||
resp["Content-Length"] = str(os.path.getsize(filepath))
|
resp["Content-Length"] = str(os.path.getsize(filepath))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -521,19 +551,3 @@ class DownloaderStatsView(APIView):
|
|||||||
"top_acodec": top_acodec,
|
"top_acodec": top_acodec,
|
||||||
"audio_vs_video": audio_vs_video,
|
"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
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings')
|
|||||||
|
|
||||||
application = ProtocolTypeRouter({
|
application = ProtocolTypeRouter({
|
||||||
"http": get_asgi_application(),
|
"http": get_asgi_application(),
|
||||||
"websocket": AuthMiddlewareStack(
|
# "websocket": AuthMiddlewareStack(
|
||||||
URLRouter(
|
# URLRouter(
|
||||||
#myapp.routing.websocket_urlpatterns
|
# #myapp.routing.websocket_urlpatterns
|
||||||
)
|
# )
|
||||||
),
|
# ),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ urlpatterns = [
|
|||||||
|
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('api/account/', include('account.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/stripe/', include('thirdparty.stripe.urls')),
|
||||||
path('api/trading212/', include('thirdparty.trading212.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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@types/react-router": "^5.1.20",
|
"@types/react-router": "^5.1.20",
|
||||||
"axios": "^1.13.0",
|
"axios": "^1.13.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.8.1"
|
"react-router-dom": "^7.8.1",
|
||||||
|
"tailwindcss": "^4.1.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.33.0",
|
"@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";
|
import Client from "../Client";
|
||||||
|
|
||||||
export type Choices = {
|
export type FormatOption = {
|
||||||
file_types: string[];
|
format_id: string;
|
||||||
qualities: 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 = {
|
export type FormatsResponse = {
|
||||||
url: string;
|
title: string | null;
|
||||||
file_type?: string;
|
duration: number | null;
|
||||||
quality?: string;
|
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 = {
|
export type DownloadJobResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
status: "pending" | "running" | "finished" | "failed";
|
status: "pending" | "running" | "finished" | "failed";
|
||||||
detail?: string;
|
detail?: string;
|
||||||
download_url?: string;
|
download_url?: string;
|
||||||
progress?: number; // 0-100
|
progress?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback when choices endpoint is unavailable or models are hardcoded
|
// Helpers
|
||||||
const FALLBACK_CHOICES: Choices = {
|
function parseContentDispositionFilename(cd?: string): string | null {
|
||||||
file_types: ["auto", "video", "audio"],
|
if (!cd) return null;
|
||||||
qualities: ["best", "good", "worst"],
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
function inferFilenameFromUrl(url: string, contentType?: string): string {
|
||||||
* 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> {
|
|
||||||
try {
|
try {
|
||||||
const res = await Client.auth.get("/api/downloader/choices/");
|
const u = new URL(url);
|
||||||
return res.data as Choices;
|
const last = u.pathname.split("/").filter(Boolean).pop();
|
||||||
|
if (last) return last;
|
||||||
} catch {
|
} catch {
|
||||||
return FALLBACK_CHOICES;
|
// ignore
|
||||||
}
|
}
|
||||||
|
if (contentType) {
|
||||||
|
const ext = contentTypeToExt(contentType);
|
||||||
|
return `download${ext ? `.${ext}` : ""}`;
|
||||||
|
}
|
||||||
|
return "download.bin";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function contentTypeToExt(ct: string): string | null {
|
||||||
* Submit a new download job (adjust path/body to your viewset).
|
const map: Record<string, string> = {
|
||||||
* Example payload: { url, file_type, quality }
|
"video/mp4": "mp4",
|
||||||
*/
|
"audio/mpeg": "mp3",
|
||||||
export async function submitDownload(payload: DownloadPayload): Promise<DownloadJobResponse> {
|
"audio/mp4": "m4a",
|
||||||
const res = await Client.auth.post("/api/downloader/jobs/", payload);
|
"audio/aac": "aac",
|
||||||
return res.data as DownloadJobResponse;
|
"audio/ogg": "ogg",
|
||||||
}
|
"video/webm": "webm",
|
||||||
|
"audio/webm": "webm",
|
||||||
/**
|
"application/octet-stream": "bin",
|
||||||
* Get job status by ID. Returns progress, status, and download_url when finished.
|
};
|
||||||
*/
|
return map[ct] || null;
|
||||||
export async function getJobStatus(id: string): Promise<DownloadJobResponse> {
|
|
||||||
const res = await Client.auth.get(`/api/downloader/jobs/${id}/`);
|
|
||||||
return res.data as DownloadJobResponse;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ footer a i{
|
|||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
footer a:hover i{
|
||||||
|
color: var(--c-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
footer{
|
footer{
|
||||||
font-family: "Roboto Mono", monospace;
|
font-family: "Roboto Mono", monospace;
|
||||||
|
|
||||||
|
|||||||
@@ -61,46 +61,28 @@ export default function Drone() {
|
|||||||
|
|
||||||
<article>
|
<article>
|
||||||
<header>
|
<header>
|
||||||
<h1>Letecké snímky dronem</h1>
|
<h1>Letecké záběry, co zaujmou</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section>
|
<section>
|
||||||
<h2>Oprávnění</h2>
|
<h2>Oprávnění</h2>
|
||||||
<p>
|
<p>Oprávnění A1/A2/A3 + radiostanice. Bezpečný provoz i v omezených zónách, povolení zajistím.</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>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Cena</h2>
|
<h2>Cena</h2>
|
||||||
<p>
|
<p>Paušál 3 000 Kč. Ostrava zdarma; mimo 10 Kč/km. Cena se může lišit dle povolení.</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>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Výstup</h2>
|
<h2>Výstup</h2>
|
||||||
<p>
|
<p>Krátký sestřih nebo surové záběry — podle potřeby.</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>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
V případě zájmu mě neváhejte <br />
|
<a href="#contacts">Zájem?</a>
|
||||||
<a href="#contacts">kontaktovat!</a>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,8 +27,11 @@
|
|||||||
transform-origin: bottom;
|
transform-origin: bottom;
|
||||||
transition: transform 0.5s ease-in-out;
|
transition: transform 0.5s ease-in-out;
|
||||||
|
|
||||||
|
transform: skew(-5deg);
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|
||||||
|
box-shadow: #000000 5px 5px 15px;
|
||||||
|
|
||||||
}
|
}
|
||||||
.portfolio div span svg{
|
.portfolio div span svg{
|
||||||
font-size: 5em;
|
font-size: 5em;
|
||||||
@@ -56,7 +59,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.portfolio .door-open{
|
.portfolio .door-open{
|
||||||
transform: rotateX(180deg);
|
transform: rotateX(90deg) skew(-2deg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portfolio>header {
|
.portfolio>header {
|
||||||
@@ -131,6 +134,8 @@
|
|||||||
|
|
||||||
border-radius: 1em;
|
border-radius: 1em;
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
|
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portfolio div article {
|
.portfolio div article {
|
||||||
@@ -145,7 +150,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.portfolio div article header a img {
|
.portfolio div article header a img {
|
||||||
padding: 2em 0;
|
|
||||||
width: 80%;
|
width: 80%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,34 @@ interface PortfolioItem {
|
|||||||
href: string
|
href: string
|
||||||
src: string
|
src: string
|
||||||
alt: 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[] = [
|
const portfolioItems: PortfolioItem[] = [
|
||||||
{
|
{
|
||||||
href: "https://davo1.cz",
|
href: "https://davo1.cz",
|
||||||
src: "/home/img/portfolio/DAVO_logo_2024_bile.png",
|
src: "/portfolio/davo1.png",
|
||||||
alt: "davo1.cz logo",
|
alt: "davo1.cz logo",
|
||||||
|
imgClassName: "bg-black rounded-lg p-4",
|
||||||
|
//className: "bg-white/5 rounded-lg p-4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "https://perlica.cz",
|
href: "https://perlica.cz",
|
||||||
src: "/home/img/portfolio/perlica-3.webp",
|
src: "/portfolio/perlica.png",
|
||||||
alt: "Perlica logo",
|
alt: "Perlica logo",
|
||||||
|
imgClassName: "rounded-lg",
|
||||||
|
// imgClassName: "max-h-12",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "http://epinger2.cz",
|
href: "http://epinger2.cz",
|
||||||
src: "/home/img/portfolio/logo_epinger.svg",
|
src: "/portfolio/epinger.png",
|
||||||
alt: "Epinger2 logo",
|
alt: "Epinger2 logo",
|
||||||
|
imgClassName: "bg-white rounded-lg",
|
||||||
|
// imgClassName: "max-h-12",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -51,10 +62,19 @@ export default function Portfolio() {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{portfolioItems.map((item, index) => (
|
{portfolioItems.map((item, index) => (
|
||||||
<article key={index} className={styles.article}>
|
<article
|
||||||
|
key={index}
|
||||||
|
className={`${styles.article} ${item.className ?? ""}`}
|
||||||
|
style={item.style}
|
||||||
|
>
|
||||||
<header>
|
<header>
|
||||||
<a href={item.href} target="_blank" rel="noopener noreferrer">
|
<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>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
<main></main>
|
<main></main>
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ nav.isSticky-nav{
|
|||||||
nav ul #nav-logo{
|
nav ul #nav-logo{
|
||||||
border-right: 0.2em solid var(--c-lines);
|
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{
|
nav ul #nav-logo span{
|
||||||
line-height: 0.75;
|
line-height: 0.75;
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
@@ -47,10 +51,21 @@ nav a{
|
|||||||
position: relative;
|
position: relative;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a:hover{
|
nav a:hover{
|
||||||
color: #fff;
|
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 {
|
nav a::before {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -67,7 +82,125 @@ nav a::before {
|
|||||||
nav a:hover::before {
|
nav a:hover::before {
|
||||||
transform: scaleX(1);
|
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 {
|
nav ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -129,6 +262,11 @@ nav ul li a {
|
|||||||
|
|
||||||
max-height: 2em;
|
max-height: 2em;
|
||||||
}
|
}
|
||||||
|
/* When TSX adds styles.open to the UL, expand it */
|
||||||
|
.open {
|
||||||
|
max-height: 20em;
|
||||||
|
}
|
||||||
|
|
||||||
nav ul:last-child{
|
nav ul:last-child{
|
||||||
padding-bottom: 1em;
|
padding-bottom: 1em;
|
||||||
}
|
}
|
||||||
@@ -139,4 +277,28 @@ nav ul li a {
|
|||||||
border-bottom: 0.2em solid var(--c-lines);
|
border-bottom: 0.2em solid var(--c-lines);
|
||||||
border-right: none;
|
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 React, { useState } from "react"
|
||||||
import styles from "./HomeNav.module.css"
|
import styles from "./HomeNav.module.css"
|
||||||
import { FaBars } from "react-icons/fa";
|
import { FaBars, FaChevronDown } from "react-icons/fa";
|
||||||
|
|
||||||
export default function HomeNav() {
|
export default function HomeNav() {
|
||||||
const [navOpen, setNavOpen] = useState(false)
|
const [navOpen, setNavOpen] = useState(false)
|
||||||
@@ -9,7 +9,12 @@ export default function HomeNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={styles.nav}>
|
<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 : ""}`}>
|
<ul className={`${styles.navList} ${navOpen ? styles.open : ""}`}>
|
||||||
<li id="nav-logo" className={styles.logo}>
|
<li id="nav-logo" className={styles.logo}>
|
||||||
@@ -21,8 +26,18 @@ export default function HomeNav() {
|
|||||||
<li>
|
<li>
|
||||||
<a href="#portfolio">Portfolio</a>
|
<a href="#portfolio">Portfolio</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li className={styles.hasSubmenu}>
|
||||||
<a href="#services">Services</a>
|
<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>
|
||||||
<li>
|
<li>
|
||||||
<a href="#contactme-form">Contact me</a>
|
<a href="#contactme-form">Contact me</a>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -19,15 +21,6 @@
|
|||||||
--c-other: #70A288; /*other*/
|
--c-other: #70A288; /*other*/
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
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(
|
return(
|
||||||
<>
|
<>
|
||||||
{/* Example usage of imported components, adjust as needed */}
|
{/* Example usage of imported components, adjust as needed */}
|
||||||
|
|
||||||
<HomeNav />
|
<HomeNav />
|
||||||
|
|
||||||
<Home /> {/*page*/}
|
<Home /> {/*page*/}
|
||||||
<Drone />
|
<div style={{margin: "10em 0"}}>
|
||||||
|
<Drone />
|
||||||
|
</div>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Portfolio />
|
<Portfolio />
|
||||||
<div style={{ margin: "6em auto", marginTop: "15em", maxWidth: "80vw" }}>
|
<div style={{ margin: "6em auto", marginTop: "15em", maxWidth: "80vw" }}>
|
||||||
|
|||||||
@@ -1,160 +1,140 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { probeFormats, downloadFormat, type FormatsResponse, type FormatOption } from "../../api/apps/Downloader";
|
||||||
getChoices,
|
|
||||||
submitDownload,
|
|
||||||
getJobStatus,
|
|
||||||
type Choices,
|
|
||||||
type DownloadJobResponse,
|
|
||||||
} from "../../api/apps/Downloader";
|
|
||||||
|
|
||||||
export default function Downloader() {
|
export default function Downloader() {
|
||||||
const [choices, setChoices] = useState<Choices>({ file_types: [], qualities: [] });
|
|
||||||
const [loadingChoices, setLoadingChoices] = useState(true);
|
|
||||||
|
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [fileType, setFileType] = useState<string>("");
|
const [probing, setProbing] = useState(false);
|
||||||
const [quality, setQuality] = useState<string>("");
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [job, setJob] = useState<DownloadJobResponse | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [formats, setFormats] = useState<FormatsResponse | null>(null);
|
||||||
|
|
||||||
// Load dropdown choices once
|
async function onProbe(e: React.FormEvent) {
|
||||||
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();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(true);
|
setFormats(null);
|
||||||
|
setProbing(true);
|
||||||
try {
|
try {
|
||||||
const created = await submitDownload({ url, file_type: fileType, quality });
|
const res = await probeFormats(url);
|
||||||
setJob(created);
|
setFormats(res);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.response?.data?.detail || e?.message || "Submission failed.");
|
setError(e?.response?.data?.detail || e?.message || "Failed to load formats.");
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setProbing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function onDownload(fmt: FormatOption) {
|
||||||
if (!job?.id) return;
|
setError(null);
|
||||||
|
setDownloadingId(fmt.format_id);
|
||||||
try {
|
try {
|
||||||
const updated = await getJobStatus(job.id);
|
const { blob, filename } = await downloadFormat(url, fmt.format_id);
|
||||||
setJob(updated);
|
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) {
|
} 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 (
|
return (
|
||||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1rem" }}>
|
<div className="max-w-3xl mx-auto p-4">
|
||||||
<h1>Downloader</h1>
|
<h1 className="text-2xl font-semibold mb-4">Downloader</h1>
|
||||||
|
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: ".75rem" }}>
|
<form onSubmit={onProbe} className="grid gap-3 mb-4">
|
||||||
<label>
|
<label className="grid gap-1">
|
||||||
URL
|
<span className="text-sm font-medium">URL</span>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
required
|
required
|
||||||
placeholder="https://example.com/video"
|
placeholder="https://example.com/video"
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
style={{ width: "100%", padding: ".5rem" }}
|
className="w-full border rounded p-2"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
<div>
|
||||||
<button type="submit" disabled={!canSubmit} style={{ padding: ".5rem 1rem" }}>
|
<button
|
||||||
{submitting ? "Submitting..." : "Start download"}
|
type="submit"
|
||||||
|
disabled={!url || probing}
|
||||||
|
className="px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{probing ? "Probing..." : "Find formats"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{job && (
|
{formats && (
|
||||||
<div style={{ marginTop: "1rem", borderTop: "1px solid #ddd", paddingTop: "1rem" }}>
|
<div className="space-y-3">
|
||||||
<h2>Job</h2>
|
<div className="text-sm text-gray-700">
|
||||||
<div>ID: {job.id}</div>
|
<div><span className="font-medium">Title:</span> {formats.title || "-"}</div>
|
||||||
<div>Status: {job.status}</div>
|
<div><span className="font-medium">Duration:</span> {formats.duration ? `${Math.round(formats.duration)} s` : "-"}</div>
|
||||||
{typeof job.progress === "number" && <div>Progress: {job.progress}%</div>}
|
<div><span className="font-medium">Max size:</span> {formatBytes(formats.max_size_bytes)}</div>
|
||||||
{job.detail && <div>Detail: {job.detail}</div>}
|
</div>
|
||||||
{job.download_url ? (
|
|
||||||
<div style={{ marginTop: ".5rem" }}>
|
<div className="border rounded overflow-hidden">
|
||||||
<a href={job.download_url} target="_blank" rel="noreferrer">
|
<div className="grid grid-cols-6 gap-2 p-2 bg-gray-50 text-sm font-medium">
|
||||||
Download file
|
<div>Format</div>
|
||||||
</a>
|
<div>Resolution</div>
|
||||||
|
<div>Type</div>
|
||||||
|
<div>Note</div>
|
||||||
|
<div>Est. size</div>
|
||||||
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="divide-y">
|
||||||
<button onClick={refreshStatus} style={{ marginTop: ".5rem", padding: ".5rem 1rem" }}>
|
{formats.options.map((o) => (
|
||||||
Refresh status
|
<div key={o.format_id} className="grid grid-cols-6 gap-2 p-2 items-center text-sm">
|
||||||
</button>
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 { defineConfig } from 'vite'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss()
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user