This commit is contained in:
2025-10-28 03:21:01 +01:00
parent 10796dcb31
commit 73da41b514
44 changed files with 1868 additions and 452 deletions

View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@types/react-router": "^5.1.20",
"axios": "^1.13.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
@@ -1807,6 +1808,23 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.0.tgz",
"integrity": "sha512-zt40Pz4zcRXra9CVV31KeyofwiNvAbJ5B6YPz9pMJ+yOSLikvPT4Yi5LjfgjRa9CawVYBaD1JQzIVcIvBejKeA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1871,6 +1889,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1939,6 +1970,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2008,6 +2051,29 @@
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.200",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz",
@@ -2015,6 +2081,51 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
@@ -2383,6 +2494,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2398,6 +2545,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -2408,6 +2564,43 @@
"node": ">=6.9.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2434,6 +2627,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -2451,6 +2656,45 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2652,6 +2896,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2676,6 +2929,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2871,6 +3145,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3313,9 +3593,9 @@
}
},
"node_modules/vite": {
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
"version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"@types/react-router": "^5.1.20",
"axios": "^1.13.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",

View File

@@ -1,19 +1,19 @@
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";
function App() {
export default function App() {
return (
<Router>
<Routes>
{/* Layout route */}
<Route path="/" element={<HomeLayout />}>
<Route index element={<Home />} />
<Route path="downloader" element={<Downloader />} />
</Route>
</Routes>
</Router>
)
}
export default App
}

268
frontend/src/api/Client.ts Normal file
View File

@@ -0,0 +1,268 @@
import axios from "axios";
// --- ENV CONFIG ---
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
const REFRESH_URL =
import.meta.env.VITE_API_REFRESH_URL || "/api/token/refresh/";
const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH || "/login";
// --- ERROR EVENT BUS ---
const ERROR_EVENT = "api:error";
type ApiErrorDetail = {
message: string;
status?: number;
url?: string;
data?: unknown;
};
// Use interface instead of arrow function types for readability
interface ApiErrorHandler {
(e: CustomEvent<ApiErrorDetail>): void;
}
function notifyError(detail: ApiErrorDetail) {
window.dispatchEvent(new CustomEvent<ApiErrorDetail>(ERROR_EVENT, { detail }));
// eslint-disable-next-line no-console
console.error("[API ERROR]", detail);
}
function onError(handler: ApiErrorHandler) {
const wrapped = handler as EventListener;
window.addEventListener(ERROR_EVENT, wrapped as EventListener);
return () => window.removeEventListener(ERROR_EVENT, wrapped);
}
// --- AXIOS INSTANCES ---
// Always send cookies. Django will set auth cookies; browser will include them automatically.
function createAxios(baseURL: string): any {
const instance = axios.create({
baseURL,
withCredentials: true, // <-- always true
headers: {
"Content-Type": "application/json",
},
timeout: 20000,
});
return instance;
}
// Use a single behavior for both: cookies are always sent
const apiPublic = createAxios(API_BASE_URL);
const apiAuth = createAxios(API_BASE_URL);
// --- REQUEST INTERCEPTOR (PUBLIC) ---
// Ensure no Authorization header is ever sent by the public client
apiPublic.interceptors.request.use(function (config: any) {
if (config?.headers && (config.headers as any).Authorization) {
delete (config.headers as any).Authorization;
}
return config;
});
// --- REQUEST INTERCEPTOR (AUTH) ---
// Do not attach Authorization header; rely on cookies set by Django.
apiAuth.interceptors.request.use(function (config: any) {
(config as any)._retryCount = (config as any)._retryCount || 0;
return config;
});
// --- RESPONSE INTERCEPTOR (AUTH) ---
// Simplified: on 401, redirect to login. Server manages refresh via cookies.
apiAuth.interceptors.response.use(
function (response: any) {
return response;
},
async function (error: any) {
if (!error.response) {
alert("Backend connection is unavailable. Please check your network.");
notifyError({
message: "Network error or backend unavailable",
url: error.config?.url,
});
return Promise.reject(error);
}
const status = error.response.status;
if (status === 401) {
clearTokens(); // optional: clear cookies client-side
window.location.assign(LOGIN_PATH);
return Promise.reject(error);
}
notifyError({
message:
(error.response.data as any)?.detail ||
(error.response.data as any)?.message ||
`Request failed with status ${status}`,
status,
url: error.config?.url,
data: error.response.data,
});
return Promise.reject(error);
}
);
// --- PUBLIC CLIENT: still emits errors and alerts on network failure ---
apiPublic.interceptors.response.use(
function (response: any) {
return response;
},
async function (error: any) {
if (!error.response) {
alert("Backend connection is unavailable. Please check your network.");
notifyError({
message: "Network error or backend unavailable",
url: error.config?.url,
});
return Promise.reject(error);
}
notifyError({
message:
(error.response.data as any)?.detail ||
(error.response.data as any)?.message ||
`Request failed with status ${error.response.status}`,
status: error.response.status,
url: error.config?.url,
data: error.response.data,
});
return Promise.reject(error);
}
);
// --- TOKEN HELPERS (NO-OPS) ---
// Django sets/rotates cookies server-side. Keep API surface to avoid breaking imports.
function setTokens(_access?: string, _refresh?: string) {
// no-op: cookies are managed by Django
}
function clearTokens() {
// optional: try to clear auth cookies client-side; server should also clear on logout
try {
document.cookie = "access_token=; Max-Age=0; path=/";
document.cookie = "refresh_token=; Max-Age=0; path=/";
} catch {
// ignore
}
}
function getAccessToken(): string | null {
// no Authorization header is used; rely purely on cookies
return null;
}
// --- EXPORT DEFAULT API WRAPPER ---
const Client = {
// Axios instances
auth: apiAuth,
public: apiPublic,
// Token helpers (kept for compatibility; now no-ops)
setTokens,
clearTokens,
getAccessToken,
// Error subscription
onError,
};
export default Client;
/**
USAGE EXAMPLES (TypeScript/React)
Import the client
--------------------------------------------------
import Client from "@/api/Client";
Login: obtain tokens and persist to cookies
--------------------------------------------------
async function login(username: string, password: string) {
// SimpleJWT default login endpoint (adjust if your backend differs)
// Example backend endpoint: POST /api/token/ -> { access, refresh }
const res = await Client.public.post("/api/token/", { username, password });
const { access, refresh } = res.data;
Client.setTokens(access, refresh);
// After this, Client.auth will automatically attach Authorization header
// and refresh when receiving a 401 (up to 2 retries).
}
Public request (no cookies, no Authorization)
--------------------------------------------------
// The public client does NOT send cookies or Authorization.
async function listPublicItems() {
const res = await Client.public.get("/api/public/items/");
return res.data;
}
Authenticated requests (auto Bearer header + refresh on 401)
--------------------------------------------------
async function fetchProfile() {
const res = await Client.auth.get("/api/users/me/");
return res.data;
}
async function updateProfile(payload: { first_name?: string; last_name?: string }) {
const res = await Client.auth.patch("/api/users/me/", payload);
return res.data;
}
Global error handling (UI notifications)
--------------------------------------------------
import { useEffect } from "react";
function useApiErrors(showToast: (msg: string) => void) {
useEffect(function () {
const unsubscribe = Client.onError(function (e) {
const { message, status } = e.detail;
showToast(status ? String(status) + ": " + message : message);
});
return unsubscribe;
}, [showToast]);
}
// Note: Network connectivity issues trigger an alert and also dispatch api:error.
// All errors are logged to console for developers.
Logout
--------------------------------------------------
function logout() {
Client.clearTokens();
window.location.assign("/login");
}
Route protection (PrivateRoute)
--------------------------------------------------
// If you created src/routes/PrivateRoute.tsx, wrap your protected routes with it.
// PrivateRoute checks for "access_token" cookie presence and redirects to /login if missing.
// Example:
// <Routes>
// <Route element={<PrivateRoute />} >
// <Route element={<MainLayout />}>
// <Route path="/" element={<Dashboard />} />
// <Route path="/profile" element={<Profile />} />
// </Route>
// </Route>
// <Route path="/login" element={<Login />} />
// </Routes>
Refresh and retry flow (what happens on 401)
--------------------------------------------------
// 1) Client.auth request receives 401 from backend
// 2) Client tries to refresh access token using refresh_token cookie
// 3) If refresh succeeds, original request is retried (max 2 times)
// 4) If still 401 (or no refresh token), tokens are cleared and user is redirected to /login
Environment variables (optional overrides)
--------------------------------------------------
// VITE_API_BASE_URL default: "http://localhost:8000"
// VITE_API_REFRESH_URL default: "/api/token/refresh/"
// VITE_LOGIN_PATH default: "/login"
*/

View File

@@ -0,0 +1,57 @@
import Client from "../Client";
export type Choices = {
file_types: string[];
qualities: string[];
};
export type DownloadPayload = {
url: string;
file_type?: string;
quality?: string;
};
export type DownloadJobResponse = {
id: string;
status: "pending" | "running" | "finished" | "failed";
detail?: string;
download_url?: string;
progress?: number; // 0-100
};
// Fallback when choices endpoint is unavailable or models are hardcoded
const FALLBACK_CHOICES: Choices = {
file_types: ["auto", "video", "audio"],
qualities: ["best", "good", "worst"],
};
/**
* Fetch dropdown choices from backend (adjust path to match your Django views).
* Expected response shape:
* { file_types: string[], qualities: string[] }
*/
export async function getChoices(): Promise<Choices> {
try {
const res = await Client.auth.get("/api/downloader/choices/");
return res.data as Choices;
} catch {
return FALLBACK_CHOICES;
}
}
/**
* Submit a new download job (adjust path/body to your viewset).
* Example payload: { url, file_type, quality }
*/
export async function submitDownload(payload: DownloadPayload): Promise<DownloadJobResponse> {
const res = await Client.auth.post("/api/downloader/jobs/", payload);
return res.data as DownloadJobResponse;
}
/**
* Get job status by ID. Returns progress, status, and download_url when finished.
*/
export async function getJobStatus(id: string): Promise<DownloadJobResponse> {
const res = await Client.auth.get(`/api/downloader/jobs/${id}/`);
return res.data as DownloadJobResponse;
}

View File

@@ -1,202 +0,0 @@
import axios from "axios";
const API_URL: string = `${import.meta.env.VITE_BACKEND_URL}/api`;
// Axios instance, můžeme používat místo globálního axios
const axios_instance = axios.create({
baseURL: API_URL,
withCredentials: true, // potřebné pro cookies
});
axios_instance.defaults.xsrfCookieName = "csrftoken";
axios_instance.defaults.xsrfHeaderName = "X-CSRFToken";
export default axios_instance;
// 🔐 Axios response interceptor: automatická obnova při 401
axios_instance.interceptors.request.use((config) => {
const getCookie = (name: string): string | null => {
let cookieValue: string | null = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + "=")) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
};
const csrfToken = getCookie("csrftoken");
if (csrfToken && config.method && ["post", "put", "patch", "delete"].includes(config.method)) {
if (!config.headers) config.headers = {};
config.headers["X-CSRFToken"] = csrfToken;
}
return config;
});
// Přidej globální response interceptor pro redirect na login při 401 s detail hláškou
axios_instance.interceptors.response.use(
(response) => response,
(error) => {
if (
error.response &&
error.response.status === 401 &&
error.response.data &&
error.response.data.detail === "Nebyly zadány přihlašovací údaje."
) {
window.location.href = "/login";
}
return Promise.reject(error);
}
);
// 🔄 Obnova access tokenu pomocí refresh cookie
export const refreshAccessToken = async (): Promise<{ access: string; refresh: string } | null> => {
try {
const res = await axios_instance.post(`/account/token/refresh/`);
return res.data as { access: string; refresh: string };
} catch (err) {
console.error("Token refresh failed", err);
await logout();
return null;
}
};
// ✅ Přihlášení
export const login = async (username: string, password: string): Promise<any> => {
await logout();
try {
const response = await axios_instance.post(`/account/token/`, { username, password });
return response.data;
} catch (err: any) {
if (err.response) {
// Server responded with a status code outside 2xx
console.log('Login error status:', err.response.status);
} else if (err.request) {
// Request was made but no response received
console.log('Login network error:', err.request);
} else {
// Something else happened
console.log('Login setup error:', err.message);
}
throw err;
}
};
// ❌ Odhlášení s CSRF tokenem
export const logout = async (): Promise<any> => {
try {
const getCookie = (name: string): string | null => {
let cookieValue: string | null = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + "=")) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
};
const csrfToken = getCookie("csrftoken");
const response = await axios_instance.post(
"/account/logout/",
{},
{
headers: {
"X-CSRFToken": csrfToken,
},
}
);
console.log(response.data);
return response.data; // např. { detail: "Logout successful" }
} catch (err) {
console.error("Logout failed", err);
throw err;
}
};
/**
* 📡 Obecný request pro API
*
* @param method - HTTP metoda (např. "get", "post", "put", "patch", "delete")
* @param endpoint - API endpoint (např. "/api/service-tickets/")
* @param data - data pro POST/PUT/DELETE requesty
* @param config - další konfigurace pro axios request
* @returns Promise<any> - vrací data z odpovědi
*/
export const apiRequest = async (
method: string,
endpoint: string,
data: Record<string, any> = {},
config: Record<string, any> = {}
): Promise<any> => {
const url = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
try {
const response = await axios_instance({
method,
url,
data: ["post", "put", "patch"].includes(method.toLowerCase()) ? data : undefined,
params: ["get", "delete"].includes(method.toLowerCase()) ? data : undefined,
...config,
});
return response.data;
} catch (err: any) {
if (err.response) {
// Server odpověděl s kódem mimo rozsah 2xx
console.error("API Error:", {
status: err.response.status,
data: err.response.data,
headers: err.response.headers,
});
} else if (err.request) {
// Request byl odeslán, ale nedošla odpověď
console.error("No response received:", err.request);
} else {
// Něco jiného se pokazilo při sestavování requestu
console.error("Request setup error:", err.message);
}
throw err;
}
};
// 👤 Funkce pro získání aktuálně přihlášeného uživatele
export async function getCurrentUser(): Promise<any> {
const response = await axios_instance.get(`${API_URL}/account/user/me/`);
return response.data; // vrací data uživatele
}
// 🔒 ✔️ Jednoduchá funkce, která kontroluje přihlášení - můžeš to upravit dle potřeby
export async function isAuthenticated(): Promise<boolean> {
try {
const user = await getCurrentUser();
return user != null;
} catch (err) {
return false; // pokud padne 401, není přihlášen
}
}
export { axios_instance, API_URL };

View File

@@ -1,26 +0,0 @@
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
/**
* Makes a general external API call using axios.
*
* @param url - The full URL of the external API endpoint.
* @param method - HTTP method (GET, POST, PUT, PATCH, DELETE, etc.).
* @param data - Request body data (for POST, PUT, PATCH). Optional.
* @param config - Additional Axios request config (headers, params, etc.). Optional.
* @returns Promise resolving to AxiosResponse<any>.
*
* @example externalApiCall("https://api.example.com/data", "post", { foo: "bar" }, { headers: { Authorization: "Bearer token" } })
*/
export async function externalApiCall(
url: string,
method: AxiosRequestConfig["method"],
data?: any,
config?: AxiosRequestConfig
): Promise<AxiosResponse<any>> {
return axios({
url,
method,
data,
...config,
});
}

View File

@@ -1,4 +1,4 @@
import { apiRequest } from "./axios";
import Client from "./Client";
/**
* Loads enum values from an OpenAPI schema for a given path, method, and field (e.g., category).
@@ -16,7 +16,7 @@ export async function fetchEnumFromSchemaJson(
schemaUrl: string = "/schema/?format=json"
): Promise<Array<{ value: string; label: string }>> {
try {
const schema = await apiRequest("get", schemaUrl);
const schema = await Client.public.get(schemaUrl);
const methodDef = schema.paths?.[path]?.[method];
if (!methodDef) {

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,9 +1,10 @@
import Footer from "../components/Footer/footer";
import ContactMeForm from "../components/Forms/ContactMe/ContactMeForm";
import HomeNav from "../components/navbar/HomeNav";
import Drone from "../features/ads/Drone/Drone";
import Portfolio from "../features/ads/Portfolio/Portfolio";
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(
@@ -12,6 +13,7 @@ export default function HomeLayout(){
<HomeNav />
<Home /> {/*page*/}
<Drone />
<Outlet />
<Portfolio />
<div style={{ margin: "6em auto", marginTop: "15em", maxWidth: "80vw" }}>
<ContactMeForm />

View File

@@ -0,0 +1,160 @@
import { useEffect, useMemo, useState } from "react";
import {
getChoices,
submitDownload,
getJobStatus,
type Choices,
type DownloadJobResponse,
} from "../../api/apps/Downloader";
export default function Downloader() {
const [choices, setChoices] = useState<Choices>({ file_types: [], qualities: [] });
const [loadingChoices, setLoadingChoices] = useState(true);
const [url, setUrl] = useState("");
const [fileType, setFileType] = useState<string>("");
const [quality, setQuality] = useState<string>("");
const [submitting, setSubmitting] = useState(false);
const [job, setJob] = useState<DownloadJobResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Load dropdown choices once
useEffect(() => {
let mounted = true;
(async () => {
setLoadingChoices(true);
try {
const data = await getChoices();
if (!mounted) return;
setChoices(data);
// preselect first option
if (!fileType && data.file_types.length > 0) setFileType(data.file_types[0]);
if (!quality && data.qualities.length > 0) setQuality(data.qualities[0]);
} catch (e: any) {
setError(e?.message || "Failed to load choices.");
} finally {
setLoadingChoices(false);
}
})();
return () => {
mounted = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const canSubmit = useMemo(() => {
return !!url && !!fileType && !!quality && !submitting;
}, [url, fileType, quality, submitting]);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const created = await submitDownload({ url, file_type: fileType, quality });
setJob(created);
} catch (e: any) {
setError(e?.response?.data?.detail || e?.message || "Submission failed.");
} finally {
setSubmitting(false);
}
}
async function refreshStatus() {
if (!job?.id) return;
try {
const updated = await getJobStatus(job.id);
setJob(updated);
} catch (e: any) {
setError(e?.response?.data?.detail || e?.message || "Failed to refresh status.");
}
}
return (
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1rem" }}>
<h1>Downloader</h1>
{error && (
<div style={{ background: "#fee", color: "#900", padding: ".5rem", marginBottom: ".75rem" }}>
{error}
</div>
)}
<form onSubmit={onSubmit} style={{ display: "grid", gap: ".75rem" }}>
<label>
URL
<input
type="url"
required
placeholder="https://example.com/video"
value={url}
onChange={(e) => setUrl(e.target.value)}
style={{ width: "100%", padding: ".5rem" }}
/>
</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".75rem" }}>
<label>
File type
<select
value={fileType}
onChange={(e) => setFileType(e.target.value)}
disabled={loadingChoices}
style={{ width: "100%", padding: ".5rem" }}
>
{choices.file_types.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<label>
Quality
<select
value={quality}
onChange={(e) => setQuality(e.target.value)}
disabled={loadingChoices}
style={{ width: "100%", padding: ".5rem" }}
>
{choices.qualities.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</select>
</label>
</div>
<div>
<button type="submit" disabled={!canSubmit} style={{ padding: ".5rem 1rem" }}>
{submitting ? "Submitting..." : "Start download"}
</button>
</div>
</form>
{job && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #ddd", paddingTop: "1rem" }}>
<h2>Job</h2>
<div>ID: {job.id}</div>
<div>Status: {job.status}</div>
{typeof job.progress === "number" && <div>Progress: {job.progress}%</div>}
{job.detail && <div>Detail: {job.detail}</div>}
{job.download_url ? (
<div style={{ marginTop: ".5rem" }}>
<a href={job.download_url} target="_blank" rel="noreferrer">
Download file
</a>
</div>
) : (
<button onClick={refreshStatus} style={{ marginTop: ".5rem", padding: ".5rem 1rem" }}>
Refresh status
</button>
)}
</div>
)}
</div>
);
}

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