commit
This commit is contained in:
@@ -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
268
frontend/src/api/Client.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import axios from "axios";
|
||||
|
||||
// --- ENV CONFIG ---
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
|
||||
const REFRESH_URL =
|
||||
import.meta.env.VITE_API_REFRESH_URL || "/api/token/refresh/";
|
||||
const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH || "/login";
|
||||
|
||||
|
||||
// --- ERROR EVENT BUS ---
|
||||
const ERROR_EVENT = "api:error";
|
||||
type ApiErrorDetail = {
|
||||
message: string;
|
||||
status?: number;
|
||||
url?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
// Use interface instead of arrow function types for readability
|
||||
interface ApiErrorHandler {
|
||||
(e: CustomEvent<ApiErrorDetail>): void;
|
||||
}
|
||||
|
||||
function notifyError(detail: ApiErrorDetail) {
|
||||
window.dispatchEvent(new CustomEvent<ApiErrorDetail>(ERROR_EVENT, { detail }));
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[API ERROR]", detail);
|
||||
}
|
||||
function onError(handler: ApiErrorHandler) {
|
||||
const wrapped = handler as EventListener;
|
||||
window.addEventListener(ERROR_EVENT, wrapped as EventListener);
|
||||
return () => window.removeEventListener(ERROR_EVENT, wrapped);
|
||||
}
|
||||
|
||||
// --- AXIOS INSTANCES ---
|
||||
// Always send cookies. Django will set auth cookies; browser will include them automatically.
|
||||
function createAxios(baseURL: string): any {
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true, // <-- always true
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 20000,
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Use a single behavior for both: cookies are always sent
|
||||
const apiPublic = createAxios(API_BASE_URL);
|
||||
const apiAuth = createAxios(API_BASE_URL);
|
||||
|
||||
// --- REQUEST INTERCEPTOR (PUBLIC) ---
|
||||
// Ensure no Authorization header is ever sent by the public client
|
||||
apiPublic.interceptors.request.use(function (config: any) {
|
||||
if (config?.headers && (config.headers as any).Authorization) {
|
||||
delete (config.headers as any).Authorization;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// --- REQUEST INTERCEPTOR (AUTH) ---
|
||||
// Do not attach Authorization header; rely on cookies set by Django.
|
||||
apiAuth.interceptors.request.use(function (config: any) {
|
||||
(config as any)._retryCount = (config as any)._retryCount || 0;
|
||||
return config;
|
||||
});
|
||||
|
||||
// --- RESPONSE INTERCEPTOR (AUTH) ---
|
||||
// Simplified: on 401, redirect to login. Server manages refresh via cookies.
|
||||
apiAuth.interceptors.response.use(
|
||||
function (response: any) {
|
||||
return response;
|
||||
},
|
||||
async function (error: any) {
|
||||
if (!error.response) {
|
||||
alert("Backend connection is unavailable. Please check your network.");
|
||||
notifyError({
|
||||
message: "Network error or backend unavailable",
|
||||
url: error.config?.url,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const status = error.response.status;
|
||||
if (status === 401) {
|
||||
clearTokens(); // optional: clear cookies client-side
|
||||
window.location.assign(LOGIN_PATH);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
notifyError({
|
||||
message:
|
||||
(error.response.data as any)?.detail ||
|
||||
(error.response.data as any)?.message ||
|
||||
`Request failed with status ${status}`,
|
||||
status,
|
||||
url: error.config?.url,
|
||||
data: error.response.data,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// --- PUBLIC CLIENT: still emits errors and alerts on network failure ---
|
||||
apiPublic.interceptors.response.use(
|
||||
function (response: any) {
|
||||
return response;
|
||||
},
|
||||
async function (error: any) {
|
||||
if (!error.response) {
|
||||
alert("Backend connection is unavailable. Please check your network.");
|
||||
notifyError({
|
||||
message: "Network error or backend unavailable",
|
||||
url: error.config?.url,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
notifyError({
|
||||
message:
|
||||
(error.response.data as any)?.detail ||
|
||||
(error.response.data as any)?.message ||
|
||||
`Request failed with status ${error.response.status}`,
|
||||
status: error.response.status,
|
||||
url: error.config?.url,
|
||||
data: error.response.data,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// --- TOKEN HELPERS (NO-OPS) ---
|
||||
// Django sets/rotates cookies server-side. Keep API surface to avoid breaking imports.
|
||||
function setTokens(_access?: string, _refresh?: string) {
|
||||
// no-op: cookies are managed by Django
|
||||
}
|
||||
function clearTokens() {
|
||||
// optional: try to clear auth cookies client-side; server should also clear on logout
|
||||
try {
|
||||
document.cookie = "access_token=; Max-Age=0; path=/";
|
||||
document.cookie = "refresh_token=; Max-Age=0; path=/";
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
function getAccessToken(): string | null {
|
||||
// no Authorization header is used; rely purely on cookies
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- EXPORT DEFAULT API WRAPPER ---
|
||||
const Client = {
|
||||
// Axios instances
|
||||
auth: apiAuth,
|
||||
public: apiPublic,
|
||||
|
||||
// Token helpers (kept for compatibility; now no-ops)
|
||||
setTokens,
|
||||
clearTokens,
|
||||
getAccessToken,
|
||||
|
||||
// Error subscription
|
||||
onError,
|
||||
};
|
||||
|
||||
export default Client;
|
||||
|
||||
/**
|
||||
USAGE EXAMPLES (TypeScript/React)
|
||||
|
||||
Import the client
|
||||
--------------------------------------------------
|
||||
import Client from "@/api/Client";
|
||||
|
||||
|
||||
Login: obtain tokens and persist to cookies
|
||||
--------------------------------------------------
|
||||
async function login(username: string, password: string) {
|
||||
// SimpleJWT default login endpoint (adjust if your backend differs)
|
||||
// Example backend endpoint: POST /api/token/ -> { access, refresh }
|
||||
const res = await Client.public.post("/api/token/", { username, password });
|
||||
const { access, refresh } = res.data;
|
||||
Client.setTokens(access, refresh);
|
||||
// After this, Client.auth will automatically attach Authorization header
|
||||
// and refresh when receiving a 401 (up to 2 retries).
|
||||
}
|
||||
|
||||
|
||||
Public request (no cookies, no Authorization)
|
||||
--------------------------------------------------
|
||||
// The public client does NOT send cookies or Authorization.
|
||||
async function listPublicItems() {
|
||||
const res = await Client.public.get("/api/public/items/");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
Authenticated requests (auto Bearer header + refresh on 401)
|
||||
--------------------------------------------------
|
||||
async function fetchProfile() {
|
||||
const res = await Client.auth.get("/api/users/me/");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function updateProfile(payload: { first_name?: string; last_name?: string }) {
|
||||
const res = await Client.auth.patch("/api/users/me/", payload);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
Global error handling (UI notifications)
|
||||
--------------------------------------------------
|
||||
import { useEffect } from "react";
|
||||
|
||||
function useApiErrors(showToast: (msg: string) => void) {
|
||||
useEffect(function () {
|
||||
const unsubscribe = Client.onError(function (e) {
|
||||
const { message, status } = e.detail;
|
||||
showToast(status ? String(status) + ": " + message : message);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [showToast]);
|
||||
}
|
||||
|
||||
// Note: Network connectivity issues trigger an alert and also dispatch api:error.
|
||||
// All errors are logged to console for developers.
|
||||
|
||||
|
||||
Logout
|
||||
--------------------------------------------------
|
||||
function logout() {
|
||||
Client.clearTokens();
|
||||
window.location.assign("/login");
|
||||
}
|
||||
|
||||
|
||||
Route protection (PrivateRoute)
|
||||
--------------------------------------------------
|
||||
// If you created src/routes/PrivateRoute.tsx, wrap your protected routes with it.
|
||||
// PrivateRoute checks for "access_token" cookie presence and redirects to /login if missing.
|
||||
|
||||
// Example:
|
||||
// <Routes>
|
||||
// <Route element={<PrivateRoute />} >
|
||||
// <Route element={<MainLayout />}>
|
||||
// <Route path="/" element={<Dashboard />} />
|
||||
// <Route path="/profile" element={<Profile />} />
|
||||
// </Route>
|
||||
// </Route>
|
||||
// <Route path="/login" element={<Login />} />
|
||||
// </Routes>
|
||||
|
||||
|
||||
Refresh and retry flow (what happens on 401)
|
||||
--------------------------------------------------
|
||||
// 1) Client.auth request receives 401 from backend
|
||||
// 2) Client tries to refresh access token using refresh_token cookie
|
||||
// 3) If refresh succeeds, original request is retried (max 2 times)
|
||||
// 4) If still 401 (or no refresh token), tokens are cleared and user is redirected to /login
|
||||
|
||||
|
||||
Environment variables (optional overrides)
|
||||
--------------------------------------------------
|
||||
// VITE_API_BASE_URL default: "http://localhost:8000"
|
||||
// VITE_API_REFRESH_URL default: "/api/token/refresh/"
|
||||
// VITE_LOGIN_PATH default: "/login"
|
||||
*/
|
||||
57
frontend/src/api/apps/Downloader.ts
Normal file
57
frontend/src/api/apps/Downloader.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import Client from "../Client";
|
||||
|
||||
export type Choices = {
|
||||
file_types: string[];
|
||||
qualities: string[];
|
||||
};
|
||||
|
||||
export type DownloadPayload = {
|
||||
url: string;
|
||||
file_type?: string;
|
||||
quality?: string;
|
||||
};
|
||||
|
||||
export type DownloadJobResponse = {
|
||||
id: string;
|
||||
status: "pending" | "running" | "finished" | "failed";
|
||||
detail?: string;
|
||||
download_url?: string;
|
||||
progress?: number; // 0-100
|
||||
};
|
||||
|
||||
// Fallback when choices endpoint is unavailable or models are hardcoded
|
||||
const FALLBACK_CHOICES: Choices = {
|
||||
file_types: ["auto", "video", "audio"],
|
||||
qualities: ["best", "good", "worst"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch dropdown choices from backend (adjust path to match your Django views).
|
||||
* Expected response shape:
|
||||
* { file_types: string[], qualities: string[] }
|
||||
*/
|
||||
export async function getChoices(): Promise<Choices> {
|
||||
try {
|
||||
const res = await Client.auth.get("/api/downloader/choices/");
|
||||
return res.data as Choices;
|
||||
} catch {
|
||||
return FALLBACK_CHOICES;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a new download job (adjust path/body to your viewset).
|
||||
* Example payload: { url, file_type, quality }
|
||||
*/
|
||||
export async function submitDownload(payload: DownloadPayload): Promise<DownloadJobResponse> {
|
||||
const res = await Client.auth.post("/api/downloader/jobs/", payload);
|
||||
return res.data as DownloadJobResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job status by ID. Returns progress, status, and download_url when finished.
|
||||
*/
|
||||
export async function getJobStatus(id: string): Promise<DownloadJobResponse> {
|
||||
const res = await Client.auth.get(`/api/downloader/jobs/${id}/`);
|
||||
return res.data as DownloadJobResponse;
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import axios from "axios";
|
||||
|
||||
const API_URL: string = `${import.meta.env.VITE_BACKEND_URL}/api`;
|
||||
|
||||
// Axios instance, můžeme používat místo globálního axios
|
||||
const axios_instance = axios.create({
|
||||
baseURL: API_URL,
|
||||
withCredentials: true, // potřebné pro cookies
|
||||
});
|
||||
axios_instance.defaults.xsrfCookieName = "csrftoken";
|
||||
axios_instance.defaults.xsrfHeaderName = "X-CSRFToken";
|
||||
|
||||
export default axios_instance;
|
||||
|
||||
// 🔐 Axios response interceptor: automatická obnova při 401
|
||||
axios_instance.interceptors.request.use((config) => {
|
||||
const getCookie = (name: string): string | null => {
|
||||
let cookieValue: string | null = null;
|
||||
if (document.cookie && document.cookie !== "") {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let cookie of cookies) {
|
||||
cookie = cookie.trim();
|
||||
if (cookie.startsWith(name + "=")) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
};
|
||||
|
||||
const csrfToken = getCookie("csrftoken");
|
||||
if (csrfToken && config.method && ["post", "put", "patch", "delete"].includes(config.method)) {
|
||||
if (!config.headers) config.headers = {};
|
||||
config.headers["X-CSRFToken"] = csrfToken;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// Přidej globální response interceptor pro redirect na login při 401 s detail hláškou
|
||||
axios_instance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (
|
||||
error.response &&
|
||||
error.response.status === 401 &&
|
||||
error.response.data &&
|
||||
error.response.data.detail === "Nebyly zadány přihlašovací údaje."
|
||||
) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 🔄 Obnova access tokenu pomocí refresh cookie
|
||||
export const refreshAccessToken = async (): Promise<{ access: string; refresh: string } | null> => {
|
||||
try {
|
||||
const res = await axios_instance.post(`/account/token/refresh/`);
|
||||
return res.data as { access: string; refresh: string };
|
||||
} catch (err) {
|
||||
console.error("Token refresh failed", err);
|
||||
await logout();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ✅ Přihlášení
|
||||
export const login = async (username: string, password: string): Promise<any> => {
|
||||
await logout();
|
||||
try {
|
||||
const response = await axios_instance.post(`/account/token/`, { username, password });
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
if (err.response) {
|
||||
// Server responded with a status code outside 2xx
|
||||
console.log('Login error status:', err.response.status);
|
||||
} else if (err.request) {
|
||||
// Request was made but no response received
|
||||
console.log('Login network error:', err.request);
|
||||
} else {
|
||||
// Something else happened
|
||||
console.log('Login setup error:', err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ❌ Odhlášení s CSRF tokenem
|
||||
export const logout = async (): Promise<any> => {
|
||||
try {
|
||||
const getCookie = (name: string): string | null => {
|
||||
let cookieValue: string | null = null;
|
||||
if (document.cookie && document.cookie !== "") {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let cookie of cookies) {
|
||||
cookie = cookie.trim();
|
||||
if (cookie.startsWith(name + "=")) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
};
|
||||
|
||||
const csrfToken = getCookie("csrftoken");
|
||||
|
||||
const response = await axios_instance.post(
|
||||
"/account/logout/",
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"X-CSRFToken": csrfToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
console.log(response.data);
|
||||
return response.data; // např. { detail: "Logout successful" }
|
||||
} catch (err) {
|
||||
console.error("Logout failed", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 📡 Obecný request pro API
|
||||
*
|
||||
* @param method - HTTP metoda (např. "get", "post", "put", "patch", "delete")
|
||||
* @param endpoint - API endpoint (např. "/api/service-tickets/")
|
||||
* @param data - data pro POST/PUT/DELETE requesty
|
||||
* @param config - další konfigurace pro axios request
|
||||
* @returns Promise<any> - vrací data z odpovědi
|
||||
*/
|
||||
export const apiRequest = async (
|
||||
method: string,
|
||||
endpoint: string,
|
||||
data: Record<string, any> = {},
|
||||
config: Record<string, any> = {}
|
||||
): Promise<any> => {
|
||||
const url = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await axios_instance({
|
||||
method,
|
||||
url,
|
||||
data: ["post", "put", "patch"].includes(method.toLowerCase()) ? data : undefined,
|
||||
params: ["get", "delete"].includes(method.toLowerCase()) ? data : undefined,
|
||||
...config,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.response) {
|
||||
// Server odpověděl s kódem mimo rozsah 2xx
|
||||
console.error("API Error:", {
|
||||
status: err.response.status,
|
||||
data: err.response.data,
|
||||
headers: err.response.headers,
|
||||
});
|
||||
} else if (err.request) {
|
||||
// Request byl odeslán, ale nedošla odpověď
|
||||
console.error("No response received:", err.request);
|
||||
} else {
|
||||
// Něco jiného se pokazilo při sestavování requestu
|
||||
console.error("Request setup error:", err.message);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 👤 Funkce pro získání aktuálně přihlášeného uživatele
|
||||
export async function getCurrentUser(): Promise<any> {
|
||||
const response = await axios_instance.get(`${API_URL}/account/user/me/`);
|
||||
return response.data; // vrací data uživatele
|
||||
}
|
||||
|
||||
// 🔒 ✔️ Jednoduchá funkce, která kontroluje přihlášení - můžeš to upravit dle potřeby
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
return user != null;
|
||||
} catch (err) {
|
||||
return false; // pokud padne 401, není přihlášen
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export { axios_instance, API_URL };
|
||||
@@ -1,26 +0,0 @@
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
|
||||
/**
|
||||
* Makes a general external API call using axios.
|
||||
*
|
||||
* @param url - The full URL of the external API endpoint.
|
||||
* @param method - HTTP method (GET, POST, PUT, PATCH, DELETE, etc.).
|
||||
* @param data - Request body data (for POST, PUT, PATCH). Optional.
|
||||
* @param config - Additional Axios request config (headers, params, etc.). Optional.
|
||||
* @returns Promise resolving to AxiosResponse<any>.
|
||||
*
|
||||
* @example externalApiCall("https://api.example.com/data", "post", { foo: "bar" }, { headers: { Authorization: "Bearer token" } })
|
||||
*/
|
||||
export async function externalApiCall(
|
||||
url: string,
|
||||
method: AxiosRequestConfig["method"],
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<any>> {
|
||||
return axios({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequest } from "./axios";
|
||||
import Client from "./Client";
|
||||
|
||||
/**
|
||||
* Loads enum values from an OpenAPI schema for a given path, method, and field (e.g., category).
|
||||
@@ -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) {
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
@@ -1,9 +1,10 @@
|
||||
import Footer from "../components/Footer/footer";
|
||||
import 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 />
|
||||
|
||||
160
frontend/src/pages/downloader/Downloader.tsx
Normal file
160
frontend/src/pages/downloader/Downloader.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getChoices,
|
||||
submitDownload,
|
||||
getJobStatus,
|
||||
type Choices,
|
||||
type DownloadJobResponse,
|
||||
} from "../../api/apps/Downloader";
|
||||
|
||||
export default function Downloader() {
|
||||
const [choices, setChoices] = useState<Choices>({ file_types: [], qualities: [] });
|
||||
const [loadingChoices, setLoadingChoices] = useState(true);
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [fileType, setFileType] = useState<string>("");
|
||||
const [quality, setQuality] = useState<string>("");
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [job, setJob] = useState<DownloadJobResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load dropdown choices once
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
setLoadingChoices(true);
|
||||
try {
|
||||
const data = await getChoices();
|
||||
if (!mounted) return;
|
||||
setChoices(data);
|
||||
// preselect first option
|
||||
if (!fileType && data.file_types.length > 0) setFileType(data.file_types[0]);
|
||||
if (!quality && data.qualities.length > 0) setQuality(data.qualities[0]);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load choices.");
|
||||
} finally {
|
||||
setLoadingChoices(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return !!url && !!fileType && !!quality && !submitting;
|
||||
}, [url, fileType, quality, submitting]);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const created = await submitDownload({ url, file_type: fileType, quality });
|
||||
setJob(created);
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || e?.message || "Submission failed.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
if (!job?.id) return;
|
||||
try {
|
||||
const updated = await getJobStatus(job.id);
|
||||
setJob(updated);
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || e?.message || "Failed to refresh status.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1rem" }}>
|
||||
<h1>Downloader</h1>
|
||||
|
||||
{error && (
|
||||
<div style={{ background: "#fee", color: "#900", padding: ".5rem", marginBottom: ".75rem" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: ".75rem" }}>
|
||||
<label>
|
||||
URL
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/video"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
style={{ width: "100%", padding: ".5rem" }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".75rem" }}>
|
||||
<label>
|
||||
File type
|
||||
<select
|
||||
value={fileType}
|
||||
onChange={(e) => setFileType(e.target.value)}
|
||||
disabled={loadingChoices}
|
||||
style={{ width: "100%", padding: ".5rem" }}
|
||||
>
|
||||
{choices.file_types.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Quality
|
||||
<select
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(e.target.value)}
|
||||
disabled={loadingChoices}
|
||||
style={{ width: "100%", padding: ".5rem" }}
|
||||
>
|
||||
{choices.qualities.map((q) => (
|
||||
<option key={q} value={q}>
|
||||
{q}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" disabled={!canSubmit} style={{ padding: ".5rem 1rem" }}>
|
||||
{submitting ? "Submitting..." : "Start download"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{job && (
|
||||
<div style={{ marginTop: "1rem", borderTop: "1px solid #ddd", paddingTop: "1rem" }}>
|
||||
<h2>Job</h2>
|
||||
<div>ID: {job.id}</div>
|
||||
<div>Status: {job.status}</div>
|
||||
{typeof job.progress === "number" && <div>Progress: {job.progress}%</div>}
|
||||
{job.detail && <div>Detail: {job.detail}</div>}
|
||||
{job.download_url ? (
|
||||
<div style={{ marginTop: ".5rem" }}>
|
||||
<a href={job.download_url} target="_blank" rel="noreferrer">
|
||||
Download file
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={refreshStatus} style={{ marginTop: ".5rem", padding: ".5rem 1rem" }}>
|
||||
Refresh status
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/src/routes/PrivateRoute.tsx
Normal file
22
frontend/src/routes/PrivateRoute.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
const nameEQ = name + "=";
|
||||
const ca = document.cookie.split(";").map((c) => c.trim());
|
||||
for (const c of ca) {
|
||||
if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ACCESS_COOKIE = "access_token";
|
||||
|
||||
export default function PrivateRoute() {
|
||||
const location = useLocation();
|
||||
const isLoggedIn = !!getCookie(ACCESS_COOKIE);
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
Reference in New Issue
Block a user