- {/* Mobile inline dropdown */}
-
-
+
+
-
-
- {/* Desktop offset dropdown anchored to right under nav */}
- {servicesOpen && (
-
)}
-
-
+
+
);
}
-
-const linkCls = ({ isActive }: { isActive: boolean }) => `nav-item px-3 py-2 rounded transition-colors ${isActive ? 'active text-brand-accent font-semibold' : 'hover:text-brand-accent'}`;
-const dropdownCls = "block px-2 py-1 rounded hover:bg-[color-mix(in_hsl,var(--c-other),transparent_85%)]";
\ No newline at end of file
diff --git a/frontend/src/components/navbar/navbar.module.css b/frontend/src/components/navbar/navbar.module.css
new file mode 100644
index 0000000..efa5628
--- /dev/null
+++ b/frontend/src/components/navbar/navbar.module.css
@@ -0,0 +1,447 @@
+.navbar {
+ width: 80%;
+ margin: auto;
+ padding: 0 2em;
+ background-color: var(--c-boxes);
+ color: white;
+ font-family: "Roboto Mono", monospace;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ position: sticky;
+ top: 0;
+ z-index: 50;
+ gap: 1rem;
+ border-bottom-left-radius: 2em;
+ border-bottom-right-radius: 2em;
+
+ --nav-margin-y: 1em;
+ opacity: 0.95;
+}
+
+/* Brand */
+.logo {
+ padding-right: 1em;
+ border-right: 0.2em solid var(--c-lines);
+}
+
+.logo a {
+ font-size: 1.8em;
+ font-weight: 700;
+ color: white;
+ text-decoration: none;
+ transition: text-shadow 0.25s ease-in-out;
+}
+
+.logo a:hover {
+ text-shadow: 0.25em 0.25em 0.2em var(--c-text);
+}
+
+/* Burger */
+.burger {
+ display: none;
+ background: none;
+ border: none;
+ color: white;
+ font-size: 1.6em;
+ cursor: pointer;
+}
+
+/* Links container */
+.links {
+ display: flex;
+ gap: 1.6rem;
+ align-items: center;
+ justify-content: space-around;
+ width: -webkit-fill-available;
+}
+
+/* Simple link */
+.linkSimple {
+ color: var(--c-text);
+ text-decoration: none;
+ font-size: 1.05em;
+ transition: transform 0.15s;
+
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+
+/* TEXT SIZE UNIFICATION */
+.linkSimple,
+.user,
+.linkButton {
+ font-size: 1.25em;
+ color: white;
+}
+
+.dropdown a {
+ font-size: 1.1em;
+ color: var(--c-text);
+}
+
+
+
+.linkSimple:hover {
+ transform: scale(1.08);
+}
+
+/* Link item with dropdown */
+.linkItem {
+ position: relative;
+}
+
+/* Unified dropdown container */
+.dropdownItem {
+ position: relative;
+}
+
+.linkButton {
+ background: none;
+ border: none;
+ cursor: pointer;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.4rem;
+ margin: var(--nav-margin-y) auto;
+}
+
+.linkButton:hover {
+ transform: scale(1.05);
+}
+
+/* chevron icons */
+.chev {
+ margin-left: 0.25rem;
+ font-size: 0.9rem;
+}
+
+.chevSmall {
+ margin-left: 0.25rem;
+ font-size: 0.75rem;
+}
+
+/* dropdown */
+.dropdown {
+ position: absolute;
+ top: auto;
+ left: 0;
+ width: -moz-max-content;
+ width: max-content;
+ background-color: var(--c-background-light);
+ /* border: 1px solid var(--c-text); */
+ padding: 0.6rem;
+ /* border-radius: 0.45rem; */
+ border-bottom-left-radius: 1em;
+ border-bottom-right-radius: 1em;
+ display: none;
+ flex-direction: column;
+ gap: 0.35rem;
+ box-shadow: 0px 20px 24px 6px rgba(0, 0, 0, 0.35);
+ z-index: 49;
+}
+
+/* show dropdown on hover or keyboard focus within */
+.linkItem:hover .dropdown,
+.linkItem:focus-within .dropdown,
+.dropdownItem:hover .dropdown,
+.dropdownItem:focus-within .dropdown {
+ display: flex;
+}
+
+/* nested wrapper for submenu items */
+.nestedWrapper {
+ display: flex;
+ flex-direction: column;
+}
+
+/* nested toggle (button that opens nested submenu) */
+.nestedToggle {
+ background: none;
+ border: none;
+ color: white !important;
+ text-align: left;
+ padding: 0;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ width: 100%;
+}
+
+.nestedToggle:hover {
+ transform: scale(1.03);
+}
+
+/* Unified dropdown toggle */
+.dropdownToggle {
+ background: none;
+ border: none;
+ color: white !important;
+ text-align: left;
+ padding: 0;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ width: 100%;
+}
+
+.dropdownToggle:hover {
+ transform: scale(1.03);
+}
+
+/* nested submenu */
+.nested {
+ margin-top: 0.25rem;
+ margin-left: 1.1rem;
+ display: none;
+ /* hidden until hover/focus within */
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+/* show nested submenu on hover/focus within */
+.nestedWrapper:hover .nested,
+.nestedWrapper:focus-within .nested {
+ display: flex;
+}
+
+/* Nested dropdown (dropdown inside dropdown) */
+.dropdown .dropdown {
+ position: static;
+ border: none;
+ box-shadow: none;
+ padding-left: 0.2rem;
+ min-width: auto;
+ margin-left: 1.1rem;
+}
+
+/* links inside dropdown / nested */
+.dropdown a,
+.dropdown button {
+ color: white;
+ text-decoration: none;
+ background: none;
+ border: none;
+ padding: 0.35rem 0.25rem;
+ text-align: left;
+ cursor: pointer;
+ transition: transform 0.12s;
+
+ display: inline-flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.dropdown a:hover,
+.dropdown button:hover {
+ transform: scale(1.04);
+}
+
+/* small icons next to dropdown links */
+.iconSmall {
+ margin-right: 0.45rem;
+ font-size: 0.95rem;
+ vertical-align: middle;
+}
+
+/* User area */
+.user {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ height: -webkit-fill-available;
+}
+
+.loginBtn {
+ width: max-content;
+ background: none;
+ border: none;
+ border-radius: 0;
+ padding: 1em;
+ color: white;
+ font-size: 0.98rem;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+
+}
+.loginBtn svg {
+ font-size: 1.5rem;
+}
+
+.loginBtn:hover {
+ background: var(--c-text);
+ transform: scale(1.03);
+}
+
+/* user dropdown */
+.userWrapper {
+ height: -webkit-fill-available;
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.userWrapper .dropdown{
+ position: absolute;
+ top: 0;
+ left: 0;
+ margin-top: 3.5em;
+ width: max-content;
+ border-top-right-radius: 1em;
+}
+.userWrapper .dropdown a, button{
+ font-size: 0.9em;
+}
+
+.userButton {
+ display: flex;
+ align-items: center;
+ width: max-content;
+ gap: 0.6rem;
+ background: none;
+ border: none;
+ color: white;
+ cursor: pointer;
+ font-size: 1rem;
+ flex-wrap: wrap;
+ justify-content: space-between;
+}
+
+.userIcon {
+ font-size: 1.6rem;
+}
+
+.avatar {
+ width: 1.8rem;
+ height: 1.8rem;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.username {
+ font-weight: 600;
+ text-overflow: ellipsis;
+ max-width: max-content;
+ text-overflow: ellipsis;
+}
+
+/* logout button */
+.logoutBtn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: none;
+ border: none;
+ color: white;
+ cursor: pointer;
+}
+
+/* Responsive: mobile */
+@media (max-width: 1010px) {
+ .navbar {
+ width: 100%;
+ }
+
+ .navbar .logo{
+ border: none;
+ }
+
+ .burger {
+ display: inline-block;
+ }
+
+ .burger svg {
+ width: auto;
+ }
+
+ .links {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 100%;
+
+ flex-direction: column;
+ gap: 0.6rem;
+ padding: 1rem 1.2rem;
+ display: none;
+ z-index: 40;
+ border-top: 1px solid rgba(255, 255, 255, 0.03);
+
+ border-bottom-left-radius: 2em;
+ border-bottom-right-radius: 2em;
+
+ transition: all 0.5s ease-in-out;
+ max-height: 0;
+
+ display: flex;
+ overflow: hidden;
+ padding: 0;
+ opacity: 0;
+
+ }
+ .links.show {
+ max-height: 100vh;
+ padding: 1rem 1.2rem;
+ background-color: var(--c-boxes);
+ opacity: 1;
+ }
+
+
+ .linkButton{
+ background-color: var(--c-background-light);
+ width: 100%;
+ align-items: center;
+ margin:auto;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1em;
+
+ transition: all 0.2s ease-in-out;
+ }
+
+ .linkButton:hover{
+ transform: none !important;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ .linkSimple{
+ margin: var(--nav-margin-y) auto;
+ }
+
+ .dropdown {
+ position: relative;
+ top: 0;
+ left: 0;
+ border: none;
+ box-shadow: none;
+ padding-left: 0.2rem;
+
+ width: 100%;
+ align-items: center;
+ }
+ .dropdownItem{
+ width: 100%;
+ }
+
+ .nested {
+ margin-left: 0.6rem;
+ }
+
+ .dropdown .dropdown {
+ margin-left: 0.6rem;
+ }
+
+ .userButton .username{
+ display: none;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx
new file mode 100644
index 0000000..fb451f0
--- /dev/null
+++ b/frontend/src/context/AuthContext.tsx
@@ -0,0 +1,97 @@
+import { createContext, useContext, useState, useEffect, ReactNode } from "react";
+
+//TODO: připraveno pro použití jenom linknout funkce z vygenerovaného api klientan a logout() a currentUser()
+
+//import { authLogin, authMe } from "../api/generated"; // your orval client
+//import { LoginSchema } from "../api/generated/types";
+/*
+export interface User {
+ id: number;
+ username: string;
+ email: string;
+ avatar?: string;
+}
+
+interface AuthContextType {
+ user: User | null;
+ isAuthenticated: boolean;
+ login: (payload: LoginSchema) => Promise
;
+ logout: () => void;
+ refreshUser: () => Promise;
+}
+
+const AuthContext = createContext(undefined);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState(null);
+
+ const isAuthenticated = !!user;
+
+ // load user when app mounts
+ useEffect(() => {
+ const token = localStorage.getItem("access");
+ if (token) refreshUser();
+ }, []);
+
+ async function refreshUser() {
+ try {
+ const { data } = await authMe(); // ORVAL HANDLES TYPING
+ setUser(data);
+ } catch {
+ setUser(null);
+ }
+ }
+
+ async function login(payload: LoginSchema) {
+ const { data } = await authLogin(payload);
+
+ // example response: { access: "...", refresh: "..." }
+ localStorage.setItem("access", data.access);
+ localStorage.setItem("refresh", data.refresh);
+
+ await refreshUser();
+ }
+
+ function logout() {
+ localStorage.removeItem("access");
+ localStorage.removeItem("refresh");
+ setUser(null);
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
+ return ctx;
+}
+*/
+
+
+
+/*
+EXAMPLES OF USE:
+
+Login
+const { data } = await authLogin(payload); // získám tokeny
+localStorage.setItem("access", data.access);
+localStorage.setItem("refresh", data.refresh);
+
+await authMe(); // poté zjistím, kdo to je
+
+Refresh
+const { data } = await authRefresh({ refresh });
+localStorage.setItem("access", data.access);
+await authMe();
+
+Me (load user)
+const { data: user } = await authMe();
+setUser(user);
+
+
+*/
\ No newline at end of file
diff --git a/frontend/src/context/UserContext.tsx b/frontend/src/context/UserContext.tsx
deleted file mode 100644
index 1a7aa75..0000000
--- a/frontend/src/context/UserContext.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import React, { createContext, useState, useEffect } from 'react';
-
-import userAPI from '../api/legacy/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>;
-}
-
-// vytvoříme a exportneme kontext !!!
-export const UserContext = createContext(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(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 (
-
- {children}
-
- );
-};
-
-
-/*
-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 (
-//
-//
-//
-// );
-// }
-
-// 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 ...;
-}
-
-*/
\ No newline at end of file
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 105daff..0cbf1b0 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -44,7 +44,6 @@ h1, h2, h3 {
button {
border-radius: 0.75rem;
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
- padding: 0.6em 1.1em;
font: inherit;
background-color: color-mix(in hsl, var(--c-background-light), black 15%);
color: var(--c-text);
diff --git a/frontend/src/layouts/HomeLayout.tsx b/frontend/src/layouts/HomeLayout.tsx
index c9adc50..3210ac8 100644
--- a/frontend/src/layouts/HomeLayout.tsx
+++ b/frontend/src/layouts/HomeLayout.tsx
@@ -1,11 +1,18 @@
import Footer from "../components/Footer/footer";
import { Outlet } from "react-router";
-import SiteNav from "../components/navbar/SiteNav";
+import SiteNav, { type User } from "../components/navbar/SiteNav";
+
+
+const userexists: User = {
+ username: "Bruno",
+ email: "",
+ avatarUrl: "",
+};
export default function HomeLayout(){
return(
<>
-
+ {}} onLogout={() => {}} />
>
diff --git a/frontend/src/pages/downloader/Downloader.tsx b/frontend/src/pages/downloader/Downloader.tsx
index cb32433..fc42164 100644
--- a/frontend/src/pages/downloader/Downloader.tsx
+++ b/frontend/src/pages/downloader/Downloader.tsx
@@ -1,206 +1,10 @@
import { useEffect, useMemo, useState } from "react";
-import {
- fetchInfo,
- downloadImmediate,
- FORMAT_EXTS,
- type InfoResponse,
- parseContentDispositionFilename,
-} from "../../api/legacy/Downloader";
export default function Downloader() {
- const [url, setUrl] = useState("");
- const [probing, setProbing] = useState(false);
- const [downloading, setDownloading] = useState(false);
- const [error, setError] = useState(null);
- const [info, setInfo] = useState(null);
-
- const [ext, setExt] = useState("mp4");
- const [videoRes, setVideoRes] = useState(undefined);
- const [audioRes, setAudioRes] = useState(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 (
-
-
Downloader
-
- {error &&
{error}
}
-
-
-
- {info && (
-
-
- {info.thumbnail && (
-

- )}
-
-
- Title: {info.title || "-"}
-
-
- Duration:{" "}
- {info.duration ? `${Math.round(info.duration)} s` : "-"}
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
+ <>
+ not implemented yet
+ >
);
}
\ No newline at end of file