This commit is contained in:
2025-10-29 00:58:37 +01:00
parent 73da41b514
commit dd9d076bd2
33 changed files with 1172 additions and 385 deletions

View File

@@ -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.

View 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()),
],
),
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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')],
},
),
]

View File

@@ -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"),
] ]

View File

@@ -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)

View File

@@ -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

View File

@@ -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
) # )
), # ),
}) })

View File

@@ -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')),
] ]

File diff suppressed because it is too large Load Diff

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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 3000. Ostrava zdarma; mimo 10/km. Cena se může lišit dle povolení.</p>
Nabízím letecké záběry dronem <br />
za cenu <u>3 000 </u>.
</p>
<p>
Pokud se nacházíte v Ostravě, doprava je zdarma. Pro oblasti mimo Ostravu účtuji 10 /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 neváhejte <br /> <a href="#contacts">Zájem?</a>
<a href="#contacts">kontaktovat!</a>
</div> </div>
</article> </article>
</div> </div>

View File

@@ -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;
} }

View File

@@ -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>

View File

@@ -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 */
}
}

View File

@@ -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>

View File

@@ -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;

View 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 />
</>
)
}

View File

@@ -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" }}>

View File

@@ -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]}`;
}

View File

@@ -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()
],
}) })