Implement SSL checker page with Hestia API integration, add email expiry alerts, and introduce new header and sidebar layout components.
This commit is contained in:
parent
00897ae0d9
commit
0b01318aa6
@ -66,7 +66,7 @@ export default function ReverseProxyPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
minHeight: "100vh",
|
minHeight: "83vh",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@ -1,129 +1,288 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import IconRefresh from "@/components/icon/icon-refresh";
|
||||||
|
import IconPlus from "@/components/icon/icon-plus";
|
||||||
|
import IconTrashLines from "@/components/icon/icon-trash-lines";
|
||||||
|
import IconServer from "@/components/icon/icon-server";
|
||||||
|
import IconLoader from "@/components/icon/icon-loader";
|
||||||
|
|
||||||
interface DomainStatus {
|
const API_BASE_URL = "/api/hestia";
|
||||||
domain: string;
|
|
||||||
validTo: string;
|
|
||||||
expired: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SSLChecker: React.FC = () => {
|
const SSLChecker = () => {
|
||||||
const [data, setData] = useState<DomainStatus[]>([]);
|
const [domains, setDomains] = useState<any[]>([]);
|
||||||
|
const [filteredDomains, setFilteredDomains] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState<{ [key: string]: string | null }>({});
|
||||||
|
const [globalLoading, setGlobalLoading] = useState(false);
|
||||||
|
const [checkingExpiry, setCheckingExpiry] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||||
|
|
||||||
|
// Filter states
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [lastChecked, setLastChecked] = useState<string>("");
|
const [filterStatus, setFilterStatus] = useState<'all' | 'secured' | 'unsecured' | 'expiring'>('all');
|
||||||
|
|
||||||
// Fetch SSL Data
|
const fetchDomains = async () => {
|
||||||
const loadSSL = async () => {
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
const res = await axios.get(`${API_BASE_URL}/dns/domains`);
|
||||||
|
const rawParsed = res.data.rawParsed || [];
|
||||||
const response = await axios.get("http://localhost:3010/api/ssl/status");
|
// If rawParsed is empty but domains exists (simple list), mapped it to objects for consistency
|
||||||
setData(response.data);
|
// but user provided rawParsed structure, so we prioritize that.
|
||||||
|
setDomains(rawParsed);
|
||||||
const now = new Date().toLocaleString();
|
setFilteredDomains(rawParsed);
|
||||||
setLastChecked(now);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("SSL API Error:", error);
|
console.error("Error fetching domains:", error);
|
||||||
|
setMessage({ type: 'error', text: "Failed to fetch domains." });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto load on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSSL();
|
fetchDomains();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Filter domains
|
// Filter Logic
|
||||||
const filteredData = data.filter((item) =>
|
useEffect(() => {
|
||||||
item.domain.toLowerCase().includes(search.toLowerCase())
|
let result = domains;
|
||||||
);
|
|
||||||
|
// Search
|
||||||
|
if (search) {
|
||||||
|
result = result.filter(d => d.domain.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status Filter
|
||||||
|
if (filterStatus === 'secured') {
|
||||||
|
result = result.filter(d => d.ssl && d.ssl.success);
|
||||||
|
} else if (filterStatus === 'unsecured') {
|
||||||
|
result = result.filter(d => !d.ssl || !d.ssl.success);
|
||||||
|
} else if (filterStatus === 'expiring') {
|
||||||
|
const today = new Date();
|
||||||
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||||
|
result = result.filter(d => {
|
||||||
|
if (!d.ssl || !d.ssl.success || !d.ssl.notAfter) return false;
|
||||||
|
const expiryDate = new Date(d.ssl.notAfter);
|
||||||
|
const diffTime = expiryDate.getTime() - today.getTime();
|
||||||
|
const diffDays = Math.ceil(diffTime / oneDayMs);
|
||||||
|
return diffDays <= 7; // Show warning for 7 days in UI, though email is 1 day
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredDomains(result);
|
||||||
|
}, [search, filterStatus, domains]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleAction = async (domain: string, action: 'add' | 'remove' | 'refresh') => {
|
||||||
|
setActionLoading(prev => ({ ...prev, [domain]: action }));
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const endpoint = action === 'add' ? '/ssl/add'
|
||||||
|
: action === 'remove' ? '/ssl/remove'
|
||||||
|
: '/ssl/refresh';
|
||||||
|
|
||||||
|
// Note: The API likely expects just { domain: "example.com" }
|
||||||
|
await axios.post(`${API_BASE_URL}${endpoint}`, { domain });
|
||||||
|
setMessage({ type: 'success', text: `Successfully ${action}ed SSL for ${domain}` });
|
||||||
|
|
||||||
|
// Refresh list to update status
|
||||||
|
fetchDomains();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error during ${action} SSL:`, error);
|
||||||
|
setMessage({ type: 'error', text: `Failed to ${action} SSL for ${domain}: ${error.response?.data?.message || error.message}` });
|
||||||
|
} finally {
|
||||||
|
setActionLoading(prev => ({ ...prev, [domain]: null }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshAll = async () => {
|
||||||
|
setGlobalLoading(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_BASE_URL}/ssl/refresh-all`);
|
||||||
|
setMessage({ type: 'success', text: "Initiated SSL refresh for all domains." });
|
||||||
|
setTimeout(fetchDomains, 2000); // Wait a bit then refresh list
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error refreshing all SSL:", error);
|
||||||
|
setMessage({ type: 'error', text: `Failed to refresh all SSL: ${error.response?.data?.message || error.message}` });
|
||||||
|
} finally {
|
||||||
|
setGlobalLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckExpiryAndNotify = async () => {
|
||||||
|
setCheckingExpiry(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await axios.post('/api/send-expiry-alert', { domains });
|
||||||
|
if (res.data.success) {
|
||||||
|
setMessage({ type: 'success', text: res.data.message });
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: "Failed to process expiry check." });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error checking expiry:", error);
|
||||||
|
setMessage({ type: 'error', text: "Error checking expiry: " + error.message });
|
||||||
|
} finally {
|
||||||
|
setCheckingExpiry(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDaysRemaining = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const today = new Date();
|
||||||
|
const expiryDate = new Date(dateStr);
|
||||||
|
const diffTime = expiryDate.getTime() - today.getTime();
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-8 text-gray-900">
|
<div className="panel">
|
||||||
<h1 className="text-3xl font-bold text-center mb-6">
|
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
SSL Expiry Status Checker
|
<h5 className="text-lg font-semibold dark:text-white-light">SSL Management</h5>
|
||||||
</h1>
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
{/* Search + Refresh */}
|
onClick={handleCheckExpiryAndNotify}
|
||||||
<div className="flex items-center justify-between mb-5">
|
disabled={checkingExpiry}
|
||||||
<input
|
className="btn btn-warning flex items-center gap-2"
|
||||||
type="text"
|
title="Check for expiring certs and send email alert"
|
||||||
placeholder="Search domain..."
|
>
|
||||||
value={search}
|
{checkingExpiry ? <IconLoader className="animate-spin" /> : null}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
Check & Notify Expiry
|
||||||
className="w-1/3 p-2 border border-gray-300 rounded-md focus:ring focus:ring-blue-300"
|
</button>
|
||||||
/>
|
<button
|
||||||
|
onClick={handleRefreshAll}
|
||||||
<button
|
disabled={globalLoading}
|
||||||
onClick={loadSSL}
|
className="btn btn-primary flex items-center gap-2"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition"
|
>
|
||||||
>
|
{globalLoading ? <IconLoader className="animate-spin" /> : <IconRefresh />}
|
||||||
Refresh
|
Refresh All SSL
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last checked time */}
|
{message && (
|
||||||
<p className="mb-4 text-sm text-gray-600">
|
<div className={`mb-4 rounded p-4 text-white ${message.type === 'success' ? 'bg-green-500' : 'bg-red-500'}`}>
|
||||||
Last checked: <span className="font-semibold">{lastChecked}</span>
|
{message.text}
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Loading Shimmer */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="p-10 bg-white rounded-xl shadow-md animate-pulse text-center text-gray-700">
|
|
||||||
Checking SSL certificates...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto bg-white rounded-xl shadow-lg border">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-100 border-b">
|
|
||||||
<th className="px-6 py-3 text-left font-semibold text-gray-700">
|
|
||||||
Domain
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left font-semibold text-gray-700">
|
|
||||||
Valid Until
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left font-semibold text-gray-700">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{filteredData.map((d) => (
|
|
||||||
<tr
|
|
||||||
key={d.domain}
|
|
||||||
className="border-b hover:bg-gray-50 transition"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4">{d.domain}</td>
|
|
||||||
<td className="px-6 py-4">{d.validTo}</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={`px-3 py-1 rounded-full text-sm font-semibold ${
|
|
||||||
d.expired
|
|
||||||
? "bg-red-100 text-red-700"
|
|
||||||
: "bg-green-100 text-green-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{d.expired ? "Expired" : "Valid"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{filteredData.length === 0 && (
|
|
||||||
<p className="text-center py-6 text-gray-500">
|
|
||||||
No matching domains found.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mb-5 flex flex-col gap-4 md:flex-row md:items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Search domains..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" className={`btn ${filterStatus === 'all' ? 'btn-primary' : 'btn-outline-primary'}`} onClick={() => setFilterStatus('all')}>All</button>
|
||||||
|
<button type="button" className={`btn ${filterStatus === 'secured' ? 'btn-success' : 'btn-outline-success'}`} onClick={() => setFilterStatus('secured')}>Secured</button>
|
||||||
|
<button type="button" className={`btn ${filterStatus === 'unsecured' ? 'btn-danger' : 'btn-outline-danger'}`} onClick={() => setFilterStatus('unsecured')}>No SSL</button>
|
||||||
|
<button type="button" className={`btn ${filterStatus === 'expiring' ? 'btn-warning' : 'btn-outline-warning'}`} onClick={() => setFilterStatus('expiring')}>Expiring Soon</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table-hover table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>SSL Status</th>
|
||||||
|
<th>Expiry</th>
|
||||||
|
<th className="text-center">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-5">
|
||||||
|
<div className="flex justify-center items-center">
|
||||||
|
<IconLoader className="animate-spin w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : filteredDomains.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-5">No domains found matching criteria.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredDomains.map((item: any, index: number) => {
|
||||||
|
const hasSSL = item.ssl && item.ssl.success;
|
||||||
|
const daysLeft = getDaysRemaining(item.ssl?.notAfter);
|
||||||
|
const isExpiring = daysLeft !== null && daysLeft <= 30; // Visible warning for 30 days
|
||||||
|
const isCritical = daysLeft !== null && daysLeft <= 3; // Critical warning
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={index} className={isCritical ? "bg-red-50 dark:bg-red-900/20" : ""}>
|
||||||
|
<td className="font-medium">{item.domain}</td>
|
||||||
|
<td>{item.ip}</td>
|
||||||
|
<td>
|
||||||
|
{hasSSL ? (
|
||||||
|
<span className="badge badge-outline-success">Secured</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-outline-danger">Not Secured</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{hasSSL && item.ssl.notAfter ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className={`text-xs ${isExpiring ? 'text-red-500 font-bold' : ''}`}>
|
||||||
|
{new Date(item.ssl.notAfter).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-500">
|
||||||
|
{daysLeft} days left
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{!hasSSL && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(item.domain, 'add')}
|
||||||
|
disabled={!!actionLoading[item.domain]}
|
||||||
|
className="btn btn-sm btn-outline-success flex items-center gap-1"
|
||||||
|
title="Add SSL"
|
||||||
|
>
|
||||||
|
{actionLoading[item.domain] === 'add' ? <IconLoader className="animate-spin w-4 h-4" /> : <IconPlus className="w-4 h-4" />}
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(item.domain, 'refresh')}
|
||||||
|
disabled={!!actionLoading[item.domain]}
|
||||||
|
className="btn btn-sm btn-outline-primary flex items-center gap-1"
|
||||||
|
title="Refresh SSL"
|
||||||
|
>
|
||||||
|
{actionLoading[item.domain] === 'refresh' ? <IconLoader className="animate-spin w-4 h-4" /> : <IconRefresh className="w-4 h-4" />}
|
||||||
|
Renew
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(item.domain, 'remove')}
|
||||||
|
disabled={!!actionLoading[item.domain]}
|
||||||
|
className="btn btn-sm btn-outline-danger flex items-center gap-1"
|
||||||
|
title="Remove SSL"
|
||||||
|
>
|
||||||
|
{actionLoading[item.domain] === 'remove' ? <IconLoader className="animate-spin w-4 h-4" /> : <IconTrashLines className="w-4 h-4" />}
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
56
app/api/hestia/[...path]/route.ts
Normal file
56
app/api/hestia/[...path]/route.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { pathname, search } = new URL(req.url);
|
||||||
|
const path = pathname.replace('/api/hestia', '');
|
||||||
|
const targetUrl = `https://api.hestiacp.metatronhost.com${path}${search}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(targetUrl, {
|
||||||
|
headers: {
|
||||||
|
// Forward relevant headers if needed, but for now just basic GET
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(response.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Proxy error for ${targetUrl}:`, error.message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: 'Failed to fetch from Hestia API',
|
||||||
|
error: error.message,
|
||||||
|
details: error.response?.data
|
||||||
|
},
|
||||||
|
{ status: error.response?.status || 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { pathname } = new URL(req.url);
|
||||||
|
const path = pathname.replace('/api/hestia', '');
|
||||||
|
const targetUrl = `https://api.hestiacp.metatronhost.com${path}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const response = await axios.post(targetUrl, body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(response.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Proxy POST error for ${targetUrl}:`, error.message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: 'Failed to post to Hestia API',
|
||||||
|
error: error.message,
|
||||||
|
details: error.response?.data
|
||||||
|
},
|
||||||
|
{ status: error.response?.status || 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -157,12 +157,12 @@ const Header = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}>
|
<header className={`bg-[#1a1f2b] z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}>
|
||||||
<div className="shadow-sm">
|
<div className="shadow-sm">
|
||||||
<div className="relative flex w-full items-center bg-white px-5 py-2.5 dark:bg-black">
|
<div className="relative flex w-full items-center bg-[#1a1f2b] px-5 py-2.5 dark:bg-black">
|
||||||
<div className="horizontal-logo flex items-center justify-between ltr:mr-2 rtl:ml-2 lg:hidden">
|
<div className="horizontal-logo flex items-center justify-between ltr:mr-2 rtl:ml-2 lg:hidden">
|
||||||
<Link href="/" className="main-logo flex shrink-0 items-center">
|
<Link href="/" className="main-logo flex shrink-0 items-center w-[200px]">
|
||||||
<img className="" src="/assets/images/black-logo.png" alt="logo" />
|
<img className="" src="/assets/images/logo.webp" alt="logo" />
|
||||||
{/* <span className="hidden align-middle text-2xl font-semibold transition-all duration-300 ltr:ml-1.5 rtl:mr-1.5 dark:text-white-light md:inline">CrawlerX</span> */}
|
{/* <span className="hidden align-middle text-2xl font-semibold transition-all duration-300 ltr:ml-1.5 rtl:mr-1.5 dark:text-white-light md:inline">CrawlerX</span> */}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -83,14 +83,14 @@ const Sidebar = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={semidark ? 'dark' : ''}>
|
<div className={'bg-[#1a1f2b] ' + semidark ? 'dark' : ''}>
|
||||||
<nav
|
<nav
|
||||||
className={`sidebar fixed bottom-0 top-0 z-50 h-full min-h-screen w-[260px] shadow-[5px_0_25px_0_rgba(94,92,154,0.1)] transition-all duration-300 ${semidark ? 'text-white-dark' : ''}`}
|
className={`sidebar fixed bottom-0 top-0 z-50 h-full min-h-screen w-[260px] shadow-[5px_0_25px_0_rgba(94,92,154,0.1)] transition-all duration-300 ${semidark ? 'text-white-dark' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="h-full bg-white dark:bg-black">
|
<div className="h-full bg-white dark:bg-black">
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
<Link href="/" className="main-logo flex shrink-0 items-center">
|
<Link href="/" className="main-logo flex shrink-0 items-center w-[200px]">
|
||||||
<img src="/assets/images/black-logo.png" alt="logo" />
|
<img src="/assets/images/logo.webp" alt="logo" />
|
||||||
{/* <span className="align-middle text-2xl font-semibold ltr:ml-1.5 rtl:mr-1.5 dark:text-white-light lg:inline">CrawlerX</span> */}
|
{/* <span className="align-middle text-2xl font-semibold ltr:ml-1.5 rtl:mr-1.5 dark:text-white-light lg:inline">CrawlerX</span> */}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
1336
package-lock.json
generated
1336
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@
|
|||||||
"@stripe/stripe-js": "^7.9.0",
|
"@stripe/stripe-js": "^7.9.0",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"@types/node": "^22.4.0",
|
"@types/node": "^22.4.0",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/react": "18.3.10",
|
"@types/react": "18.3.10",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
@ -25,6 +26,7 @@
|
|||||||
"i18next": "^23.13.0",
|
"i18next": "^23.13.0",
|
||||||
"next": "14.2.13",
|
"next": "14.2.13",
|
||||||
"ni18n": "^1.0.5",
|
"ni18n": "^1.0.5",
|
||||||
|
"nodemailer": "^7.0.11",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-animate-height": "^3.1.0",
|
"react-animate-height": "^3.1.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
|
|||||||
BIN
public/assets/images/logo.webp
Normal file
BIN
public/assets/images/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Loading…
x
Reference in New Issue
Block a user