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.
- Organized by `src/api/`, `components/`, `features/`, `layouts/`, `pages/`, `routes/`.
- Uses React Router layouts and nested routes (see `src/layouts/`, `src/routes/`).
- **docker-compose.yml**: Orchestrates backend, frontend, Redis, and Postgres for local/dev.
- Uses Tailwind CSS for styling (configured via `src/index.css` with `@import "tailwindcss";`). Prefer utility classes over custom CSS.
## Developer Workflows
- **Backend**
@@ -39,6 +39,7 @@ This monorepo contains a Django backend and a Vite/React frontend, orchestrated
- Route definitions and guards in `src/routes/` (`ROUTES.md`).
- Use TypeScript strict mode (see `tsconfig.*.json`).
- Linting: ESLint config in `eslint.config.js`.
- Styling: Tailwind CSS is present. Prefer utility classes; keep minimal component-scoped CSS. Global/base styles live in `src/index.css`. Avoid inline styles and CSS-in-JS unless necessary.
### Frontend API Client (required)
All frontend API calls must use the shared client at frontend/src/api/Client.ts.

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
from django.db import models
from django.contrib.auth.models import AbstractUser, Group, Permission
from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission
from django.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator
from django.conf import settings
@@ -16,7 +16,13 @@ import logging
logger = logging.getLogger(__name__)
class CustomUserManager(UserManager):
# Inherit get_by_natural_key and all auth behaviors
use_in_migrations = True
class ActiveUserManager(CustomUserManager):
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
class CustomUser(SoftDeleteModel, AbstractUser):
groups = models.ManyToManyField(
@@ -83,9 +89,10 @@ class CustomUser(SoftDeleteModel, AbstractUser):
"email"
]
def __str__(self):
return f"{self.email} at {self.create_time.strftime('%d-%m-%Y %H:%M:%S')}"
# Ensure default manager has get_by_natural_key
objects = CustomUserManager()
# Optional convenience manager for active users only
active = ActiveUserManager()
def delete(self, *args, **kwargs):
self.is_active = False
@@ -93,25 +100,29 @@ class CustomUser(SoftDeleteModel, AbstractUser):
return super().delete(*args, **kwargs)
def save(self, *args, **kwargs):
if self.pk is None: # if newely created user
from django.contrib.auth.models import Group
group, _ = Group.objects.get_or_create(name=self.role)
self.groups.set([group])
is_new = self._state.adding # True if object hasn't been saved yet
# Pre-save flags for new users
if is_new:
if self.is_superuser or self.role == "admin":
# ensure admin flags are consistent
self.is_active = True
if self.role == 'admin':
self.is_staff = True
self.is_superuser = True
if self.is_superuser:
self.role = 'admin'
self.role = "admin"
else:
self.is_staff = False
# First save to obtain a primary key
super().save(*args, **kwargs)
# Assign group after we have a PK
if is_new:
from django.contrib.auth.models import Group
group, _ = Group.objects.get_or_create(name=self.role)
# Use add/set now that PK exists
self.groups.set([group])
return super().save(*args, **kwargs)

View File

@@ -27,21 +27,16 @@ class CustomUserSerializer(serializers.ModelSerializer):
"last_name",
"email",
"role",
"account_type",
"email_verified",
"phone_number",
"create_time",
"var_symbol",
"bank_account",
"ICO",
"RC",
"city",
"street",
"PSC",
"GDPR",
"postal_code",
"gdpr",
"is_active",
]
read_only_fields = ["id", "create_time", "GDPR", "username"] # <-- removed "account_type"
read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type"
def update(self, instance, validated_data):
user = self.context["request"].user

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
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
#moviepy
#yt-dlp
yt-dlp
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 .views import DownloaderLogView, DownloaderStatsView
from .views import DownloaderFormatsView, DownloaderFileView
from .views import DownloaderFormatsView, DownloaderFileView, DownloaderStatsView
urlpatterns = [
# Probe formats for a URL (size-checked)
path("api/downloader/formats/", DownloaderFormatsView.as_view(), name="downloader-formats"),
path("formats/", DownloaderFormatsView.as_view(), name="downloader-formats"),
# Download selected format (enforces size limit)
path("api/downloader/download/", DownloaderFileView.as_view(), name="downloader-download"),
path("download/", DownloaderFileView.as_view(), name="downloader-download"),
# Aggregated statistics
path("api/downloader/stats/", DownloaderStatsView.as_view(), name="downloader-stats"),
# Legacy helper
path("api/downloader/logs/", DownloaderLogView.as_view(), name="downloader-log"),
path("stats/", DownloaderStatsView.as_view(), name="downloader-stats"),
]

View File

@@ -9,6 +9,7 @@ from django.http import StreamingHttpResponse, JsonResponse
from django.utils.text import slugify
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from django.db.utils import OperationalError, ProgrammingError
# docs + schema helpers
from rest_framework import serializers
@@ -26,6 +27,7 @@ import math
import json
import tempfile
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote as urlquote
from .models import DownloaderModel
from .serializers import DownloaderLogSerializer
@@ -141,9 +143,30 @@ def _client_meta(request) -> Tuple[Optional[Any], Optional[str], Optional[str]]:
user = getattr(request, "user", None)
return user, ip, ua
# Safe logger: swallow DB errors if table is missing/not migrated yet
def _log_safely(*, info, requested_format, status: str, url: str, user, ip_address: str, user_agent: str, error_message: str | None = None):
try:
DownloaderModel.from_ydl_info(
info=info,
requested_format=requested_format,
status=status,
url=url,
user=user,
ip_address=ip_address,
user_agent=user_agent,
error_message=error_message,
)
except (OperationalError, ProgrammingError):
# migrations not applied or table missing ignore
pass
except Exception:
# never break the request on logging failures
pass
class DownloaderFormatsView(APIView):
"""Probe media URL and return available formats with estimated sizes and limit flags."""
permission_classes = [AllowAny]
authentication_classes = []
@extend_schema(
tags=["downloader"],
@@ -224,7 +247,7 @@ class DownloaderFormatsView(APIView):
except Exception as e:
# log probe error
user, ip, ua = _client_meta(request)
DownloaderModel.from_ydl_info(
_log_safely(
info={"webpage_url": url},
requested_format=None,
status="probe_error",
@@ -258,7 +281,7 @@ class DownloaderFormatsView(APIView):
# Log probe
user, ip, ua = _client_meta(request)
DownloaderModel.from_ydl_info(
_log_safely(
info=info,
requested_format=None,
status="probe_ok",
@@ -280,6 +303,7 @@ class DownloaderFormatsView(APIView):
class DownloaderFileView(APIView):
"""Download selected format if under max size, then stream the file back."""
permission_classes = [AllowAny]
authentication_classes = []
@extend_schema(
tags=["downloader"],
@@ -288,7 +312,7 @@ class DownloaderFileView(APIView):
description="Downloads with a strict max filesize guard and streams as application/octet-stream.",
request=DownloadRequestSchema,
responses={
200: OpenApiResponse(response=OpenApiTypes.BINARY, media_type="application/octet-stream"),
200: OpenApiTypes.BINARY, # was OpenApiResponse(..., media_type="application/octet-stream")
400: OpenApiResponse(response=ErrorResponseSchema),
413: OpenApiResponse(response=ErrorResponseSchema),
500: OpenApiResponse(response=ErrorResponseSchema),
@@ -334,7 +358,7 @@ class DownloaderFileView(APIView):
info = ydl.extract_info(url, download=False)
except Exception as e:
user, ip, ua = _client_meta(request)
DownloaderModel.from_ydl_info(
_log_safely(
info={"webpage_url": url},
requested_format=fmt_id,
status="precheck_error",
@@ -358,7 +382,7 @@ class DownloaderFileView(APIView):
est_size = _estimate_size_bytes(selected, duration)
if est_size is not None and est_size > max_bytes:
user, ip, ua = _client_meta(request)
DownloaderModel.from_ydl_info(
_log_safely(
info=selected,
requested_format=fmt_id,
status="blocked_by_size",
@@ -400,7 +424,7 @@ class DownloaderFileView(APIView):
filepath = result.get("requested_downloads", [{}])[0].get("filepath") or result.get("_filename")
except Exception as e:
user, ip, ua = _client_meta(request)
DownloaderModel.from_ydl_info(
_log_safely(
info=selected,
requested_format=fmt_id,
status="download_error",
@@ -425,7 +449,7 @@ class DownloaderFileView(APIView):
try:
selected_info = dict(selected)
selected_info["filesize"] = os.path.getsize(filepath)
DownloaderModel.from_ydl_info(
_log_safely(
info=selected_info,
requested_format=fmt_id,
status="success",
@@ -451,7 +475,13 @@ class DownloaderFileView(APIView):
pass
resp = StreamingHttpResponse(file_generator(filepath), content_type="application/octet-stream")
resp["Content-Disposition"] = f'attachment; filename="{safe_name}"'
# Include both plain and RFC 5987 encoded filename
resp["Content-Disposition"] = (
f'attachment; filename="{safe_name}"; filename*=UTF-8\'\'{urlquote(safe_name)}'
)
# Expose headers so the browser can read them via XHR/fetch
resp["X-Filename"] = safe_name
resp["Access-Control-Expose-Headers"] = "Content-Disposition, X-Filename, Content-Length, Content-Type"
try:
resp["Content-Length"] = str(os.path.getsize(filepath))
except Exception:
@@ -521,19 +551,3 @@ class DownloaderStatsView(APIView):
"top_acodec": top_acodec,
"audio_vs_video": audio_vs_video,
})
# Minimal placeholder so existing URL doesn't break; prefer using automatic logs above.
class DownloaderLogView(APIView):
permission_classes = [AllowAny]
@extend_schema(
tags=["downloader"],
operation_id="downloader_log_helper",
summary="Deprecated helper",
description="Use /api/downloader/formats/ then /api/downloader/download/.",
responses={200: inline_serializer(name="LogHelper", fields={"detail": serializers.CharField()})},
)
def post(self, request):
"""POST to the deprecated log helper endpoint."""
return Response({"detail": "Use /api/downloader/formats/ then /api/downloader/download/."}, status=200)

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

View File

@@ -17,9 +17,9 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings')
application = ProtocolTypeRouter({
"http": get_asgi_application(),
"websocket": AuthMiddlewareStack(
URLRouter(
#myapp.routing.websocket_urlpatterns
)
),
# "websocket": AuthMiddlewareStack(
# URLRouter(
# #myapp.routing.websocket_urlpatterns
# )
# ),
})

View File

@@ -32,8 +32,11 @@ urlpatterns = [
path('admin/', admin.site.urls),
path('api/account/', include('account.urls')),
#path('api/commerce/', include('commerce.urls')),
#path('api/advertisments/', include('advertisements.urls')),
path('api/stripe/', include('thirdparty.stripe.urls')),
path('api/trading212/', include('thirdparty.trading212.urls')),
path('api/downloader/', include('thirdparty.downloader.urls')),
]

File diff suppressed because it is too large Load Diff

View File

@@ -10,12 +10,14 @@
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
"@types/react-router": "^5.1.20",
"axios": "^1.13.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"react-router-dom": "^7.8.1"
"react-router-dom": "^7.8.1",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
"@eslint/js": "^9.33.0",

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";
export type Choices = {
file_types: string[];
qualities: string[];
export type FormatOption = {
format_id: string;
ext: string | null;
vcodec: string | null;
acodec: string | null;
fps: number | null;
tbr: number | null;
abr: number | null;
vbr: number | null;
asr: number | null;
filesize: number | null;
filesize_approx: number | null;
estimated_size_bytes: number | null;
size_ok: boolean;
format_note: string | null;
resolution: string | null; // e.g. "1920x1080"
audio_only: boolean;
};
export type DownloadPayload = {
url: string;
file_type?: string;
quality?: string;
export type FormatsResponse = {
title: string | null;
duration: number | null;
extractor: string | null;
video_id: string | null;
max_size_bytes: number;
options: FormatOption[];
};
// Probe available formats for a URL (no auth required)
export async function probeFormats(url: string): Promise<FormatsResponse> {
const res = await Client.public.post("/api/downloader/formats/", { url });
return res.data as FormatsResponse;
}
// Download selected format as a Blob and resolve filename from headers
export async function downloadFormat(url: string, format_id: string): Promise<{ blob: Blob; filename: string }> {
const res = await Client.public.post(
"/api/downloader/download/",
{ url, format_id },
{ responseType: "blob" }
);
// Try to parse Content-Disposition filename first, then X-Filename (exposed by backend)
const cd = res.headers?.["content-disposition"] as string | undefined;
const xfn = res.headers?.["x-filename"] as string | undefined;
const filename =
parseContentDispositionFilename(cd) ||
(xfn && xfn.trim()) ||
inferFilenameFromUrl(url, (res.headers?.["content-type"] as string | undefined)) ||
"download.bin";
return { blob: res.data as Blob, filename };
}
// Deprecated types kept for compatibility if referenced elsewhere
export type Choices = { file_types: string[]; qualities: string[] };
export type DownloadJobResponse = {
id: string;
status: "pending" | "running" | "finished" | "failed";
detail?: string;
download_url?: string;
progress?: number; // 0-100
progress?: number;
};
// Fallback when choices endpoint is unavailable or models are hardcoded
const FALLBACK_CHOICES: Choices = {
file_types: ["auto", "video", "audio"],
qualities: ["best", "good", "worst"],
};
// Helpers
function parseContentDispositionFilename(cd?: string): string | null {
if (!cd) return null;
// filename*=UTF-8''encoded or filename="plain"
const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
const plainMatch = cd.match(/filename\s*=\s*"([^"]+)"/i) || cd.match(/filename\s*=\s*([^;]+)/i);
return plainMatch?.[1]?.trim() || null;
}
/**
* Fetch dropdown choices from backend (adjust path to match your Django views).
* Expected response shape:
* { file_types: string[], qualities: string[] }
*/
export async function getChoices(): Promise<Choices> {
function inferFilenameFromUrl(url: string, contentType?: string): string {
try {
const res = await Client.auth.get("/api/downloader/choices/");
return res.data as Choices;
const u = new URL(url);
const last = u.pathname.split("/").filter(Boolean).pop();
if (last) return last;
} catch {
return FALLBACK_CHOICES;
// ignore
}
if (contentType) {
const ext = contentTypeToExt(contentType);
return `download${ext ? `.${ext}` : ""}`;
}
return "download.bin";
}
/**
* Submit a new download job (adjust path/body to your viewset).
* Example payload: { url, file_type, quality }
*/
export async function submitDownload(payload: DownloadPayload): Promise<DownloadJobResponse> {
const res = await Client.auth.post("/api/downloader/jobs/", payload);
return res.data as DownloadJobResponse;
}
/**
* Get job status by ID. Returns progress, status, and download_url when finished.
*/
export async function getJobStatus(id: string): Promise<DownloadJobResponse> {
const res = await Client.auth.get(`/api/downloader/jobs/${id}/`);
return res.data as DownloadJobResponse;
function contentTypeToExt(ct: string): string | null {
const map: Record<string, string> = {
"video/mp4": "mp4",
"audio/mpeg": "mp3",
"audio/mp4": "m4a",
"audio/aac": "aac",
"audio/ogg": "ogg",
"video/webm": "webm",
"audio/webm": "webm",
"application/octet-stream": "bin",
};
return map[ct] || null;
}

View File

@@ -6,6 +6,10 @@ footer a i{
color: white;
text-decoration: none;
}
footer a:hover i{
color: var(--c-text);
text-decoration: none;
}
footer{
font-family: "Roboto Mono", monospace;

View File

@@ -61,46 +61,28 @@ export default function Drone() {
<article>
<header>
<h1>Letecké snímky dronem</h1>
<h1>Letecké záběry, co zaujmou</h1>
</header>
<main>
<section>
<h2>Oprávnění</h2>
<p>
A1, A2, A3 a průkaz na vysílačku!
<br />
Mohu garantovat bezpečný provoz dronu i ve složitějších podmínkách.
Mám také možnost žádat o povolení k letu v blízkosti letišť!
</p>
<p>Oprávnění A1/A2/A3 + radiostanice. Bezpečný provoz i v omezených zónách, povolení zajistím.</p>
</section>
<section>
<h2>Cena</h2>
<p>
Nabízím letecké záběry dronem <br />
za cenu <u>3 000 </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>
<p>Paušál 3000. Ostrava zdarma; mimo 10/km. Cena se může lišit dle povolení.</p>
</section>
<section>
<h2>Výstup</h2>
<p>
Rád Vám připravím jednoduchý sestřih videa, který můžete rychle použít,
nebo Vám mohu poskytnout samotné záběry k vlastní editaci.
</p>
<p>Krátký sestřih nebo surové záběry podle potřeby.</p>
</section>
</main>
<div>
V případě zájmu neváhejte <br />
<a href="#contacts">kontaktovat!</a>
<a href="#contacts">Zájem?</a>
</div>
</article>
</div>

View File

@@ -27,8 +27,11 @@
transform-origin: bottom;
transition: transform 0.5s ease-in-out;
transform: skew(-5deg);
z-index: 3;
box-shadow: #000000 5px 5px 15px;
}
.portfolio div span svg{
font-size: 5em;
@@ -56,7 +59,7 @@
}
.portfolio .door-open{
transform: rotateX(180deg);
transform: rotateX(90deg) skew(-2deg) !important;
}
.portfolio>header {
@@ -131,6 +134,8 @@
border-radius: 1em;
border-top-left-radius: 0;
aspect-ratio: 16 / 9;
}
.portfolio div article {
@@ -145,7 +150,6 @@
}
.portfolio div article header a img {
padding: 2em 0;
width: 80%;
margin: auto;
}

View File

@@ -6,23 +6,34 @@ interface PortfolioItem {
href: string
src: string
alt: string
// Optional per-item styling (prefer Tailwind utility classes in className/imgClassName)
className?: string
imgClassName?: string
style?: React.CSSProperties
imgStyle?: React.CSSProperties
}
const portfolioItems: PortfolioItem[] = [
{
href: "https://davo1.cz",
src: "/home/img/portfolio/DAVO_logo_2024_bile.png",
src: "/portfolio/davo1.png",
alt: "davo1.cz logo",
imgClassName: "bg-black rounded-lg p-4",
//className: "bg-white/5 rounded-lg p-4",
},
{
href: "https://perlica.cz",
src: "/home/img/portfolio/perlica-3.webp",
src: "/portfolio/perlica.png",
alt: "Perlica logo",
imgClassName: "rounded-lg",
// imgClassName: "max-h-12",
},
{
href: "http://epinger2.cz",
src: "/home/img/portfolio/logo_epinger.svg",
src: "/portfolio/epinger.png",
alt: "Epinger2 logo",
imgClassName: "bg-white rounded-lg",
// imgClassName: "max-h-12",
},
]
@@ -51,10 +62,19 @@ export default function Portfolio() {
</span>
{portfolioItems.map((item, index) => (
<article key={index} className={styles.article}>
<article
key={index}
className={`${styles.article} ${item.className ?? ""}`}
style={item.style}
>
<header>
<a href={item.href} target="_blank" rel="noopener noreferrer">
<img src={item.src} alt={item.alt} />
<img
src={item.src}
alt={item.alt}
className={item.imgClassName}
style={item.imgStyle}
/>
</a>
</header>
<main></main>

View File

@@ -36,6 +36,10 @@ nav.isSticky-nav{
nav ul #nav-logo{
border-right: 0.2em solid var(--c-lines);
}
/* Add class alias for logo used in TSX */
.logo {
border-right: 0.2em solid var(--c-lines);
}
nav ul #nav-logo span{
line-height: 0.75;
font-size: 1.5em;
@@ -47,10 +51,21 @@ nav a{
position: relative;
text-decoration: none;
}
nav a:hover{
color: #fff;
}
/* Unify link/summary layout to prevent distortion */
nav a,
nav summary {
color: #fff;
transition: color 1s;
position: relative;
text-decoration: none;
cursor: pointer;
display: inline-block; /* ensure consistent inline sizing */
vertical-align: middle; /* align with neighbors */
padding: 0; /* keep padding controlled by li */
}
nav a::before {
content: "";
@@ -67,7 +82,125 @@ nav a::before {
nav a:hover::before {
transform: scaleX(1);
}
nav summary:hover {
color: #fff;
}
/* underline effect shared for links and summary */
nav a::before,
nav summary::before {
content: "";
position: absolute;
display: block;
width: 100%;
height: 2px;
bottom: 0;
left: 0;
background-color: #fff;
transform: scaleX(0);
transition: transform 0.3s ease;
}
nav a:hover::before,
nav summary:hover::before {
transform: scaleX(1);
}
/* Submenu support */
.hasSubmenu {
position: relative;
vertical-align: middle; /* align with other inline items */
}
/* Keep details inline to avoid breaking the first row flow */
.hasSubmenu details {
display: inline-block;
margin: 0;
padding: 0;
}
/* Ensure "Services" and caret stay on the same line */
.hasSubmenu details > summary {
display: inline-flex; /* horizontal layout */
align-items: center; /* vertical alignment */
gap: 0.5em; /* space between text and icon */
white-space: nowrap; /* prevent wrapping */
}
/* Hide native disclosure icon/marker on summary */
.hasSubmenu details > summary {
list-style: none;
outline: none;
}
.hasSubmenu details > summary::-webkit-details-marker {
display: none;
}
.hasSubmenu details > summary::marker {
content: "";
}
/* Reusable caret for submenu triggers */
.caret {
transition: transform 0.2s ease-in-out;
}
/* Rotate caret when submenu is open */
.hasSubmenu details[open] .caret {
transform: rotate(180deg);
}
/* Submenu box: place directly under nav with a tiny gap (no overlap) */
.submenu {
list-style: none;
margin: 1em 0;
padding: 0.5em 0;
position: absolute;
left: 0;
top: calc(100% + 0.25em);
display: none;
background: var(--c-background-light);
border: 1px solid var(--c-lines);
border-radius: 0.75em;
min-width: max-content;
text-align: left;
z-index: 10;
}
.submenu li {
display: block;
padding: 0;
}
.submenu a {
display: inline-block;
padding: 0; /* remove padding so underline equals text width */
margin: 0.35em 0; /* spacing without affecting underline width */
}
/* Show submenu when open */
.hasSubmenu details[open] .submenu {
display: flex;
flex-direction: column;
}
/* Hamburger toggle class (used by TSX) */
.toggle {
display: none;
transition: transform 0.5s ease;
}
.toggleRotated {
transform: rotate(180deg);
}
/* Bridge TSX classnames to existing rules */
.navList {
list-style: none;
padding: 0;
}
.navList li {
display: inline;
padding: 0 3em;
}
.navList li a {
text-decoration: none;
}
nav ul {
list-style: none;
@@ -129,6 +262,11 @@ nav ul li a {
max-height: 2em;
}
/* When TSX adds styles.open to the UL, expand it */
.open {
max-height: 20em;
}
nav ul:last-child{
padding-bottom: 1em;
}
@@ -139,4 +277,28 @@ nav ul li a {
border-bottom: 0.2em solid var(--c-lines);
border-right: none;
}
/* Show hamburger on mobile */
.toggle {
margin-top: 0.25em;
margin-left: 0.75em;
position: absolute;
left: 0;
display: block;
font-size: 2em;
}
/* Submenu stacks inline under the parent item on mobile */
.submenu {
position: static;
border: none;
border-radius: 0;
background: transparent;
padding: 0 0 0.5em 0.5em;
min-width: unset;
}
.submenu a {
display: inline-block;
padding: 0; /* keep no padding on mobile too */
margin: 0.25em 0.5em; /* spacing via margin */
}
}

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react"
import styles from "./HomeNav.module.css"
import { FaBars } from "react-icons/fa";
import { FaBars, FaChevronDown } from "react-icons/fa";
export default function HomeNav() {
const [navOpen, setNavOpen] = useState(false)
@@ -9,7 +9,12 @@ export default function HomeNav() {
return (
<nav className={styles.nav}>
<FaBars className={styles.toggle} onClick={toggleNav} />
<FaBars
className={`${styles.toggle} ${navOpen ? styles.toggleRotated : ""}`}
onClick={toggleNav}
aria-label="Toggle navigation"
aria-expanded={navOpen}
/>
<ul className={`${styles.navList} ${navOpen ? styles.open : ""}`}>
<li id="nav-logo" className={styles.logo}>
@@ -21,8 +26,18 @@ export default function HomeNav() {
<li>
<a href="#portfolio">Portfolio</a>
</li>
<li>
<a href="#services">Services</a>
<li className={styles.hasSubmenu}>
<details>
<summary>
Services
<FaChevronDown className={`${styles.caret} ml-2 inline-block`} aria-hidden="true" />
</summary>
<ul className={styles.submenu}>
<li><a href="#web">Web development</a></li>
<li><a href="#integration">Integrations</a></li>
<li><a href="#support">Support</a></li>
</ul>
</details>
</li>
<li>
<a href="#contactme-form">Contact me</a>

View File

@@ -1,3 +1,5 @@
@import "tailwindcss";
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
@@ -19,15 +21,6 @@
--c-other: #70A288; /*other*/
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;

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(
<>
{/* 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" }}>

View File

@@ -1,160 +1,140 @@
import { useEffect, useMemo, useState } from "react";
import {
getChoices,
submitDownload,
getJobStatus,
type Choices,
type DownloadJobResponse,
} from "../../api/apps/Downloader";
import { useState } from "react";
import { probeFormats, downloadFormat, type FormatsResponse, type FormatOption } from "../../api/apps/Downloader";
export default function Downloader() {
const [choices, setChoices] = useState<Choices>({ file_types: [], qualities: [] });
const [loadingChoices, setLoadingChoices] = useState(true);
const [url, setUrl] = useState("");
const [fileType, setFileType] = useState<string>("");
const [quality, setQuality] = useState<string>("");
const [submitting, setSubmitting] = useState(false);
const [job, setJob] = useState<DownloadJobResponse | null>(null);
const [probing, setProbing] = useState(false);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [formats, setFormats] = useState<FormatsResponse | null>(null);
// Load dropdown choices once
useEffect(() => {
let mounted = true;
(async () => {
setLoadingChoices(true);
try {
const data = await getChoices();
if (!mounted) return;
setChoices(data);
// preselect first option
if (!fileType && data.file_types.length > 0) setFileType(data.file_types[0]);
if (!quality && data.qualities.length > 0) setQuality(data.qualities[0]);
} catch (e: any) {
setError(e?.message || "Failed to load choices.");
} finally {
setLoadingChoices(false);
}
})();
return () => {
mounted = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const canSubmit = useMemo(() => {
return !!url && !!fileType && !!quality && !submitting;
}, [url, fileType, quality, submitting]);
async function onSubmit(e: React.FormEvent) {
async function onProbe(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
setFormats(null);
setProbing(true);
try {
const created = await submitDownload({ url, file_type: fileType, quality });
setJob(created);
const res = await probeFormats(url);
setFormats(res);
} catch (e: any) {
setError(e?.response?.data?.detail || e?.message || "Submission failed.");
setError(e?.response?.data?.detail || e?.message || "Failed to load formats.");
} finally {
setSubmitting(false);
setProbing(false);
}
}
async function refreshStatus() {
if (!job?.id) return;
async function onDownload(fmt: FormatOption) {
setError(null);
setDownloadingId(fmt.format_id);
try {
const updated = await getJobStatus(job.id);
setJob(updated);
const { blob, filename } = await downloadFormat(url, fmt.format_id);
const link = document.createElement("a");
const href = URL.createObjectURL(blob);
link.href = href;
link.download = filename || "download.bin";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(href);
} catch (e: any) {
setError(e?.response?.data?.detail || e?.message || "Failed to refresh status.");
setError(e?.response?.data?.detail || e?.message || "Download failed.");
} finally {
setDownloadingId(null);
}
}
return (
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1rem" }}>
<h1>Downloader</h1>
<div className="max-w-3xl mx-auto p-4">
<h1 className="text-2xl font-semibold mb-4">Downloader</h1>
{error && (
<div style={{ background: "#fee", color: "#900", padding: ".5rem", marginBottom: ".75rem" }}>
<div className="mb-3 rounded border border-red-300 bg-red-50 text-red-700 p-2">
{error}
</div>
)}
<form onSubmit={onSubmit} style={{ display: "grid", gap: ".75rem" }}>
<label>
URL
<form onSubmit={onProbe} className="grid gap-3 mb-4">
<label className="grid gap-1">
<span className="text-sm font-medium">URL</span>
<input
type="url"
required
placeholder="https://example.com/video"
value={url}
onChange={(e) => setUrl(e.target.value)}
style={{ width: "100%", padding: ".5rem" }}
className="w-full border rounded p-2"
/>
</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".75rem" }}>
<label>
File type
<select
value={fileType}
onChange={(e) => setFileType(e.target.value)}
disabled={loadingChoices}
style={{ width: "100%", padding: ".5rem" }}
>
{choices.file_types.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<label>
Quality
<select
value={quality}
onChange={(e) => setQuality(e.target.value)}
disabled={loadingChoices}
style={{ width: "100%", padding: ".5rem" }}
>
{choices.qualities.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</select>
</label>
</div>
<div>
<button type="submit" disabled={!canSubmit} style={{ padding: ".5rem 1rem" }}>
{submitting ? "Submitting..." : "Start download"}
<button
type="submit"
disabled={!url || probing}
className="px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
>
{probing ? "Probing..." : "Find formats"}
</button>
</div>
</form>
{job && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #ddd", paddingTop: "1rem" }}>
<h2>Job</h2>
<div>ID: {job.id}</div>
<div>Status: {job.status}</div>
{typeof job.progress === "number" && <div>Progress: {job.progress}%</div>}
{job.detail && <div>Detail: {job.detail}</div>}
{job.download_url ? (
<div style={{ marginTop: ".5rem" }}>
<a href={job.download_url} target="_blank" rel="noreferrer">
Download file
</a>
{formats && (
<div className="space-y-3">
<div className="text-sm text-gray-700">
<div><span className="font-medium">Title:</span> {formats.title || "-"}</div>
<div><span className="font-medium">Duration:</span> {formats.duration ? `${Math.round(formats.duration)} s` : "-"}</div>
<div><span className="font-medium">Max size:</span> {formatBytes(formats.max_size_bytes)}</div>
</div>
) : (
<button onClick={refreshStatus} style={{ marginTop: ".5rem", padding: ".5rem 1rem" }}>
Refresh status
<div className="border rounded overflow-hidden">
<div className="grid grid-cols-6 gap-2 p-2 bg-gray-50 text-sm font-medium">
<div>Format</div>
<div>Resolution</div>
<div>Type</div>
<div>Note</div>
<div>Est. size</div>
<div></div>
</div>
<div className="divide-y">
{formats.options.map((o) => (
<div key={o.format_id} className="grid grid-cols-6 gap-2 p-2 items-center text-sm">
<div className="truncate">{o.format_id}{o.ext ? `.${o.ext}` : ""}</div>
<div>{o.resolution || (o.audio_only ? "audio" : "-")}</div>
<div>{o.audio_only ? "Audio" : "Video"}</div>
<div className="truncate">{o.format_note || "-"}</div>
<div className={o.size_ok ? "text-gray-800" : "text-red-600"}>
{o.estimated_size_bytes ? formatBytes(o.estimated_size_bytes) : (o.filesize || o.filesize_approx) ? "~" + formatBytes((o.filesize || o.filesize_approx)!) : "?"}
{!o.size_ok && " (too big)"}
</div>
<div className="text-right">
<button
onClick={() => onDownload(o)}
disabled={!o.size_ok || downloadingId === o.format_id}
className="px-2 py-1 rounded bg-emerald-600 text-white disabled:opacity-50"
>
{downloadingId === o.format_id ? "Downloading..." : "Download"}
</button>
</div>
</div>
))}
</div>
</div>
{!formats.options.length && (
<div className="text-sm text-gray-600">No formats available.</div>
)}
</div>
)}
</div>
);
}
function formatBytes(bytes?: number | null): string {
if (!bytes || bytes <= 0) return "-";
const units = ["B", "KB", "MB", "GB"];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}

View File

@@ -1,7 +1,11 @@
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
tailwindcss()
],
})