mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2025-12-31 04:00:03 +01:00
(feature): formating rules
This commit is contained in:
parent
e321a3237f
commit
7d7ebd298a
@ -1,3 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals"
|
"extends": [
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"plugin:prettier/recommended"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"prettier/prettier": "error",
|
||||||
|
"semi": "off",
|
||||||
|
"quotes": "off",
|
||||||
|
"indent": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/ClientApp/.prettierignore
Normal file
25
src/ClientApp/.prettierignore
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Ignore artifacts:
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Ignore all configuration files:
|
||||||
|
*.config.js
|
||||||
|
*.config.ts
|
||||||
|
|
||||||
|
# Ignore specific files:
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Ignore logs:
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Ignore minified files:
|
||||||
|
*.min.js
|
||||||
|
|
||||||
|
# Ignore compiled code:
|
||||||
|
*.d.ts
|
||||||
|
|
||||||
|
# Ignore specific directories:
|
||||||
|
public
|
||||||
7
src/ClientApp/.prettierrc
Normal file
7
src/ClientApp/.prettierrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"trailingComma": "none"
|
||||||
|
}
|
||||||
23
src/ClientApp/.vscode/settings.json
vendored
Normal file
23
src/ClientApp/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
// "editor.codeActionsOnSave": {
|
||||||
|
// "source.fixAll.eslint": true
|
||||||
|
// },
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[json]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,12 +1,9 @@
|
|||||||
enum ApiRoutes {
|
enum ApiRoutes {
|
||||||
|
|
||||||
CACHE_ACCOUNTS = 'api/cache/accounts',
|
CACHE_ACCOUNTS = 'api/cache/accounts',
|
||||||
CACHE_ACCOUNT = 'api/cache/account/{accountId}',
|
CACHE_ACCOUNT = 'api/cache/account/{accountId}',
|
||||||
CACHE_ACCOUNT_CONTACTS = 'api/cache/account/{accountId}/contacts',
|
CACHE_ACCOUNT_CONTACTS = 'api/cache/account/{accountId}/contacts',
|
||||||
CACHE_ACCOUNT_CONTACT = 'api/cache/account/{accountId}/contacts/{index}',
|
CACHE_ACCOUNT_CONTACT = 'api/cache/account/{accountId}/contacts/{index}',
|
||||||
CACHE_ACCOUNT_HOSTNAMES = 'api/cache/account/{accountId}/hostnames',
|
CACHE_ACCOUNT_HOSTNAMES = 'api/cache/account/{accountId}/hostnames'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// CERTS_FLOW_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`,
|
// CERTS_FLOW_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`,
|
||||||
// CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`,
|
// CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`,
|
||||||
@ -19,15 +16,11 @@ enum ApiRoutes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetApiRoute = (route: ApiRoutes, ...args: string[]): string => {
|
const GetApiRoute = (route: ApiRoutes, ...args: string[]): string => {
|
||||||
let result: string = route;
|
let result: string = route
|
||||||
args.forEach(arg => {
|
args.forEach((arg) => {
|
||||||
result = result.replace(/{.*?}/, arg);
|
result = result.replace(/{.*?}/, arg)
|
||||||
});
|
})
|
||||||
return `http://localhost:5000/${result}`;
|
return `http://localhost:5000/${result}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { GetApiRoute, ApiRoutes }
|
||||||
export {
|
|
||||||
GetApiRoute,
|
|
||||||
ApiRoutes
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
const ContactPage = () => {
|
const ContactPage = () => {
|
||||||
return (<>
|
return (
|
||||||
|
<>
|
||||||
<h1 className="text-2xl font-bold">Contact Us</h1>
|
<h1 className="text-2xl font-bold">Contact Us</h1>
|
||||||
<p>This is the contact page content.</p>
|
<p>This is the contact page content.</p>
|
||||||
</>
|
</>
|
||||||
|
|||||||
41
src/ClientApp/app/functions/deepCopy.ts
Normal file
41
src/ClientApp/app/functions/deepCopy.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const deepCopy = <T>(input: T): T => {
|
||||||
|
const map = new Map()
|
||||||
|
|
||||||
|
const clone = (item: any): any => {
|
||||||
|
if (item === null || typeof item !== 'object') {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.has(item)) {
|
||||||
|
return map.get(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: any
|
||||||
|
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
result = []
|
||||||
|
map.set(item, result)
|
||||||
|
item.forEach((element, index) => {
|
||||||
|
result[index] = clone(element)
|
||||||
|
})
|
||||||
|
} else if (item instanceof Date) {
|
||||||
|
result = new Date(item)
|
||||||
|
map.set(item, result)
|
||||||
|
} else if (item instanceof RegExp) {
|
||||||
|
result = new RegExp(item)
|
||||||
|
map.set(item, result)
|
||||||
|
} else {
|
||||||
|
result = Object.create(Object.getPrototypeOf(item))
|
||||||
|
map.set(item, result)
|
||||||
|
Object.keys(item).forEach((key) => {
|
||||||
|
result[key] = clone(item[key])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { deepCopy }
|
||||||
3
src/ClientApp/app/functions/index.ts
Normal file
3
src/ClientApp/app/functions/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { deepCopy } from './deepCopy'
|
||||||
|
|
||||||
|
export { deepCopy }
|
||||||
@ -1,4 +1,4 @@
|
|||||||
"use client"
|
'use client'
|
||||||
|
|
||||||
import React, { FC, useState, useEffect, useRef } from 'react'
|
import React, { FC, useState, useEffect, useRef } from 'react'
|
||||||
import { SideMenu } from '@/components/sidemenu'
|
import { SideMenu } from '@/components/sidemenu'
|
||||||
@ -13,8 +13,8 @@ import { store } from '@/redux/store'
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const metadata: Metadata = {
|
const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'Create Next App',
|
||||||
description: "Generated by create next app",
|
description: 'Generated by create next app'
|
||||||
}
|
}
|
||||||
|
|
||||||
const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
|
const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
@ -75,16 +75,16 @@ const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
<Loader />
|
<Loader />
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<SideMenu isCollapsed={isSidebarCollapsed} toggleSidebar={manuallyToggleSidebar} />
|
<SideMenu
|
||||||
|
isCollapsed={isSidebarCollapsed}
|
||||||
|
toggleSidebar={manuallyToggleSidebar}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
<TopMenu onToggleOffCanvas={toggleOffCanvas} />
|
<TopMenu onToggleOffCanvas={toggleOffCanvas} />
|
||||||
<main className="flex-1 p-4 overflow-y-auto">
|
<main className="flex-1 p-4 overflow-y-auto">{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
<Footer className="flex-shrink-0" />
|
<Footer className="flex-shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OffCanvas isOpen={isOffCanvasOpen} onClose={toggleOffCanvas} />
|
<OffCanvas isOpen={isOffCanvasOpen} onClose={toggleOffCanvas} />
|
||||||
|
|||||||
@ -1,26 +1,18 @@
|
|||||||
"use client"
|
'use client'
|
||||||
|
|
||||||
import { ApiRoutes, GetApiRoute } from "@/ApiRoutes"
|
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
|
||||||
import { httpService } from "@/services/httpService"
|
import { httpService } from '@/services/httpService'
|
||||||
import { FormEvent, useEffect, useRef, useState } from "react"
|
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||||
import { useValidation, isValidEmail, isValidHostname } from "@/hooks/useValidation"
|
import {
|
||||||
import { CustomButton, CustomInput } from "@/controls"
|
useValidation,
|
||||||
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid"
|
isValidEmail,
|
||||||
import { GetAccountResponse } from "@/models/letsEncryptServer/cache/responses/GetAccountResponse"
|
isValidHostname
|
||||||
|
} from '@/hooks/useValidation'
|
||||||
interface CacheAccountHostname {
|
import { CustomButton, CustomInput } from '@/controls'
|
||||||
hostname: string
|
import { TrashIcon, PlusIcon } from '@heroicons/react/24/solid'
|
||||||
expires: Date
|
import { GetAccountResponse } from '@/models/letsEncryptServer/cache/responses/GetAccountResponse'
|
||||||
isUpcomingExpire: boolean
|
import { deepCopy } from './functions'
|
||||||
}
|
import { CacheAccount } from '@/entities/CacheAccount'
|
||||||
|
|
||||||
interface CacheAccount {
|
|
||||||
accountId: string
|
|
||||||
description?: string
|
|
||||||
contacts: string[]
|
|
||||||
hostnames: CacheAccountHostname[]
|
|
||||||
isEditMode: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [accounts, setAccounts] = useState<CacheAccount[]>([])
|
const [accounts, setAccounts] = useState<CacheAccount[]>([])
|
||||||
@ -31,46 +23,48 @@ export default function Page() {
|
|||||||
error: contactError,
|
error: contactError,
|
||||||
handleChange: handleContactChange
|
handleChange: handleContactChange
|
||||||
} = useValidation({
|
} = useValidation({
|
||||||
initialValue:"",
|
initialValue: '',
|
||||||
validateFn: isValidEmail,
|
validateFn: isValidEmail,
|
||||||
errorMessage: "Invalid email format."
|
errorMessage: 'Invalid email format.'
|
||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
value: newHostname,
|
value: newHostname,
|
||||||
error: hostnameError,
|
error: hostnameError,
|
||||||
handleChange: handleHostnameChange
|
handleChange: handleHostnameChange
|
||||||
} = useValidation({
|
} = useValidation({
|
||||||
initialValue: "",
|
initialValue: '',
|
||||||
validateFn: isValidHostname,
|
validateFn: isValidHostname,
|
||||||
errorMessage: "Invalid hostname format."})
|
errorMessage: 'Invalid hostname format.'
|
||||||
|
})
|
||||||
|
|
||||||
const init = useRef(false)
|
const init = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (init.current) return
|
if (init.current) return
|
||||||
|
|
||||||
|
console.log('Fetching accounts')
|
||||||
console.log("Fetching accounts")
|
|
||||||
|
|
||||||
const fetchAccounts = async () => {
|
const fetchAccounts = async () => {
|
||||||
const newAccounts: CacheAccount[] = []
|
const newAccounts: CacheAccount[] = []
|
||||||
const accounts = await httpService.get<GetAccountResponse []>(GetApiRoute(ApiRoutes.CACHE_ACCOUNTS))
|
const accounts = await httpService.get<GetAccountResponse[]>(
|
||||||
|
GetApiRoute(ApiRoutes.CACHE_ACCOUNTS)
|
||||||
|
)
|
||||||
|
|
||||||
accounts?.forEach((account) => {
|
accounts?.forEach((account) => {
|
||||||
newAccounts.push({
|
newAccounts.push({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
contacts: account.contacts,
|
contacts: account.contacts,
|
||||||
hostnames: account.hostnames.map(h => ({
|
hostnames: account.hostnames.map((h) => ({
|
||||||
hostname: h.hostname,
|
hostname: h.hostname,
|
||||||
expires: new Date(h.expires),
|
expires: new Date(h.expires),
|
||||||
isUpcomingExpire: h.isUpcomingExpire
|
isUpcomingExpire: h.isUpcomingExpire
|
||||||
})),
|
})),
|
||||||
isEditMode: false
|
isEditMode: false
|
||||||
})
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
setAccounts(newAccounts)
|
setAccounts(newAccounts)
|
||||||
setInitialAccounts(JSON.parse(JSON.stringify(newAccounts))) // Clone initial state
|
setInitialAccounts(deepCopy(newAccounts)) // Clone initial state
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchAccounts()
|
fetchAccounts()
|
||||||
@ -78,131 +72,197 @@ export default function Page() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggleEditMode = (accountId: string) => {
|
const toggleEditMode = (accountId: string) => {
|
||||||
setAccounts(accounts.map(account =>
|
setAccounts(
|
||||||
account.accountId === accountId ? { ...account, isEditMode: !account.isEditMode } : account
|
accounts.map((account) =>
|
||||||
))
|
account.accountId === accountId
|
||||||
|
? { ...account, isEditMode: !account.isEditMode }
|
||||||
|
: account
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteAccount = (accountId: string) => {
|
const deleteAccount = (accountId: string) => {
|
||||||
setAccounts(accounts.filter(account => account.accountId !== accountId))
|
setAccounts(accounts.filter((account) => account.accountId !== accountId))
|
||||||
|
|
||||||
// TODO: Revoke all certificates
|
// TODO: Revoke all certificates
|
||||||
// TODO: Remove from cache
|
// TODO: Remove from cache
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteContact = (accountId: string, contact: string) => {
|
const deleteContact = (accountId: string, contact: string) => {
|
||||||
const account = accounts.find(account => account.accountId === accountId)
|
const account = accounts.find((account) => account.accountId === accountId)
|
||||||
if (account?.contacts.length ?? 0 < 1) return
|
if (account?.contacts.length ?? 0 < 1) return
|
||||||
|
|
||||||
// TODO: Remove from cache
|
// TODO: Remove from cache
|
||||||
httpService.delete(GetApiRoute(ApiRoutes.CACHE_ACCOUNT_CONTACT, accountId, contact))
|
httpService.delete(
|
||||||
|
GetApiRoute(ApiRoutes.CACHE_ACCOUNT_CONTACT, accountId, contact)
|
||||||
|
)
|
||||||
|
|
||||||
setAccounts(accounts.map(account =>
|
setAccounts(
|
||||||
|
accounts.map((account) =>
|
||||||
account.accountId === accountId
|
account.accountId === accountId
|
||||||
? { ...account, contacts: account.contacts.filter(c => c !== contact) }
|
? {
|
||||||
|
...account,
|
||||||
|
contacts: account.contacts.filter((c) => c !== contact)
|
||||||
|
}
|
||||||
: account
|
: account
|
||||||
))
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addContact = (accountId: string) => {
|
const addContact = (accountId: string) => {
|
||||||
if (newContact.trim() === "" || contactError) {
|
if (newContact.trim() === '' || contactError) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accounts.find(account => account.accountId === accountId)?.contacts.includes(newContact.trim()))
|
if (
|
||||||
|
accounts
|
||||||
|
.find((account) => account.accountId === accountId)
|
||||||
|
?.contacts.includes(newContact.trim())
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
setAccounts(accounts.map(account =>
|
setAccounts(
|
||||||
|
accounts.map((account) =>
|
||||||
account.accountId === accountId
|
account.accountId === accountId
|
||||||
? { ...account, contacts: [...account.contacts, newContact.trim()] }
|
? { ...account, contacts: [...account.contacts, newContact.trim()] }
|
||||||
: account
|
: account
|
||||||
))
|
)
|
||||||
handleContactChange("")
|
)
|
||||||
|
handleContactChange('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteHostname = (accountId: string, hostname: string) => {
|
const deleteHostname = (accountId: string, hostname: string) => {
|
||||||
const account = accounts.find(account => account.accountId === accountId)
|
const account = accounts.find((account) => account.accountId === accountId)
|
||||||
if (account?.hostnames.length ?? 0 < 1) return
|
if (account?.hostnames.length ?? 0 < 1) return
|
||||||
|
|
||||||
// TODO: Revoke certificate
|
// TODO: Revoke certificate
|
||||||
// TODO: Remove from cache
|
// TODO: Remove from cache
|
||||||
|
|
||||||
setAccounts(accounts.map(account =>
|
setAccounts(
|
||||||
|
accounts.map((account) =>
|
||||||
account.accountId === accountId
|
account.accountId === accountId
|
||||||
? { ...account, hostnames: account.hostnames.filter(h => h.hostname !== hostname) }
|
? {
|
||||||
|
...account,
|
||||||
|
hostnames: account.hostnames.filter(
|
||||||
|
(h) => h.hostname !== hostname
|
||||||
|
)
|
||||||
|
}
|
||||||
: account
|
: account
|
||||||
))
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addHostname = (accountId: string) => {
|
const addHostname = (accountId: string) => {
|
||||||
if (newHostname.trim() === "" || hostnameError) {
|
if (newHostname.trim() === '' || hostnameError) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accounts.find(account => account.accountId === accountId)?.hostnames.some(h => h.hostname === newHostname.trim()))
|
if (
|
||||||
|
accounts
|
||||||
|
.find((account) => account.accountId === accountId)
|
||||||
|
?.hostnames.some((h) => h.hostname === newHostname.trim())
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
setAccounts(accounts.map(account =>
|
setAccounts(
|
||||||
|
accounts.map((account) =>
|
||||||
account.accountId === accountId
|
account.accountId === accountId
|
||||||
? { ...account, hostnames: [...account.hostnames, { hostname: newHostname.trim(), expires: new Date(), isUpcomingExpire: false }] }
|
? {
|
||||||
|
...account,
|
||||||
|
hostnames: [
|
||||||
|
...account.hostnames,
|
||||||
|
{
|
||||||
|
hostname: newHostname.trim(),
|
||||||
|
expires: new Date(),
|
||||||
|
isUpcomingExpire: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
: account
|
: account
|
||||||
))
|
)
|
||||||
handleHostnameChange("")
|
)
|
||||||
|
handleHostnameChange('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>, accountId: string) => {
|
const handleSubmit = async (
|
||||||
|
e: FormEvent<HTMLFormElement>,
|
||||||
|
accountId: string
|
||||||
|
) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const account = accounts.find(acc => acc.accountId === accountId)
|
const account = accounts.find((acc) => acc.accountId === accountId)
|
||||||
const initialAccount = initialAccounts.find(acc => acc.accountId === accountId)
|
const initialAccount = initialAccounts.find(
|
||||||
|
(acc) => acc.accountId === accountId
|
||||||
|
)
|
||||||
|
|
||||||
if (!account || !initialAccount) return
|
if (!account || !initialAccount) return
|
||||||
|
|
||||||
const contactChanges = {
|
const contactChanges = {
|
||||||
added: account.contacts.filter(contact => !initialAccount.contacts.includes(contact)),
|
added: account.contacts.filter(
|
||||||
removed: initialAccount.contacts.filter(contact => !account.contacts.includes(contact))
|
(contact) => !initialAccount.contacts.includes(contact)
|
||||||
|
),
|
||||||
|
removed: initialAccount.contacts.filter(
|
||||||
|
(contact) => !account.contacts.includes(contact)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostnameChanges = {
|
const hostnameChanges = {
|
||||||
added: account.hostnames.filter(hostname => !initialAccount.hostnames.some(h => h.hostname === hostname.hostname)),
|
added: account.hostnames.filter(
|
||||||
removed: initialAccount.hostnames.filter(hostname => !account.hostnames.some(h => h.hostname === hostname.hostname))
|
(hostname) =>
|
||||||
|
!initialAccount.hostnames.some(
|
||||||
|
(h) => h.hostname === hostname.hostname
|
||||||
|
)
|
||||||
|
),
|
||||||
|
removed: initialAccount.hostnames.filter(
|
||||||
|
(hostname) =>
|
||||||
|
!account.hostnames.some((h) => h.hostname === hostname.hostname)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle contact changes
|
// Handle contact changes
|
||||||
if (contactChanges.added.length > 0) {
|
if (contactChanges.added.length > 0) {
|
||||||
// TODO: POST new contacts
|
// TODO: POST new contacts
|
||||||
console.log("Added contacts:", contactChanges.added)
|
console.log('Added contacts:', contactChanges.added)
|
||||||
}
|
}
|
||||||
if (contactChanges.removed.length > 0) {
|
if (contactChanges.removed.length > 0) {
|
||||||
// TODO: DELETE removed contacts
|
// TODO: DELETE removed contacts
|
||||||
console.log("Removed contacts:", contactChanges.removed)
|
console.log('Removed contacts:', contactChanges.removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle hostname changes
|
// Handle hostname changes
|
||||||
if (hostnameChanges.added.length > 0) {
|
if (hostnameChanges.added.length > 0) {
|
||||||
// TODO: POST new hostnames
|
// TODO: POST new hostnames
|
||||||
console.log("Added hostnames:", hostnameChanges.added)
|
console.log('Added hostnames:', hostnameChanges.added)
|
||||||
}
|
}
|
||||||
if (hostnameChanges.removed.length > 0) {
|
if (hostnameChanges.removed.length > 0) {
|
||||||
// TODO: DELETE removed hostnames
|
// TODO: DELETE removed hostnames
|
||||||
console.log("Removed hostnames:", hostnameChanges.removed)
|
console.log('Removed hostnames:', hostnameChanges.removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current state as initial state
|
// Save current state as initial state
|
||||||
setInitialAccounts(JSON.parse(JSON.stringify(accounts)))
|
setInitialAccounts(deepCopy(accounts))
|
||||||
toggleEditMode(accountId)
|
toggleEditMode(accountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<div className="container mx-auto p-4">
|
||||||
<h1 className="text-4xl font-bold text-center mb-8">LetsEncrypt Auto Renew</h1>
|
<h1 className="text-4xl font-bold text-center mb-8">
|
||||||
{
|
LetsEncrypt Auto Renew
|
||||||
accounts.map(account => (
|
</h1>
|
||||||
<div key={account.accountId} className="bg-white shadow-lg rounded-lg p-6 mb-6">
|
{accounts.map((account) => (
|
||||||
|
<div
|
||||||
|
key={account.accountId}
|
||||||
|
className="bg-white shadow-lg rounded-lg p-6 mb-6"
|
||||||
|
>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-2xl font-semibold">Account: {account.accountId}</h2>
|
<h2 className="text-2xl font-semibold">
|
||||||
<CustomButton onClick={() => toggleEditMode(account.accountId)} className="bg-blue-500 text-white px-3 py-1 rounded">
|
Account: {account.accountId}
|
||||||
{account.isEditMode ? "View Mode" : "Edit Mode"}
|
</h2>
|
||||||
|
<CustomButton
|
||||||
|
onClick={() => toggleEditMode(account.accountId)}
|
||||||
|
className="bg-blue-500 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
{account.isEditMode ? 'View Mode' : 'Edit Mode'}
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
</div>
|
</div>
|
||||||
{account.isEditMode ? (
|
{account.isEditMode ? (
|
||||||
@ -213,16 +273,22 @@ export default function Page() {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
||||||
<ul className="list-disc list-inside pl-4 mb-2">
|
<ul className="list-disc list-inside pl-4 mb-2">
|
||||||
{
|
{account.contacts.map((contact) => (
|
||||||
account.contacts.map(contact => (
|
<li
|
||||||
<li key={contact} className="text-gray-700 flex justify-between items-center mb-2">
|
key={contact}
|
||||||
|
className="text-gray-700 flex justify-between items-center mb-2"
|
||||||
|
>
|
||||||
{contact}
|
{contact}
|
||||||
<button onClick={() => deleteContact(account.accountId, contact)} className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
deleteContact(account.accountId, contact)
|
||||||
|
}
|
||||||
|
className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10"
|
||||||
|
>
|
||||||
<TrashIcon className="h-5 w-5 text-white" />
|
<TrashIcon className="h-5 w-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))
|
))}
|
||||||
}
|
|
||||||
</ul>
|
</ul>
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<CustomInput
|
<CustomInput
|
||||||
@ -236,7 +302,10 @@ export default function Page() {
|
|||||||
errorClassName="text-red-500 text-sm mt-1"
|
errorClassName="text-red-500 text-sm mt-1"
|
||||||
className="mr-2 flex-grow"
|
className="mr-2 flex-grow"
|
||||||
/>
|
/>
|
||||||
<button onClick={() => addContact(account.accountId)} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
|
<button
|
||||||
|
onClick={() => addContact(account.accountId)}
|
||||||
|
className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center"
|
||||||
|
>
|
||||||
<PlusIcon className="h-5 w-5 text-white" />
|
<PlusIcon className="h-5 w-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -244,21 +313,32 @@ export default function Page() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
|
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
|
||||||
<ul className="list-disc list-inside pl-4 mb-2">
|
<ul className="list-disc list-inside pl-4 mb-2">
|
||||||
{
|
{account.hostnames.map((hostname) => (
|
||||||
account.hostnames.map(hostname => (
|
<li
|
||||||
<li key={hostname.hostname} className="text-gray-700 flex justify-between items-center mb-2">
|
key={hostname.hostname}
|
||||||
|
className="text-gray-700 flex justify-between items-center mb-2"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
{hostname.hostname} - {hostname.expires.toDateString()} -
|
{hostname.hostname} - {hostname.expires.toDateString()}{' '}
|
||||||
<span className={`ml-2 px-2 py-1 rounded ${hostname.isUpcomingExpire ? 'bg-yellow-200 text-yellow-800' : 'bg-green-200 text-green-800'}`}>
|
-
|
||||||
{hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'}
|
<span
|
||||||
|
className={`ml-2 px-2 py-1 rounded ${hostname.isUpcomingExpire ? 'bg-yellow-200 text-yellow-800' : 'bg-green-200 text-green-800'}`}
|
||||||
|
>
|
||||||
|
{hostname.isUpcomingExpire
|
||||||
|
? 'Upcoming'
|
||||||
|
: 'Not Upcoming'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => deleteHostname(account.accountId, hostname.hostname)} className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
deleteHostname(account.accountId, hostname.hostname)
|
||||||
|
}
|
||||||
|
className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10"
|
||||||
|
>
|
||||||
<TrashIcon className="h-5 w-5 text-white" />
|
<TrashIcon className="h-5 w-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))
|
))}
|
||||||
}
|
|
||||||
</ul>
|
</ul>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<CustomInput
|
<CustomInput
|
||||||
@ -272,16 +352,25 @@ export default function Page() {
|
|||||||
errorClassName="text-red-500 text-sm mt-1"
|
errorClassName="text-red-500 text-sm mt-1"
|
||||||
className="mr-2 flex-grow"
|
className="mr-2 flex-grow"
|
||||||
/>
|
/>
|
||||||
<button onClick={() => addHostname(account.accountId)} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
|
<button
|
||||||
|
onClick={() => addHostname(account.accountId)}
|
||||||
|
className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center"
|
||||||
|
>
|
||||||
<PlusIcon className="h-5 w-5 text-white" />
|
<PlusIcon className="h-5 w-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-4">
|
<div className="flex justify-between mt-4">
|
||||||
<button onClick={() => deleteAccount(account.accountId)} className="bg-red-500 text-white px-3 py-1 rounded">
|
<button
|
||||||
|
onClick={() => deleteAccount(account.accountId)}
|
||||||
|
className="bg-red-500 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
<TrashIcon className="h-5 w-5 text-white" />
|
<TrashIcon className="h-5 w-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<CustomButton type="submit" className="bg-green-500 text-white px-3 py-1 rounded">
|
<CustomButton
|
||||||
|
type="submit"
|
||||||
|
className="bg-green-500 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
Submit
|
Submit
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
</div>
|
</div>
|
||||||
@ -294,35 +383,34 @@ export default function Page() {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
||||||
<ul className="list-disc list-inside pl-4 mb-2">
|
<ul className="list-disc list-inside pl-4 mb-2">
|
||||||
{
|
{account.contacts.map((contact) => (
|
||||||
account.contacts.map(contact => (
|
|
||||||
<li key={contact} className="text-gray-700 mb-2">
|
<li key={contact} className="text-gray-700 mb-2">
|
||||||
{contact}
|
{contact}
|
||||||
</li>
|
</li>
|
||||||
))
|
))}
|
||||||
}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
|
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
|
||||||
<ul className="list-disc list-inside pl-4 mb-2">
|
<ul className="list-disc list-inside pl-4 mb-2">
|
||||||
{
|
{account.hostnames.map((hostname) => (
|
||||||
account.hostnames.map(hostname => (
|
|
||||||
<li key={hostname.hostname} className="text-gray-700 mb-2">
|
<li key={hostname.hostname} className="text-gray-700 mb-2">
|
||||||
{hostname.hostname} - {hostname.expires.toDateString()} -
|
{hostname.hostname} - {hostname.expires.toDateString()} -
|
||||||
<span className={`ml-2 px-2 py-1 rounded ${hostname.isUpcomingExpire ? 'bg-yellow-200 text-yellow-800' : 'bg-green-200 text-green-800'}`}>
|
<span
|
||||||
{hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'}
|
className={`ml-2 px-2 py-1 rounded ${hostname.isUpcomingExpire ? 'bg-yellow-200 text-yellow-800' : 'bg-green-200 text-green-800'}`}
|
||||||
|
>
|
||||||
|
{hostname.isUpcomingExpire
|
||||||
|
? 'Upcoming'
|
||||||
|
: 'Not Upcoming'}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))
|
))}
|
||||||
}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +1,26 @@
|
|||||||
"use client"
|
'use client'
|
||||||
|
|
||||||
import { ApiRoutes, GetApiRoute } from "@/ApiRoutes"
|
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
|
||||||
import { httpService } from "@/services/httpService"
|
import { httpService } from '@/services/httpService'
|
||||||
import { FormEvent, useEffect, useRef, useState } from "react"
|
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||||
import { useValidation, isValidContact, isValidHostname } from "@/hooks/useValidation"
|
import {
|
||||||
import { CustomButton, CustomInput } from "@/controls"
|
useValidation,
|
||||||
import { FaTrash, FaPlus } from "react-icons/fa"
|
isValidContact,
|
||||||
import { GetAccountResponse } from "@/models/letsEncryptServer/cache/responses/GetAccountResponse"
|
isValidHostname
|
||||||
|
} from '@/hooks/useValidation'
|
||||||
interface CacheAccountHostname {
|
import { CustomButton, CustomInput } from '@/controls'
|
||||||
hostname: string
|
import { FaTrash, FaPlus } from 'react-icons/fa'
|
||||||
expires: Date
|
import { GetAccountResponse } from '@/models/letsEncryptServer/cache/responses/GetAccountResponse'
|
||||||
isUpcomingExpire: boolean
|
import { deepCopy } from '../functions'
|
||||||
}
|
|
||||||
|
|
||||||
interface CacheAccount {
|
interface CacheAccount {
|
||||||
accountId: string
|
|
||||||
description?: string
|
description?: string
|
||||||
contacts: string[]
|
contacts: string[]
|
||||||
hostnames: CacheAccountHostname[]
|
hostnames: string[]
|
||||||
isEditMode: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RegisterPage = () => {
|
const RegisterPage = () => {
|
||||||
const [accounts, setAccounts] = useState<CacheAccount[]>([])
|
const [account, setAccount] = useState<CacheAccount | null>(null)
|
||||||
const [initialAccounts, setInitialAccounts] = useState<CacheAccount[]>([])
|
|
||||||
const [description, setDescription] = useState("")
|
|
||||||
const [contacts, setContacts] = useState<string[]>([])
|
|
||||||
const [hostnames, setHostnames] = useState<string[]>([])
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
value: newContact,
|
value: newContact,
|
||||||
@ -35,9 +28,9 @@ const RegisterPage = () => {
|
|||||||
handleChange: handleContactChange,
|
handleChange: handleContactChange,
|
||||||
reset: resetContact
|
reset: resetContact
|
||||||
} = useValidation({
|
} = useValidation({
|
||||||
initialValue: "",
|
initialValue: '',
|
||||||
validateFn: isValidContact,
|
validateFn: isValidContact,
|
||||||
errorMessage: "Invalid contact. Must be a valid email or phone number."
|
errorMessage: 'Invalid contact. Must be a valid email or phone number.'
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -46,9 +39,9 @@ const RegisterPage = () => {
|
|||||||
handleChange: handleHostnameChange,
|
handleChange: handleHostnameChange,
|
||||||
reset: resetHostname
|
reset: resetHostname
|
||||||
} = useValidation({
|
} = useValidation({
|
||||||
initialValue: "",
|
initialValue: '',
|
||||||
validateFn: isValidHostname,
|
validateFn: isValidHostname,
|
||||||
errorMessage: "Invalid hostname format."
|
errorMessage: 'Invalid hostname format.'
|
||||||
})
|
})
|
||||||
|
|
||||||
const init = useRef(false)
|
const init = useRef(false)
|
||||||
@ -56,84 +49,90 @@ const RegisterPage = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (init.current) return
|
if (init.current) return
|
||||||
|
|
||||||
const fetchAccounts = async () => {
|
|
||||||
const newAccounts: CacheAccount[] = []
|
|
||||||
const accounts = await httpService.get<GetAccountResponse[]>(GetApiRoute(ApiRoutes.CACHE_ACCOUNTS))
|
|
||||||
|
|
||||||
accounts?.forEach((account) => {
|
|
||||||
newAccounts.push({
|
|
||||||
accountId: account.accountId,
|
|
||||||
contacts: account.contacts,
|
|
||||||
hostnames: account.hostnames.map(h => ({
|
|
||||||
hostname: h.hostname,
|
|
||||||
expires: new Date(h.expires),
|
|
||||||
isUpcomingExpire: h.isUpcomingExpire
|
|
||||||
})),
|
|
||||||
isEditMode: false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
setAccounts(newAccounts)
|
|
||||||
setInitialAccounts(JSON.parse(JSON.stringify(newAccounts))) // Clone initial state
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAccounts()
|
|
||||||
init.current = true
|
init.current = true
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleDescription = (description: string) => {}
|
||||||
|
|
||||||
const handleAddContact = () => {
|
const handleAddContact = () => {
|
||||||
if (newContact.trim() !== "" && !contactError) {
|
if (newContact !== '' || contactError) return
|
||||||
setContacts([...contacts, newContact.trim()])
|
|
||||||
resetContact()
|
setAccount((prev) => {
|
||||||
|
const newAccount: CacheAccount =
|
||||||
|
prev !== null
|
||||||
|
? deepCopy(prev)
|
||||||
|
: {
|
||||||
|
contacts: [],
|
||||||
|
hostnames: []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newAccount.contacts.push(newContact)
|
||||||
|
|
||||||
|
return newAccount
|
||||||
|
})
|
||||||
|
|
||||||
|
resetContact()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddHostname = () => {
|
const handleAddHostname = () => {
|
||||||
if (newHostname.trim() !== "" && !hostnameError) {
|
if (newHostname !== '' || hostnameError) return
|
||||||
setHostnames([...hostnames, newHostname.trim()])
|
|
||||||
resetHostname()
|
setAccount((prev) => {
|
||||||
|
const newAccount: CacheAccount =
|
||||||
|
prev !== null
|
||||||
|
? deepCopy(prev)
|
||||||
|
: {
|
||||||
|
contacts: [],
|
||||||
|
hostnames: []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newAccount.hostnames.push(newHostname)
|
||||||
|
|
||||||
|
return newAccount
|
||||||
|
})
|
||||||
|
|
||||||
|
resetHostname()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteContact = (contact: string) => {
|
const handleDeleteContact = (contact: string) => {
|
||||||
setContacts(contacts.filter(c => c !== contact))
|
setAccount((prev) => {
|
||||||
|
if (prev === null) return null
|
||||||
|
|
||||||
|
const newAccount = deepCopy(prev)
|
||||||
|
newAccount.contacts = newAccount.contacts.filter((c) => c !== contact)
|
||||||
|
|
||||||
|
return newAccount
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteHostname = (hostname: string) => {
|
const handleDeleteHostname = (hostname: string) => {
|
||||||
setHostnames(hostnames.filter(h => h !== hostname))
|
setAccount((prev) => {
|
||||||
|
if (prev === null) return null
|
||||||
|
|
||||||
|
const newAccount = deepCopy(prev)
|
||||||
|
newAccount.hostnames = newAccount.hostnames.filter((h) => h !== hostname)
|
||||||
|
|
||||||
|
return newAccount
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (!description || contacts.length === 0 || hostnames.length === 0) {
|
console.log(account)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const newAccount = {
|
|
||||||
description,
|
|
||||||
contacts,
|
|
||||||
hostnames: hostnames.map(hostname => ({ hostname, expires: new Date(), isUpcomingExpire: false }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Implement API call to create new account
|
|
||||||
console.log("New account data:", newAccount)
|
|
||||||
|
|
||||||
// Reset form fields
|
|
||||||
setDescription("")
|
|
||||||
setContacts([])
|
|
||||||
setHostnames([])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<div className="container mx-auto p-4">
|
||||||
<h1 className="text-4xl font-bold text-center mb-8">Register LetsEncrypt Account</h1>
|
<h1 className="text-4xl font-bold text-center mb-8">
|
||||||
|
Register LetsEncrypt Account
|
||||||
|
</h1>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<CustomInput
|
<CustomInput
|
||||||
type="text"
|
type="text"
|
||||||
value={description}
|
value={account?.description ?? ''}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={handleDescription}
|
||||||
placeholder="Account Description"
|
placeholder="Account Description"
|
||||||
title="Description"
|
title="Description"
|
||||||
inputClassName="border p-2 rounded w-full"
|
inputClassName="border p-2 rounded w-full"
|
||||||
@ -143,10 +142,17 @@ const RegisterPage = () => {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
||||||
<ul className="list-disc list-inside pl-4 mb-2">
|
<ul className="list-disc list-inside pl-4 mb-2">
|
||||||
{contacts.map(contact => (
|
{account?.contacts.map((contact) => (
|
||||||
<li key={contact} className="text-gray-700 flex justify-between items-center mb-2">
|
<li
|
||||||
|
key={contact}
|
||||||
|
className="text-gray-700 flex justify-between items-center mb-2"
|
||||||
|
>
|
||||||
{contact}
|
{contact}
|
||||||
<button type="button" onClick={() => handleDeleteContact(contact)} className="bg-red-500 text-white px-2 py-1 rounded ml-4">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteContact(contact)}
|
||||||
|
className="bg-red-500 text-white px-2 py-1 rounded ml-4"
|
||||||
|
>
|
||||||
<FaTrash />
|
<FaTrash />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@ -164,7 +170,11 @@ const RegisterPage = () => {
|
|||||||
errorClassName="text-red-500 text-sm mt-1"
|
errorClassName="text-red-500 text-sm mt-1"
|
||||||
className="mr-2 flex-grow"
|
className="mr-2 flex-grow"
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={handleAddContact} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddContact}
|
||||||
|
className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center"
|
||||||
|
>
|
||||||
<FaPlus />
|
<FaPlus />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -172,10 +182,17 @@ const RegisterPage = () => {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
|
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
|
||||||
<ul className="list-disc list-inside pl-4 mb-2">
|
<ul className="list-disc list-inside pl-4 mb-2">
|
||||||
{hostnames.map(hostname => (
|
{account?.hostnames.map((hostname) => (
|
||||||
<li key={hostname} className="text-gray-700 flex justify-between items-center mb-2">
|
<li
|
||||||
|
key={hostname}
|
||||||
|
className="text-gray-700 flex justify-between items-center mb-2"
|
||||||
|
>
|
||||||
{hostname}
|
{hostname}
|
||||||
<button type="button" onClick={() => handleDeleteHostname(hostname)} className="bg-red-500 text-white px-2 py-1 rounded ml-4">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteHostname(hostname)}
|
||||||
|
className="bg-red-500 text-white px-2 py-1 rounded ml-4"
|
||||||
|
>
|
||||||
<FaTrash />
|
<FaTrash />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@ -193,12 +210,19 @@ const RegisterPage = () => {
|
|||||||
errorClassName="text-red-500 text-sm mt-1"
|
errorClassName="text-red-500 text-sm mt-1"
|
||||||
className="mr-2 flex-grow"
|
className="mr-2 flex-grow"
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={handleAddHostname} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddHostname}
|
||||||
|
className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center"
|
||||||
|
>
|
||||||
<FaPlus />
|
<FaPlus />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CustomButton type="submit" className="bg-green-500 text-white px-3 py-1 rounded">
|
<CustomButton
|
||||||
|
type="submit"
|
||||||
|
className="bg-green-500 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
Create Account
|
Create Account
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Footer = (props: FooterProps) => {
|
const Footer = (props: FooterProps) => {
|
||||||
|
|
||||||
const { className } = props
|
const { className } = props
|
||||||
return (
|
return (
|
||||||
<footer className={`bg-gray-900 text-white text-center p-4 ${className}`}>
|
<footer className={`bg-gray-900 text-white text-center p-4 ${className}`}>
|
||||||
@ -15,6 +13,4 @@ const Footer = (props: FooterProps) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { Footer }
|
||||||
Footer
|
|
||||||
}
|
|
||||||
|
|||||||
@ -7,7 +7,9 @@ import './loader.css'
|
|||||||
|
|
||||||
const Loader: React.FC = () => {
|
const Loader: React.FC = () => {
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const activeRequests = useSelector((state: RootState) => state.loader.activeRequests)
|
const activeRequests = useSelector(
|
||||||
|
(state: RootState) => state.loader.activeRequests
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeout: NodeJS.Timeout | null = null
|
let timeout: NodeJS.Timeout | null = null
|
||||||
@ -35,6 +37,4 @@ const Loader: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { Loader }
|
||||||
Loader
|
|
||||||
}
|
|
||||||
|
|||||||
@ -29,6 +29,4 @@ const OffCanvas: FC<OffCanvasProps> = ({ isOpen, onClose }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { OffCanvas }
|
||||||
OffCanvas
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,42 +1,50 @@
|
|||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react'
|
||||||
import { FaHome, FaUserPlus, FaBars, FaSyncAlt } from 'react-icons/fa';
|
import { FaHome, FaUserPlus, FaBars, FaSyncAlt } from 'react-icons/fa'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface SideMenuProps {
|
interface SideMenuProps {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: <FaSyncAlt />, label: 'Auto Renew', path: '/' },
|
{ icon: <FaSyncAlt />, label: 'Auto Renew', path: '/' },
|
||||||
{ icon: <FaUserPlus />, label: 'Register', path: '/register' }
|
{ icon: <FaUserPlus />, label: 'Register', path: '/register' }
|
||||||
];
|
]
|
||||||
|
|
||||||
const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
|
const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
|
||||||
return (
|
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 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">
|
<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 />
|
<FaBars />
|
||||||
</button>
|
</button>
|
||||||
<h1 className={`${isCollapsed ? 'hidden' : 'block'} text-2xl font-bold ml-12`}>Certs UI</h1>
|
<h1
|
||||||
|
className={`${isCollapsed ? 'hidden' : 'block'} text-2xl font-bold ml-12`}
|
||||||
|
>
|
||||||
|
Certs UI
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1">
|
<nav className="flex-1">
|
||||||
<ul>
|
<ul>
|
||||||
{menuItems.map((item, index) => (
|
{menuItems.map((item, index) => (
|
||||||
<li key={index} className="hover:bg-gray-700">
|
<li key={index} className="hover:bg-gray-700">
|
||||||
<Link href={item.path} className="flex items-center w-full p-4">
|
<Link href={item.path} className="flex items-center w-full p-4">
|
||||||
<span className={`${isCollapsed ? 'mr-0' : 'mr-4'}`}>{item.icon}</span>
|
<span className={`${isCollapsed ? 'mr-0' : 'mr-4'}`}>
|
||||||
<span className={`${isCollapsed ? 'hidden' : 'block'}`}>{item.label}</span>
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<span className={`${isCollapsed ? 'hidden' : 'block'}`}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export {
|
export { SideMenu }
|
||||||
SideMenu
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"use client" // Add this line
|
'use client' // Add this line
|
||||||
|
|
||||||
import React, { FC, useState } from 'react'
|
import React, { FC, useState } from 'react'
|
||||||
import { FaCog, FaBars } from 'react-icons/fa'
|
import { FaCog, FaBars } from 'react-icons/fa'
|
||||||
@ -54,6 +54,4 @@ const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { TopMenu }
|
||||||
TopMenu
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"use client"
|
'use client'
|
||||||
import React, { FC } from 'react'
|
import React, { FC } from 'react'
|
||||||
|
|
||||||
interface CustomButtonProps {
|
interface CustomButtonProps {
|
||||||
@ -6,12 +6,17 @@ interface CustomButtonProps {
|
|||||||
className?: string
|
className?: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
type?: "button" | "submit" | "reset"
|
type?: 'button' | 'submit' | 'reset'
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomButton: FC<CustomButtonProps> = (props) => {
|
const CustomButton: FC<CustomButtonProps> = (props) => {
|
||||||
|
const {
|
||||||
const { onClick, className = '', children, disabled = false, type = 'button' } = props
|
onClick,
|
||||||
|
className = '',
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
type = 'button'
|
||||||
|
} = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// components/CustomInput.tsx
|
// components/CustomInput.tsx
|
||||||
"use client"
|
'use client'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
interface CustomInputProps {
|
interface CustomInputProps {
|
||||||
@ -25,9 +25,8 @@ const CustomInput: React.FC<CustomInputProps> = ({
|
|||||||
errorClassName = '',
|
errorClassName = '',
|
||||||
className = ''
|
className = ''
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
onChange?.(e.target.value);
|
onChange?.(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
import { CustomButton } from "./customButton";
|
import { CustomButton } from './customButton'
|
||||||
import { CustomInput } from "./customInput";
|
import { CustomInput } from './customInput'
|
||||||
|
|
||||||
export {
|
export { CustomButton, CustomInput }
|
||||||
CustomButton,
|
|
||||||
CustomInput
|
|
||||||
}
|
|
||||||
|
|||||||
9
src/ClientApp/entities/CacheAccount.ts
Normal file
9
src/ClientApp/entities/CacheAccount.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { CacheAccountHostname } from './CacheAccountHostname'
|
||||||
|
|
||||||
|
export interface CacheAccount {
|
||||||
|
accountId: string
|
||||||
|
description?: string
|
||||||
|
contacts: string[]
|
||||||
|
hostnames: CacheAccountHostname[]
|
||||||
|
isEditMode: boolean
|
||||||
|
}
|
||||||
5
src/ClientApp/entities/CacheAccountHostname.ts
Normal file
5
src/ClientApp/entities/CacheAccountHostname.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface CacheAccountHostname {
|
||||||
|
hostname: string
|
||||||
|
expires: Date
|
||||||
|
isUpcomingExpire: boolean
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
// Helper functions for validation
|
// Helper functions for validation
|
||||||
const isValidEmail = (email: string) => {
|
const isValidEmail = (email: string) => {
|
||||||
@ -28,20 +28,23 @@ interface UseValidationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Custom hook for input validation
|
// Custom hook for input validation
|
||||||
const useValidation = ({ initialValue, validateFn, errorMessage }: UseValidationProps) => {
|
const useValidation = ({
|
||||||
|
initialValue,
|
||||||
|
validateFn,
|
||||||
|
errorMessage
|
||||||
|
}: UseValidationProps) => {
|
||||||
const [value, setValue] = useState(initialValue)
|
const [value, setValue] = useState(initialValue)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const handleChange = (newValue: string) => {
|
const handleChange = (newValue: string) => {
|
||||||
|
|
||||||
console.log(newValue)
|
console.log(newValue)
|
||||||
setValue(newValue)
|
setValue(newValue)
|
||||||
if (newValue.trim() === "") {
|
if (newValue.trim() === '') {
|
||||||
setError("This field cannot be empty.")
|
setError('This field cannot be empty.')
|
||||||
} else if (!validateFn(newValue.trim())) {
|
} else if (!validateFn(newValue.trim())) {
|
||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
} else {
|
} else {
|
||||||
setError("")
|
setError('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,7 +52,13 @@ const useValidation = ({ initialValue, validateFn, errorMessage }: UseValidation
|
|||||||
handleChange(initialValue)
|
handleChange(initialValue)
|
||||||
}, [initialValue])
|
}, [initialValue])
|
||||||
|
|
||||||
return { value, error, handleChange, reset: () => setValue("") }
|
return { value, error, handleChange, reset: () => setValue('') }
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useValidation, isValidEmail, isValidPhoneNumber, isValidContact, isValidHostname }
|
export {
|
||||||
|
useValidation,
|
||||||
|
isValidEmail,
|
||||||
|
isValidPhoneNumber,
|
||||||
|
isValidContact,
|
||||||
|
isValidHostname
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { HostnameResponse } from "./HostnameResponse";
|
import { HostnameResponse } from './HostnameResponse'
|
||||||
|
|
||||||
export interface GetAccountResponse {
|
export interface GetAccountResponse {
|
||||||
accountId: string,
|
accountId: string
|
||||||
contacts: string[],
|
contacts: string[]
|
||||||
hostnames: HostnameResponse[],
|
hostnames: HostnameResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { HostnameResponse } from "./HostnameResponse";
|
import { HostnameResponse } from './HostnameResponse'
|
||||||
|
|
||||||
export interface GetHostnamesResponse {
|
export interface GetHostnamesResponse {
|
||||||
hostnames: HostnameResponse[]
|
hostnames: HostnameResponse[]
|
||||||
|
|||||||
106
src/ClientApp/package-lock.json
generated
106
src/ClientApp/package-lock.json
generated
@ -23,7 +23,10 @@
|
|||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.3",
|
"eslint-config-next": "14.2.3",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
|
"prettier": "^3.3.2",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
@ -435,6 +438,18 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pkgr/core": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/unts"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.2.5",
|
"version": "2.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz",
|
||||||
@ -1639,6 +1654,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-config-prettier": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-import-resolver-node": {
|
"node_modules/eslint-import-resolver-node": {
|
||||||
"version": "0.3.9",
|
"version": "0.3.9",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
|
||||||
@ -1801,6 +1828,36 @@
|
|||||||
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8"
|
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-plugin-prettier": {
|
||||||
|
"version": "5.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz",
|
||||||
|
"integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"prettier-linter-helpers": "^1.0.0",
|
||||||
|
"synckit": "^0.8.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint-plugin-prettier"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/eslint": ">=8.0.0",
|
||||||
|
"eslint": ">=8.0.0",
|
||||||
|
"eslint-config-prettier": "*",
|
||||||
|
"prettier": ">=3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/eslint": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"eslint-config-prettier": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-plugin-react": {
|
"node_modules/eslint-plugin-react": {
|
||||||
"version": "7.34.2",
|
"version": "7.34.2",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.2.tgz",
|
||||||
@ -1976,6 +2033,12 @@
|
|||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||||
@ -3717,6 +3780,33 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prettier-linter-helpers": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"fast-diff": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@ -4434,6 +4524,22 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/synckit": {
|
||||||
|
"version": "0.8.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
|
||||||
|
"integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@pkgr/core": "^0.1.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/unts"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.4",
|
"version": "3.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz",
|
||||||
|
|||||||
@ -24,7 +24,10 @@
|
|||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.3",
|
"eslint-config-next": "14.2.3",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
|
"prettier": "^3.3.2",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ interface LoaderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialState: LoaderState = {
|
const initialState: LoaderState = {
|
||||||
activeRequests: 0,
|
activeRequests: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaderSlice = createSlice({
|
const loaderSlice = createSlice({
|
||||||
@ -23,8 +23,8 @@ const loaderSlice = createSlice({
|
|||||||
},
|
},
|
||||||
reset: (state) => {
|
reset: (state) => {
|
||||||
state.activeRequests = 0
|
state.activeRequests = 0
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { increment, decrement, reset } = loaderSlice.actions
|
export const { increment, decrement, reset } = loaderSlice.actions
|
||||||
|
|||||||
@ -8,25 +8,28 @@ interface ToastState {
|
|||||||
|
|
||||||
const initialState: ToastState = {
|
const initialState: ToastState = {
|
||||||
message: '',
|
message: '',
|
||||||
type: 'info',
|
type: 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastSlice = createSlice({
|
const toastSlice = createSlice({
|
||||||
name: 'toast',
|
name: 'toast',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
showToast: (state, action: PayloadAction<{
|
showToast: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
message: string
|
message: string
|
||||||
type: 'success' | 'error' | 'info' | 'warning'
|
type: 'success' | 'error' | 'info' | 'warning'
|
||||||
}>) => {
|
}>
|
||||||
|
) => {
|
||||||
state.message = action.payload.message
|
state.message = action.payload.message
|
||||||
state.type = action.payload.type
|
state.type = action.payload.type
|
||||||
},
|
},
|
||||||
clearToast: (state) => {
|
clearToast: (state) => {
|
||||||
state.message = ''
|
state.message = ''
|
||||||
state.type = 'info'
|
state.type = 'info'
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { showToast, clearToast } = toastSlice.actions
|
export const { showToast, clearToast } = toastSlice.actions
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import toastReducer from '@/redux/slices/toastSlice'
|
|||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
loader: loaderReducer,
|
loader: loaderReducer,
|
||||||
toast: toastReducer,
|
toast: toastReducer
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof store.getState>
|
export type RootState = ReturnType<typeof store.getState>
|
||||||
|
|||||||
@ -1,167 +1,213 @@
|
|||||||
import { store } from '@/redux/store';
|
import { store } from '@/redux/store'
|
||||||
import { increment, decrement } from '@/redux/slices/loaderSlice';
|
import { increment, decrement } from '@/redux/slices/loaderSlice'
|
||||||
import { showToast } from '@/redux/slices/toastSlice';
|
import { showToast } from '@/redux/slices/toastSlice'
|
||||||
|
|
||||||
interface RequestInterceptor {
|
interface RequestInterceptor {
|
||||||
(req: XMLHttpRequest): void;
|
(req: XMLHttpRequest): void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResponseInterceptor<T> {
|
interface ResponseInterceptor<T> {
|
||||||
(response: T | null, error: ProblemDetails | null): T | void;
|
(response: T | null, error: ProblemDetails | null): T | void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProblemDetails {
|
interface ProblemDetails {
|
||||||
Title: string;
|
Title: string
|
||||||
Detail: string | null;
|
Detail: string | null
|
||||||
Status: number;
|
Status: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HttpServiceCallbacks {
|
interface HttpServiceCallbacks {
|
||||||
onIncrement?: () => void;
|
onIncrement?: () => void
|
||||||
onDecrement?: () => void;
|
onDecrement?: () => void
|
||||||
onShowToast?: (message: string, type: 'info' | 'error') => void;
|
onShowToast?: (message: string, type: 'info' | 'error') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
class HttpService {
|
class HttpService {
|
||||||
private requestInterceptors: RequestInterceptor[] = [];
|
private requestInterceptors: RequestInterceptor[] = []
|
||||||
private responseInterceptors: Array<ResponseInterceptor<any>> = [];
|
private responseInterceptors: Array<ResponseInterceptor<any>> = []
|
||||||
private callbacks: HttpServiceCallbacks;
|
private callbacks: HttpServiceCallbacks
|
||||||
|
|
||||||
constructor(callbacks: HttpServiceCallbacks) {
|
constructor(callbacks: HttpServiceCallbacks) {
|
||||||
this.callbacks = callbacks;
|
this.callbacks = callbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
private invokeIncrement(): void {
|
private invokeIncrement(): void {
|
||||||
this.callbacks.onIncrement?.();
|
this.callbacks.onIncrement?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
private invokeDecrement(): void {
|
private invokeDecrement(): void {
|
||||||
this.callbacks.onDecrement?.();
|
this.callbacks.onDecrement?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
private invokeShowToast(message: string, type: 'info' | 'error'): void {
|
private invokeShowToast(message: string, type: 'info' | 'error'): void {
|
||||||
this.callbacks.onShowToast?.(message, type);
|
this.callbacks.onShowToast?.(message, type)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<TResponse>(method: string, url: string, data?: any): Promise<TResponse | null> {
|
private async request<TResponse>(
|
||||||
const xhr = new XMLHttpRequest();
|
method: string,
|
||||||
xhr.open(method, url);
|
url: string,
|
||||||
|
data?: any
|
||||||
|
): Promise<TResponse | null> {
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
xhr.open(method, url)
|
||||||
|
|
||||||
this.handleRequestInterceptors(xhr);
|
this.handleRequestInterceptors(xhr)
|
||||||
|
|
||||||
if (data && typeof data !== 'string') {
|
if (data && typeof data !== 'string') {
|
||||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
xhr.setRequestHeader('Content-Type', 'application/json')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.invokeIncrement();
|
this.invokeIncrement()
|
||||||
|
|
||||||
return new Promise<TResponse | null>((resolve) => {
|
return new Promise<TResponse | null>((resolve) => {
|
||||||
xhr.onload = () => this.handleLoad<TResponse>(xhr, resolve);
|
xhr.onload = () => this.handleLoad<TResponse>(xhr, resolve)
|
||||||
xhr.onerror = () => this.handleNetworkError(resolve);
|
xhr.onerror = () => this.handleNetworkError(resolve)
|
||||||
xhr.send(data ? JSON.stringify(data) : null);
|
xhr.send(data ? JSON.stringify(data) : null)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleRequestInterceptors(xhr: XMLHttpRequest): void {
|
private handleRequestInterceptors(xhr: XMLHttpRequest): void {
|
||||||
this.requestInterceptors.forEach(interceptor => {
|
this.requestInterceptors.forEach((interceptor) => {
|
||||||
try {
|
try {
|
||||||
interceptor(xhr);
|
interceptor(xhr)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const problemDetails = this.createProblemDetails('Request Interceptor Error', error, 0);
|
const problemDetails = this.createProblemDetails(
|
||||||
this.showProblemDetails(problemDetails);
|
'Request Interceptor Error',
|
||||||
|
error,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
this.showProblemDetails(problemDetails)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleResponseInterceptors<TResponse>(response: TResponse | null, error: ProblemDetails | null): TResponse | null {
|
private handleResponseInterceptors<TResponse>(
|
||||||
this.responseInterceptors.forEach(interceptor => {
|
response: TResponse | null,
|
||||||
|
error: ProblemDetails | null
|
||||||
|
): TResponse | null {
|
||||||
|
this.responseInterceptors.forEach((interceptor) => {
|
||||||
try {
|
try {
|
||||||
interceptor(response, error);
|
interceptor(response, error)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const problemDetails = this.createProblemDetails('Response Interceptor Error', e, 0);
|
const problemDetails = this.createProblemDetails(
|
||||||
this.showProblemDetails(problemDetails);
|
'Response Interceptor Error',
|
||||||
|
e,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
this.showProblemDetails(problemDetails)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
return response;
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleLoad<TResponse>(xhr: XMLHttpRequest, resolve: (value: TResponse | null) => void): void {
|
private handleLoad<TResponse>(
|
||||||
this.invokeDecrement();
|
xhr: XMLHttpRequest,
|
||||||
|
resolve: (value: TResponse | null) => void
|
||||||
|
): void {
|
||||||
|
this.invokeDecrement()
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
this.handleSuccessfulResponse<TResponse>(xhr, resolve);
|
this.handleSuccessfulResponse<TResponse>(xhr, resolve)
|
||||||
} else {
|
} else {
|
||||||
this.handleErrorResponse(xhr, resolve);
|
this.handleErrorResponse(xhr, resolve)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSuccessfulResponse<TResponse>(xhr: XMLHttpRequest, resolve: (value: TResponse | null) => void): void {
|
private handleSuccessfulResponse<TResponse>(
|
||||||
|
xhr: XMLHttpRequest,
|
||||||
|
resolve: (value: TResponse | null) => void
|
||||||
|
): void {
|
||||||
try {
|
try {
|
||||||
if (xhr.response) {
|
if (xhr.response) {
|
||||||
const response = JSON.parse(xhr.response);
|
const response = JSON.parse(xhr.response)
|
||||||
resolve(this.handleResponseInterceptors(response, null) as TResponse);
|
resolve(this.handleResponseInterceptors(response, null) as TResponse)
|
||||||
} else {
|
} else {
|
||||||
resolve(null);
|
resolve(null)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const problemDetails = this.createProblemDetails('Response Parse Error', error, xhr.status);
|
const problemDetails = this.createProblemDetails(
|
||||||
this.showProblemDetails(problemDetails);
|
'Response Parse Error',
|
||||||
resolve(null);
|
error,
|
||||||
|
xhr.status
|
||||||
|
)
|
||||||
|
this.showProblemDetails(problemDetails)
|
||||||
|
resolve(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleErrorResponse<TResponse>(xhr: XMLHttpRequest, resolve: (value: TResponse | null) => void): void {
|
private handleErrorResponse<TResponse>(
|
||||||
const problemDetails = this.createProblemDetails(xhr.statusText, xhr.responseText, xhr.status);
|
xhr: XMLHttpRequest,
|
||||||
this.showProblemDetails(problemDetails);
|
resolve: (value: TResponse | null) => void
|
||||||
resolve(this.handleResponseInterceptors(null, problemDetails));
|
): void {
|
||||||
|
const problemDetails = this.createProblemDetails(
|
||||||
|
xhr.statusText,
|
||||||
|
xhr.responseText,
|
||||||
|
xhr.status
|
||||||
|
)
|
||||||
|
this.showProblemDetails(problemDetails)
|
||||||
|
resolve(this.handleResponseInterceptors(null, problemDetails))
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleNetworkError<TResponse>(resolve: (value: TResponse | null) => void): void {
|
private handleNetworkError<TResponse>(
|
||||||
const problemDetails = this.createProblemDetails('Network Error', null, 0);
|
resolve: (value: TResponse | null) => void
|
||||||
this.showProblemDetails(problemDetails);
|
): void {
|
||||||
resolve(this.handleResponseInterceptors(null, problemDetails));
|
const problemDetails = this.createProblemDetails('Network Error', null, 0)
|
||||||
|
this.showProblemDetails(problemDetails)
|
||||||
|
resolve(this.handleResponseInterceptors(null, problemDetails))
|
||||||
}
|
}
|
||||||
|
|
||||||
private createProblemDetails(title: string, detail: any, status: number): ProblemDetails {
|
private createProblemDetails(
|
||||||
|
title: string,
|
||||||
|
detail: any,
|
||||||
|
status: number
|
||||||
|
): ProblemDetails {
|
||||||
return {
|
return {
|
||||||
Title: title,
|
Title: title,
|
||||||
Detail: detail instanceof Error ? detail.message : String(detail),
|
Detail: detail instanceof Error ? detail.message : String(detail),
|
||||||
Status: status
|
Status: status
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private showProblemDetails(problemDetails: ProblemDetails): void {
|
private showProblemDetails(problemDetails: ProblemDetails): void {
|
||||||
if (problemDetails.Detail) {
|
if (problemDetails.Detail) {
|
||||||
const errorMessages = problemDetails.Detail.split(',');
|
const errorMessages = problemDetails.Detail.split(',')
|
||||||
errorMessages.forEach(message => {
|
errorMessages.forEach((message) => {
|
||||||
this.invokeShowToast(message.trim(), 'error');
|
this.invokeShowToast(message.trim(), 'error')
|
||||||
});
|
})
|
||||||
} else {
|
} else {
|
||||||
this.invokeShowToast('Unknown error', 'error');
|
this.invokeShowToast('Unknown error', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get<TResponse>(url: string): Promise<TResponse | null> {
|
public async get<TResponse>(url: string): Promise<TResponse | null> {
|
||||||
return await this.request<TResponse>('GET', url);
|
return await this.request<TResponse>('GET', url)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async post<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | null> {
|
public async post<TRequest, TResponse>(
|
||||||
return await this.request<TResponse>('POST', url, data);
|
url: string,
|
||||||
|
data: TRequest
|
||||||
|
): Promise<TResponse | null> {
|
||||||
|
return await this.request<TResponse>('POST', url, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async put<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | null> {
|
public async put<TRequest, TResponse>(
|
||||||
return await this.request<TResponse>('PUT', url, data);
|
url: string,
|
||||||
|
data: TRequest
|
||||||
|
): Promise<TResponse | null> {
|
||||||
|
return await this.request<TResponse>('PUT', url, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete<TResponse>(url: string): Promise<TResponse | null> {
|
public async delete<TResponse>(url: string): Promise<TResponse | null> {
|
||||||
return await this.request<TResponse>('DELETE', url);
|
return await this.request<TResponse>('DELETE', url)
|
||||||
}
|
}
|
||||||
|
|
||||||
public addRequestInterceptor(interceptor: RequestInterceptor): void {
|
public addRequestInterceptor(interceptor: RequestInterceptor): void {
|
||||||
this.requestInterceptors.push(interceptor);
|
this.requestInterceptors.push(interceptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
public addResponseInterceptor<TResponse>(interceptor: ResponseInterceptor<TResponse>): void {
|
public addResponseInterceptor<TResponse>(
|
||||||
this.responseInterceptors.push(interceptor);
|
interceptor: ResponseInterceptor<TResponse>
|
||||||
|
): void {
|
||||||
|
this.responseInterceptors.push(interceptor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,20 +215,21 @@ class HttpService {
|
|||||||
const httpService = new HttpService({
|
const httpService = new HttpService({
|
||||||
onIncrement: () => store.dispatch(increment()),
|
onIncrement: () => store.dispatch(increment()),
|
||||||
onDecrement: () => store.dispatch(decrement()),
|
onDecrement: () => store.dispatch(decrement()),
|
||||||
onShowToast: (message: string, type: 'info' | 'error') => store.dispatch(showToast({ message, type })),
|
onShowToast: (message: string, type: 'info' | 'error') =>
|
||||||
});
|
store.dispatch(showToast({ message, type }))
|
||||||
|
})
|
||||||
|
|
||||||
// Add loader state handling via interceptors
|
// Add loader state handling via interceptors
|
||||||
httpService.addRequestInterceptor((xhr) => {
|
httpService.addRequestInterceptor((xhr) => {
|
||||||
// Additional request logic can be added here
|
// Additional request logic can be added here
|
||||||
});
|
})
|
||||||
|
|
||||||
httpService.addResponseInterceptor((response, error) => {
|
httpService.addResponseInterceptor((response, error) => {
|
||||||
// Additional response logic can be added here
|
// Additional response logic can be added here
|
||||||
return response;
|
return response
|
||||||
});
|
})
|
||||||
|
|
||||||
export { httpService };
|
export { httpService }
|
||||||
|
|
||||||
// Example usage of the httpService
|
// Example usage of the httpService
|
||||||
// async function fetchData() {
|
// async function fetchData() {
|
||||||
@ -193,4 +240,3 @@ export { httpService };
|
|||||||
// console.error('Failed to fetch data');
|
// console.error('Failed to fetch data');
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user