(feature): front end layout improve

This commit is contained in:
Maksym Sadovnychyy 2024-06-12 22:25:03 +02:00
parent 1fe7c0a3da
commit 2f87b455ca
20 changed files with 420 additions and 64 deletions

4
.gitignore vendored
View File

@ -262,5 +262,5 @@ __pycache__/
*.pyc
**/*docker_compose/LetsEncryptServer/acme
**/*docker_compose/LetsEncryptServer/cache
**/*docker-compose/LetsEncryptServer/acme
**/*docker-compose/LetsEncryptServer/cache

View File

@ -0,0 +1,15 @@
enum ApiRoutes {
CACHE_GET_ACCOUNTS = `/api/Cache/GetAccounts`,
CACHE_GET_CONTACTS = `/api/Cache/GetContacts/{accountId}`,
CACHE_SET_CONTACTS = `/api/Cache/SetContacts/{accountId}`,
CERTS_FLOW_CONFIGURE_CLIENT = `/api/CertsFlow/ConfigureClient`,
CERTS_FLOW_TERMS_OF_SERVICE = `/api/CertsFlow/TermsOfService/{sessionId}`,
CERTS_FLOW_INIT = `/api/CertsFlow/Init/{sessionId}/{accountId}`,
CERTS_FLOW_NEW_ORDER = `/api/CertsFlow/NewOrder/{sessionId}`,
CERTS_FLOW_GET_ORDER = `/api/CertsFlow/GetOrder/{sessionId}`,
CERTS_FLOW_GET_CERTIFICATES = `/api/CertsFlow/GetCertificates/{sessionId}`,
CERTS_FLOW_APPLY_CERTIFICATES = `/api/CertsFlow/ApplyCertificates/{sessionId}`,
CERTS_FLOW_HOSRS_WITH_UPCOMING_SSL_EXPIRY = `/api/CertsFlow/HostsWithUpcomingSslExpiry/{sessionId}`
}

View File

@ -1,11 +1,12 @@
"use client"; // Add this line
import React, { FC, useState } from 'react';
import React, { FC, useState, useEffect, useRef } from 'react';
import './globals.css';
import { SideMenu } from '../components/sidemenu';
import { TopMenu } from '../components/topmenu';
import { Footer } from '../components/footer';
import { OffCanvas } from '../components/offcanvas';
import Loader from '../components/loader'; // Import the Loader component
import { Metadata } from 'next';
const metadata: Metadata = {
@ -13,32 +14,71 @@ const metadata: Metadata = {
description: "Generated by create next app",
};
const Layout = ({ children }: Readonly<{
children: React.ReactNode;
}>) => {
const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isOffCanvasOpen, setIsOffCanvasOpen] = useState(false);
const [isManuallyCollapsed, setManuallyCollapsed] = useState(false);
const [isLoading, setIsLoading] = useState(true); // State to control the loader visibility
const init = useRef(false);
const toggleSidebar = () => {
setIsSidebarCollapsed(!isSidebarCollapsed);
setIsSidebarCollapsed((prev) => !prev);
};
const manuallyToggleSidebar = () => {
setManuallyCollapsed((prev) => !prev);
toggleSidebar();
};
const handleResize = () => {
if (!isManuallyCollapsed) {
if (window.innerWidth <= 768) {
if (!isSidebarCollapsed) setIsSidebarCollapsed(true);
} else {
if (isSidebarCollapsed) setIsSidebarCollapsed(false);
}
} else {
if (isManuallyCollapsed) return;
// Reset manualCollapse if the window is resized to a state that should automatically collapse/expand the sidebar
if (window.innerWidth > 768 && isSidebarCollapsed) {
setIsSidebarCollapsed(false);
} else if (window.innerWidth <= 768 && !isSidebarCollapsed) {
setIsSidebarCollapsed(true);
}
}
};
useEffect(() => {
if (!init.current) {
handleResize(); // Set the initial state based on the current window width
init.current = true;
}
window.addEventListener('resize', handleResize);
setTimeout(() => setIsLoading(false), 2000); // Simulate loading for 2 seconds
return () => {
window.removeEventListener('resize', handleResize);
};
}, [isSidebarCollapsed, isManuallyCollapsed]);
const [isOffCanvasOpen, setIsOffCanvasOpen] = useState(false);
const toggleOffCanvas = () => {
setIsOffCanvasOpen(!isOffCanvasOpen);
setIsOffCanvasOpen((prev) => !prev);
};
return (
<html lang="en">
{/* <head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</head> */}
<body className="h-screen overflow-hidden">
{isLoading && <Loader />} {/* Show loader if isLoading is true */}
<div className="flex h-full">
<SideMenu isCollapsed={isSidebarCollapsed} toggleSidebar={toggleSidebar} />
<SideMenu isCollapsed={isSidebarCollapsed} toggleSidebar={manuallyToggleSidebar} />
<div className="flex flex-col flex-1">
<TopMenu onToggleOffCanvas={toggleOffCanvas} />
<main className={`flex-1 p-4 transition-transform duration-300 ${isOffCanvasOpen ? 'transform translate-x-64' : ''}`}>
<main className="flex-1 p-4 transition-transform duration-300">
{children}
</main>
<Footer />

View File

@ -3,7 +3,7 @@ import React from 'react';
const Footer = () => {
return (
<footer className="bg-gray-900 text-white text-center p-4">
<p>&copy; 2024 Your Company</p>
<p>&copy; {new Date().getFullYear()} MAKS-IT</p>
</footer>
);
};

View File

@ -0,0 +1,13 @@
import React from 'react';
import './loader.css'; // Add your loader styles here
const Loader: React.FC = () => {
return (
<div className="loader-overlay">
<div className="spinner"></div>
<div className="loading-text">Loading...</div>
</div>
);
};
export default Loader;

View File

@ -0,0 +1,34 @@
.loader-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.1); /* 10% transparent background */
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
flex-direction: column;
}
.spinner {
border: 8px solid rgba(255, 255, 255, 0.3);
border-top: 8px solid #3498db;
border-radius: 50%;
width: 80px;
height: 80px;
animation: spin 1s linear infinite;
}
.loading-text {
margin-top: 20px;
font-size: 1.2em;
color: #3498db;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@ -1,4 +1,4 @@
import React, { FC } from 'react';
import React, { FC, useEffect, useRef } from 'react';
import { FaHome, FaUser, FaCog, FaBars } from 'react-icons/fa';
interface SideMenuProps {
@ -7,12 +7,13 @@ interface SideMenuProps {
}
const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
return (
<div className={`flex flex-col bg-gray-800 text-white transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'} h-full`}>
<div className="flex items-center h-16 bg-gray-900 relative">
{/* <button onClick={toggleSidebar} className="absolute left-4">
<button onClick={toggleSidebar} className="absolute left-4">
<FaBars />
</button> */}
</button>
<h1 className={`${isCollapsed ? 'hidden' : 'block'} text-2xl font-bold ml-12`}>Logo</h1>
</div>
<nav className="flex-1">

View File

@ -17,7 +17,7 @@ const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => {
return (
<header className="bg-gray-900 text-white flex items-center p-4">
<nav className="flex-1 flex justify-between items-center">
<nav className="flex-1 flex justify-between items-center h-8">
<ul className="hidden md:flex space-x-4">
<li className="hover:bg-gray-700 p-2 rounded">
<Link href="/">Home</Link>
@ -29,9 +29,7 @@ const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => {
<Link href="/contact">Contact</Link>
</li>
</ul>
<button onClick={toggleMenu} className="md:hidden">
<FaBars />
</button>
{isMenuOpen && (
<ul className="absolute top-16 right-0 bg-gray-900 w-48 md:hidden">
<li className="hover:bg-gray-700 p-2">
@ -49,6 +47,9 @@ const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => {
<button onClick={onToggleOffCanvas} className="ml-4">
<FaCog />
</button>
<button onClick={toggleMenu} className="md:hidden">
<FaBars />
</button>
</header>
);
};

View File

@ -0,0 +1,12 @@
import { HttpService } from "./services/HttpService";
const httpService = new HttpService();
httpService.addRequestInterceptor(xhr => {
});
httpService.addResponseInterceptor(response => {
return response;
});

View File

@ -8,10 +8,12 @@
"name": "my-nextjs-app",
"version": "0.1.0",
"dependencies": {
"@reduxjs/toolkit": "^2.2.5",
"next": "14.2.3",
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.2.1"
"react-icons": "^5.2.1",
"react-redux": "^9.1.2"
},
"devDependencies": {
"@types/node": "^20",
@ -423,6 +425,29 @@
"node": ">=14"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz",
"integrity": "sha512-aeFA/s5NCG7NoJe/MhmwREJxRkDs0ZaSqt0MxhWUrwCf1UQXpwR87RROJEql0uAkLI6U7snBOYOcKw83ew3FPg==",
"dependencies": {
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rushstack/eslint-patch": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz",
@ -462,13 +487,13 @@
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
"dev": true
"devOptional": true
},
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
"dev": true,
"devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@ -483,6 +508,11 @@
"@types/react": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA=="
},
"node_modules/@typescript-eslint/parser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
@ -1154,7 +1184,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true
"devOptional": true
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@ -2388,6 +2418,15 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -3737,6 +3776,28 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
},
"node_modules/react-redux": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.2.tgz",
"integrity": "sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w==",
"dependencies": {
"@types/use-sync-external-store": "^0.0.3",
"use-sync-external-store": "^1.0.0"
},
"peerDependencies": {
"@types/react": "^18.2.25",
"react": "^18.0",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -3758,6 +3819,19 @@
"node": ">=8.10.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz",
@ -3803,6 +3877,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
},
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@ -4585,6 +4664,14 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
"integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -9,10 +9,12 @@
"lint": "next lint"
},
"dependencies": {
"@reduxjs/toolkit": "^2.2.5",
"next": "14.2.3",
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.2.1"
"react-icons": "^5.2.1",
"react-redux": "^9.1.2"
},
"devDependencies": {
"@types/node": "^20",

View File

@ -0,0 +1,122 @@
interface RequestInterceptor {
(req: XMLHttpRequest): void;
}
interface ResponseInterceptor<T> {
(response: T): T;
}
interface ProblemDetails {
Title: string;
Detail: string | null;
Status: number;
}
class HttpService {
private requestInterceptors: Array<RequestInterceptor> = [];
private responseInterceptors: Array<ResponseInterceptor<any>> = [];
private request<TResponse>(method: string, url: string, data?: any): Promise<TResponse> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
// Apply request interceptors
this.requestInterceptors.forEach(interceptor => {
try {
interceptor(xhr);
} catch (error) {
reject({
Title: 'Request Interceptor Error',
Detail: error instanceof Error ? error.message : 'Unknown error',
Status: 0
});
return;
}
});
// Set Content-Type header for JSON data
if (data && typeof data !== 'string') {
xhr.setRequestHeader('Content-Type', 'application/json');
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
let response: TResponse;
try {
response = JSON.parse(xhr.response);
} catch (error) {
reject({
Title: 'Response Parse Error',
Detail: error instanceof Error ? error.message : 'Unknown error',
Status: xhr.status
});
return;
}
// Apply response interceptors
try {
this.responseInterceptors.forEach(interceptor => {
response = interceptor(response);
});
} catch (error) {
reject({
Title: 'Response Interceptor Error',
Detail: error instanceof Error ? error.message : 'Unknown error',
Status: xhr.status
});
return;
}
resolve(response);
} else {
const problemDetails: ProblemDetails = {
Title: xhr.statusText,
Detail: xhr.responseText,
Status: xhr.status
};
reject(problemDetails);
}
};
xhr.onerror = () => {
const problemDetails: ProblemDetails = {
Title: 'Network Error',
Detail: null,
Status: 0
};
reject(problemDetails);
};
xhr.send(data ? JSON.stringify(data) : null);
});
}
public get<TResponse>(url: string): Promise<TResponse | ProblemDetails> {
return this.request<TResponse>('GET', url);
}
public post<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | ProblemDetails> {
return this.request<TResponse>('POST', url, data);
}
public put<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | ProblemDetails> {
return this.request<TResponse>('PUT', url, data);
}
public delete<TResponse>(url: string): Promise<TResponse | ProblemDetails> {
return this.request<TResponse>('DELETE', url);
}
public addRequestInterceptor(interceptor: RequestInterceptor): void {
this.requestInterceptors.push(interceptor);
}
public addResponseInterceptor<TResponse>(interceptor: ResponseInterceptor<TResponse | ProblemDetails>): void {
this.responseInterceptors.push(interceptor);
}
}
export {
HttpService
};

View File

@ -27,6 +27,12 @@ public class CacheController {
}
[HttpGet("[action]")]
public async Task<IActionResult> GetAccounts() {
var result = await _cacheService.ListCachedAccountsAsync();
return result.ToActionResult();
}
[HttpGet("[action]/{accountId}")]
public async Task<IActionResult> GetContacts(Guid accountId) {
var result = await _cacheService.GetContactsAsync(accountId);

View File

@ -0,0 +1,32 @@
using System.Net;
namespace MaksIT.LetsEncryptServer.Middlewares {
public class GlobalExceptionMiddleware {
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger) {
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context) {
try {
await _next(context);
}
catch (Exception ex) {
_logger.LogError(ex, "An unhandled exception occurred.");
await HandleExceptionAsync(context);
}
}
private static Task HandleExceptionAsync(HttpContext context) {
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/json";
var response = new { message = "An error occurred while processing your request." };
return context.Response.WriteAsJsonAsync(response);
}
}
}

View File

@ -2,6 +2,7 @@ using MaksIT.LetsEncryptServer;
using MaksIT.LetsEncrypt.Services;
using MaksIT.LetsEncryptServer.Services;
using MaksIT.LetsEncryptServer.BackgroundServices;
using MaksIT.LetsEncryptServer.Middlewares;
var builder = WebApplication.CreateBuilder(args);
@ -23,6 +24,8 @@ builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddCors();
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
@ -37,7 +40,13 @@ var app = builder.Build();
if (app.Environment.IsDevelopment()) {
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors(builder => builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
}
else {
// app.UseMiddleware<GlobalExceptionMiddleware>();
}
app.UseMiddleware<GlobalExceptionMiddleware>();
app.UseAuthorization();

View File

@ -1,18 +1,18 @@
version: '3.9'
services:
haproxy:
ports:
- "8080:8080"
volumes:
- ./docker-compose/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
depends_on:
- letsencryptapp
- letsencryptserver
# haproxy:
# ports:
# - "8080:8080"
# volumes:
# - ./docker-compose/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
# depends_on:
# - letsencryptapp
# - letsencryptserver
letsencryptapp:
ports:
- "3000:3000"
# letsencryptapp:
# ports:
# - "3000:3000"
letsencryptserver:
environment:

View File

@ -2,14 +2,14 @@ version: '3.9'
services:
haproxy:
image: haproxy:3.0.0-alpine
# haproxy:
# image: haproxy:3.0.0-alpine
letsencryptapp:
image: ${DOCKER_REGISTRY-}letsencryptapp
build:
context: .
dockerfile: ClientApp/Dockerfile
# letsencryptapp:
# image: ${DOCKER_REGISTRY-}letsencryptapp
# build:
# context: .
# dockerfile: ClientApp/Dockerfile
letsencryptserver:
image: ${DOCKER_REGISTRY-}letsencryptserver

View File

@ -1,16 +0,0 @@
server {
listen 3000;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@ -1 +0,0 @@
VTpgCMVyk7RMm0qKEURrgcfHI5Y0RKRWlF0Up1y1xMs.CmDayuKv1cGaB2xr6W5cZk_Jqbyonzm29xtXVNgBMAQ

View File

@ -1 +0,0 @@
sf6oq1qazlokoWqGJam03udFEYOanTus2DmShjnfAcw.CmDayuKv1cGaB2xr6W5cZk_Jqbyonzm29xtXVNgBMAQ