okay
This commit is contained in:
649
frontend/package-lock.json
generated
649
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"axios": "^1.13.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.8.1"
|
||||
"react-router-dom": "^7.8.1",
|
||||
"tailwindcss": "^4.1.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
|
||||
BIN
frontend/public/portfolio/davo1.png
Normal file
BIN
frontend/public/portfolio/davo1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
frontend/public/portfolio/epinger.png
Normal file
BIN
frontend/public/portfolio/epinger.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/portfolio/perlica.png
Normal file
BIN
frontend/public/portfolio/perlica.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -1,57 +1,104 @@
|
||||
import Client from "../Client";
|
||||
|
||||
export type Choices = {
|
||||
file_types: string[];
|
||||
qualities: string[];
|
||||
export type FormatOption = {
|
||||
format_id: string;
|
||||
ext: string | null;
|
||||
vcodec: string | null;
|
||||
acodec: string | null;
|
||||
fps: number | null;
|
||||
tbr: number | null;
|
||||
abr: number | null;
|
||||
vbr: number | null;
|
||||
asr: number | null;
|
||||
filesize: number | null;
|
||||
filesize_approx: number | null;
|
||||
estimated_size_bytes: number | null;
|
||||
size_ok: boolean;
|
||||
format_note: string | null;
|
||||
resolution: string | null; // e.g. "1920x1080"
|
||||
audio_only: boolean;
|
||||
};
|
||||
|
||||
export type DownloadPayload = {
|
||||
url: string;
|
||||
file_type?: string;
|
||||
quality?: string;
|
||||
export type FormatsResponse = {
|
||||
title: string | null;
|
||||
duration: number | null;
|
||||
extractor: string | null;
|
||||
video_id: string | null;
|
||||
max_size_bytes: number;
|
||||
options: FormatOption[];
|
||||
};
|
||||
|
||||
// Probe available formats for a URL (no auth required)
|
||||
export async function probeFormats(url: string): Promise<FormatsResponse> {
|
||||
const res = await Client.public.post("/api/downloader/formats/", { url });
|
||||
return res.data as FormatsResponse;
|
||||
}
|
||||
|
||||
// Download selected format as a Blob and resolve filename from headers
|
||||
export async function downloadFormat(url: string, format_id: string): Promise<{ blob: Blob; filename: string }> {
|
||||
const res = await Client.public.post(
|
||||
"/api/downloader/download/",
|
||||
{ url, format_id },
|
||||
{ responseType: "blob" }
|
||||
);
|
||||
|
||||
// Try to parse Content-Disposition filename first, then X-Filename (exposed by backend)
|
||||
const cd = res.headers?.["content-disposition"] as string | undefined;
|
||||
const xfn = res.headers?.["x-filename"] as string | undefined;
|
||||
const filename =
|
||||
parseContentDispositionFilename(cd) ||
|
||||
(xfn && xfn.trim()) ||
|
||||
inferFilenameFromUrl(url, (res.headers?.["content-type"] as string | undefined)) ||
|
||||
"download.bin";
|
||||
|
||||
return { blob: res.data as Blob, filename };
|
||||
}
|
||||
|
||||
// Deprecated types kept for compatibility if referenced elsewhere
|
||||
export type Choices = { file_types: string[]; qualities: string[] };
|
||||
export type DownloadJobResponse = {
|
||||
id: string;
|
||||
status: "pending" | "running" | "finished" | "failed";
|
||||
detail?: string;
|
||||
download_url?: string;
|
||||
progress?: number; // 0-100
|
||||
progress?: number;
|
||||
};
|
||||
|
||||
// Fallback when choices endpoint is unavailable or models are hardcoded
|
||||
const FALLBACK_CHOICES: Choices = {
|
||||
file_types: ["auto", "video", "audio"],
|
||||
qualities: ["best", "good", "worst"],
|
||||
};
|
||||
// Helpers
|
||||
function parseContentDispositionFilename(cd?: string): string | null {
|
||||
if (!cd) return null;
|
||||
// filename*=UTF-8''encoded or filename="plain"
|
||||
const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
|
||||
const plainMatch = cd.match(/filename\s*=\s*"([^"]+)"/i) || cd.match(/filename\s*=\s*([^;]+)/i);
|
||||
return plainMatch?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch dropdown choices from backend (adjust path to match your Django views).
|
||||
* Expected response shape:
|
||||
* { file_types: string[], qualities: string[] }
|
||||
*/
|
||||
export async function getChoices(): Promise<Choices> {
|
||||
function inferFilenameFromUrl(url: string, contentType?: string): string {
|
||||
try {
|
||||
const res = await Client.auth.get("/api/downloader/choices/");
|
||||
return res.data as Choices;
|
||||
const u = new URL(url);
|
||||
const last = u.pathname.split("/").filter(Boolean).pop();
|
||||
if (last) return last;
|
||||
} catch {
|
||||
return FALLBACK_CHOICES;
|
||||
// ignore
|
||||
}
|
||||
if (contentType) {
|
||||
const ext = contentTypeToExt(contentType);
|
||||
return `download${ext ? `.${ext}` : ""}`;
|
||||
}
|
||||
return "download.bin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a new download job (adjust path/body to your viewset).
|
||||
* Example payload: { url, file_type, quality }
|
||||
*/
|
||||
export async function submitDownload(payload: DownloadPayload): Promise<DownloadJobResponse> {
|
||||
const res = await Client.auth.post("/api/downloader/jobs/", payload);
|
||||
return res.data as DownloadJobResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job status by ID. Returns progress, status, and download_url when finished.
|
||||
*/
|
||||
export async function getJobStatus(id: string): Promise<DownloadJobResponse> {
|
||||
const res = await Client.auth.get(`/api/downloader/jobs/${id}/`);
|
||||
return res.data as DownloadJobResponse;
|
||||
function contentTypeToExt(ct: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
"video/mp4": "mp4",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/mp4": "m4a",
|
||||
"audio/aac": "aac",
|
||||
"audio/ogg": "ogg",
|
||||
"video/webm": "webm",
|
||||
"audio/webm": "webm",
|
||||
"application/octet-stream": "bin",
|
||||
};
|
||||
return map[ct] || null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ footer a i{
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
footer a:hover i{
|
||||
color: var(--c-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
footer{
|
||||
font-family: "Roboto Mono", monospace;
|
||||
|
||||
|
||||
@@ -61,46 +61,28 @@ export default function Drone() {
|
||||
|
||||
<article>
|
||||
<header>
|
||||
<h1>Letecké snímky dronem</h1>
|
||||
<h1>Letecké záběry, co zaujmou</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section>
|
||||
<h2>Oprávnění</h2>
|
||||
<p>
|
||||
A1, A2, A3 a průkaz na vysílačku!
|
||||
<br />
|
||||
Mohu garantovat bezpečný provoz dronu i ve složitějších podmínkách.
|
||||
Mám také možnost žádat o povolení k letu v blízkosti letišť!
|
||||
</p>
|
||||
<p>Oprávnění A1/A2/A3 + radiostanice. Bezpečný provoz i v omezených zónách, povolení zajistím.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Cena</h2>
|
||||
<p>
|
||||
Nabízím letecké záběry dronem <br />
|
||||
za cenu <u>3 000 Kč</u>.
|
||||
</p>
|
||||
<p>
|
||||
Pokud se nacházíte v Ostravě, doprava je zdarma. Pro oblasti mimo Ostravu účtuji 10 Kč/km.
|
||||
</p>
|
||||
<p>
|
||||
Cena se může odvíjet ještě podle složitosti získaní povolení.*
|
||||
</p>
|
||||
<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>
|
||||
Rád Vám připravím jednoduchý sestřih videa, který můžete rychle použít,
|
||||
nebo Vám mohu poskytnout samotné záběry k vlastní editaci.
|
||||
</p>
|
||||
<p>Krátký sestřih nebo surové záběry — podle potřeby.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div>
|
||||
V případě zájmu mě neváhejte <br />
|
||||
<a href="#contacts">kontaktovat!</a>
|
||||
<a href="#contacts">Zájem?</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -27,8 +27,11 @@
|
||||
transform-origin: bottom;
|
||||
transition: transform 0.5s ease-in-out;
|
||||
|
||||
transform: skew(-5deg);
|
||||
z-index: 3;
|
||||
|
||||
box-shadow: #000000 5px 5px 15px;
|
||||
|
||||
}
|
||||
.portfolio div span svg{
|
||||
font-size: 5em;
|
||||
@@ -56,7 +59,7 @@
|
||||
}
|
||||
|
||||
.portfolio .door-open{
|
||||
transform: rotateX(180deg);
|
||||
transform: rotateX(90deg) skew(-2deg) !important;
|
||||
}
|
||||
|
||||
.portfolio>header {
|
||||
@@ -131,6 +134,8 @@
|
||||
|
||||
border-radius: 1em;
|
||||
border-top-left-radius: 0;
|
||||
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.portfolio div article {
|
||||
@@ -145,7 +150,6 @@
|
||||
}
|
||||
|
||||
.portfolio div article header a img {
|
||||
padding: 2em 0;
|
||||
width: 80%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
@@ -6,23 +6,34 @@ interface PortfolioItem {
|
||||
href: string
|
||||
src: string
|
||||
alt: string
|
||||
// Optional per-item styling (prefer Tailwind utility classes in className/imgClassName)
|
||||
className?: string
|
||||
imgClassName?: string
|
||||
style?: React.CSSProperties
|
||||
imgStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
const portfolioItems: PortfolioItem[] = [
|
||||
{
|
||||
href: "https://davo1.cz",
|
||||
src: "/home/img/portfolio/DAVO_logo_2024_bile.png",
|
||||
src: "/portfolio/davo1.png",
|
||||
alt: "davo1.cz logo",
|
||||
imgClassName: "bg-black rounded-lg p-4",
|
||||
//className: "bg-white/5 rounded-lg p-4",
|
||||
},
|
||||
{
|
||||
href: "https://perlica.cz",
|
||||
src: "/home/img/portfolio/perlica-3.webp",
|
||||
src: "/portfolio/perlica.png",
|
||||
alt: "Perlica logo",
|
||||
imgClassName: "rounded-lg",
|
||||
// imgClassName: "max-h-12",
|
||||
},
|
||||
{
|
||||
href: "http://epinger2.cz",
|
||||
src: "/home/img/portfolio/logo_epinger.svg",
|
||||
src: "/portfolio/epinger.png",
|
||||
alt: "Epinger2 logo",
|
||||
imgClassName: "bg-white rounded-lg",
|
||||
// imgClassName: "max-h-12",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -51,10 +62,19 @@ export default function Portfolio() {
|
||||
</span>
|
||||
|
||||
{portfolioItems.map((item, index) => (
|
||||
<article key={index} className={styles.article}>
|
||||
<article
|
||||
key={index}
|
||||
className={`${styles.article} ${item.className ?? ""}`}
|
||||
style={item.style}
|
||||
>
|
||||
<header>
|
||||
<a href={item.href} target="_blank" rel="noopener noreferrer">
|
||||
<img src={item.src} alt={item.alt} />
|
||||
<img
|
||||
src={item.src}
|
||||
alt={item.alt}
|
||||
className={item.imgClassName}
|
||||
style={item.imgStyle}
|
||||
/>
|
||||
</a>
|
||||
</header>
|
||||
<main></main>
|
||||
|
||||
@@ -36,6 +36,10 @@ nav.isSticky-nav{
|
||||
nav ul #nav-logo{
|
||||
border-right: 0.2em solid var(--c-lines);
|
||||
}
|
||||
/* Add class alias for logo used in TSX */
|
||||
.logo {
|
||||
border-right: 0.2em solid var(--c-lines);
|
||||
}
|
||||
nav ul #nav-logo span{
|
||||
line-height: 0.75;
|
||||
font-size: 1.5em;
|
||||
@@ -47,10 +51,21 @@ nav a{
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav a:hover{
|
||||
color: #fff;
|
||||
}
|
||||
/* Unify link/summary layout to prevent distortion */
|
||||
nav a,
|
||||
nav summary {
|
||||
color: #fff;
|
||||
transition: color 1s;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
display: inline-block; /* ensure consistent inline sizing */
|
||||
vertical-align: middle; /* align with neighbors */
|
||||
padding: 0; /* keep padding controlled by li */
|
||||
}
|
||||
|
||||
nav a::before {
|
||||
content: "";
|
||||
@@ -67,7 +82,125 @@ nav a::before {
|
||||
nav a:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
nav summary:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* underline effect shared for links and summary */
|
||||
nav a::before,
|
||||
nav summary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: #fff;
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
nav a:hover::before,
|
||||
nav summary:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* Submenu support */
|
||||
.hasSubmenu {
|
||||
position: relative;
|
||||
vertical-align: middle; /* align with other inline items */
|
||||
}
|
||||
|
||||
/* Keep details inline to avoid breaking the first row flow */
|
||||
.hasSubmenu details {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Ensure "Services" and caret stay on the same line */
|
||||
.hasSubmenu details > summary {
|
||||
display: inline-flex; /* horizontal layout */
|
||||
align-items: center; /* vertical alignment */
|
||||
gap: 0.5em; /* space between text and icon */
|
||||
white-space: nowrap; /* prevent wrapping */
|
||||
}
|
||||
|
||||
/* Hide native disclosure icon/marker on summary */
|
||||
.hasSubmenu details > summary {
|
||||
list-style: none;
|
||||
outline: none;
|
||||
}
|
||||
.hasSubmenu details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.hasSubmenu details > summary::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Reusable caret for submenu triggers */
|
||||
.caret {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Rotate caret when submenu is open */
|
||||
.hasSubmenu details[open] .caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Submenu box: place directly under nav with a tiny gap (no overlap) */
|
||||
.submenu {
|
||||
list-style: none;
|
||||
margin: 1em 0;
|
||||
padding: 0.5em 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 0.25em);
|
||||
display: none;
|
||||
background: var(--c-background-light);
|
||||
border: 1px solid var(--c-lines);
|
||||
border-radius: 0.75em;
|
||||
min-width: max-content;
|
||||
text-align: left;
|
||||
z-index: 10;
|
||||
}
|
||||
.submenu li {
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
.submenu a {
|
||||
display: inline-block;
|
||||
padding: 0; /* remove padding so underline equals text width */
|
||||
margin: 0.35em 0; /* spacing without affecting underline width */
|
||||
}
|
||||
|
||||
/* Show submenu when open */
|
||||
.hasSubmenu details[open] .submenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hamburger toggle class (used by TSX) */
|
||||
.toggle {
|
||||
display: none;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
.toggleRotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Bridge TSX classnames to existing rules */
|
||||
.navList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.navList li {
|
||||
display: inline;
|
||||
padding: 0 3em;
|
||||
}
|
||||
.navList li a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style: none;
|
||||
@@ -129,6 +262,11 @@ nav ul li a {
|
||||
|
||||
max-height: 2em;
|
||||
}
|
||||
/* When TSX adds styles.open to the UL, expand it */
|
||||
.open {
|
||||
max-height: 20em;
|
||||
}
|
||||
|
||||
nav ul:last-child{
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
@@ -139,4 +277,28 @@ nav ul li a {
|
||||
border-bottom: 0.2em solid var(--c-lines);
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
/* Show hamburger on mobile */
|
||||
.toggle {
|
||||
margin-top: 0.25em;
|
||||
margin-left: 0.75em;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
display: block;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
/* Submenu stacks inline under the parent item on mobile */
|
||||
.submenu {
|
||||
position: static;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0 0 0.5em 0.5em;
|
||||
min-width: unset;
|
||||
}
|
||||
.submenu a {
|
||||
display: inline-block;
|
||||
padding: 0; /* keep no padding on mobile too */
|
||||
margin: 0.25em 0.5em; /* spacing via margin */
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react"
|
||||
import styles from "./HomeNav.module.css"
|
||||
import { FaBars } from "react-icons/fa";
|
||||
import { FaBars, FaChevronDown } from "react-icons/fa";
|
||||
|
||||
export default function HomeNav() {
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
@@ -9,7 +9,12 @@ export default function HomeNav() {
|
||||
|
||||
return (
|
||||
<nav className={styles.nav}>
|
||||
<FaBars className={styles.toggle} onClick={toggleNav} />
|
||||
<FaBars
|
||||
className={`${styles.toggle} ${navOpen ? styles.toggleRotated : ""}`}
|
||||
onClick={toggleNav}
|
||||
aria-label="Toggle navigation"
|
||||
aria-expanded={navOpen}
|
||||
/>
|
||||
|
||||
<ul className={`${styles.navList} ${navOpen ? styles.open : ""}`}>
|
||||
<li id="nav-logo" className={styles.logo}>
|
||||
@@ -21,8 +26,18 @@ export default function HomeNav() {
|
||||
<li>
|
||||
<a href="#portfolio">Portfolio</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#services">Services</a>
|
||||
<li className={styles.hasSubmenu}>
|
||||
<details>
|
||||
<summary>
|
||||
Services
|
||||
<FaChevronDown className={`${styles.caret} ml-2 inline-block`} aria-hidden="true" />
|
||||
</summary>
|
||||
<ul className={styles.submenu}>
|
||||
<li><a href="#web">Web development</a></li>
|
||||
<li><a href="#integration">Integrations</a></li>
|
||||
<li><a href="#support">Support</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#contactme-form">Contact me</a>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
@@ -19,15 +21,6 @@
|
||||
--c-other: #70A288; /*other*/
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
|
||||
28
frontend/src/layouts/Default.tsx
Normal file
28
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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -10,9 +10,13 @@ export default function HomeLayout(){
|
||||
return(
|
||||
<>
|
||||
{/* Example usage of imported components, adjust as needed */}
|
||||
|
||||
<HomeNav />
|
||||
|
||||
<Home /> {/*page*/}
|
||||
<Drone />
|
||||
<div style={{margin: "10em 0"}}>
|
||||
<Drone />
|
||||
</div>
|
||||
<Outlet />
|
||||
<Portfolio />
|
||||
<div style={{ margin: "6em auto", marginTop: "15em", maxWidth: "80vw" }}>
|
||||
|
||||
@@ -1,160 +1,140 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getChoices,
|
||||
submitDownload,
|
||||
getJobStatus,
|
||||
type Choices,
|
||||
type DownloadJobResponse,
|
||||
} from "../../api/apps/Downloader";
|
||||
import { useState } from "react";
|
||||
import { probeFormats, downloadFormat, type FormatsResponse, type FormatOption } from "../../api/apps/Downloader";
|
||||
|
||||
export default function Downloader() {
|
||||
const [choices, setChoices] = useState<Choices>({ file_types: [], qualities: [] });
|
||||
const [loadingChoices, setLoadingChoices] = useState(true);
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [fileType, setFileType] = useState<string>("");
|
||||
const [quality, setQuality] = useState<string>("");
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [job, setJob] = useState<DownloadJobResponse | null>(null);
|
||||
const [probing, setProbing] = useState(false);
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formats, setFormats] = useState<FormatsResponse | null>(null);
|
||||
|
||||
// Load dropdown choices once
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
setLoadingChoices(true);
|
||||
try {
|
||||
const data = await getChoices();
|
||||
if (!mounted) return;
|
||||
setChoices(data);
|
||||
// preselect first option
|
||||
if (!fileType && data.file_types.length > 0) setFileType(data.file_types[0]);
|
||||
if (!quality && data.qualities.length > 0) setQuality(data.qualities[0]);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load choices.");
|
||||
} finally {
|
||||
setLoadingChoices(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return !!url && !!fileType && !!quality && !submitting;
|
||||
}, [url, fileType, quality, submitting]);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
async function onProbe(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
setFormats(null);
|
||||
setProbing(true);
|
||||
try {
|
||||
const created = await submitDownload({ url, file_type: fileType, quality });
|
||||
setJob(created);
|
||||
const res = await probeFormats(url);
|
||||
setFormats(res);
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || e?.message || "Submission failed.");
|
||||
setError(e?.response?.data?.detail || e?.message || "Failed to load formats.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setProbing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
if (!job?.id) return;
|
||||
async function onDownload(fmt: FormatOption) {
|
||||
setError(null);
|
||||
setDownloadingId(fmt.format_id);
|
||||
try {
|
||||
const updated = await getJobStatus(job.id);
|
||||
setJob(updated);
|
||||
const { blob, filename } = await downloadFormat(url, fmt.format_id);
|
||||
const link = document.createElement("a");
|
||||
const href = URL.createObjectURL(blob);
|
||||
link.href = href;
|
||||
link.download = filename || "download.bin";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(href);
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || e?.message || "Failed to refresh status.");
|
||||
setError(e?.response?.data?.detail || e?.message || "Download failed.");
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1rem" }}>
|
||||
<h1>Downloader</h1>
|
||||
<div className="max-w-3xl mx-auto p-4">
|
||||
<h1 className="text-2xl font-semibold mb-4">Downloader</h1>
|
||||
|
||||
{error && (
|
||||
<div style={{ background: "#fee", color: "#900", padding: ".5rem", marginBottom: ".75rem" }}>
|
||||
<div className="mb-3 rounded border border-red-300 bg-red-50 text-red-700 p-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: ".75rem" }}>
|
||||
<label>
|
||||
URL
|
||||
<form onSubmit={onProbe} className="grid gap-3 mb-4">
|
||||
<label className="grid gap-1">
|
||||
<span className="text-sm font-medium">URL</span>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/video"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
style={{ width: "100%", padding: ".5rem" }}
|
||||
className="w-full border rounded p-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".75rem" }}>
|
||||
<label>
|
||||
File type
|
||||
<select
|
||||
value={fileType}
|
||||
onChange={(e) => setFileType(e.target.value)}
|
||||
disabled={loadingChoices}
|
||||
style={{ width: "100%", padding: ".5rem" }}
|
||||
>
|
||||
{choices.file_types.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Quality
|
||||
<select
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(e.target.value)}
|
||||
disabled={loadingChoices}
|
||||
style={{ width: "100%", padding: ".5rem" }}
|
||||
>
|
||||
{choices.qualities.map((q) => (
|
||||
<option key={q} value={q}>
|
||||
{q}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" disabled={!canSubmit} style={{ padding: ".5rem 1rem" }}>
|
||||
{submitting ? "Submitting..." : "Start download"}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!url || probing}
|
||||
className="px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||||
>
|
||||
{probing ? "Probing..." : "Find formats"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{job && (
|
||||
<div style={{ marginTop: "1rem", borderTop: "1px solid #ddd", paddingTop: "1rem" }}>
|
||||
<h2>Job</h2>
|
||||
<div>ID: {job.id}</div>
|
||||
<div>Status: {job.status}</div>
|
||||
{typeof job.progress === "number" && <div>Progress: {job.progress}%</div>}
|
||||
{job.detail && <div>Detail: {job.detail}</div>}
|
||||
{job.download_url ? (
|
||||
<div style={{ marginTop: ".5rem" }}>
|
||||
<a href={job.download_url} target="_blank" rel="noreferrer">
|
||||
Download file
|
||||
</a>
|
||||
{formats && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-700">
|
||||
<div><span className="font-medium">Title:</span> {formats.title || "-"}</div>
|
||||
<div><span className="font-medium">Duration:</span> {formats.duration ? `${Math.round(formats.duration)} s` : "-"}</div>
|
||||
<div><span className="font-medium">Max size:</span> {formatBytes(formats.max_size_bytes)}</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded overflow-hidden">
|
||||
<div className="grid grid-cols-6 gap-2 p-2 bg-gray-50 text-sm font-medium">
|
||||
<div>Format</div>
|
||||
<div>Resolution</div>
|
||||
<div>Type</div>
|
||||
<div>Note</div>
|
||||
<div>Est. size</div>
|
||||
<div></div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={refreshStatus} style={{ marginTop: ".5rem", padding: ".5rem 1rem" }}>
|
||||
Refresh status
|
||||
</button>
|
||||
<div className="divide-y">
|
||||
{formats.options.map((o) => (
|
||||
<div key={o.format_id} className="grid grid-cols-6 gap-2 p-2 items-center text-sm">
|
||||
<div className="truncate">{o.format_id}{o.ext ? `.${o.ext}` : ""}</div>
|
||||
<div>{o.resolution || (o.audio_only ? "audio" : "-")}</div>
|
||||
<div>{o.audio_only ? "Audio" : "Video"}</div>
|
||||
<div className="truncate">{o.format_note || "-"}</div>
|
||||
<div className={o.size_ok ? "text-gray-800" : "text-red-600"}>
|
||||
{o.estimated_size_bytes ? formatBytes(o.estimated_size_bytes) : (o.filesize || o.filesize_approx) ? "~" + formatBytes((o.filesize || o.filesize_approx)!) : "?"}
|
||||
{!o.size_ok && " (too big)"}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<button
|
||||
onClick={() => onDownload(o)}
|
||||
disabled={!o.size_ok || downloadingId === o.format_id}
|
||||
className="px-2 py-1 rounded bg-emerald-600 text-white disabled:opacity-50"
|
||||
>
|
||||
{downloadingId === o.format_id ? "Downloading..." : "Download"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!formats.options.length && (
|
||||
<div className="text-sm text-gray-600">No formats available.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number | null): string {
|
||||
if (!bytes || bytes <= 0) return "-";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let i = 0;
|
||||
let n = bytes;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user