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