Compare commits
13 Commits
main
...
2118f002d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2118f002d1 | |||
| 602c5a40f1 | |||
| 05055415de | |||
| de5f54f4bc | |||
| a324a9cf49 | |||
| 47b9770a70 | |||
|
|
4791bbc92c | ||
| 8dd4f6e731 | |||
| dd9d076bd2 | |||
| 73da41b514 | |||
| 10796dcb31 | |||
| f5cf8bbaa7 | |||
| d0227e4539 |
119
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Copilot Instructions for Vontor CZ
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This monorepo contains a Django backend and a Vite/React frontend, orchestrated via Docker Compose. The project is designed for a Czech e-marketplace, with custom payment integrations and real-time features.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- **backend/**: Django project (`vontor_cz`), custom `account` app, and third-party payment integrations (`thirdparty/`).
|
||||||
|
- Uses Django REST Framework, Channels (ASGI), Celery, and S3/static/media via `django-storages`.
|
||||||
|
- Custom user model: `account.CustomUser`.
|
||||||
|
- API docs: DRF Spectacular (`/api/schema/`).
|
||||||
|
- **frontend/**: Vite + React + TypeScript app.
|
||||||
|
- Organized by `src/api/`, `components/`, `features/`, `layouts/`, `pages/`, `routes/`.
|
||||||
|
- Uses React Router layouts and nested routes (see `src/layouts/`, `src/routes/`).
|
||||||
|
- Uses Tailwind CSS for styling (configured via `src/index.css` with `@import "tailwindcss";`). Prefer utility classes over custom CSS.
|
||||||
|
|
||||||
|
## Developer Workflows
|
||||||
|
- **Backend**
|
||||||
|
- Local dev: `python manage.py runserver` (or use Docker Compose)
|
||||||
|
- Migrations: `python manage.py makemigrations && python manage.py migrate`
|
||||||
|
- Celery: `celery -A vontor_cz worker -l info`
|
||||||
|
- Channels: Daphne/ASGI (see Docker Compose command)
|
||||||
|
- Env config: `.env` files in `backend/` (see `.gitignore` for secrets)
|
||||||
|
- **Frontend**
|
||||||
|
- Install: `npm install`
|
||||||
|
- Dev server: `npm run dev`
|
||||||
|
- Build: `npm run build`
|
||||||
|
- Preview: `npm run preview`
|
||||||
|
- Static assets: `src/assets/` (import in JS/CSS), `public/` (referenced in HTML)
|
||||||
|
|
||||||
|
## Conventions & Patterns
|
||||||
|
- **Backend**
|
||||||
|
- Use environment variables for secrets and config (see `settings.py`).
|
||||||
|
- Static/media files: S3 in production, local in dev (see `settings.py`).
|
||||||
|
- API versioning and docs: DRF Spectacular config in `settings.py`.
|
||||||
|
- Custom permissions, filters, and serializers in each app.
|
||||||
|
- **Frontend**
|
||||||
|
- Use React Router layouts for shared UI (see `src/layouts/`, `LAYOUTS.md`).
|
||||||
|
- API calls and JWT handling in `src/api/`.
|
||||||
|
- Route definitions and guards in `src/routes/` (`ROUTES.md`).
|
||||||
|
- Use TypeScript strict mode (see `tsconfig.*.json`).
|
||||||
|
- Linting: ESLint config in `eslint.config.js`.
|
||||||
|
- Styling: Tailwind CSS is present. Prefer utility classes; keep minimal component-scoped CSS. Global/base styles live in `src/index.css`. Avoid inline styles and CSS-in-JS unless necessary.
|
||||||
|
|
||||||
|
### Frontend API Client (required)
|
||||||
|
All frontend API calls must use the shared client at frontend/src/api/Client.ts.
|
||||||
|
|
||||||
|
- 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
|
||||||
|
- **Payments**: `thirdparty/` contains custom integrations for Stripe, GoPay, Trading212.
|
||||||
|
- **Real-time**: Django Channels (ASGI, Redis) for websockets.
|
||||||
|
- **Task queue**: Celery + Redis for async/background jobs.
|
||||||
|
- **API**: REST endpoints, JWT auth, API key support.
|
||||||
|
|
||||||
|
## References
|
||||||
|
- [frontend/REACT.md](../frontend/REACT.md): Frontend structure, workflows, and conventions.
|
||||||
|
- [frontend/src/layouts/LAYOUTS.md](../frontend/src/layouts/LAYOUTS.md): Layout/component patterns.
|
||||||
|
- [frontend/src/routes/ROUTES.md](../frontend/src/routes/ROUTES.md): Routing conventions.
|
||||||
|
- [backend/vontor_cz/settings.py](../backend/vontor_cz/settings.py): All backend config, env, and integration details.
|
||||||
|
- [docker-compose.yml](../docker-compose.yml): Service orchestration and dev workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**When in doubt, check the referenced markdown files and `settings.py` for project-specific logic and patterns.**
|
||||||
165
.gitignore copy
@@ -1,165 +0,0 @@
|
|||||||
#
|
|
||||||
*.mp4
|
|
||||||
backups/
|
|
||||||
collectedstaticfiles/
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
*.pot
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
|
||||||
.pybuilder/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
||||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
||||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
||||||
# install all needed dependencies.
|
|
||||||
#Pipfile.lock
|
|
||||||
|
|
||||||
# poetry
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
||||||
#poetry.lock
|
|
||||||
|
|
||||||
# pdm
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
||||||
#pdm.lock
|
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
||||||
# in version control.
|
|
||||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
||||||
.pdm.toml
|
|
||||||
.pdm-python
|
|
||||||
.pdm-build/
|
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
||||||
__pypackages__/
|
|
||||||
|
|
||||||
# Celery stuff
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
|
|
||||||
# SageMath parsed files
|
|
||||||
*.sage.py
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env
|
|
||||||
.venv
|
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env.bak/
|
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
# Spyder project settings
|
|
||||||
.spyderproject
|
|
||||||
.spyproject
|
|
||||||
|
|
||||||
# Rope project settings
|
|
||||||
.ropeproject
|
|
||||||
|
|
||||||
|
|
||||||
# mypy
|
|
||||||
.mypy_cache/
|
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
|
||||||
.pyre/
|
|
||||||
|
|
||||||
# pytype static type analyzer
|
|
||||||
.pytype/
|
|
||||||
|
|
||||||
# Cython debug symbols
|
|
||||||
cython_debug/
|
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
15
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Launch Chrome against localhost",
|
||||||
|
"url": "http://localhost:8080",
|
||||||
|
"webRoot": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
README.md
@@ -1,23 +0,0 @@
|
|||||||
# vontor-cz
|
|
||||||
|
|
||||||
## venv
|
|
||||||
- windows
|
|
||||||
|
|
||||||
```
|
|
||||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
|
|
||||||
python -m venv venv
|
|
||||||
.\venv\Scripts\Activate
|
|
||||||
|
|
||||||
#start server
|
|
||||||
daphne -b localhost -p 8000 vontor_cz.asgi:application
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## docker compose
|
|
||||||
spuštění dockeru pro lokální hosting, s instantníma změnami během editace ve vscodu.
|
|
||||||
```docker-compose up --build```
|
|
||||||
|
|
||||||
## dns reset windows
|
|
||||||
```ipconfig /flushdns```
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from .models import MyModel # Importuj modely
|
|
||||||
|
|
||||||
# Příklad přizpůsobení zobrazení modelu v administrátorské sekci
|
|
||||||
class MyModelAdmin(admin.ModelAdmin):
|
|
||||||
# Určují se pole, která se zobrazí v seznamu (list view)
|
|
||||||
list_display = ('field1', 'field2', 'field3')
|
|
||||||
# Určuje, podle kterých polí lze vyhledávat
|
|
||||||
search_fields = ('field1', 'field2')
|
|
||||||
# Aktivuje filtrování podle hodnoty pole v pravém postranním panelu
|
|
||||||
list_filter = ('field1', 'field2')
|
|
||||||
# Určuje pole, která se zobrazí ve formuláři při detailním pohledu na model
|
|
||||||
fields = ('field1', 'field2', 'field3')
|
|
||||||
# Definuje rozložení polí ve formuláři
|
|
||||||
fieldsets = (
|
|
||||||
(None, {
|
|
||||||
'fields': ('field1', 'field2'),
|
|
||||||
}),
|
|
||||||
('Další informace', {
|
|
||||||
'classes': ('collapse',),
|
|
||||||
'fields': ('field3',),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
# Nastavuje výchozí řazení záznamů při jejich zobrazení
|
|
||||||
ordering = ('field1',)
|
|
||||||
# Určuje počet záznamů zobrazených na jedné stránce
|
|
||||||
list_per_page = 10
|
|
||||||
# Definuje akce dostupné pro vybrané objekty
|
|
||||||
actions = ['custom_action']
|
|
||||||
|
|
||||||
# Příklad vlastní akce
|
|
||||||
def custom_action(self, request, queryset):
|
|
||||||
# Vlastní logika pro akci
|
|
||||||
queryset.update(field1='Updated Value')
|
|
||||||
|
|
||||||
# Registrování modelu s vlastními nastaveními administrátorského rozhraní
|
|
||||||
admin.site.register(MyModel, MyModelAdmin)
|
|
||||||
24
absolete_frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
11
absolete_frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
113
absolete_frontend/REACT.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Vontor CZ
|
||||||
|
|
||||||
|
Welcome to **Vontor CZ**!
|
||||||
|
|
||||||
|
## frontend Folder Overview
|
||||||
|
|
||||||
|
The `frontend` folder contains all code and assets for the client-side React application. Its structure typically includes:
|
||||||
|
|
||||||
|
- **src/**
|
||||||
|
Tady se ukladají věci, které uživateli se nepředavají přímo spíš takové stavební kostky.
|
||||||
|
- **api/**
|
||||||
|
TypeScript/JS soubory které se starají o API a o JWT tokeny.
|
||||||
|
Čistě pracují s backendem
|
||||||
|
- **context/**
|
||||||
|
Kontext si načte data které mu předáš a můžeš si je předávat mezi komponenty rychleji.
|
||||||
|
- **hooks/**
|
||||||
|
Pracuje s API a formátují to do výstupu který potřebujeme.
|
||||||
|
|
||||||
|
- **components/**
|
||||||
|
Konktrétní komponenty které se vykreslují na stránce.
|
||||||
|
|
||||||
|
Už využívají už hooky a contexty pro vykreslení informaci bez složite logiky (ať je komponenta hezky čistá a né moc obsáhla).
|
||||||
|
|
||||||
|
- **features/**
|
||||||
|
Nejsou to celé stránky, ale hotové komponenty.
|
||||||
|
|
||||||
|
Obsahuje všechny komponenty plus její hooky, API, state a utils potřebné pro jednu konkrétní funkcionalitu aplikace. (použijí se jenom jednou)
|
||||||
|
|
||||||
|
Features zajišťují modularitu a přehlednost aplikace.
|
||||||
|
|
||||||
|
Příklad: komponenta košík, která zahrnuje API volání, state management a UI komponenty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- **layouts/**
|
||||||
|
Tohle je jenom komponenta, která načte další komponenty, ale používá se jako layout kde jsou například navigace footer a ostatní se opakující prvky stránky, ale hlavní obsah ještě není součastí! Ten se načte skrz <outlet/> v pages.
|
||||||
|
- **pages/**
|
||||||
|
Tady se jde do finále tady se vkládají samostatné komponenty a tvoří se už hlavní obsah stránky a vkládají se komponenty a tvoří se logika mezi ně.
|
||||||
|
- **routes/**
|
||||||
|
tady se ukládají routy které například zabraňuji načtení stránky nepříhlášeným uživatelům nebo jenom pro ty s určitou roli/oprávněním... tyhle route komponenty se pak využívají v
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- **assets/**
|
||||||
|
Obrázky, fonty, a ostatní statické soubory.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
- **App.tsx**
|
||||||
|
Root komponenta pro aplikaci, kde se nastavují routy pro jednotlivé stránky.
|
||||||
|
Pozor nemyslím ty routy ze složky routes/ ... to jsou jenom obaly pro konktrétní routy pro jednotlivé stránky.
|
||||||
|
- **main.tsx**
|
||||||
|
Vstupní bod pro načítaní aplikace (načíta se první komponenta App.jsx)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- **utils/**
|
||||||
|
Sběrné místo pro pomocné funkce, které nejsou přímo komponenty nebo hooky, ale jsou znovupoužitelné napříč aplikací.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- **index.css**
|
||||||
|
globální styly
|
||||||
|
- **App.css**
|
||||||
|
obsahuje stylovaní layoutu, navigace, footer, error okna atd. takové věci které zůstavjí vždy stejně
|
||||||
|
|
||||||
|
- **public/**
|
||||||
|
Složka public obsahuje statické soubory dostupné přímo přes URL (např. index.html, favicon, obrázky), které React přímo nereenderuje ani neoptimalizuje.
|
||||||
|
|
||||||
|
- **package.json**
|
||||||
|
něco jak requirements.txt v pythonu
|
||||||
|
|
||||||
|
- **vite.config.js / vite.config.ts**
|
||||||
|
Vite konfigurace pro building a serving frontend aplikace.
|
||||||
|
|
||||||
|
- **Dockerfile**
|
||||||
|
Konfigurace pro Docker
|
||||||
|
|
||||||
|
## Getting Started :3
|
||||||
|
|
||||||
|
1. **Instalace balíčku (bere z package.json):**
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start dev server:**
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Build pro produkci(finále):**
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Preview production build:**
|
||||||
|
```sh
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a New React Project with Vite
|
||||||
|
|
||||||
|
If you want to start a new project:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm create vite@latest
|
||||||
|
# Choose 'react' or 'react-ts' for TypeScript
|
||||||
|
cd your-project
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
23
absolete_frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default tseslint.config([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
14
absolete_frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="reset.css">
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4267
absolete_frontend/package-lock.json
generated
Normal file
36
absolete_frontend/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"@types/react-router": "^5.1.20",
|
||||||
|
"axios": "^1.13.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-router-dom": "^7.8.1",
|
||||||
|
"tailwindcss": "^4.1.16"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.33.0",
|
||||||
|
"@types/axios": "^0.9.36",
|
||||||
|
"@types/react": "^19.1.10",
|
||||||
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"eslint": "^9.33.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.3.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.39.1",
|
||||||
|
"vite": "^7.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
absolete_frontend/public/PUBLIC.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
# Anything you import in JS/CSS → src/assets/
|
||||||
|
|
||||||
|
# Anything you reference directly in HTML → public/ something like: <img src="/images/foo.png">
|
||||||
BIN
absolete_frontend/public/portfolio/davo1.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
absolete_frontend/public/portfolio/epinger.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
absolete_frontend/public/portfolio/perlica.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
42
absolete_frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
33
absolete_frontend/src/App.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route, Link, Outlet } from "react-router-dom"
|
||||||
|
import Home from "./pages/home/home";
|
||||||
|
import HomeLayout from "./layouts/HomeLayout";
|
||||||
|
import Downloader from "./pages/downloader/Downloader";
|
||||||
|
|
||||||
|
import PrivateRoute from "./routes/PrivateRoute";
|
||||||
|
|
||||||
|
import { UserContextProvider } from "./context/UserContext";
|
||||||
|
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<UserContextProvider>
|
||||||
|
|
||||||
|
{/* Layout route */}
|
||||||
|
<Route path="/" element={<HomeLayout />}>
|
||||||
|
<Route index element={<Home />} />
|
||||||
|
<Route path="downloader" element={<Downloader />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route element={<PrivateRoute />}>
|
||||||
|
{/* Protected routes go here */}
|
||||||
|
<Route path="/" element={<HomeLayout />} >
|
||||||
|
<Route path="protected-downloader" element={<Downloader />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
</UserContextProvider>
|
||||||
|
</Router>
|
||||||
|
)
|
||||||
|
}
|
||||||
275
absolete_frontend/src/api/Client.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
// --- ENV CONFIG ---
|
||||||
|
const API_BASE_URL =
|
||||||
|
import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
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, // cookies
|
||||||
|
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();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function Logout() {
|
||||||
|
try {
|
||||||
|
const LogOutResponse = apiAuth.post("/api/logout/");
|
||||||
|
|
||||||
|
if (LogOutResponse.body.detail != "Logout successful") {
|
||||||
|
throw new Error("Logout failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearTokens();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during logout:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClearTokens(){
|
||||||
|
document.cookie = "access_token=; Max-Age=0; path=/";
|
||||||
|
document.cookie = "refresh_token=; Max-Age=0; path=/";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- EXPORT DEFAULT API WRAPPER ---
|
||||||
|
const Client = {
|
||||||
|
// Axios instances
|
||||||
|
auth: apiAuth,
|
||||||
|
public: apiPublic,
|
||||||
|
|
||||||
|
Logout,
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
*/
|
||||||
114
absolete_frontend/src/api/apps/Downloader.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import Client from "../Client";
|
||||||
|
|
||||||
|
// Available output containers (must match backend)
|
||||||
|
export const FORMAT_EXTS = ["mp4", "mkv", "webm", "flv", "mov", "avi", "ogg"] as const;
|
||||||
|
export type FormatExt = (typeof FORMAT_EXTS)[number];
|
||||||
|
|
||||||
|
export type InfoResponse = {
|
||||||
|
title: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
thumbnail: string | null;
|
||||||
|
video_resolutions: string[]; // e.g. ["2160p", "1440p", "1080p", ...]
|
||||||
|
audio_resolutions: string[]; // e.g. ["320kbps", "160kbps", ...]
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET info for a URL
|
||||||
|
export async function fetchInfo(url: string): Promise<InfoResponse> {
|
||||||
|
const res = await Client.public.get("/api/downloader/download/", {
|
||||||
|
params: { url },
|
||||||
|
});
|
||||||
|
return res.data as InfoResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST to stream binary immediately; returns { blob, filename }
|
||||||
|
export async function downloadImmediate(args: {
|
||||||
|
url: string;
|
||||||
|
ext: FormatExt;
|
||||||
|
videoResolution?: string | number; // "1080p" or 1080
|
||||||
|
audioResolution?: string | number; // "160kbps" or 160
|
||||||
|
}): Promise<{ blob: Blob; filename: string }> {
|
||||||
|
const video_quality = toHeight(args.videoResolution);
|
||||||
|
const audio_quality = toKbps(args.audioResolution);
|
||||||
|
|
||||||
|
if (video_quality == null || audio_quality == null) {
|
||||||
|
throw new Error("Please select both video and audio quality.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await Client.public.post(
|
||||||
|
"/api/downloader/download/",
|
||||||
|
{
|
||||||
|
url: args.url,
|
||||||
|
ext: args.ext,
|
||||||
|
video_quality,
|
||||||
|
audio_quality,
|
||||||
|
},
|
||||||
|
{ responseType: "blob" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const cd = res.headers?.["content-disposition"] as string | undefined;
|
||||||
|
const xfn = res.headers?.["x-filename"] as string | undefined;
|
||||||
|
const filename =
|
||||||
|
parseContentDispositionFilename(cd) ||
|
||||||
|
(xfn && xfn.trim()) ||
|
||||||
|
inferFilenameFromUrl(args.url, res.headers?.["content-type"] as string | undefined) ||
|
||||||
|
`download.${args.ext}`;
|
||||||
|
|
||||||
|
return { blob: res.data as Blob, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
export function parseContentDispositionFilename(cd?: string): string | null {
|
||||||
|
if (!cd) return null;
|
||||||
|
const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||||
|
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
|
||||||
|
const plainMatch = cd.match(/filename\s*=\s*"([^"]+)"/i) || cd.match(/filename\s*=\s*([^;]+)/i);
|
||||||
|
return plainMatch?.[1]?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferFilenameFromUrl(url: string, contentType?: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const last = u.pathname.split("/").filter(Boolean).pop();
|
||||||
|
if (last) return last;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (contentType) {
|
||||||
|
const ext = contentTypeToExt(contentType);
|
||||||
|
return `download${ext ? `.${ext}` : ""}`;
|
||||||
|
}
|
||||||
|
return "download.bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeToExt(ct?: string): string | null {
|
||||||
|
if (!ct) return null;
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"video/mp4": "mp4",
|
||||||
|
"video/x-matroska": "mkv",
|
||||||
|
"video/webm": "webm",
|
||||||
|
"video/x-flv": "flv",
|
||||||
|
"video/quicktime": "mov",
|
||||||
|
"video/x-msvideo": "avi",
|
||||||
|
"video/ogg": "ogg",
|
||||||
|
"application/octet-stream": "bin",
|
||||||
|
};
|
||||||
|
return map[ct] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHeight(v?: string | number): number | undefined {
|
||||||
|
if (typeof v === "number") return v || undefined;
|
||||||
|
if (!v) return undefined;
|
||||||
|
const m = /^(\d{2,4})p$/i.exec(v.trim());
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? (n as number) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKbps(v?: string | number): number | undefined {
|
||||||
|
if (typeof v === "number") return v || undefined;
|
||||||
|
if (!v) return undefined;
|
||||||
|
const m = /^(\d{2,4})\s*kbps$/i.exec(v.trim());
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? (n as number) : undefined;
|
||||||
|
}
|
||||||
41
absolete_frontend/src/api/get_chocies.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Client from "./Client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads enum values from an OpenAPI schema for a given path, method, and field (e.g., category).
|
||||||
|
*
|
||||||
|
* @param path - API path, e.g., "/api/service-tickets/"
|
||||||
|
* @param method - HTTP method
|
||||||
|
* @param field - field name in parameters or request
|
||||||
|
* @param schemaUrl - URL of the JSON schema, default "/api/schema/?format=json"
|
||||||
|
* @returns Promise<Array<{ value: string; label: string }>>
|
||||||
|
*/
|
||||||
|
export async function fetchEnumFromSchemaJson(
|
||||||
|
path: string,
|
||||||
|
method: "get" | "post" | "patch" | "put",
|
||||||
|
field: string,
|
||||||
|
schemaUrl: string = "/schema/?format=json"
|
||||||
|
): Promise<Array<{ value: string; label: string }>> {
|
||||||
|
try {
|
||||||
|
const schema = await Client.public.get(schemaUrl);
|
||||||
|
|
||||||
|
const methodDef = schema.paths?.[path]?.[method];
|
||||||
|
if (!methodDef) {
|
||||||
|
throw new Error(`Method ${method.toUpperCase()} for ${path} not found in schema.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in "parameters" (e.g., GET query parameters)
|
||||||
|
const param = methodDef.parameters?.find((p: any) => p.name === field);
|
||||||
|
|
||||||
|
if (param?.schema?.enum) {
|
||||||
|
return param.schema.enum.map((val: string) => ({
|
||||||
|
value: val,
|
||||||
|
label: val,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Field '${field}' does not contain enum`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading enum values:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
absolete_frontend/src/api/models/User.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// frontend/src/api/model/user.js
|
||||||
|
// User API model for searching users by username
|
||||||
|
// Structure matches other model files (see order.js for reference)
|
||||||
|
|
||||||
|
import Client from '../Client';
|
||||||
|
|
||||||
|
const API_BASE_URL = "/account/users";
|
||||||
|
|
||||||
|
const userAPI = {
|
||||||
|
/**
|
||||||
|
* Get current authenticated user
|
||||||
|
* @returns {Promise<User>}
|
||||||
|
*/
|
||||||
|
async getCurrentUser() {
|
||||||
|
const response = await Client.auth.get(`${API_BASE_URL}/me/`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all users
|
||||||
|
* @returns {Promise<Array<User>>}
|
||||||
|
*/
|
||||||
|
async getUsers(params: Object) {
|
||||||
|
const response = await Client.auth.get(`${API_BASE_URL}/`, { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single user by ID
|
||||||
|
* @param {number|string} id
|
||||||
|
* @returns {Promise<User>}
|
||||||
|
*/
|
||||||
|
async getUser(id: number) {
|
||||||
|
const response = await Client.auth.get(`${API_BASE_URL}/${id}/`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a user by ID
|
||||||
|
* @param {number|string} id
|
||||||
|
* @param {Object} data
|
||||||
|
* @returns {Promise<User>}
|
||||||
|
*/
|
||||||
|
async updateUser(id: number, data: Object) {
|
||||||
|
const response = await Client.auth.patch(`${API_BASE_URL}/${id}/`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a user by ID
|
||||||
|
* @param {number|string} id
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async deleteUser(id: number) {
|
||||||
|
const response = await Client.auth.delete(`${API_BASE_URL}/${id}/`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new user
|
||||||
|
* @param {Object} data
|
||||||
|
* @returns {Promise<User>}
|
||||||
|
*/
|
||||||
|
async createUser(data: Object) {
|
||||||
|
const response = await Client.auth.post(`${API_BASE_URL}/`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search users by username (partial match)
|
||||||
|
* @param {Object} params - { username: string }
|
||||||
|
* @returns {Promise<Array<User>>}
|
||||||
|
*/
|
||||||
|
async searchUsers(params: { username: string }) {
|
||||||
|
// Adjust the endpoint as needed for your backend
|
||||||
|
const response = await Client.auth.get(`${API_BASE_URL}/`, { params });
|
||||||
|
console.log("User search response:", response.data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default userAPI;
|
||||||
19
absolete_frontend/src/api/websockets/WebSocketClient.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
const wsUri = "ws://127.0.0.1/";
|
||||||
|
|
||||||
|
const websocket = new WebSocket(wsUri);
|
||||||
|
|
||||||
|
websocket.onopen = function (event) {
|
||||||
|
console.log("WebSocket is open now.", event);
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onmessage = function (event) {
|
||||||
|
console.log("WebSocket message received:", event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onclose = function (event) {
|
||||||
|
console.log("WebSocket is closed now.", event.reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onerror = function (event) {
|
||||||
|
console.error("WebSocket error observed:", event);
|
||||||
|
};
|
||||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 823 B After Width: | Height: | Size: 823 B |
|
Before Width: | Height: | Size: 705 B After Width: | Height: | Size: 705 B |
|
Before Width: | Height: | Size: 366 B After Width: | Height: | Size: 366 B |
|
Before Width: | Height: | Size: 722 B After Width: | Height: | Size: 722 B |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
40
absolete_frontend/src/components/Footer/footer.module.css
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
footer a{
|
||||||
|
color: var(--c-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
footer a i{
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
footer a:hover i{
|
||||||
|
color: var(--c-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
footer{
|
||||||
|
font-family: "Roboto Mono", monospace;
|
||||||
|
|
||||||
|
background-color: var(--c-boxes);
|
||||||
|
|
||||||
|
margin-top: 2em;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
color: white;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
footer address{
|
||||||
|
padding: 1em;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
footer .contacts{
|
||||||
|
font-size: 2em;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 990px){
|
||||||
|
footer{
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
padding-top: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
absolete_frontend/src/components/Footer/footer.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import styles from "./footer.module.css"
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer id="contacts">
|
||||||
|
<div>
|
||||||
|
<h1>vontor.cz</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<address>
|
||||||
|
Written by <b>David Bruno Vontor | © 2025</b>
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
Tel.:{" "}
|
||||||
|
<a href="tel:+420605512624">
|
||||||
|
<u>+420 605 512 624</u>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
E-mail:{" "}
|
||||||
|
<a href="mailto:brunovontor@gmail.com">
|
||||||
|
<u>brunovontor@gmail.com</u>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
IČO:{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.rzp.cz/verejne-udaje/cs/udaje/vyber-subjektu;ico=21613109;"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<u>21613109</u>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</address>
|
||||||
|
|
||||||
|
<div className="contacts">
|
||||||
|
<a
|
||||||
|
href="https://github.com/Brunobrno"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<i className="fa fa-github"></i>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.instagram.com/brunovontor/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<i className="fa fa-instagram"></i>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://twitter.com/BVontor"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<i className="fa-brands fa-x-twitter"></i>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://steamcommunity.com/id/Brunobrno/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<i className="fa-brands fa-steam"></i>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.youtube.com/@brunovontor"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<i className="fa-brands fa-youtube"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useState, useRef } from "react"
|
||||||
|
import styles from "./contact-me.module.css"
|
||||||
|
import { LuMousePointerClick } from "react-icons/lu";
|
||||||
|
|
||||||
|
export default function ContactMeForm() {
|
||||||
|
const [opened, setOpened] = useState(false)
|
||||||
|
const [contentMoveUp, setContentMoveUp] = useState(false)
|
||||||
|
const [openingBehind, setOpeningBehind] = useState(false)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const openingRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
// form submission logic here
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOpen = () => {
|
||||||
|
if (!opened) {
|
||||||
|
setOpened(true)
|
||||||
|
setOpeningBehind(false)
|
||||||
|
setContentMoveUp(false)
|
||||||
|
// Wait for the rotate-opening animation to finish before moving content up
|
||||||
|
// The actual moveUp will be handled in onTransitionEnd
|
||||||
|
} else {
|
||||||
|
setContentMoveUp(false)
|
||||||
|
setOpeningBehind(false)
|
||||||
|
setTimeout(() => setOpened(false), 1000) // match transition duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTransitionEnd = (e: React.TransitionEvent<HTMLDivElement>) => {
|
||||||
|
if (opened && e.propertyName === "transform") {
|
||||||
|
setContentMoveUp(true)
|
||||||
|
// Move the opening behind after the animation
|
||||||
|
setTimeout(() => setOpeningBehind(true), 10)
|
||||||
|
}
|
||||||
|
if (!opened && e.propertyName === "transform") {
|
||||||
|
setOpeningBehind(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles["contact-me"]}>
|
||||||
|
<div
|
||||||
|
ref={openingRef}
|
||||||
|
className={
|
||||||
|
[
|
||||||
|
styles.opening,
|
||||||
|
opened ? styles["rotate-opening"] : "",
|
||||||
|
openingBehind ? styles["opening-behind"] : ""
|
||||||
|
].filter(Boolean).join(" ")
|
||||||
|
}
|
||||||
|
onClick={toggleOpen}
|
||||||
|
onTransitionEnd={handleTransitionEnd}
|
||||||
|
>
|
||||||
|
<LuMousePointerClick/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
contentMoveUp
|
||||||
|
? `${styles.content} ${styles["content-moveup"]}`
|
||||||
|
: styles.content
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="Váš email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
placeholder="Vaše zpráva"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input type="submit"/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cover}></div>
|
||||||
|
<div className={styles.triangle}></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -35,6 +35,11 @@
|
|||||||
transform: rotateX(180deg);
|
transform: rotateX(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.opening svg{
|
||||||
|
margin: auto;
|
||||||
|
font-size: 3em;
|
||||||
|
margin-top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.contact-me .content {
|
.contact-me .content {
|
||||||
@@ -89,7 +94,7 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.opening-behind { z-index: 0 !important; }
|
||||||
|
|
||||||
.contact-me .cover {
|
.contact-me .cover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
BIN
absolete_frontend/src/components/Forms/ContactMe/readme.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
90
absolete_frontend/src/components/ads/Drone/Drone.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { useEffect, useRef } from "react"
|
||||||
|
import styles from "./drone.module.css"
|
||||||
|
|
||||||
|
export default function Drone() {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||||
|
const sourceRef = useRef<HTMLSourceElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function setVideoDroneQuality() {
|
||||||
|
if (!sourceRef.current || !videoRef.current) return
|
||||||
|
|
||||||
|
const videoSources = {
|
||||||
|
fullHD: "static/home/video/drone-background-video-1080p.mp4", // For desktops (1920x1080)
|
||||||
|
hd: "static/home/video/drone-background-video-720p.mp4", // For tablets/smaller screens (1280x720)
|
||||||
|
lowRes: "static/home/video/drone-background-video-480p.mp4" // For mobile devices or low performance (854x480)
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenWidth = window.innerWidth
|
||||||
|
|
||||||
|
// Pick appropriate source
|
||||||
|
if (screenWidth >= 1920) {
|
||||||
|
sourceRef.current.src =
|
||||||
|
"https://vontor-cz.s3.eu-central-1.amazonaws.com/" + videoSources.fullHD
|
||||||
|
} else if (screenWidth >= 1280) {
|
||||||
|
sourceRef.current.src =
|
||||||
|
"https://vontor-cz.s3.eu-central-1.amazonaws.com/" + videoSources.hd
|
||||||
|
} else {
|
||||||
|
sourceRef.current.src =
|
||||||
|
"https://vontor-cz.s3.eu-central-1.amazonaws.com/" + videoSources.lowRes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload video
|
||||||
|
videoRef.current.load()
|
||||||
|
console.log("Drone video set!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run once on mount
|
||||||
|
setVideoDroneQuality()
|
||||||
|
|
||||||
|
// Optional: rerun on resize
|
||||||
|
window.addEventListener("resize", setVideoDroneQuality)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", setVideoDroneQuality)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.drone}`}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
id="drone-video"
|
||||||
|
className={styles.videoBackground}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
>
|
||||||
|
<source ref={sourceRef} id="video-source" type="video/mp4" />
|
||||||
|
Your browser does not support video.
|
||||||
|
</video>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>Letecké záběry, co zaujmou</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<h2>Oprávnění</h2>
|
||||||
|
<p>Oprávnění A1/A2/A3 + radiostanice. Bezpečný provoz i v omezených zónách, povolení zajistím.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Cena</h2>
|
||||||
|
<p>Paušál 3 000 Kč. Ostrava zdarma; mimo 10 Kč/km. Cena se může lišit dle povolení.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Výstup</h2>
|
||||||
|
<p>Krátký sestřih nebo surové záběry — podle potřeby.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="#contacts">Zájem?</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.drone .video-background {
|
.drone .videoBackground {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
BIN
absolete_frontend/src/components/ads/Drone/readme.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -20,15 +20,26 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #c2a67d;
|
background-color: #c2a67d;
|
||||||
|
color: #5e5747;
|
||||||
|
|
||||||
border-radius: 1em;
|
border-radius: 1em;
|
||||||
|
|
||||||
transform-origin: bottom;
|
transform-origin: bottom;
|
||||||
transition: transform 0.5s ease-in-out;
|
transition: transform 0.5s ease-in-out;
|
||||||
|
|
||||||
|
transform: skew(-5deg);
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|
||||||
|
box-shadow: #000000 5px 5px 15px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
.portfolio div span svg{
|
||||||
|
font-size: 5em;
|
||||||
|
cursor: pointer;
|
||||||
|
animation: shake 0.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
0% { transform: translateX(0); }
|
0% { transform: translateX(0); }
|
||||||
@@ -48,14 +59,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.portfolio .door-open{
|
.portfolio .door-open{
|
||||||
transform: rotateX(180deg);
|
transform: rotateX(90deg) skew(-2deg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portfolio>header {
|
.portfolio>header {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 0;
|
||||||
top: -4.7em;
|
top: -3.7em;
|
||||||
left: 0;
|
left: 0;
|
||||||
padding: 1em 3em;
|
padding: 1em 3em;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
@@ -112,6 +123,7 @@
|
|||||||
|
|
||||||
|
|
||||||
.portfolio div {
|
.portfolio div {
|
||||||
|
width: 100%;
|
||||||
padding: 3em;
|
padding: 3em;
|
||||||
background-color: #cdc19c;
|
background-color: #cdc19c;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -122,6 +134,8 @@
|
|||||||
|
|
||||||
border-radius: 1em;
|
border-radius: 1em;
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
|
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portfolio div article {
|
.portfolio div article {
|
||||||
@@ -136,7 +150,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.portfolio div article header a img {
|
.portfolio div article header a img {
|
||||||
padding: 2em 0;
|
|
||||||
width: 80%;
|
width: 80%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
86
absolete_frontend/src/components/ads/Portfolio/Portfolio.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React, { useState } from "react"
|
||||||
|
import styles from "./Portfolio.module.css"
|
||||||
|
import { LuMousePointerClick } from "react-icons/lu";
|
||||||
|
|
||||||
|
interface PortfolioItem {
|
||||||
|
href: string
|
||||||
|
src: string
|
||||||
|
alt: string
|
||||||
|
// Optional per-item styling (prefer Tailwind utility classes in className/imgClassName)
|
||||||
|
className?: string
|
||||||
|
imgClassName?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
imgStyle?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolioItems: PortfolioItem[] = [
|
||||||
|
{
|
||||||
|
href: "https://davo1.cz",
|
||||||
|
src: "/portfolio/davo1.png",
|
||||||
|
alt: "davo1.cz logo",
|
||||||
|
imgClassName: "bg-black rounded-lg p-4",
|
||||||
|
//className: "bg-white/5 rounded-lg p-4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "https://perlica.cz",
|
||||||
|
src: "/portfolio/perlica.png",
|
||||||
|
alt: "Perlica logo",
|
||||||
|
imgClassName: "rounded-lg",
|
||||||
|
// imgClassName: "max-h-12",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "http://epinger2.cz",
|
||||||
|
src: "/portfolio/epinger.png",
|
||||||
|
alt: "Epinger2 logo",
|
||||||
|
imgClassName: "bg-white rounded-lg",
|
||||||
|
// imgClassName: "max-h-12",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Portfolio() {
|
||||||
|
const [doorOpen, setDoorOpen] = useState(false)
|
||||||
|
|
||||||
|
const toggleDoor = () => setDoorOpen((prev) => !prev)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.portfolio} id="portfolio">
|
||||||
|
<header>
|
||||||
|
<h1>Portfolio</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
doorOpen
|
||||||
|
? `${styles.door} ${styles["door-open"]}`
|
||||||
|
: styles.door
|
||||||
|
}
|
||||||
|
onClick={toggleDoor}
|
||||||
|
>
|
||||||
|
<LuMousePointerClick/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{portfolioItems.map((item, index) => (
|
||||||
|
<article
|
||||||
|
key={index}
|
||||||
|
className={`${styles.article} ${item.className ?? ""}`}
|
||||||
|
style={item.style}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<a href={item.href} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img
|
||||||
|
src={item.src}
|
||||||
|
alt={item.alt}
|
||||||
|
className={item.imgClassName}
|
||||||
|
style={item.imgStyle}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
<main></main>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
absolete_frontend/src/components/ads/Portfolio/readme.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
304
absolete_frontend/src/components/navbar/HomeNav.module.css
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
nav{
|
||||||
|
padding: 1.1em;
|
||||||
|
|
||||||
|
font-family: "Roboto Mono", monospace;
|
||||||
|
|
||||||
|
position: -webkit-sticky;
|
||||||
|
position: sticky;
|
||||||
|
top: 0; /* required */
|
||||||
|
|
||||||
|
transition: top 1s ease-in-out, border-radius 1s ease-in-out;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
z-index: 5;
|
||||||
|
padding-left: 2em;
|
||||||
|
padding-right: 2em;
|
||||||
|
width: max-content;
|
||||||
|
|
||||||
|
background: var(--c-boxes);
|
||||||
|
/*background: -moz-linear-gradient(117deg, rgba(34,34,34,1) 0%, rgba(59,54,54,1) 100%);
|
||||||
|
background: -webkit-linear-gradient(117deg, rgba(34,34,34,1) 0%, rgba(59,54,54,1) 100%);
|
||||||
|
background: linear-gradient(117deg, rgba(34,34,34,1) 0%, rgba(59,54,54,1) 100%);
|
||||||
|
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr="#222222",endColorstr="#3b3636",GradientType=1);*/
|
||||||
|
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
border-radius: 2em;
|
||||||
|
}
|
||||||
|
nav.isSticky-nav{
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
nav ul #nav-logo{
|
||||||
|
border-right: 0.2em solid var(--c-lines);
|
||||||
|
}
|
||||||
|
/* Add class alias for logo used in TSX */
|
||||||
|
.logo {
|
||||||
|
border-right: 0.2em solid var(--c-lines);
|
||||||
|
}
|
||||||
|
nav ul #nav-logo span{
|
||||||
|
line-height: 0.75;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
nav a{
|
||||||
|
color: #fff;
|
||||||
|
transition: color 1s;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
nav a:hover{
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
/* Unify link/summary layout to prevent distortion */
|
||||||
|
nav a,
|
||||||
|
nav summary {
|
||||||
|
color: #fff;
|
||||||
|
transition: color 1s;
|
||||||
|
position: relative;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block; /* ensure consistent inline sizing */
|
||||||
|
vertical-align: middle; /* align with neighbors */
|
||||||
|
padding: 0; /* keep padding controlled by li */
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
nav a:hover::before {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
nav summary:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* underline effect shared for links and summary */
|
||||||
|
nav a::before,
|
||||||
|
nav summary::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
nav a:hover::before,
|
||||||
|
nav summary:hover::before {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenu support */
|
||||||
|
.hasSubmenu {
|
||||||
|
position: relative;
|
||||||
|
vertical-align: middle; /* align with other inline items */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep details inline to avoid breaking the first row flow */
|
||||||
|
.hasSubmenu details {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure "Services" and caret stay on the same line */
|
||||||
|
.hasSubmenu details > summary {
|
||||||
|
display: inline-flex; /* horizontal layout */
|
||||||
|
align-items: center; /* vertical alignment */
|
||||||
|
gap: 0.5em; /* space between text and icon */
|
||||||
|
white-space: nowrap; /* prevent wrapping */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide native disclosure icon/marker on summary */
|
||||||
|
.hasSubmenu details > summary {
|
||||||
|
list-style: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.hasSubmenu details > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.hasSubmenu details > summary::marker {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reusable caret for submenu triggers */
|
||||||
|
.caret {
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rotate caret when submenu is open */
|
||||||
|
.hasSubmenu details[open] .caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenu box: place directly under nav with a tiny gap (no overlap) */
|
||||||
|
.submenu {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1em 0;
|
||||||
|
padding: 0.5em 0;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(100% + 0.25em);
|
||||||
|
display: none;
|
||||||
|
background: var(--c-background-light);
|
||||||
|
border: 1px solid var(--c-lines);
|
||||||
|
border-radius: 0.75em;
|
||||||
|
min-width: max-content;
|
||||||
|
text-align: left;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.submenu li {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.submenu a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0; /* remove padding so underline equals text width */
|
||||||
|
margin: 0.35em 0; /* spacing without affecting underline width */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show submenu when open */
|
||||||
|
.hasSubmenu details[open] .submenu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger toggle class (used by TSX) */
|
||||||
|
.toggle {
|
||||||
|
display: none;
|
||||||
|
transition: transform 0.5s ease;
|
||||||
|
}
|
||||||
|
.toggleRotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bridge TSX classnames to existing rules */
|
||||||
|
.navList {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.navList li {
|
||||||
|
display: inline;
|
||||||
|
padding: 0 3em;
|
||||||
|
}
|
||||||
|
.navList li a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul li {
|
||||||
|
display: inline;
|
||||||
|
padding: 0 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul li a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
#toggle-nav{
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
-webkit-transition: transform 0.5s ease;
|
||||||
|
-moz-transition: transform 0.5s ease;
|
||||||
|
-o-transition: transform 0.5s ease;
|
||||||
|
-ms-transition: transform 0.5s ease;
|
||||||
|
transition: transform 0.5s ease;
|
||||||
|
}
|
||||||
|
.toggle-nav-rotated {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
.nav-open{
|
||||||
|
max-height: 20em;
|
||||||
|
}
|
||||||
|
@media only screen and (max-width: 990px){
|
||||||
|
#toggle-nav{
|
||||||
|
margin-top: 0.25em;
|
||||||
|
margin-left: 0.75em;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
display: block;
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
nav{
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 1em;
|
||||||
|
border-bottom-right-radius: 1em;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
nav ul {
|
||||||
|
margin-top: 1em;
|
||||||
|
gap: 2em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
-webkit-transition: max-height 1s ease;
|
||||||
|
-moz-transition: max-height 1s ease;
|
||||||
|
-o-transition: max-height 1s ease;
|
||||||
|
-ms-transition: max-height 1s ease;
|
||||||
|
transition: max-height 1s ease;
|
||||||
|
|
||||||
|
max-height: 2em;
|
||||||
|
}
|
||||||
|
/* When TSX adds styles.open to the UL, expand it */
|
||||||
|
.open {
|
||||||
|
max-height: 20em;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul:last-child{
|
||||||
|
padding-bottom: 1em;
|
||||||
|
}
|
||||||
|
nav ul #nav-logo {
|
||||||
|
margin: auto;
|
||||||
|
padding-bottom: 0.5em;
|
||||||
|
margin-bottom: -1em;
|
||||||
|
border-bottom: 0.2em solid var(--c-lines);
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
/* Show hamburger on mobile */
|
||||||
|
.toggle {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
margin-left: 0.75em;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
display: block;
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenu stacks inline under the parent item on mobile */
|
||||||
|
.submenu {
|
||||||
|
position: static;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0 0 0.5em 0.5em;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
.submenu a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0; /* keep no padding on mobile too */
|
||||||
|
margin: 0.25em 0.5em; /* spacing via margin */
|
||||||
|
}
|
||||||
|
}
|
||||||
52
absolete_frontend/src/components/navbar/HomeNav.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useState, useContext } from "react"
|
||||||
|
import styles from "./HomeNav.module.css"
|
||||||
|
import { FaBars, FaChevronDown } from "react-icons/fa";
|
||||||
|
|
||||||
|
import { UserContext } from "../../context/UserContext";
|
||||||
|
|
||||||
|
export default function HomeNav() {
|
||||||
|
const [navOpen, setNavOpen] = useState(false)
|
||||||
|
|
||||||
|
const toggleNav = () => setNavOpen((prev) => !prev)
|
||||||
|
|
||||||
|
const { user } = useContext(UserContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={styles.nav}>
|
||||||
|
<FaBars
|
||||||
|
className={`${styles.toggle} ${navOpen ? styles.toggleRotated : ""}`}
|
||||||
|
onClick={toggleNav}
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
aria-expanded={navOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul className={`${styles.navList} ${navOpen ? styles.open : ""}`}>
|
||||||
|
<li id="nav-logo" className={styles.logo}>
|
||||||
|
<span>vontor.cz</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#portfolio">Portfolio</a>
|
||||||
|
</li>
|
||||||
|
<li className={styles.hasSubmenu}>
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
Services
|
||||||
|
<FaChevronDown className={`${styles.caret} ml-2 inline-block`} aria-hidden="true" />
|
||||||
|
</summary>
|
||||||
|
<ul className={styles.submenu}>
|
||||||
|
<li><a href="#web">Web development</a></li>
|
||||||
|
<li><a href="#integration">Integrations</a></li>
|
||||||
|
<li><a href="#support">Support</a></li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#contactme-form">Contact me</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
absolete_frontend/src/context/Context.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
# EXAMPLE USAGE OF CONTEXT IN A COMPONENT:
|
||||||
|
|
||||||
|
## Wrap your app tree with the provider (e.g., in App.tsx)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { UserContextProvider } from "../context/UserContext";
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<UserContextProvider>
|
||||||
|
<YourRoutes />
|
||||||
|
</UserContextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Consume in any child component
|
||||||
|
```tsx
|
||||||
|
import React, { useContext } from "react"
|
||||||
|
import { UserContext } from '../context/UserContext';
|
||||||
|
|
||||||
|
export default function ExampleComponent() {
|
||||||
|
const { user, setUser } = useContext(UserContext);
|
||||||
|
|
||||||
|
|
||||||
|
return ...;
|
||||||
|
}
|
||||||
|
```
|
||||||
75
absolete_frontend/src/context/UserContext.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||||
|
|
||||||
|
import userAPI from '../api/models/User';
|
||||||
|
|
||||||
|
// definice uživatele
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// určíme typ kontextu
|
||||||
|
interface GlobalContextType {
|
||||||
|
user: User | null;
|
||||||
|
setUser: React.Dispatch<React.SetStateAction<User | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// vytvoříme a exportneme kontext !!!
|
||||||
|
export const UserContext = createContext<GlobalContextType | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
// hook pro použití kontextu
|
||||||
|
// zabal routy do téhle komponenty!!!
|
||||||
|
export const UserContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
const currentUser = await userAPI.getCurrentUser();
|
||||||
|
setUser(currentUser);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user:', error);
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUser();
|
||||||
|
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={{ user, setUser }}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
EXAMPLE USAGE OF CONTEXT IN A COMPONENT:
|
||||||
|
|
||||||
|
// Wrap your app tree with the provider (e.g., in App.tsx)
|
||||||
|
// import { UserContextProvider } from "../context/UserContext";
|
||||||
|
// function App() {
|
||||||
|
// return (
|
||||||
|
// <UserContextProvider>
|
||||||
|
// <YourRoutes />
|
||||||
|
// </UserContextProvider>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Consume in any child component
|
||||||
|
import React, { useContext } from "react"
|
||||||
|
import { UserContext } from '../context/UserContext';
|
||||||
|
|
||||||
|
export default function ExampleComponent() {
|
||||||
|
const { user, setUser } = useContext(UserContext);
|
||||||
|
|
||||||
|
|
||||||
|
return ...;
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
67
absolete_frontend/src/index.css
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
--c-background: #031D44; /*background*/
|
||||||
|
--c-background-light: #04395E; /*background-highlight*/
|
||||||
|
--c-boxes: #24719f;; /*boxes*/
|
||||||
|
--c-lines: #87a9da; /*lines*/
|
||||||
|
--c-text: #CAF0F8; /*text*/
|
||||||
|
--c-other: #70A288; /*other*/
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
absolete_frontend/src/layouts/Default.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Footer from "../components/Footer/footer";
|
||||||
|
import ContactMeForm from "../components/Forms/ContactMe/ContactMeForm";
|
||||||
|
import HomeNav from "../components/navbar/HomeNav";
|
||||||
|
import Drone from "../components/ads/Drone/Drone";
|
||||||
|
import Portfolio from "../components/ads/Portfolio/Portfolio";
|
||||||
|
import Home from "../pages/home/home";
|
||||||
|
import { Outlet } from "react-router";
|
||||||
|
|
||||||
|
export default function HomeLayout(){
|
||||||
|
return(
|
||||||
|
<>
|
||||||
|
{/* Example usage of imported components, adjust as needed */}
|
||||||
|
|
||||||
|
<HomeNav />
|
||||||
|
|
||||||
|
<Home /> {/*page*/}
|
||||||
|
<div style={{margin: "10em 0"}}>
|
||||||
|
<Drone />
|
||||||
|
</div>
|
||||||
|
<Outlet />
|
||||||
|
<Portfolio />
|
||||||
|
<div style={{ margin: "6em auto", marginTop: "15em", maxWidth: "80vw" }}>
|
||||||
|
<ContactMeForm />
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
absolete_frontend/src/layouts/HomeLayout.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Footer from "../components/Footer/footer";
|
||||||
|
import ContactMeForm from "../components/Forms/ContactMe/ContactMeForm";
|
||||||
|
import HomeNav from "../components/navbar/HomeNav";
|
||||||
|
import Drone from "../components/ads/Drone/Drone";
|
||||||
|
import Portfolio from "../components/ads/Portfolio/Portfolio";
|
||||||
|
import Home from "../pages/home/home";
|
||||||
|
import { Outlet } from "react-router";
|
||||||
|
|
||||||
|
export default function HomeLayout(){
|
||||||
|
return(
|
||||||
|
<>
|
||||||
|
{/* Example usage of imported components, adjust as needed */}
|
||||||
|
|
||||||
|
<HomeNav />
|
||||||
|
|
||||||
|
<Home /> {/*page*/}
|
||||||
|
<div style={{margin: "10em 0"}}>
|
||||||
|
<Drone />
|
||||||
|
</div>
|
||||||
|
<Outlet />
|
||||||
|
<Portfolio />
|
||||||
|
<div style={{ margin: "6em auto", marginTop: "15em", maxWidth: "80vw" }}>
|
||||||
|
<ContactMeForm />
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
absolete_frontend/src/layouts/LAYOUTS.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Layouts in React Router
|
||||||
|
|
||||||
|
## 📌 What is a Layout?
|
||||||
|
A **layout** in React Router is just a **React component** that wraps multiple pages with shared structure or styling (e.g., header, footer, sidebar).
|
||||||
|
|
||||||
|
Layouts usually contain:
|
||||||
|
- Global UI elements (navigation, footer, etc.)
|
||||||
|
- An `<Outlet />` component where nested routes will render their content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Folder Structure Example
|
||||||
|
|
||||||
|
src/
|
||||||
|
layouts/
|
||||||
|
├── MainLayout.jsx
|
||||||
|
└── AdminLayout.jsx
|
||||||
|
pages/
|
||||||
|
├── HomePage.jsx
|
||||||
|
├── AboutPage.jsx
|
||||||
|
└── DashboardPage.jsx
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 How Layouts Are Used in Routes
|
||||||
|
|
||||||
|
### 1. Layout as a Parent Route
|
||||||
|
Use the layout component as the `element` of a **parent route** and place **pages** inside as nested routes.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
|
import MainLayout from "./layouts/MainLayout";
|
||||||
|
import HomePage from "./pages/HomePage";
|
||||||
|
import AboutPage from "./pages/AboutPage";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<MainLayout />}>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/about" element={<AboutPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Inside the MainLayout.jsx
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function MainLayout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header>Header</header>
|
||||||
|
<main>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<footer>Footer</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
10
absolete_frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
206
absolete_frontend/src/pages/downloader/Downloader.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
fetchInfo,
|
||||||
|
downloadImmediate,
|
||||||
|
FORMAT_EXTS,
|
||||||
|
type InfoResponse,
|
||||||
|
parseContentDispositionFilename,
|
||||||
|
} from "../../api/apps/Downloader";
|
||||||
|
|
||||||
|
export default function Downloader() {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [probing, setProbing] = useState(false);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [info, setInfo] = useState<InfoResponse | null>(null);
|
||||||
|
|
||||||
|
const [ext, setExt] = useState<typeof FORMAT_EXTS[number]>("mp4");
|
||||||
|
const [videoRes, setVideoRes] = useState<string | undefined>(undefined);
|
||||||
|
const [audioRes, setAudioRes] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (info?.video_resolutions?.length && !videoRes) {
|
||||||
|
setVideoRes(info.video_resolutions[0]);
|
||||||
|
}
|
||||||
|
if (info?.audio_resolutions?.length && !audioRes) {
|
||||||
|
setAudioRes(info.audio_resolutions[0]);
|
||||||
|
}
|
||||||
|
}, [info]);
|
||||||
|
|
||||||
|
async function onProbe(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
setProbing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchInfo(url);
|
||||||
|
setInfo(res);
|
||||||
|
// reset selections from fresh info
|
||||||
|
setVideoRes(res.video_resolutions?.[0]);
|
||||||
|
setAudioRes(res.audio_resolutions?.[0]);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(
|
||||||
|
e?.response?.data?.error ||
|
||||||
|
e?.response?.data?.detail ||
|
||||||
|
e?.message ||
|
||||||
|
"Failed to get info."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setProbing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDownload() {
|
||||||
|
setError(null);
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const { blob, filename } = await downloadImmediate({
|
||||||
|
url,
|
||||||
|
ext,
|
||||||
|
videoResolution: videoRes,
|
||||||
|
audioResolution: audioRes,
|
||||||
|
});
|
||||||
|
const name = filename || parseContentDispositionFilename("") || `download.${ext}`;
|
||||||
|
const href = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = href;
|
||||||
|
a.download = name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(href);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(
|
||||||
|
e?.response?.data?.error ||
|
||||||
|
e?.response?.data?.detail ||
|
||||||
|
e?.message ||
|
||||||
|
"Download failed."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDownload = useMemo(
|
||||||
|
() => !!url && !!ext && !!videoRes && !!audioRes,
|
||||||
|
[url, ext, videoRes, audioRes]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-4 space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold">Downloader</h1>
|
||||||
|
|
||||||
|
{error && <div className="rounded border border-red-300 bg-red-50 text-red-700 p-2">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={onProbe} className="grid gap-3">
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm font-medium">URL</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://example.com/video"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
className="w-full border rounded p-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!url || probing}
|
||||||
|
className="px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{probing ? "Probing..." : "Get info"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDownload}
|
||||||
|
disabled={!canDownload || downloading}
|
||||||
|
className="px-3 py-2 rounded bg-emerald-600 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{downloading ? "Downloading..." : "Download"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{info && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{info.thumbnail && (
|
||||||
|
<img
|
||||||
|
src={info.thumbnail}
|
||||||
|
alt={info.title || "thumbnail"}
|
||||||
|
className="w-40 h-24 object-cover rounded border"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="text-sm text-gray-800 space-y-1">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Title:</span> {info.title || "-"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Duration:</span>{" "}
|
||||||
|
{info.duration ? `${Math.round(info.duration)} s` : "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-3">
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm font-medium">Container</span>
|
||||||
|
<select
|
||||||
|
value={ext}
|
||||||
|
onChange={(e) => setExt(e.target.value as any)}
|
||||||
|
className="border rounded p-2"
|
||||||
|
>
|
||||||
|
{FORMAT_EXTS.map((x) => (
|
||||||
|
<option key={x} value={x}>
|
||||||
|
{x.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm font-medium">Video resolution</span>
|
||||||
|
<select
|
||||||
|
value={videoRes || ""}
|
||||||
|
onChange={(e) => setVideoRes(e.target.value || undefined)}
|
||||||
|
className="border rounded p-2"
|
||||||
|
>
|
||||||
|
{info.video_resolutions?.length ? (
|
||||||
|
info.video_resolutions.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value="">-</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm font-medium">Audio bitrate</span>
|
||||||
|
<select
|
||||||
|
value={audioRes || ""}
|
||||||
|
onChange={(e) => setAudioRes(e.target.value || undefined)}
|
||||||
|
className="border rounded p-2"
|
||||||
|
>
|
||||||
|
{info.audio_resolutions?.length ? (
|
||||||
|
info.audio_resolutions.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value="">-</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
absolete_frontend/src/pages/home/Home.module.css
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Doto:wght@300&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap');
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Exo:ital,wght@0,100..900;1,100..900&display=swap');
|
||||||
|
|
||||||
|
:root{
|
||||||
|
--c-background: #031D44; /*background*/
|
||||||
|
--c-background-light: #04395E; /*background-highlight*/
|
||||||
|
--c-boxes: #24719f;; /*boxes*/
|
||||||
|
--c-lines: #87a9da; /*lines*/
|
||||||
|
--c-text: #CAF0F8; /*text*/
|
||||||
|
--c-other: #70A288; /*other*/
|
||||||
|
}
|
||||||
|
|
||||||
|
html{
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body{
|
||||||
|
font-family: "Exo", serif;
|
||||||
|
|
||||||
|
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doto-font{
|
||||||
|
font-family: "Doto", serif;
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-variation-settings: "ROND" 0;
|
||||||
|
}
|
||||||
|
.bebas-neue-regular {
|
||||||
|
font-family: "Bebas Neue", sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.introduction {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--c-text);
|
||||||
|
|
||||||
|
padding-bottom: 10em;
|
||||||
|
margin-top: 6em;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
top:0;
|
||||||
|
|
||||||
|
/* gap: 4em;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.introduction h1{
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introduction article {
|
||||||
|
/*background-color: cadetblue;*/
|
||||||
|
|
||||||
|
padding: 2em;
|
||||||
|
border-radius: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introduction article header {}
|
||||||
|
|
||||||
|
.introduction article:nth-child(1) {
|
||||||
|
width: 100%;
|
||||||
|
/* transform: rotate(5deg); */
|
||||||
|
align-self: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
.introduction article:nth-child(2) {
|
||||||
|
width: 50%;
|
||||||
|
transform: rotate(3deg);
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introduction article:nth-child(3) {
|
||||||
|
width: 50%;
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
align-self: flex-start;
|
||||||
|
}*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.animation-introduction{
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
z-index: -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-introduction ul{
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
/*overflow: hidden; ZAPNOUT KDYŽ NECHCEŠ ANIMACI PŘECHÁZET DO OSTATNÍCH DIVŮ*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-introduction ul li{
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
list-style: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(255, 255, 255, 35%);
|
||||||
|
animation: animate 4s linear infinite;
|
||||||
|
bottom: -150px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-introduction ul li:nth-child(1){
|
||||||
|
left: 25%;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.animation-introduction ul li:nth-child(2){
|
||||||
|
left: 10%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
animation-delay: 2s;
|
||||||
|
animation-duration: 12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-introduction ul li:nth-child(3){
|
||||||
|
left: 70%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
animation-delay: 4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-introduction ul li:nth-child(4){
|
||||||
|
left: 40%;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
animation-duration: 18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-introduction ul li:nth-child(5){
|
||||||
|
left: 65%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-introduction ul li:nth-child(6){
|
||||||
|
left: 75%;
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
animation-delay: 3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-introduction ul li:nth-child(7){
|
||||||
|
left: 35%;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
animation-delay: 7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-introduction ul li:nth-child(8){
|
||||||
|
left: 50%;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
animation-delay: 15s;
|
||||||
|
animation-duration: 45s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-introduction ul li:nth-child(9){
|
||||||
|
left: 20%;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
animation-delay: 2s;
|
||||||
|
animation-duration: 35s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-introduction ul li:nth-child(10){
|
||||||
|
left: 85%;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
animation-duration: 11s;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes animate {
|
||||||
|
|
||||||
|
0%{
|
||||||
|
transform: translateY(0) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100%{
|
||||||
|
transform: translateY(-1000px) rotate(720deg);
|
||||||
|
opacity: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media only screen and (max-width: 990px) {
|
||||||
|
.animation-introduction ul li:nth-child(6){
|
||||||
|
left: 67%;
|
||||||
|
}
|
||||||
|
.animation-introduction ul li:nth-child(10) {
|
||||||
|
left: 60%;
|
||||||
|
}
|
||||||
|
.introduction {
|
||||||
|
margin: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introduction article {
|
||||||
|
width: auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
align-self: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
absolete_frontend/src/pages/home/home.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useEffect } from "react"
|
||||||
|
import styles from "./Home.module.css"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
const randomId = "spark-" + Math.floor(Math.random() * 100000)
|
||||||
|
|
||||||
|
const spark = document.createElement("div")
|
||||||
|
spark.className = "spark-cursor"
|
||||||
|
spark.id = randomId
|
||||||
|
document.body.appendChild(spark)
|
||||||
|
|
||||||
|
// pozice a barva
|
||||||
|
spark.style.top = `${event.pageY}px`
|
||||||
|
spark.style.left = `${event.pageX}px`
|
||||||
|
spark.style.filter = `hue-rotate(${Math.random() * 360}deg)`
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const span = document.createElement("span")
|
||||||
|
span.style.transform = `rotate(${i * 45}deg)`
|
||||||
|
spark.appendChild(span)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
spark.querySelectorAll("span").forEach((s) => {
|
||||||
|
(s as HTMLElement).classList.add("animate")
|
||||||
|
})
|
||||||
|
}, 10)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
spark.remove()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.addEventListener("click", handleClick)
|
||||||
|
|
||||||
|
// cleanup když komponenta zmizí
|
||||||
|
return () => {
|
||||||
|
document.body.removeEventListener("click", handleClick)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<></>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
absolete_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 />;
|
||||||
|
}
|
||||||
71
absolete_frontend/src/routes/ROUTES.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Routes Folder
|
||||||
|
|
||||||
|
This folder contains the route definitions and components used to manage routing in the React application. It includes public and private routes, as well as nested layouts.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
routes/
|
||||||
|
├── PrivateRoute.jsx
|
||||||
|
├── AppRoutes.jsx
|
||||||
|
└── index.js
|
||||||
|
|
||||||
|
|
||||||
|
### `PrivateRoute.jsx`
|
||||||
|
|
||||||
|
`PrivateRoute` is a wrapper component that restricts access to certain routes based on the user's authentication status. Only logged-in users can access routes wrapped inside `PrivateRoute`.
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth"; // custom hook to get auth status
|
||||||
|
|
||||||
|
const PrivateRoute = () => {
|
||||||
|
const { isLoggedIn } = useAuth();
|
||||||
|
|
||||||
|
return isLoggedIn ? <Outlet /> : <Navigate to="/login" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrivateRoute;
|
||||||
|
```
|
||||||
|
|
||||||
|
` <Outlet /> ` allows nested routes to be rendered inside the PrivateRoute.
|
||||||
|
|
||||||
|
### AppRoutes.jsx
|
||||||
|
|
||||||
|
This file contains all the route definitions example of the app. It can use layouts from the layouts folder to wrap sections of the app.
|
||||||
|
|
||||||
|
Example Usage
|
||||||
|
```jsx
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import PrivateRoute from "./PrivateRoute";
|
||||||
|
|
||||||
|
// Layouts
|
||||||
|
import MainLayout from "../layouts/MainLayout";
|
||||||
|
import AuthLayout from "../layouts/AuthLayout";
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
import Dashboard from "../pages/Dashboard";
|
||||||
|
import Profile from "../pages/Profile";
|
||||||
|
import Login from "../pages/Login";
|
||||||
|
|
||||||
|
const AppRoutes = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Public Routes */}
|
||||||
|
<Route element={<AuthLayout />}>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Private Routes */}
|
||||||
|
<Route element={<PrivateRoute />}>
|
||||||
|
<Route element={<MainLayout />}>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/profile" element={<Profile />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppRoutes;
|
||||||
|
```
|
||||||
1
absolete_frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
27
absolete_frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
absolete_frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
absolete_frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
11
absolete_frontend/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss()
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
# Create your models here.
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
|
|
||||||
class UserProfile(models.Model):
|
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
|
||||||
# Add custom fields here
|
|
||||||
bio = models.TextField(blank=True)
|
|
||||||
birthdate = models.DateField(null=True, blank=True)
|
|
||||||
profile_picture = models.ImageField(upload_to='profile_pics/', null=True, blank=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f'{self.user.username} Profile'
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from rest_framework_api_key.permissions import HasAPIKey
|
|
||||||
|
|
||||||
class UserEditAPIKeyPermissions(HasAPIKey):
|
|
||||||
"""
|
|
||||||
Custom permision for restricting access using API key.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
from .models import User
|
|
||||||
|
|
||||||
#Serializers are for what views can show fields of models
|
|
||||||
|
|
||||||
class PublicUserSerializers(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for public User fields
|
|
||||||
"""
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = ['id', 'username']
|
|
||||||
|
|
||||||
class SecureUserSerializers(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for all User fields
|
|
||||||
Requires API key
|
|
||||||
"""
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'bio']
|
|
||||||
10
api/urls.py
@@ -1,10 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from .views import PublicUserView, SecureUserUpdateView
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
# URL for the public view to list users with public fields
|
|
||||||
path('users/', PublicUserView.as_view(), name='public-user-list'),
|
|
||||||
|
|
||||||
# URL for secure view to retrieve and update user with all fields
|
|
||||||
path('users/<int:pk>/', SecureUserUpdateView.as_view(), name='secure-user-update'),
|
|
||||||
]
|
|
||||||
20
api/views.py
@@ -1,20 +0,0 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
|
|
||||||
from rest_framework import generics, permissions
|
|
||||||
from .models import User
|
|
||||||
from .serializers import PublicUserSerializers, SecureUserSerializers
|
|
||||||
from .permissions import UserEditAPIKeyPermissions
|
|
||||||
|
|
||||||
#Public view: List users with only public fields
|
|
||||||
class PublicUserView(generics.ListAPIView):
|
|
||||||
queryset = User.objects.all()
|
|
||||||
serializer_class = PublicUserSerializers
|
|
||||||
permission_classes = [permissions.AllowAny]
|
|
||||||
|
|
||||||
#Secure view for retrive/update user all fields (API key)
|
|
||||||
class SecureUserUpdateView(generics.RetrieveUpdateAPIView):
|
|
||||||
queryset = User.objects.all()
|
|
||||||
serializer_class = SecureUserSerializers
|
|
||||||
permission_classes = [UserEditAPIKeyPermissions]
|
|
||||||
12
backend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt update && apt install ffmpeg -y
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
23
backend/account/admin.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id", "email", "role", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified", "create_time"
|
||||||
|
)
|
||||||
|
list_filter = ("role", "gdpr", "is_active", "email_verified", "city", "postal_code")
|
||||||
|
search_fields = ("email", "phone_number", "city", "street", "postal_code")
|
||||||
|
ordering = ("-create_time",)
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("email", "password", "role")}),
|
||||||
|
(_("Personal info"), {"fields": ("phone_number", "city", "street", "postal_code")}),
|
||||||
|
(_("Permissions"), {"fields": ("gdpr", "is_active", "email_verified")}),
|
||||||
|
(_("Important dates"), {"fields": ("create_time",)}),
|
||||||
|
)
|
||||||
|
readonly_fields = ("create_time",)
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class HomeConfig(AppConfig):
|
class AccountConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'home'
|
name = 'account'
|
||||||
24
backend/account/filters.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import django_filters
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserFilter(django_filters.FilterSet):
|
||||||
|
role = django_filters.CharFilter(field_name="role", lookup_expr="exact")
|
||||||
|
email = django_filters.CharFilter(field_name="email", lookup_expr="icontains")
|
||||||
|
phone_number = django_filters.CharFilter(field_name="phone_number", lookup_expr="icontains")
|
||||||
|
city = django_filters.CharFilter(field_name="city", lookup_expr="icontains")
|
||||||
|
street = django_filters.CharFilter(field_name="street", lookup_expr="icontains")
|
||||||
|
postal_code = django_filters.CharFilter(field_name="postal_code", lookup_expr="exact")
|
||||||
|
gdpr = django_filters.BooleanFilter(field_name="gdpr")
|
||||||
|
is_active = django_filters.BooleanFilter(field_name="is_active")
|
||||||
|
email_verified = django_filters.BooleanFilter(field_name="email_verified")
|
||||||
|
create_time_after = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="gte")
|
||||||
|
create_time_before = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="lte")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"role", "email", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified",
|
||||||
|
"create_time_after", "create_time_before"
|
||||||
|
]
|
||||||
54
backend/account/migrations/0001_initial.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-10-28 22:28
|
||||||
|
|
||||||
|
import account.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.core.validators
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CustomUser',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||||
|
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||||
|
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||||
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('role', models.CharField(choices=[('admin', 'Admin'), ('mod', 'Moderator'), ('regular', 'Regular')], default='regular', max_length=20)),
|
||||||
|
('phone_number', models.CharField(blank=True, max_length=16, null=True, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])),
|
||||||
|
('email_verified', models.BooleanField(default=False)),
|
||||||
|
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
|
||||||
|
('gdpr', models.BooleanField(default=False)),
|
||||||
|
('is_active', models.BooleanField(default=False)),
|
||||||
|
('create_time', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('city', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('street', models.CharField(blank=True, max_length=200, null=True)),
|
||||||
|
('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', account.models.CustomUserManager()),
|
||||||
|
('active', account.models.ActiveUserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-10-31 07:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('account', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='email_verification_sent_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='email_verification_token',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=128, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
160
backend/account/models.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission
|
||||||
|
from django.core.validators import RegexValidator
|
||||||
|
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from vontor_cz.models import SoftDeleteModel
|
||||||
|
|
||||||
|
from django.contrib.auth.models import UserManager
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class CustomUserManager(UserManager):
|
||||||
|
# Inherit get_by_natural_key and all auth behaviors
|
||||||
|
use_in_migrations = True
|
||||||
|
|
||||||
|
class ActiveUserManager(CustomUserManager):
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(is_active=True)
|
||||||
|
|
||||||
|
class CustomUser(SoftDeleteModel, AbstractUser):
|
||||||
|
groups = models.ManyToManyField(
|
||||||
|
Group,
|
||||||
|
related_name="customuser_set", # <- přidáš related_name
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to.",
|
||||||
|
related_query_name="customuser",
|
||||||
|
)
|
||||||
|
user_permissions = models.ManyToManyField(
|
||||||
|
Permission,
|
||||||
|
related_name="customuser_set", # <- přidáš related_name
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this user.",
|
||||||
|
related_query_name="customuser",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Role(models.TextChoices):
|
||||||
|
ADMIN = "admin", "Admin"
|
||||||
|
MANAGER = "mod", "Moderator"
|
||||||
|
CUSTOMER = "regular", "Regular"
|
||||||
|
|
||||||
|
role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
phone_number = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
|
||||||
|
unique=True,
|
||||||
|
max_length=16,
|
||||||
|
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)
|
||||||
|
|
||||||
|
# + fields for email verification flow
|
||||||
|
email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True)
|
||||||
|
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
gdpr = models.BooleanField(default=False)
|
||||||
|
is_active = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
create_time = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|
||||||
|
city = models.CharField(null=True, blank=True, max_length=100)
|
||||||
|
street = models.CharField(null=True, blank=True, max_length=200)
|
||||||
|
|
||||||
|
postal_code = models.CharField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
|
||||||
|
max_length=5,
|
||||||
|
validators=[
|
||||||
|
RegexValidator(
|
||||||
|
regex=r'^\d{5}$',
|
||||||
|
message="Postal code must contain exactly 5 digits.",
|
||||||
|
code='invalid_postal_code'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
USERNAME_FIELD = "username"
|
||||||
|
REQUIRED_FIELDS = [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ensure default manager has get_by_natural_key
|
||||||
|
objects = CustomUserManager()
|
||||||
|
# Optional convenience manager for active users only
|
||||||
|
active = ActiveUserManager()
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
self.is_active = False
|
||||||
|
|
||||||
|
return super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
is_new = self._state.adding # True if object hasn't been saved yet
|
||||||
|
|
||||||
|
# Pre-save flags for new users
|
||||||
|
if is_new:
|
||||||
|
if self.is_superuser or self.role == "admin":
|
||||||
|
# ensure admin flags are consistent
|
||||||
|
self.is_active = True
|
||||||
|
self.is_staff = True
|
||||||
|
self.is_superuser = True
|
||||||
|
self.role = "admin"
|
||||||
|
else:
|
||||||
|
self.is_staff = False
|
||||||
|
|
||||||
|
# First save to obtain a primary key
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Assign group after we have a PK
|
||||||
|
if is_new:
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
group, _ = Group.objects.get_or_create(name=self.role)
|
||||||
|
# Use add/set now that PK exists
|
||||||
|
self.groups.set([group])
|
||||||
|
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def generate_email_verification_token(self, length: int = 48, save: bool = True) -> str:
|
||||||
|
token = get_random_string(length=length)
|
||||||
|
self.email_verification_token = token
|
||||||
|
self.email_verification_sent_at = timezone.now()
|
||||||
|
if save:
|
||||||
|
self.save(update_fields=["email_verification_token", "email_verification_sent_at"])
|
||||||
|
return token
|
||||||
|
|
||||||
|
def verify_email_token(self, token: str, max_age_hours: int = 48, save: bool = True) -> bool:
|
||||||
|
if not token or not self.email_verification_token:
|
||||||
|
return False
|
||||||
|
# optional expiry check
|
||||||
|
if self.email_verification_sent_at:
|
||||||
|
age = timezone.now() - self.email_verification_sent_at
|
||||||
|
if age > timedelta(hours=max_age_hours):
|
||||||
|
return False
|
||||||
|
if token != self.email_verification_token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.email_verified:
|
||||||
|
self.email_verified = True
|
||||||
|
# clear token after success
|
||||||
|
self.email_verification_token = None
|
||||||
|
self.email_verification_sent_at = None
|
||||||
|
if save:
|
||||||
|
self.save(update_fields=["email_verified", "email_verification_token", "email_verification_sent_at"])
|
||||||
|
return True
|
||||||
|
|
||||||
57
backend/account/permissions.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from urllib import request
|
||||||
|
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework_api_key.permissions import HasAPIKey
|
||||||
|
|
||||||
|
|
||||||
|
#TOHLE POUŽÍT!!!
|
||||||
|
#Prostě stačí vložit: RoleAllowed('seller','cityClerk')
|
||||||
|
def RoleAllowed(*roles):
|
||||||
|
"""
|
||||||
|
Allows safe methods for any authenticated user.
|
||||||
|
Allows unsafe methods only for users with specific roles.
|
||||||
|
Allows access if a valid API key is provided.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
RoleAllowed('admin', 'user')
|
||||||
|
"""
|
||||||
|
class SafeOrRolePermission(BasePermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
# Má API klíč?
|
||||||
|
has_api_key = HasAPIKey().has_permission(request, view)
|
||||||
|
|
||||||
|
# Allow safe methods for any authenticated user
|
||||||
|
if request.method in SAFE_METHODS:
|
||||||
|
return IsAuthenticated().has_permission(request, view)
|
||||||
|
|
||||||
|
# Otherwise, check the user's role
|
||||||
|
user = request.user
|
||||||
|
return user and user.is_authenticated and getattr(user, "role", None) in roles
|
||||||
|
|
||||||
|
return SafeOrRolePermission
|
||||||
|
|
||||||
|
|
||||||
|
def OnlyRolesAllowed(*roles):
|
||||||
|
class SafeOrRolePermission(BasePermission):
|
||||||
|
"""
|
||||||
|
Allows all methods only for users with specific roles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
# Otherwise, check the user's role
|
||||||
|
user = request.user
|
||||||
|
return user and user.is_authenticated and getattr(user, "role", None) in roles
|
||||||
|
|
||||||
|
return SafeOrRolePermission
|
||||||
|
|
||||||
|
|
||||||
|
# For Settings.py
|
||||||
|
class AdminOnly(BasePermission):
|
||||||
|
""" Allows access only to users with the 'admin' role.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BasePermission (rest_framework.permissions.BasePermission): Base class for permission classes.
|
||||||
|
"""
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
||||||
|
|
||||||
205
backend/account/serializers.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import re
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
from .permissions import *
|
||||||
|
|
||||||
|
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class CustomUserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"username",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"role",
|
||||||
|
"email_verified",
|
||||||
|
"phone_number",
|
||||||
|
"create_time",
|
||||||
|
"city",
|
||||||
|
"street",
|
||||||
|
"postal_code",
|
||||||
|
"gdpr",
|
||||||
|
"is_active",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type"
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
user = self.context["request"].user
|
||||||
|
staff_only_fields = ["role", "email_verified", "var_symbol", "is_active"]
|
||||||
|
|
||||||
|
if user.role not in ["admin", "cityClerk"]:
|
||||||
|
unauthorized = [f for f in staff_only_fields if f in validated_data]
|
||||||
|
if unauthorized:
|
||||||
|
raise PermissionDenied(f"You are not allowed to modify: {', '.join(unauthorized)}")
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Token obtaining Default Serializer
|
||||||
|
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||||
|
username_field = User.USERNAME_FIELD
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
login = attrs.get("username")
|
||||||
|
password = attrs.get("password")
|
||||||
|
|
||||||
|
# Allow login by username or email
|
||||||
|
user = User.objects.filter(email__iexact=login).first() or \
|
||||||
|
User.objects.filter(username__iexact=login).first()
|
||||||
|
|
||||||
|
if user is None or not user.check_password(password):
|
||||||
|
raise serializers.ValidationError(_("No active account found with the given credentials"))
|
||||||
|
|
||||||
|
# Call the parent validation to create token
|
||||||
|
data = super().validate({
|
||||||
|
self.username_field: user.username,
|
||||||
|
"password": password
|
||||||
|
})
|
||||||
|
|
||||||
|
data["user_id"] = user.id
|
||||||
|
data["username"] = user.username
|
||||||
|
data["email"] = user.email
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# user creating section start ------------------------------------------
|
||||||
|
class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||||
|
password = serializers.CharField(
|
||||||
|
write_only=True,
|
||||||
|
help_text="Heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici."
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'first_name', 'last_name', 'email', 'phone_number', 'password',
|
||||||
|
'city', 'street', 'postal_code', 'gdpr'
|
||||||
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
'first_name': {'required': True, 'help_text': 'Křestní jméno uživatele'},
|
||||||
|
'last_name': {'required': True, 'help_text': 'Příjmení uživatele'},
|
||||||
|
'email': {'required': True, 'help_text': 'Emailová adresa uživatele'},
|
||||||
|
'phone_number': {'required': True, 'help_text': 'Telefonní číslo uživatele'},
|
||||||
|
'city': {'required': True, 'help_text': 'Město uživatele'},
|
||||||
|
'street': {'required': True, 'help_text': 'Ulice uživatele'},
|
||||||
|
'postal_code': {'required': True, 'help_text': 'PSČ uživatele'},
|
||||||
|
'gdpr': {'required': True, 'help_text': 'Souhlas se zpracováním osobních údajů'},
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_password(self, value):
|
||||||
|
if len(value) < 8:
|
||||||
|
raise serializers.ValidationError("Password must be at least 8 characters long.")
|
||||||
|
if not re.search(r"[A-Z]", value):
|
||||||
|
raise serializers.ValidationError("Password must contain at least one uppercase letter.")
|
||||||
|
if not re.search(r"[a-z]", value):
|
||||||
|
raise serializers.ValidationError("Password must contain at least one lowercase letter.")
|
||||||
|
if not re.search(r"\d", value):
|
||||||
|
raise serializers.ValidationError("Password must contain at least one digit.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
email = data.get("email")
|
||||||
|
phone = data.get("phone_number")
|
||||||
|
dgpr = data.get("GDPR")
|
||||||
|
if not dgpr:
|
||||||
|
raise serializers.ValidationError({"GDPR": "You must agree to the GDPR to register."})
|
||||||
|
|
||||||
|
if User.objects.filter(email=email).exists():
|
||||||
|
raise serializers.ValidationError({"email": "Account with this email already exists."})
|
||||||
|
|
||||||
|
if phone and User.objects.filter(phone_number=phone).exists():
|
||||||
|
raise serializers.ValidationError({"phone_number": "Account with this phone number already exists."})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
password = validated_data.pop("password")
|
||||||
|
username = validated_data.get("username", "")
|
||||||
|
user = User.objects.create(
|
||||||
|
username=username,
|
||||||
|
is_active=False, #uživatel je defaultně deaktivovaný
|
||||||
|
**validated_data
|
||||||
|
)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
class UserActivationSerializer(serializers.Serializer):
|
||||||
|
user_id = serializers.IntegerField()
|
||||||
|
var_symbol = serializers.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(9999999999)])
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
try:
|
||||||
|
user = User.objects.get(pk=self.validated_data['user_id'])
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise NotFound("User with this ID does not exist.")
|
||||||
|
user.var_symbol = self.validated_data['var_symbol']
|
||||||
|
user.is_active = True
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
return {
|
||||||
|
"id": instance.id,
|
||||||
|
"email": instance.email,
|
||||||
|
"var_symbol": instance.var_symbol,
|
||||||
|
"is_active": instance.is_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'user_id', 'var_symbol'
|
||||||
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
'user_id': {'required': True, 'help_text': 'ID uživatele'},
|
||||||
|
'var_symbol': {'required': True, 'help_text': 'Variablní symbol, zadán úředníkem'},
|
||||||
|
}
|
||||||
|
# user creating section end --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequestSerializer(serializers.Serializer):
|
||||||
|
email = serializers.EmailField(
|
||||||
|
help_text="E-mail registrovaného a aktivního uživatele, na který bude zaslán reset hesla."
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_email(self, value):
|
||||||
|
if not User.objects.filter(email=value, is_active=True).exists():
|
||||||
|
raise serializers.ValidationError("Účet s tímto emailem neexistuje nebo není aktivní.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
class PasswordResetConfirmSerializer(serializers.Serializer):
|
||||||
|
password = serializers.CharField(
|
||||||
|
write_only=True,
|
||||||
|
help_text="Nové heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici."
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_password(self, value):
|
||||||
|
import re
|
||||||
|
if len(value) < 8:
|
||||||
|
raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.")
|
||||||
|
if not re.search(r"[A-Z]", value):
|
||||||
|
raise serializers.ValidationError("Musí obsahovat velké písmeno.")
|
||||||
|
if not re.search(r"[a-z]", value):
|
||||||
|
raise serializers.ValidationError("Musí obsahovat malé písmeno.")
|
||||||
|
if not re.search(r"\d", value):
|
||||||
|
raise serializers.ValidationError("Musí obsahovat číslici.")
|
||||||
|
return value
|
||||||
152
backend/account/tasks.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
from celery.utils.log import get_task_logger
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.http import urlsafe_base64_encode
|
||||||
|
from django.utils.encoding import force_bytes
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from .tokens import *
|
||||||
|
from .models import CustomUser
|
||||||
|
|
||||||
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
|
def send_email_with_context(recipients, subject, message=None, template_name=None, html_template_name=None, context=None):
|
||||||
|
"""
|
||||||
|
General function to send emails with a specific context.
|
||||||
|
Supports rendering plain text and HTML templates.
|
||||||
|
Converts `user` in context to a plain dict to avoid template access to the model.
|
||||||
|
"""
|
||||||
|
if isinstance(recipients, str):
|
||||||
|
recipients = [recipients]
|
||||||
|
|
||||||
|
html_message = None
|
||||||
|
if template_name or html_template_name:
|
||||||
|
# Best effort to resolve both templates if only one provided
|
||||||
|
if not template_name and html_template_name:
|
||||||
|
template_name = html_template_name.replace(".html", ".txt")
|
||||||
|
if not html_template_name and template_name:
|
||||||
|
html_template_name = template_name.replace(".txt", ".html")
|
||||||
|
|
||||||
|
ctx = dict(context or {})
|
||||||
|
# Sanitize user if someone passes the model by mistake
|
||||||
|
if "user" in ctx and not isinstance(ctx["user"], dict):
|
||||||
|
try:
|
||||||
|
ctx["user"] = _build_user_template_ctx(ctx["user"])
|
||||||
|
except Exception:
|
||||||
|
ctx["user"] = {}
|
||||||
|
|
||||||
|
message = render_to_string(template_name, ctx)
|
||||||
|
html_message = render_to_string(html_template_name, ctx)
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=message or "",
|
||||||
|
from_email=None,
|
||||||
|
recipient_list=recipients,
|
||||||
|
fail_silently=False,
|
||||||
|
html_message=html_message,
|
||||||
|
)
|
||||||
|
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
||||||
|
logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"E-mail se neodeslal: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_template_ctx(user: CustomUser) -> dict:
|
||||||
|
"""
|
||||||
|
Return a plain dict for templates instead of passing the DB model.
|
||||||
|
Provides aliases to avoid template errors (firstname vs first_name).
|
||||||
|
Adds a backward-compatible key 'get_full_name' for templates using `user.get_full_name`.
|
||||||
|
"""
|
||||||
|
first_name = getattr(user, "first_name", "") or ""
|
||||||
|
last_name = getattr(user, "last_name", "") or ""
|
||||||
|
full_name = f"{first_name} {last_name}".strip()
|
||||||
|
return {
|
||||||
|
"id": user.pk,
|
||||||
|
"email": getattr(user, "email", "") or "",
|
||||||
|
"first_name": first_name,
|
||||||
|
"firstname": first_name, # alias for templates using `firstname`
|
||||||
|
"last_name": last_name,
|
||||||
|
"lastname": last_name, # alias for templates using `lastname`
|
||||||
|
"full_name": full_name,
|
||||||
|
"get_full_name": full_name, # compatibility for templates using method-style access
|
||||||
|
}
|
||||||
|
|
||||||
|
#----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# This function sends an email to the user for email verification after registration.
|
||||||
|
@shared_task
|
||||||
|
def send_email_verification_task(user_id):
|
||||||
|
try:
|
||||||
|
user = CustomUser.objects.get(pk=user_id)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
|
# {changed} generate and store a per-user token
|
||||||
|
token = user.generate_email_verification_token()
|
||||||
|
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"user": _build_user_template_ctx(user),
|
||||||
|
"action_url": verify_url,
|
||||||
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
|
"cta_label": "Ověřit e‑mail",
|
||||||
|
}
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Ověření e‑mailu",
|
||||||
|
template_name="email/email_verification.txt",
|
||||||
|
html_template_name="email/email_verification.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_email_test_task(email):
|
||||||
|
context = {
|
||||||
|
"action_url": settings.FRONTEND_URL,
|
||||||
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
|
"cta_label": "Otevřít aplikaci",
|
||||||
|
}
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=email,
|
||||||
|
subject="Testovací e‑mail",
|
||||||
|
template_name="email/test.txt",
|
||||||
|
html_template_name="email/test.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_password_reset_email_task(user_id):
|
||||||
|
try:
|
||||||
|
user = CustomUser.objects.get(pk=user_id)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
|
token = password_reset_token.make_token(user)
|
||||||
|
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"user": _build_user_template_ctx(user),
|
||||||
|
"action_url": reset_url,
|
||||||
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
|
"cta_label": "Obnovit heslo",
|
||||||
|
}
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Obnova hesla",
|
||||||
|
template_name="email/password_reset.txt",
|
||||||
|
html_template_name="email/password_reset.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
46
backend/account/templates/emails/email_verification.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="cs">
|
||||||
|
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:24px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
||||||
|
Ověření e‑mailu
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||||
|
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
||||||
|
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
||||||
|
{% endwith %}
|
||||||
|
<p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou e‑mailovou adresu kliknutím na tlačítko níže.</p>
|
||||||
|
|
||||||
|
{% if action_url and cta_label %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#2563eb" style="border-radius:6px;">
|
||||||
|
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
||||||
|
{{ cta_label }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
||||||
|
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||