Refactor navbar and remove legacy API/context
Replaces HomeNav with a new SiteNav and associated CSS module, updating navigation structure and user menu. Removes legacy API client, downloader, user model, and UserContext in favor of a new AuthContext stub for future authentication logic. Also cleans up HeroCarousel and minor CSS fixes.
This commit is contained in:
@@ -3,7 +3,7 @@ import Home from "./pages/home/home";
|
|||||||
import HomeLayout from "./layouts/HomeLayout";
|
import HomeLayout from "./layouts/HomeLayout";
|
||||||
import Downloader from "./pages/downloader/Downloader";
|
import Downloader from "./pages/downloader/Downloader";
|
||||||
import PrivateRoute from "./routes/PrivateRoute";
|
import PrivateRoute from "./routes/PrivateRoute";
|
||||||
import { UserContextProvider } from "./context/UserContext";
|
//import { UserContextProvider } from "./context/UserContext";
|
||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
import PortfolioPage from "./pages/portfolio/PortfolioPage";
|
import PortfolioPage from "./pages/portfolio/PortfolioPage";
|
||||||
@@ -14,7 +14,7 @@ import ScrollToTop from "./components/common/ScrollToTop";
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<UserContextProvider>
|
{/* <UserContextProvider> */}
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
@@ -35,7 +35,7 @@ export default function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</UserContextProvider>
|
{/* </UserContextProvider> */}
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
|
|
||||||
// --- ENV CONFIG ---
|
|
||||||
const API_BASE_URL =
|
|
||||||
import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
|
|
||||||
|
|
||||||
const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH || "/login";
|
|
||||||
|
|
||||||
|
|
||||||
// --- ERROR EVENT BUS ---
|
|
||||||
const ERROR_EVENT = "api:error";
|
|
||||||
type ApiErrorDetail = {
|
|
||||||
message: string;
|
|
||||||
status?: number;
|
|
||||||
url?: string;
|
|
||||||
data?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use interface instead of arrow function types for readability
|
|
||||||
interface ApiErrorHandler {
|
|
||||||
(e: CustomEvent<ApiErrorDetail>): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function notifyError(detail: ApiErrorDetail) {
|
|
||||||
window.dispatchEvent(new CustomEvent<ApiErrorDetail>(ERROR_EVENT, { detail }));
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("[API ERROR]", detail);
|
|
||||||
}
|
|
||||||
function onError(handler: ApiErrorHandler) {
|
|
||||||
const wrapped = handler as EventListener;
|
|
||||||
window.addEventListener(ERROR_EVENT, wrapped as EventListener);
|
|
||||||
|
|
||||||
return () => window.removeEventListener(ERROR_EVENT, wrapped);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- AXIOS INSTANCES ---
|
|
||||||
// Always send cookies. Django will set auth cookies; browser will include them automatically.
|
|
||||||
function createAxios(baseURL: string): any {
|
|
||||||
const instance = axios.create({
|
|
||||||
baseURL,
|
|
||||||
withCredentials: true, // cookies
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
timeout: 20000,
|
|
||||||
});
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a single behavior for both: cookies are always sent
|
|
||||||
const apiPublic = createAxios(API_BASE_URL);
|
|
||||||
const apiAuth = createAxios(API_BASE_URL);
|
|
||||||
|
|
||||||
|
|
||||||
// --- REQUEST INTERCEPTOR (PUBLIC) ---
|
|
||||||
// Ensure no Authorization header is ever sent by the public client
|
|
||||||
apiPublic.interceptors.request.use(function (config: any) {
|
|
||||||
if (config?.headers && (config.headers as any).Authorization) {
|
|
||||||
delete (config.headers as any).Authorization;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- REQUEST INTERCEPTOR (AUTH) ---
|
|
||||||
// Do not attach Authorization header; rely on cookies set by Django.
|
|
||||||
apiAuth.interceptors.request.use(function (config: any) {
|
|
||||||
(config as any)._retryCount = (config as any)._retryCount || 0;
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- RESPONSE INTERCEPTOR (AUTH) ---
|
|
||||||
// Simplified: on 401, redirect to login. Server manages refresh via cookies.
|
|
||||||
apiAuth.interceptors.response.use(
|
|
||||||
function (response: any) {
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
async function (error: any) {
|
|
||||||
if (!error.response) {
|
|
||||||
alert("Backend connection is unavailable. Please check your network.");
|
|
||||||
|
|
||||||
notifyError({
|
|
||||||
message: "Network error or backend unavailable",
|
|
||||||
url: error.config?.url,
|
|
||||||
});
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = error.response.status;
|
|
||||||
if (status === 401) {
|
|
||||||
ClearTokens();
|
|
||||||
window.location.assign(LOGIN_PATH);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyError({
|
|
||||||
message:
|
|
||||||
(error.response.data as any)?.detail ||
|
|
||||||
(error.response.data as any)?.message ||
|
|
||||||
`Request failed with status ${status}`,
|
|
||||||
status,
|
|
||||||
url: error.config?.url,
|
|
||||||
data: error.response.data,
|
|
||||||
});
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- PUBLIC CLIENT: still emits errors and alerts on network failure ---
|
|
||||||
apiPublic.interceptors.response.use(
|
|
||||||
function (response: any) {
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
async function (error: any) {
|
|
||||||
if (!error.response) {
|
|
||||||
alert("Backend connection is unavailable. Please check your network.");
|
|
||||||
notifyError({
|
|
||||||
message: "Network error or backend unavailable",
|
|
||||||
url: error.config?.url,
|
|
||||||
});
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
notifyError({
|
|
||||||
message:
|
|
||||||
(error.response.data as any)?.detail ||
|
|
||||||
(error.response.data as any)?.message ||
|
|
||||||
`Request failed with status ${error.response.status}`,
|
|
||||||
status: error.response.status,
|
|
||||||
url: error.config?.url,
|
|
||||||
data: error.response.data,
|
|
||||||
});
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function Logout() {
|
|
||||||
try {
|
|
||||||
const LogOutResponse = apiAuth.post("/api/logout/");
|
|
||||||
|
|
||||||
if (LogOutResponse.body.detail != "Logout successful") {
|
|
||||||
throw new Error("Logout failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
ClearTokens();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error during logout:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClearTokens(){
|
|
||||||
document.cookie = "access_token=; Max-Age=0; path=/";
|
|
||||||
document.cookie = "refresh_token=; Max-Age=0; path=/";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- EXPORT DEFAULT API WRAPPER ---
|
|
||||||
const Client = {
|
|
||||||
// Axios instances
|
|
||||||
auth: apiAuth,
|
|
||||||
public: apiPublic,
|
|
||||||
|
|
||||||
Logout,
|
|
||||||
|
|
||||||
// Error subscription
|
|
||||||
onError,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Client;
|
|
||||||
|
|
||||||
/**
|
|
||||||
USAGE EXAMPLES (TypeScript/React)
|
|
||||||
|
|
||||||
Import the client
|
|
||||||
--------------------------------------------------
|
|
||||||
import Client from "@/api/Client";
|
|
||||||
|
|
||||||
|
|
||||||
Login: obtain tokens and persist to cookies
|
|
||||||
--------------------------------------------------
|
|
||||||
async function login(username: string, password: string) {
|
|
||||||
// SimpleJWT default login endpoint (adjust if your backend differs)
|
|
||||||
// Example backend endpoint: POST /api/token/ -> { access, refresh }
|
|
||||||
const res = await Client.public.post("/api/token/", { username, password });
|
|
||||||
const { access, refresh } = res.data;
|
|
||||||
Client.setTokens(access, refresh);
|
|
||||||
// After this, Client.auth will automatically attach Authorization header
|
|
||||||
// and refresh when receiving a 401 (up to 2 retries).
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Public request (no cookies, no Authorization)
|
|
||||||
--------------------------------------------------
|
|
||||||
// The public client does NOT send cookies or Authorization.
|
|
||||||
async function listPublicItems() {
|
|
||||||
const res = await Client.public.get("/api/public/items/");
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Authenticated requests (auto Bearer header + refresh on 401)
|
|
||||||
--------------------------------------------------
|
|
||||||
async function fetchProfile() {
|
|
||||||
const res = await Client.auth.get("/api/users/me/");
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateProfile(payload: { first_name?: string; last_name?: string }) {
|
|
||||||
const res = await Client.auth.patch("/api/users/me/", payload);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Global error handling (UI notifications)
|
|
||||||
--------------------------------------------------
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
function useApiErrors(showToast: (msg: string) => void) {
|
|
||||||
useEffect(function () {
|
|
||||||
const unsubscribe = Client.onError(function (e) {
|
|
||||||
const { message, status } = e.detail;
|
|
||||||
showToast(status ? String(status) + ": " + message : message);
|
|
||||||
});
|
|
||||||
return unsubscribe;
|
|
||||||
}, [showToast]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Network connectivity issues trigger an alert and also dispatch api:error.
|
|
||||||
// All errors are logged to console for developers.
|
|
||||||
|
|
||||||
|
|
||||||
Logout
|
|
||||||
--------------------------------------------------
|
|
||||||
function logout() {
|
|
||||||
Client.clearTokens();
|
|
||||||
window.location.assign("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Route protection (PrivateRoute)
|
|
||||||
--------------------------------------------------
|
|
||||||
// If you created src/routes/PrivateRoute.tsx, wrap your protected routes with it.
|
|
||||||
// PrivateRoute checks for "access_token" cookie presence and redirects to /login if missing.
|
|
||||||
|
|
||||||
// Example:
|
|
||||||
// <Routes>
|
|
||||||
// <Route element={<PrivateRoute />} >
|
|
||||||
// <Route element={<MainLayout />}>
|
|
||||||
// <Route path="/" element={<Dashboard />} />
|
|
||||||
// <Route path="/profile" element={<Profile />} />
|
|
||||||
// </Route>
|
|
||||||
// </Route>
|
|
||||||
// <Route path="/login" element={<Login />} />
|
|
||||||
// </Routes>
|
|
||||||
|
|
||||||
|
|
||||||
Refresh and retry flow (what happens on 401)
|
|
||||||
--------------------------------------------------
|
|
||||||
// 1) Client.auth request receives 401 from backend
|
|
||||||
// 2) Client tries to refresh access token using refresh_token cookie
|
|
||||||
// 3) If refresh succeeds, original request is retried (max 2 times)
|
|
||||||
// 4) If still 401 (or no refresh token), tokens are cleared and user is redirected to /login
|
|
||||||
|
|
||||||
|
|
||||||
Environment variables (optional overrides)
|
|
||||||
--------------------------------------------------
|
|
||||||
// VITE_API_BASE_URL default: "http://localhost:8000"
|
|
||||||
// VITE_API_REFRESH_URL default: "/api/token/refresh/"
|
|
||||||
// VITE_LOGIN_PATH default: "/login"
|
|
||||||
*/
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import Client from "./Client";
|
|
||||||
|
|
||||||
// Available output containers (must match backend)
|
|
||||||
export const FORMAT_EXTS = ["mp4", "mkv", "webm", "flv", "mov", "avi", "ogg"] as const;
|
|
||||||
export type FormatExt = (typeof FORMAT_EXTS)[number];
|
|
||||||
|
|
||||||
export type InfoResponse = {
|
|
||||||
title: string | null;
|
|
||||||
duration: number | null;
|
|
||||||
thumbnail: string | null;
|
|
||||||
video_resolutions: string[]; // e.g. ["2160p", "1440p", "1080p", ...]
|
|
||||||
audio_resolutions: string[]; // e.g. ["320kbps", "160kbps", ...]
|
|
||||||
};
|
|
||||||
|
|
||||||
// GET info for a URL
|
|
||||||
export async function fetchInfo(url: string): Promise<InfoResponse> {
|
|
||||||
const res = await Client.public.get("/api/downloader/download/", {
|
|
||||||
params: { url },
|
|
||||||
});
|
|
||||||
return res.data as InfoResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST to stream binary immediately; returns { blob, filename }
|
|
||||||
export async function downloadImmediate(args: {
|
|
||||||
url: string;
|
|
||||||
ext: FormatExt;
|
|
||||||
videoResolution?: string | number; // "1080p" or 1080
|
|
||||||
audioResolution?: string | number; // "160kbps" or 160
|
|
||||||
}): Promise<{ blob: Blob; filename: string }> {
|
|
||||||
const video_quality = toHeight(args.videoResolution);
|
|
||||||
const audio_quality = toKbps(args.audioResolution);
|
|
||||||
|
|
||||||
if (video_quality == null || audio_quality == null) {
|
|
||||||
throw new Error("Please select both video and audio quality.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await Client.public.post(
|
|
||||||
"/api/downloader/download/",
|
|
||||||
{
|
|
||||||
url: args.url,
|
|
||||||
ext: args.ext,
|
|
||||||
video_quality,
|
|
||||||
audio_quality,
|
|
||||||
},
|
|
||||||
{ responseType: "blob" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const cd = res.headers?.["content-disposition"] as string | undefined;
|
|
||||||
const xfn = res.headers?.["x-filename"] as string | undefined;
|
|
||||||
const filename =
|
|
||||||
parseContentDispositionFilename(cd) ||
|
|
||||||
(xfn && xfn.trim()) ||
|
|
||||||
inferFilenameFromUrl(args.url, res.headers?.["content-type"] as string | undefined) ||
|
|
||||||
`download.${args.ext}`;
|
|
||||||
|
|
||||||
return { blob: res.data as Blob, filename };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helpers
|
|
||||||
export function parseContentDispositionFilename(cd?: string): string | null {
|
|
||||||
if (!cd) return null;
|
|
||||||
const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
|
||||||
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
|
|
||||||
const plainMatch = cd.match(/filename\s*=\s*"([^"]+)"/i) || cd.match(/filename\s*=\s*([^;]+)/i);
|
|
||||||
return plainMatch?.[1]?.trim() || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferFilenameFromUrl(url: string, contentType?: string): string {
|
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
const last = u.pathname.split("/").filter(Boolean).pop();
|
|
||||||
if (last) return last;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
if (contentType) {
|
|
||||||
const ext = contentTypeToExt(contentType);
|
|
||||||
return `download${ext ? `.${ext}` : ""}`;
|
|
||||||
}
|
|
||||||
return "download.bin";
|
|
||||||
}
|
|
||||||
|
|
||||||
function contentTypeToExt(ct?: string): string | null {
|
|
||||||
if (!ct) return null;
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
"video/mp4": "mp4",
|
|
||||||
"video/x-matroska": "mkv",
|
|
||||||
"video/webm": "webm",
|
|
||||||
"video/x-flv": "flv",
|
|
||||||
"video/quicktime": "mov",
|
|
||||||
"video/x-msvideo": "avi",
|
|
||||||
"video/ogg": "ogg",
|
|
||||||
"application/octet-stream": "bin",
|
|
||||||
};
|
|
||||||
return map[ct] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toHeight(v?: string | number): number | undefined {
|
|
||||||
if (typeof v === "number") return v || undefined;
|
|
||||||
if (!v) return undefined;
|
|
||||||
const m = /^(\d{2,4})p$/i.exec(v.trim());
|
|
||||||
if (m) return parseInt(m[1], 10);
|
|
||||||
const n = Number(v);
|
|
||||||
return Number.isFinite(n) ? (n as number) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toKbps(v?: string | number): number | undefined {
|
|
||||||
if (typeof v === "number") return v || undefined;
|
|
||||||
if (!v) return undefined;
|
|
||||||
const m = /^(\d{2,4})\s*kbps$/i.exec(v.trim());
|
|
||||||
if (m) return parseInt(m[1], 10);
|
|
||||||
const n = Number(v);
|
|
||||||
return Number.isFinite(n) ? (n as number) : undefined;
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// frontend/src/api/model/user.js
|
|
||||||
// User API model for searching users by username
|
|
||||||
// Structure matches other model files (see order.js for reference)
|
|
||||||
|
|
||||||
import Client from '../Client';
|
|
||||||
|
|
||||||
const API_BASE_URL = "/account/users";
|
|
||||||
|
|
||||||
const userAPI = {
|
|
||||||
/**
|
|
||||||
* Get current authenticated user
|
|
||||||
* @returns {Promise<User>}
|
|
||||||
*/
|
|
||||||
async getCurrentUser() {
|
|
||||||
const response = await Client.auth.get(`${API_BASE_URL}/me/`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all users
|
|
||||||
* @returns {Promise<Array<User>>}
|
|
||||||
*/
|
|
||||||
async getUsers(params: Object) {
|
|
||||||
const response = await Client.auth.get(`${API_BASE_URL}/`, { params });
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single user by ID
|
|
||||||
* @param {number|string} id
|
|
||||||
* @returns {Promise<User>}
|
|
||||||
*/
|
|
||||||
async getUser(id: number) {
|
|
||||||
const response = await Client.auth.get(`${API_BASE_URL}/${id}/`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a user by ID
|
|
||||||
* @param {number|string} id
|
|
||||||
* @param {Object} data
|
|
||||||
* @returns {Promise<User>}
|
|
||||||
*/
|
|
||||||
async updateUser(id: number, data: Object) {
|
|
||||||
const response = await Client.auth.patch(`${API_BASE_URL}/${id}/`, data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a user by ID
|
|
||||||
* @param {number|string} id
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async deleteUser(id: number) {
|
|
||||||
const response = await Client.auth.delete(`${API_BASE_URL}/${id}/`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new user
|
|
||||||
* @param {Object} data
|
|
||||||
* @returns {Promise<User>}
|
|
||||||
*/
|
|
||||||
async createUser(data: Object) {
|
|
||||||
const response = await Client.auth.post(`${API_BASE_URL}/`, data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search users by username (partial match)
|
|
||||||
* @param {Object} params - { username: string }
|
|
||||||
* @returns {Promise<Array<User>>}
|
|
||||||
*/
|
|
||||||
async searchUsers(params: { username: string }) {
|
|
||||||
// Adjust the endpoint as needed for your backend
|
|
||||||
const response = await Client.auth.get(`${API_BASE_URL}/`, { params });
|
|
||||||
console.log("User search response:", response.data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default userAPI;
|
|
||||||
@@ -103,4 +103,4 @@ footer .links a{
|
|||||||
padding-top: 1em;
|
padding-top: 1em;
|
||||||
gap: 2em;
|
gap: 2em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,62 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const videos = ["dQw4w9WgXcQ", "M7lc1UVf-VE", "aqz-KE-bpKQ"]; // placeholder IDs
|
|
||||||
|
|
||||||
export default function HeroCarousel() {
|
export default function HeroCarousel() {
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => setIndex(i => (i + 1) % videos.length), 10000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="home" className="relative min-h-[80vh] md:min-h-[85vh] flex items-center justify-center overflow-hidden">
|
<>
|
||||||
{/* Background Gradient and animated glows */}
|
</>
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-[var(--c-background-light)] via-[var(--c-background)] to-[var(--c-background)] -z-10" />
|
|
||||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
|
||||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-brand-accent/10 rounded-full blur-3xl animate-pulse" />
|
|
||||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-brand-lines/10 rounded-full blur-3xl animate-pulse delay-1000" />
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-brand-boxes/10 rounded-full blur-3xl animate-pulse delay-2000" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative container mx-auto px-4 py-10 grid lg:grid-cols-2 gap-10 items-center">
|
|
||||||
{/* Text */}
|
|
||||||
<div className="text-center lg:text-left">
|
|
||||||
<h1 className="text-4xl md:text-6xl font-bold mb-4 leading-tight">
|
|
||||||
<span className="text-rainbow">Welcome to</span><br />
|
|
||||||
<span className="text-brand-text">Vontor.cz</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg md:text-xl text-brand-text/80 mb-6">Creative Tech & Design by <span className="text-brand-accent font-semibold">Bruno Vontor</span></p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
|
|
||||||
<a href="#portfolio" className="px-8 py-3 bg-gradient-to-r from-[var(--c-other)] to-[var(--c-lines)] text-brand-text font-semibold rounded-lg hover:shadow-glow transition-all duration-300 transform hover:scale-105">View Portfolio</a>
|
|
||||||
<a href="#contact" className="px-8 py-3 border-2 border-brand-lines text-brand-lines font-semibold rounded-lg hover:bg-brand-lines hover:text-brand-bg transition-all duration-300">Get In Touch</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video carousel */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="relative aspect-video bg-brand-bgLight rounded-xl overflow-hidden shadow-2xl">
|
|
||||||
{videos.map((v,i) => (
|
|
||||||
<iframe
|
|
||||||
key={v}
|
|
||||||
src={`https://www.youtube.com/embed/${v}?autoplay=${i===index?1:0}&mute=1&loop=1&playlist=${v}`}
|
|
||||||
title={`Slide ${i+1}`}
|
|
||||||
className={`absolute inset-0 w-full h-full transition-opacity duration-700 ${i===index? 'opacity-100':'opacity-0'}`}
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
||||||
allowFullScreen
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-brand-bg/60 to-transparent pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
{/* Indicators */}
|
|
||||||
<div className="flex justify-center mt-4 space-x-2">
|
|
||||||
{videos.map((_,i) => (
|
|
||||||
<button key={i} onClick={()=>setIndex(i)} aria-label={`Go to slide ${i+1}`} className={`w-3 h-3 rounded-full transition-all duration-300 ${i===index? 'bg-brand-accent':'bg-brand-lines/40 hover:bg-brand-lines/60'}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
nav{
|
|
||||||
padding: 1.1em;
|
|
||||||
|
|
||||||
font-family: "Roboto Mono", monospace;
|
|
||||||
|
|
||||||
position: -webkit-sticky;
|
|
||||||
position: sticky;
|
|
||||||
top: 0; /* required */
|
|
||||||
|
|
||||||
transition: top 1s ease-in-out, border-radius 1s ease-in-out;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
z-index: 5;
|
|
||||||
padding-left: 2em;
|
|
||||||
padding-right: 2em;
|
|
||||||
width: max-content;
|
|
||||||
|
|
||||||
background: var(--c-boxes);
|
|
||||||
/*background: -moz-linear-gradient(117deg, rgba(34,34,34,1) 0%, rgba(59,54,54,1) 100%);
|
|
||||||
background: -webkit-linear-gradient(117deg, rgba(34,34,34,1) 0%, rgba(59,54,54,1) 100%);
|
|
||||||
background: linear-gradient(117deg, rgba(34,34,34,1) 0%, rgba(59,54,54,1) 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr="#222222",endColorstr="#3b3636",GradientType=1);*/
|
|
||||||
|
|
||||||
color: var(--c-text);
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
margin: auto;
|
|
||||||
|
|
||||||
border-radius: 2em;
|
|
||||||
}
|
|
||||||
nav.isSticky-nav{
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
}
|
|
||||||
nav ul #nav-logo{
|
|
||||||
border-right: 0.2em solid var(--c-lines);
|
|
||||||
}
|
|
||||||
/* Add class alias for logo used in TSX */
|
|
||||||
.logo {
|
|
||||||
border-right: 0.2em solid var(--c-lines);
|
|
||||||
}
|
|
||||||
nav ul #nav-logo span{
|
|
||||||
line-height: 0.75;
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
nav a{
|
|
||||||
color: #fff;
|
|
||||||
transition: color 1s;
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
nav a:hover{
|
|
||||||
color: var(--c-text);
|
|
||||||
}
|
|
||||||
/* Unify link/summary layout to prevent distortion */
|
|
||||||
nav a,
|
|
||||||
nav summary {
|
|
||||||
color: #fff;
|
|
||||||
transition: color 1s;
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-block; /* ensure consistent inline sizing */
|
|
||||||
vertical-align: middle; /* align with neighbors */
|
|
||||||
padding: 0; /* keep padding controlled by li */
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 2px;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
background-color: var(--c-other);
|
|
||||||
transform: scaleX(0);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
nav a:hover::before {
|
|
||||||
transform: scaleX(1);
|
|
||||||
}
|
|
||||||
nav summary:hover {
|
|
||||||
color: var(--c-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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: var(--c-other);
|
|
||||||
transform: scaleX(0);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
nav a:hover::before,
|
|
||||||
nav summary:hover::before {
|
|
||||||
transform: scaleX(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Submenu support */
|
|
||||||
.hasSubmenu {
|
|
||||||
position: relative;
|
|
||||||
vertical-align: middle; /* align with other inline items */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep details inline to avoid breaking the first row flow */
|
|
||||||
.hasSubmenu details {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure "Services" and caret stay on the same line */
|
|
||||||
.hasSubmenu details > summary {
|
|
||||||
display: inline-flex; /* horizontal layout */
|
|
||||||
align-items: center; /* vertical alignment */
|
|
||||||
gap: 0.5em; /* space between text and icon */
|
|
||||||
white-space: nowrap; /* prevent wrapping */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide native disclosure icon/marker on summary */
|
|
||||||
.hasSubmenu details > summary {
|
|
||||||
list-style: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.hasSubmenu details > summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.hasSubmenu details > summary::marker {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reusable caret for submenu triggers */
|
|
||||||
.caret {
|
|
||||||
transition: transform 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rotate caret when submenu is open */
|
|
||||||
.hasSubmenu details[open] .caret {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Submenu box: place directly under nav with a tiny gap (no overlap) */
|
|
||||||
.submenu {
|
|
||||||
list-style: none;
|
|
||||||
margin: 1em 0;
|
|
||||||
padding: 0.5em 0;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: calc(100% + 0.25em);
|
|
||||||
display: none;
|
|
||||||
background: var(--c-background-light);
|
|
||||||
border: 1px solid var(--c-lines);
|
|
||||||
border-radius: 0.75em;
|
|
||||||
min-width: max-content;
|
|
||||||
text-align: left;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.submenu li {
|
|
||||||
display: block;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.submenu a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0; /* remove padding so underline equals text width */
|
|
||||||
margin: 0.35em 0; /* spacing without affecting underline width */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Show submenu when open */
|
|
||||||
.hasSubmenu details[open] .submenu {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hamburger toggle class (used by TSX) */
|
|
||||||
.toggle {
|
|
||||||
display: none;
|
|
||||||
transition: transform 0.5s ease;
|
|
||||||
}
|
|
||||||
.toggleRotated {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bridge TSX classnames to existing rules */
|
|
||||||
.navList {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.navList li {
|
|
||||||
display: inline;
|
|
||||||
padding: 0 3em;
|
|
||||||
}
|
|
||||||
.navList li a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav ul li {
|
|
||||||
display: inline;
|
|
||||||
padding: 0 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav ul li a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
#toggle-nav{
|
|
||||||
display: none;
|
|
||||||
|
|
||||||
-webkit-transition: transform 0.5s ease;
|
|
||||||
-moz-transition: transform 0.5s ease;
|
|
||||||
-o-transition: transform 0.5s ease;
|
|
||||||
-ms-transition: transform 0.5s ease;
|
|
||||||
transition: transform 0.5s ease;
|
|
||||||
}
|
|
||||||
.toggle-nav-rotated {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
.nav-open{
|
|
||||||
max-height: 20em;
|
|
||||||
}
|
|
||||||
@media only screen and (max-width: 990px){
|
|
||||||
#toggle-nav{
|
|
||||||
margin-top: 0.25em;
|
|
||||||
margin-left: 0.75em;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
display: block;
|
|
||||||
font-size: 2em;
|
|
||||||
}
|
|
||||||
nav{
|
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-bottom-left-radius: 1em;
|
|
||||||
border-bottom-right-radius: 1em;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
nav ul {
|
|
||||||
margin-top: 1em;
|
|
||||||
gap: 2em;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
-webkit-transition: max-height 1s ease;
|
|
||||||
-moz-transition: max-height 1s ease;
|
|
||||||
-o-transition: max-height 1s ease;
|
|
||||||
-ms-transition: max-height 1s ease;
|
|
||||||
transition: max-height 1s ease;
|
|
||||||
|
|
||||||
max-height: 2em;
|
|
||||||
}
|
|
||||||
/* When TSX adds styles.open to the UL, expand it */
|
|
||||||
.open {
|
|
||||||
max-height: 20em;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav ul:last-child{
|
|
||||||
padding-bottom: 1em;
|
|
||||||
}
|
|
||||||
nav ul #nav-logo {
|
|
||||||
margin: auto;
|
|
||||||
padding-bottom: 0.5em;
|
|
||||||
margin-bottom: -1em;
|
|
||||||
border-bottom: 0.2em solid var(--c-lines);
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
/* Show hamburger on mobile */
|
|
||||||
.toggle {
|
|
||||||
margin-top: 0.25em;
|
|
||||||
margin-left: 0.75em;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
display: block;
|
|
||||||
font-size: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Submenu stacks inline under the parent item on mobile */
|
|
||||||
.submenu {
|
|
||||||
position: static;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0 0 0.5em 0.5em;
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
.submenu a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0; /* keep no padding on mobile too */
|
|
||||||
margin: 0.25em 0.5em; /* spacing via margin */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import styles from "./HomeNav.module.css"
|
|
||||||
import { FaBars, FaChevronDown } from "react-icons/fa";
|
|
||||||
|
|
||||||
export default function HomeNav() {
|
|
||||||
const [navOpen, setNavOpen] = useState(false)
|
|
||||||
|
|
||||||
const toggleNav = () => setNavOpen((prev) => !prev)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className={styles.nav}>
|
|
||||||
<div
|
|
||||||
className={`inline-flex items-center justify-center w-12 h-12 rounded-xl bg-brandGradient text-white shadow-glow cursor-pointer ${styles.toggle} ${navOpen ? styles.toggleRotated : ""}`}
|
|
||||||
onClick={toggleNav}
|
|
||||||
aria-label="Toggle navigation"
|
|
||||||
aria-expanded={navOpen}
|
|
||||||
>
|
|
||||||
<FaBars />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className={`${styles.navList} ${navOpen ? styles.open : ""}`}>
|
|
||||||
<li id="nav-logo" className={styles.logo}>
|
|
||||||
<span>vontor.cz</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/">Home</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#portfolio">Portfolio</a>
|
|
||||||
</li>
|
|
||||||
<li className={styles.hasSubmenu}>
|
|
||||||
<details>
|
|
||||||
<summary>
|
|
||||||
Services
|
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-brandGradient text-white ml-2 shadow-glow">
|
|
||||||
<FaChevronDown className={styles.caret} aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
<ul className={styles.submenu}>
|
|
||||||
<li><a href="#web">Web development</a></li>
|
|
||||||
<li><a href="#integration">Integrations</a></li>
|
|
||||||
<li><a href="#support">Support</a></li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#contactme-form">Contact me</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,73 +1,156 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { NavLink } from "react-router-dom";
|
import {
|
||||||
import { FaBars, FaTimes, FaChevronDown } from "react-icons/fa";
|
FaUserCircle,
|
||||||
|
FaSignOutAlt,
|
||||||
|
FaSignInAlt,
|
||||||
|
FaBars,
|
||||||
|
FaChevronDown,
|
||||||
|
FaGlobe,
|
||||||
|
FaWrench,
|
||||||
|
FaDownload,
|
||||||
|
FaGitAlt,
|
||||||
|
FaPlayCircle,
|
||||||
|
FaUsers,
|
||||||
|
FaHandsHelping,
|
||||||
|
FaProjectDiagram,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import {FaClapperboard, FaCubes} from "react-icons/fa6";
|
||||||
|
import styles from "./navbar.module.css";
|
||||||
|
|
||||||
/* Responsive sticky navigation bar using theme variables */
|
export interface User {
|
||||||
export default function SiteNav() {
|
username: string;
|
||||||
const [open, setOpen] = useState(false);
|
email?: string;
|
||||||
const [servicesOpen, setServicesOpen] = useState(false);
|
avatarUrl?: string;
|
||||||
const [scrolled, setScrolled] = useState(false);
|
}
|
||||||
|
|
||||||
|
interface NavbarProps {
|
||||||
|
user: User | null;
|
||||||
|
onLogin: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Navbar({ user, onLogin, onLogout }: NavbarProps) {
|
||||||
|
const [mobileMenu, setMobileMenu] = useState(false);
|
||||||
|
const navRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// close on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 50);
|
function handleClick(e: MouseEvent) {
|
||||||
onScroll();
|
if (!navRef.current) return;
|
||||||
window.addEventListener('scroll', onScroll);
|
if (!navRef.current.contains(e.target as Node)) {
|
||||||
return () => window.removeEventListener('scroll', onScroll);
|
// close only mobile menu here; dropdowns are CSS-controlled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("click", handleClick);
|
||||||
|
return () => window.removeEventListener("click", handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// close dropdowns on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setMobileMenu(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`sticky top-0 z-50 transition-all ${scrolled ? 'bg-brand-bg/95 backdrop-blur-md shadow-lg' : 'bg-transparent'}`}>
|
<nav className={styles.navbar} ref={navRef} aria-label="Hlavní navigace">
|
||||||
<nav className="relative container mx-auto px-4 flex items-center justify-between h-16 text-brand-text font-medium">
|
{/* mobile burger */}
|
||||||
<div className="text-xl tracking-wide font-semibold">
|
<button
|
||||||
<NavLink to="/" className="inline-block px-2 py-1 rounded nav-item text-rainbow font-bold">vontor.cz</NavLink>
|
className={styles.burger}
|
||||||
|
onClick={() => setMobileMenu((p) => !p)}
|
||||||
|
aria-expanded={mobileMenu}
|
||||||
|
aria-label="Otevřít menu"
|
||||||
|
>
|
||||||
|
<FaBars />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* left: brand */}
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<a href="/" aria-label="vontor.cz home">vontor.cz</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* center links */}
|
||||||
|
<div className={`${styles.links} ${mobileMenu ? styles.show : ""}`} role="menubar">
|
||||||
|
{/* Services with submenu */}
|
||||||
|
<div className={styles.dropdownItem}>
|
||||||
|
<button
|
||||||
|
className={styles.linkButton}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<FaHandsHelping className={styles.iconSmall}/> Služby <FaChevronDown className={styles.chev} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={styles.dropdown} role="menu" aria-label="Služby submenu">
|
||||||
|
<a href="/services/web" role="menuitem"><FaGlobe className={styles.iconSmall}/> Weby</a>
|
||||||
|
|
||||||
|
{/* Filmařina as a simple link (no dropdown) */}
|
||||||
|
<a href="/services/film" role="menuitem">
|
||||||
|
<FaClapperboard className={styles.iconSmall}/> Filmařina
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
|
<a href="/services/drone-service" role="menuitem"><FaWrench className={styles.iconSmall}/> Servis dronu</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
aria-label="Toggle navigation"
|
{/* Aplikace standalone submenu */}
|
||||||
onClick={() => setOpen(o => !o)}
|
<div className={styles.dropdownItem}>
|
||||||
className="md:hidden p-2 rounded-xl glow focus:outline-none bg-brandGradient text-white shadow-glow"
|
<button className={styles.linkButton} aria-haspopup="true">
|
||||||
>
|
<FaCubes className={styles.iconSmall}/> Aplikace <FaChevronDown className={styles.chev} />
|
||||||
{open ? <FaTimes /> : <FaBars />}
|
</button>
|
||||||
</button>
|
<div className={styles.dropdown} role="menu" aria-label="Aplikace submenu">
|
||||||
<ul className={`md:flex md:items-center md:gap-8 md:static absolute left-0 w-full md:w-auto transition-all duration-300 ${open ? 'top-16 bg-brand-bg/95 pb-6 rounded-lg backdrop-blur-md' : 'top-[-500px]'}`}>
|
<a href="/apps/downloader" role="menuitem"><FaDownload className={styles.iconSmall}/> Downloader</a>
|
||||||
<li className="flex"><NavLink to="/" onClick={()=>setOpen(false)} className={linkCls}>Home</NavLink></li>
|
<a href="/apps/git" role="menuitem"><FaGitAlt className={styles.iconSmall}/> Git</a>
|
||||||
<li className="flex"><NavLink to="/portfolio" onClick={()=>setOpen(false)} className={linkCls}>Portfolio</NavLink></li>
|
<a href="/apps/dema" role="menuitem"><FaPlayCircle className={styles.iconSmall}/> Dema</a>
|
||||||
<li className="flex"><NavLink to="/skills" onClick={()=>setOpen(false)} className={linkCls}>Skills</NavLink></li>
|
<a href="/apps/social" role="menuitem"><FaUsers className={styles.iconSmall}/> Social</a>
|
||||||
<li className="flex"><NavLink to="/hosting-security" onClick={()=>setOpen(false)} className={linkCls}>Hosting & Security</NavLink></li>
|
</div>
|
||||||
<li className="flex"><NavLink to="/donate" onClick={()=>setOpen(false)} className={linkCls}>Donate / Shop</NavLink></li>
|
</div>
|
||||||
<li className="flex"><NavLink to="/contact" onClick={()=>setOpen(false)} className={linkCls}>Contact</NavLink></li>
|
|
||||||
<li className="relative">
|
<a className={styles.linkSimple} href="#contacts"><FaGlobe className={styles.iconSmall}/> Kontakt</a>
|
||||||
|
<a className={styles.linkSimple} href="/projects"><FaProjectDiagram className={styles.iconSmall}/> Projekty</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/*FIXME: STRČIT USER ČÁST DO LINK SKUPINY ABY TO BYLO KOMPATIBILNI PRO MOBIL*/}
|
||||||
|
{/* right: user area */}
|
||||||
|
<div className={styles.user}>
|
||||||
|
{!user ? (
|
||||||
|
<button className={styles.loginBtn} onClick={onLogin} aria-label="Přihlásit">
|
||||||
|
<FaSignInAlt />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className={`${styles.userWrapper} ${styles.dropdownItem}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
className={styles.userButton}
|
||||||
onClick={() => setServicesOpen(s => !s)}
|
aria-haspopup="true"
|
||||||
className={`nav-item px-3 py-2 flex items-center gap-1`}
|
|
||||||
aria-haspopup="true" aria-expanded={servicesOpen}
|
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1">More <span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-brandGradient text-white"><FaChevronDown className={`transition-transform ${servicesOpen ? 'rotate-180' : ''}`} /></span></span>
|
{user.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt={`${user.username} avatar`} className={styles.avatar} />
|
||||||
|
) : (
|
||||||
|
<FaUserCircle className={styles.userIcon} />
|
||||||
|
)}
|
||||||
|
<span className={styles.username}>{user.username}</span>
|
||||||
|
<FaChevronDown className={styles.chev}/>
|
||||||
</button>
|
</button>
|
||||||
{/* Mobile inline dropdown */}
|
|
||||||
<div className={`md:hidden w-full mt-2 ${servicesOpen ? 'block' : 'hidden'}`}>
|
<div className={styles.dropdown} role="menu" aria-label="Uživatelské menu">
|
||||||
<ul className="space-y-2 text-sm glass p-4">
|
<a href="/profile" role="menuitem">Profil</a>
|
||||||
<li><a href="#live" className={`${dropdownCls} nav-item`}>Live Performance</a></li>
|
<a href="/billing" role="menuitem">Nastavení</a>
|
||||||
<li><a href="#shop" className={`${dropdownCls} nav-item`}>Support Journey</a></li>
|
<a href="/billing" role="menuitem">Platby</a>
|
||||||
<li><a href="#portfolio" className={`${dropdownCls} nav-item`}>Featured Work</a></li>
|
|
||||||
</ul>
|
<button className={styles.logoutBtn} onClick={onLogout} role="menuitem">
|
||||||
|
<FaSignOutAlt /> Odhlásit se
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{/* Desktop offset dropdown anchored to right under nav */}
|
|
||||||
{servicesOpen && (
|
|
||||||
<div className="hidden md:block absolute top-full right-4 translate-y-2 min-w-56 glass p-4 shadow-xl">
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
<li><a href="#live" className={`${dropdownCls} nav-item`}>Live Performance</a></li>
|
|
||||||
<li><a href="#shop" className={`${dropdownCls} nav-item`}>Support Journey</a></li>
|
|
||||||
<li><a href="#portfolio" className={`${dropdownCls} nav-item`}>Featured Work</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</div>
|
||||||
</header>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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%)]";
|
|
||||||
447
frontend/src/components/navbar/navbar.module.css
Normal file
447
frontend/src/components/navbar/navbar.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
frontend/src/context/AuthContext.tsx
Normal file
97
frontend/src/context/AuthContext.tsx
Normal file
@@ -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<void>;
|
||||||
|
logout: () => void;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(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 (
|
||||||
|
<AuthContext.Provider value={{ user, isAuthenticated, login, logout, refreshUser }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
|
||||||
|
*/
|
||||||
@@ -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<React.SetStateAction<User | null>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// vytvoříme a exportneme kontext !!!
|
|
||||||
export const UserContext = createContext<GlobalContextType | null>(null);
|
|
||||||
|
|
||||||
|
|
||||||
// hook pro použití kontextu
|
|
||||||
// zabal routy do téhle komponenty!!!
|
|
||||||
export const UserContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
|
|
||||||
const fetchUser = async () => {
|
|
||||||
try {
|
|
||||||
const currentUser = await userAPI.getCurrentUser();
|
|
||||||
setUser(currentUser);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load user:', error);
|
|
||||||
setUser(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUser();
|
|
||||||
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserContext.Provider value={{ user, setUser }}>
|
|
||||||
{children}
|
|
||||||
</UserContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
EXAMPLE USAGE OF CONTEXT IN A COMPONENT:
|
|
||||||
|
|
||||||
// Wrap your app tree with the provider (e.g., in App.tsx)
|
|
||||||
// import { UserContextProvider } from "../context/UserContext";
|
|
||||||
// function App() {
|
|
||||||
// return (
|
|
||||||
// <UserContextProvider>
|
|
||||||
// <YourRoutes />
|
|
||||||
// </UserContextProvider>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Consume in any child component
|
|
||||||
import React, { useContext } from "react"
|
|
||||||
import { UserContext } from '../context/UserContext';
|
|
||||||
|
|
||||||
export default function ExampleComponent() {
|
|
||||||
const { user, setUser } = useContext(UserContext);
|
|
||||||
|
|
||||||
|
|
||||||
return ...;
|
|
||||||
}
|
|
||||||
|
|
||||||
*/
|
|
||||||
@@ -44,7 +44,6 @@ h1, h2, h3 {
|
|||||||
button {
|
button {
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
|
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
|
||||||
padding: 0.6em 1.1em;
|
|
||||||
font: inherit;
|
font: inherit;
|
||||||
background-color: color-mix(in hsl, var(--c-background-light), black 15%);
|
background-color: color-mix(in hsl, var(--c-background-light), black 15%);
|
||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import Footer from "../components/Footer/footer";
|
import Footer from "../components/Footer/footer";
|
||||||
import { Outlet } from "react-router";
|
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(){
|
export default function HomeLayout(){
|
||||||
return(
|
return(
|
||||||
<>
|
<>
|
||||||
<SiteNav />
|
<SiteNav user={userexists} onLogin={() => {}} onLogout={() => {}} />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,206 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
|
||||||
fetchInfo,
|
|
||||||
downloadImmediate,
|
|
||||||
FORMAT_EXTS,
|
|
||||||
type InfoResponse,
|
|
||||||
parseContentDispositionFilename,
|
|
||||||
} from "../../api/legacy/Downloader";
|
|
||||||
|
|
||||||
export default function Downloader() {
|
export default function Downloader() {
|
||||||
const [url, setUrl] = useState("");
|
|
||||||
const [probing, setProbing] = useState(false);
|
|
||||||
const [downloading, setDownloading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [info, setInfo] = useState<InfoResponse | null>(null);
|
|
||||||
|
|
||||||
const [ext, setExt] = useState<typeof FORMAT_EXTS[number]>("mp4");
|
|
||||||
const [videoRes, setVideoRes] = useState<string | undefined>(undefined);
|
|
||||||
const [audioRes, setAudioRes] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (info?.video_resolutions?.length && !videoRes) {
|
|
||||||
setVideoRes(info.video_resolutions[0]);
|
|
||||||
}
|
|
||||||
if (info?.audio_resolutions?.length && !audioRes) {
|
|
||||||
setAudioRes(info.audio_resolutions[0]);
|
|
||||||
}
|
|
||||||
}, [info]);
|
|
||||||
|
|
||||||
async function onProbe(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setInfo(null);
|
|
||||||
setProbing(true);
|
|
||||||
try {
|
|
||||||
const res = await fetchInfo(url);
|
|
||||||
setInfo(res);
|
|
||||||
// reset selections from fresh info
|
|
||||||
setVideoRes(res.video_resolutions?.[0]);
|
|
||||||
setAudioRes(res.audio_resolutions?.[0]);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(
|
|
||||||
e?.response?.data?.error ||
|
|
||||||
e?.response?.data?.detail ||
|
|
||||||
e?.message ||
|
|
||||||
"Failed to get info."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setProbing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDownload() {
|
|
||||||
setError(null);
|
|
||||||
setDownloading(true);
|
|
||||||
try {
|
|
||||||
const { blob, filename } = await downloadImmediate({
|
|
||||||
url,
|
|
||||||
ext,
|
|
||||||
videoResolution: videoRes,
|
|
||||||
audioResolution: audioRes,
|
|
||||||
});
|
|
||||||
const name = filename || parseContentDispositionFilename("") || `download.${ext}`;
|
|
||||||
const href = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = href;
|
|
||||||
a.download = name;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(href);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(
|
|
||||||
e?.response?.data?.error ||
|
|
||||||
e?.response?.data?.detail ||
|
|
||||||
e?.message ||
|
|
||||||
"Download failed."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setDownloading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canDownload = useMemo(
|
|
||||||
() => !!url && !!ext && !!videoRes && !!audioRes,
|
|
||||||
[url, ext, videoRes, audioRes]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto p-4 space-y-4">
|
<>
|
||||||
<h1 className="text-2xl font-semibold">Downloader</h1>
|
not implemented yet
|
||||||
|
</>
|
||||||
{error && <div className="rounded border border-red-300 bg-red-50 text-red-700 p-2">{error}</div>}
|
|
||||||
|
|
||||||
<form onSubmit={onProbe} className="grid gap-3">
|
|
||||||
<label className="grid gap-1">
|
|
||||||
<span className="text-sm font-medium">URL</span>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
required
|
|
||||||
placeholder="https://example.com/video"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
className="w-full border rounded p-2"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!url || probing}
|
|
||||||
className="px-3 py-2 rounded bg-brand-accent text-brand-text disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{probing ? "Probing..." : "Get info"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onDownload}
|
|
||||||
disabled={!canDownload || downloading}
|
|
||||||
className="px-3 py-2 rounded bg-brand-accent text-brand-text disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{downloading ? "Downloading..." : "Download"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{info && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{info.thumbnail && (
|
|
||||||
<img
|
|
||||||
src={info.thumbnail}
|
|
||||||
alt={info.title || "thumbnail"}
|
|
||||||
className="w-40 h-24 object-cover rounded border"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="text-sm text-brand-text/90 space-y-1">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Title:</span> {info.title || "-"}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Duration:</span>{" "}
|
|
||||||
{info.duration ? `${Math.round(info.duration)} s` : "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-3">
|
|
||||||
<label className="grid gap-1">
|
|
||||||
<span className="text-sm font-medium">Container</span>
|
|
||||||
<select
|
|
||||||
value={ext}
|
|
||||||
onChange={(e) => setExt(e.target.value as any)}
|
|
||||||
className="border rounded p-2"
|
|
||||||
>
|
|
||||||
{FORMAT_EXTS.map((x) => (
|
|
||||||
<option key={x} value={x}>
|
|
||||||
{x.toUpperCase()}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="grid gap-1">
|
|
||||||
<span className="text-sm font-medium">Video resolution</span>
|
|
||||||
<select
|
|
||||||
value={videoRes || ""}
|
|
||||||
onChange={(e) => setVideoRes(e.target.value || undefined)}
|
|
||||||
className="border rounded p-2"
|
|
||||||
>
|
|
||||||
{info.video_resolutions?.length ? (
|
|
||||||
info.video_resolutions.map((r) => (
|
|
||||||
<option key={r} value={r}>
|
|
||||||
{r}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<option value="">-</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="grid gap-1">
|
|
||||||
<span className="text-sm font-medium">Audio bitrate</span>
|
|
||||||
<select
|
|
||||||
value={audioRes || ""}
|
|
||||||
onChange={(e) => setAudioRes(e.target.value || undefined)}
|
|
||||||
className="border rounded p-2"
|
|
||||||
>
|
|
||||||
{info.audio_resolutions?.length ? (
|
|
||||||
info.audio_resolutions.map((r) => (
|
|
||||||
<option key={r} value={r}>
|
|
||||||
{r}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<option value="">-</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user