(feature): formating rules

This commit is contained in:
Maksym Sadovnychyy 2024-06-20 12:22:40 +02:00
parent e321a3237f
commit 7d7ebd298a
33 changed files with 1236 additions and 842 deletions

View File

@ -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"
}
} }

View 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

View File

@ -0,0 +1,7 @@
{
"singleQuote": true,
"semi": false,
"tabWidth": 2,
"endOfLine": "lf",
"trailingComma": "none"
}

23
src/ClientApp/.vscode/settings.json vendored Normal file
View 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"
]
}

View File

@ -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
}

View File

@ -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>
</> </>

View 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 }

View File

@ -0,0 +1,3 @@
import { deepCopy } from './deepCopy'
export { deepCopy }

View File

@ -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} />

View File

@ -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>
) )
} }

View File

@ -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>

View File

@ -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
}

View File

@ -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
}

View File

@ -29,6 +29,4 @@ const OffCanvas: FC<OffCanvasProps> = ({ isOpen, onClose }) => {
) )
} }
export { export { OffCanvas }
OffCanvas
}

View File

@ -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
};

View File

@ -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
}

View File

@ -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

View File

@ -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 (

View File

@ -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
}

View File

@ -0,0 +1,9 @@
import { CacheAccountHostname } from './CacheAccountHostname'
export interface CacheAccount {
accountId: string
description?: string
contacts: string[]
hostnames: CacheAccountHostname[]
isEditMode: boolean
}

View File

@ -0,0 +1,5 @@
export interface CacheAccountHostname {
hostname: string
expires: Date
isUpcomingExpire: boolean
}

View File

@ -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
}

View File

@ -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[]
} }

View File

@ -1,4 +1,4 @@
import { HostnameResponse } from "./HostnameResponse"; import { HostnameResponse } from './HostnameResponse'
export interface GetHostnamesResponse { export interface GetHostnamesResponse {
hostnames: HostnameResponse[] hostnames: HostnameResponse[]

View File

@ -2,4 +2,4 @@ export interface HostnameResponse {
hostname: string hostname: string
expires: string expires: string
isUpcomingExpire: boolean isUpcomingExpire: boolean
} }

View File

@ -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",

View File

@ -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"
} }

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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');
// } // }
// } // }