feat: initialise template

This commit is contained in:
etwodev
2025-11-11 15:19:09 +00:00
commit 911693f3c3
74 changed files with 8676 additions and 0 deletions

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
/.swc
# next.js
/.next/
/out/
/dist
# production
/build
# misc
.DS_Store
*.pem
.marker*
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# database
*.db
*.sqlite
# logs
.logs

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.18.0

12
.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"arrowParens": "avoid",
"bracketSpacing": true,
"printWidth": 80,
"[yaml]": {
"editor.autoIndent": "advanced"
}
}

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# frontend-template
Frontend templates for using auth.etwo.dev

6
commitlint.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'body-max-line-length': [0],
},
};

83
eslint.config.cjs Normal file
View File

@@ -0,0 +1,83 @@
const tsParser = require('@typescript-eslint/parser');
const stylisticPlugin = require('@stylistic/eslint-plugin');
const tsPlugin = require ('@typescript-eslint/eslint-plugin')
const reactPlugin = require('eslint-plugin-react');
const sortPlugin = require('eslint-plugin-simple-import-sort');
/** @type {import("eslint").FlatConfig[]} */
module.exports = [
{
ignores: ['node_modules/**', 'dist/**', 'wailjs/**'],
languageOptions: {
globals: {
expect: 'readonly',
},
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
tsconfigRootDir: __dirname,
},
},
plugins: {
'@stylistic': stylisticPlugin,
'@typescript-eslint': tsPlugin,
react: reactPlugin,
'simple-import-sort': sortPlugin,
},
settings: {
react: {
version: 'detect',
},
},
rules: {
'@typescript-eslint/no-misused-new': 'error',
semi: ['error', 'always'],
'@typescript-eslint/no-empty-function': 'error',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'@stylistic/member-delimiter-style': [
'error',
{
multiline: { delimiter: 'semi', requireLast: true },
singleline: { delimiter: 'semi', requireLast: false },
},
],
'no-var': 'error',
'no-eval': 'error',
'eol-last': 'error',
'no-console': 'error',
'default-case': 'error',
semi: 'off',
'no-regex-spaces': 'error',
'constructor-super': 'error',
'no-invalid-regexp': 'error',
curly: ['error', 'multi-line'],
'no-irregular-whitespace': 'error',
'quote-props': ['error', 'as-needed'],
'linebreak-style': ['error', 'unix'],
'padding-line-between-statements': [
'error',
{ blankLine: 'always', prev: '*', next: 'return' },
],
'prefer-const': ['error', { destructuring: 'all' }],
'comma-dangle': ['error', 'always-multiline'],
indent: [
'error',
2,
{ SwitchCase: 1, ignoredNodes: ['ConditionalExpression', 'TemplateLiteral'] },
],
},
},
{
files: ['.eslintrc.{js,cjs}'],
languageOptions: {
sourceType: 'script',
},
},
];

29
global.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.png' {
const value: string;
export default value;
}
declare module '*.jpg' {
const value: string;
export default value;
}
declare module '*.jpeg' {
const value: string;
export default value;
}
declare module '*.svg' {
const value: string;
export default value;
}
declare module '*.gif' {
const value: string;
export default value;
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Single Sign On - etwo.dev</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

10
jest.config.cjs Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
verbose: true,
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
setupFilesAfterEnv: ['./src/setupTests.ts'],
};

67
package.json Normal file
View File

@@ -0,0 +1,67 @@
{
"name": "frontend",
"private": false,
"version": "1.0.0",
"main": "src/index.ts",
"type": "module",
"scripts": {
"dev": "vite --open",
"test": "jest --watch --maxWorkers=25%",
"preview": "vite preview",
"build": "tsc && vite build",
"setup": "cp .env.template .env",
"format": "prettier . --write --ignore-path=.prettierignore",
"lint": "eslint --ext .ts,.tsx,.js ."
},
"dependencies": {
"@fontsource/bricolage-grotesque": "^5.2.8",
"@fontsource/inter": "^5.2.6",
"@mantine/core": "^8.2.4",
"@mantine/dates": "^8.2.4",
"@mantine/hooks": "^8.2.4",
"@tabler/icons-react": "^3.34.1",
"axios": "^1.11.0",
"dayjs": "^1.11.13",
"react": "^19.1.1",
"react-confetti": "^6.4.0",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.4",
"zustand": "^5.0.7"
},
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@stylistic/eslint-plugin": "^5.2.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^24.2.0",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.32.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-simple-import-sort": "^12.1.1",
"jest": "^30.0.5",
"jest-environment-jsdom": "^30.0.5",
"lint-staged": "^16.1.4",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.6.2",
"testdouble": "^3.20.2",
"ts-jest": "^29.4.1",
"typescript": "5.8.3",
"vite": "^7.0.6"
},
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

14
postcss.config.cjs Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

BIN
public/android-chrome-192x192.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
public/android-chrome-512x512.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
public/apple-touch-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/favicon-16x16.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

BIN
public/favicon-32x32.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

25
src/App.tsx Executable file
View File

@@ -0,0 +1,25 @@
import '@fontsource/inter/300.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/700.css';
import '@fontsource/inter/800.css';
import '@fontsource/inter/900.css';
import '@fontsource/bricolage-grotesque/300.css';
import '@fontsource/bricolage-grotesque/400.css';
import '@fontsource/bricolage-grotesque/500.css';
import '@fontsource/bricolage-grotesque/700.css';
import '@fontsource/bricolage-grotesque/800.css';
import './global.css';
import '@mantine/core/styles.css';
import { Providers } from '@/lib/providers';
import { Route, Routes } from 'react-router-dom';
import { NotFoundPage } from './components/NotFoundPage';
export const App = (): React.JSX.Element => (
<Providers>
<Routes>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Providers>
);

16
src/__test__/App.test.tsx Executable file
View File

@@ -0,0 +1,16 @@
import { render, RenderResult, screen } from '@testing-library/react';
import React from 'react';
import { App } from '../App';
describe('App.tsx', () => {
const subject = (): RenderResult => render(<App />);
describe('when the application is enabled', () => {
it('renders the dom', () => {
subject();
expect(screen.getByText('VITE + REACT + TS + WAILS')).toBeInTheDocument();
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,42 @@
import { Image, Alert, Title, Text, Button, Stack, Group } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { IconArrowLeft } from '@tabler/icons-react';
import { FlexCenter } from '../shared/FlexCenter';
import SAJEmote from './SAJ.png';
export const NotFoundPage = (): React.JSX.Element => {
const navigate = useNavigate();
return (
<FlexCenter direction="column">
<Alert
color="red"
radius="md"
variant="light"
title={
<Group>
<Image w="24px" src={SAJEmote} />
<Title order={1}>404 Page Not Found</Title>
</Group>
}
>
<Stack align="center" gap="xs">
<Text size="md" c="dimmed">
The page you are looking for doesnt exist or may have been moved.
</Text>
<Button
mt="sm"
fullWidth
color="red"
variant="light"
leftSection={<IconArrowLeft size={18} />}
onClick={() => navigate('/dashboard')}
>
Back to Home
</Button>
</Stack>
</Alert>
</FlexCenter>
);
};

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
interface ClockProps {
locales?: Intl.LocalesArgument;
options?: Intl.DateTimeFormatOptions;
}
/**
* A very simple, unstyled, clock
*/
export const Clock = ({ locales, options }: ClockProps): React.JSX.Element => {
const [time, setTime] = useState<Date | null>(null);
useEffect(() => {
const interval = setInterval(() => setTime(new Date()), 1000);
return (): void => clearInterval(interval);
}, []);
if (!time) return <>Loading...</>;
return <>{time.toLocaleTimeString(locales, options)}</>;
};

View File

@@ -0,0 +1,24 @@
.icon {
width: 22px;
height: 22px;
}
.dark {
@mixin dark {
display: none;
}
@mixin light {
display: block;
}
}
.light {
@mixin light {
display: none;
}
@mixin dark {
display: block;
}
}

View File

@@ -0,0 +1,29 @@
import { ActionIcon, MantineSize } from '@mantine/core';
import { IconMoon, IconSun } from '@tabler/icons-react';
import cx from 'clsx';
import { useStrictColorScheme } from '@/lib/hooks';
import styles from './ColorSchemeToggle.module.css';
interface ColorSchemeToggleProps {
size: MantineSize;
}
/**
* Mantine Color Scheme Toggle
*
* @see https://mantine.dev/theming/color-schemes/#color-scheme-value-caveats
*/
export const ColorSchemeToggle = ({
size,
}: ColorSchemeToggleProps): React.JSX.Element => {
const { toggleColorScheme } = useStrictColorScheme();
return (
<ActionIcon onClick={toggleColorScheme} size={size} variant="default">
<IconSun className={cx(styles.icon, styles.light)} stroke={1.5} />
<IconMoon className={cx(styles.icon, styles.dark)} stroke={1.5} />
</ActionIcon>
);
};

View File

@@ -0,0 +1,43 @@
import { ActionIcon } from '@mantine/core';
import { useState } from 'react';
import Confetti from 'react-confetti';
interface ConfettiButtonProps {
density?: number;
duration?: number;
icon: React.ReactNode;
}
export const ConfettiButton = ({
icon,
density = 500,
duration = 1000,
}: ConfettiButtonProps): React.JSX.Element => {
const [isConfettiActive, setIsConfettiActive] = useState(false);
const [isConfettiClean, setIsConfettiClean] = useState(false);
const handleClick = (): void => {
setIsConfettiActive(true);
setIsConfettiClean(true);
setTimeout(() => {
setIsConfettiActive(false);
}, duration);
setTimeout(() => {
setIsConfettiActive(false);
}, duration + 1000);
};
return (
<>
<ActionIcon variant="transparent" onClick={handleClick}>
{icon}
</ActionIcon>
{isConfettiClean && (
<Confetti numberOfPieces={isConfettiActive ? density : 0} />
)}
</>
);
};

View File

@@ -0,0 +1,41 @@
import { ActionIcon, CopyButton, Tooltip } from '@mantine/core';
import { IconCheck, IconCopy } from '@tabler/icons-react';
interface CopyActionIconProps {
value: string;
tooltip?: string;
}
/**
* Mantine CopyButton
*
* @see https://mantine.dev/core/copy-button/
*/
export const CopyActionIcon = ({
value,
tooltip,
}: CopyActionIconProps): React.JSX.Element => (
<CopyButton value={value}>
{({ copied, copy }) =>
tooltip ? (
<Tooltip label={tooltip}>
<ActionIcon color="dimmed" onClick={copy} variant="light">
{copied ? (
<IconCheck color="teal" size={'1rem'} stroke={1.5} />
) : (
<IconCopy size={'1rem'} stroke={1.5} />
)}
</ActionIcon>
</Tooltip>
) : (
<ActionIcon color="dimmed" onClick={copy} variant="light">
{copied ? (
<IconCheck color="teal" size={'1rem'} stroke={1.5} />
) : (
<IconCopy size={'1rem'} stroke={1.5} />
)}
</ActionIcon>
)
}
</CopyButton>
);

View File

@@ -0,0 +1,34 @@
import { Button, ButtonProps, CopyButton } from '@mantine/core';
interface CopyActionIconProps {
label: string;
value: string;
copyLabel?: string;
buttonProps?: ButtonProps;
}
/**
* Mantine CopyButton
*
* @see https://mantine.dev/core/copy-button/
*/
export const CopyLabelButton = ({
label,
value,
buttonProps,
copyLabel = 'copied',
}: CopyActionIconProps): React.JSX.Element => (
<CopyButton value={value}>
{({ copied, copy }) => (
<Button
fullWidth
onClick={copy}
variant="light"
color={copied ? 'teal' : 'dimmed'}
{...buttonProps}
>
{copied ? copyLabel : label}
</Button>
)}
</CopyButton>
);

View File

@@ -0,0 +1,6 @@
.center {
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,18 @@
import { Flex, FlexProps } from '@mantine/core';
import styles from './FlexCenter.module.css';
interface FlexCenterProps extends FlexProps {
children: React.ReactNode;
}
export const FlexCenter = ({
children,
direction,
wrap,
gap,
}: FlexCenterProps): React.JSX.Element => (
<Flex gap={gap} direction={direction} wrap={wrap} className={styles.center}>
{children}
</Flex>
);

View File

@@ -0,0 +1,38 @@
interface IconProps extends React.SVGProps<SVGSVGElement> {
size?: number;
color?: string;
heart?: boolean;
}
export const IconLesbian: React.FC<IconProps> = ({
size = 24,
heart = false,
color = 'currentColor',
...props
}) => {
return (
<svg
{...props}
fill={color}
width={size}
height={size}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{heart && (
<defs>
<clipPath id="heartClip">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</clipPath>
</defs>
)}
<g clipPath="url(#heartClip)">
<rect fill="#D52D00" width="24" height="4.8" y="0" />
<rect fill="#FF9A56" width="24" height="4.8" y="4.8" />
<rect fill="#FFF" width="24" height="4.8" y="9.6" />
<rect fill="#D362A4" width="24" height="4.8" y="14.4" />
<rect fill="#A30262" width="24" height="4.8" y="19.2" />
</g>
</svg>
);
};

View File

@@ -0,0 +1,69 @@
import { useEffect } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { Title, Loader, Center } from '@mantine/core';
import { useFetchCurrentUser } from '@/lib/utils/auth/hooks/useFetchCurrentUser';
import { useSession } from '@/lib/utils/auth/hooks/useSession';
import { FlexCenter } from '../FlexCenter';
interface ProtectedRouteProps {
roles?: string[];
}
/**
* ProtectedRoute
* ---------------
* This route guard ensures that only authenticated users can access
* private routes such as the dashboard or internal management pages.
*
* If the user is not authenticated or the session check fails,
* they are redirected to the login page.
*
* ✅ Example usage:
*
* <Route element={<ProtectedRoute roles=["whitelisted", "admin"] />}>
* <Route path="/dashboard" element={<DashboardLayout />}>
* <Route index element={<DashboardHome />} />
* <Route path="settings" element={<SettingsPage />} />
* </Route>
* </Route>
*
* Behavior:
* - While checking session → shows loader
* - Authenticated user → allowed to access dashboard
* - Unauthenticated user → redirected to /login
*
* @returns JSX.Element
*/
export const PublicRoute = ({
roles = [],
}: ProtectedRouteProps): React.JSX.Element => {
const { data, isLoading, error, fetchCurrentUser } = useFetchCurrentUser();
useEffect(() => {
if (!data && !isLoading && !error) fetchCurrentUser();
}, [data, isLoading, fetchCurrentUser, error]);
if (isLoading) {
return (
<FlexCenter direction="column">
<Title order={4} ml="sm">
Checking session...
</Title>
<Loader color="blue" size="lg" />
</FlexCenter>
);
}
if (error || !data?.data?.user_id) {
return <Navigate to="/" replace />;
}
const user = data.data;
if (roles.length > 0 && !roles.includes(user.role)) {
return <Navigate to="/" replace />;
}
return <Outlet />;
};

View File

@@ -0,0 +1,49 @@
import { useSession } from '@/lib/utils/auth/hooks/useSession';
import { Title, Loader } from '@mantine/core';
import { Navigate, Outlet } from 'react-router-dom';
import { FlexCenter } from '../FlexCenter';
/**
* PublicRoute
* ------------
* This route guard prevents authenticated users from accessing public routes
* such as login, register, or landing pages. If a valid session exists,
* the user is redirected to the dashboard instead.
*
* Otherwise, it renders the child routes component via <Outlet />.
*
* ✅ Example usage:
*
* <Route element={<PublicRoute />}>
* <Route path="/login" element={<LoginPage />} />
* <Route path="/register" element={<RegisterPage />} />
* </Route>
*
* Behavior:
* - While checking session → shows loader
* - Authenticated user → redirected to /dashboard
* - Unauthenticated user → allowed to access the page
*
* @returns JSX.Element
*/
export const PublicRoute = (): React.JSX.Element => {
const { isLoading, error, isAuthenticated } = useSession();
if (isLoading) {
return (
<FlexCenter direction="column">
<Title order={4} ml="sm">
Checking session...
</Title>
<Loader color="blue" size="lg" />
</FlexCenter>
);
}
if (isAuthenticated && !error) {
return <Navigate to="/dashboard" replace />;
}
return <Outlet />;
};

View File

@@ -0,0 +1,51 @@
.list {
top: 0;
left: 0;
right: 0;
z-index: 2;
display: flex;
width: min-content;
position: absolute;
justify-content: flex-start;
}
.burger {
top: 0;
left: 0;
right: 0;
z-index: 2;
position: absolute;
}
.indicator {
background-color: var(--mantine-color-white);
border-radius: var(--mantine-radius-md);
border: 1px solid var(--mantine-color-gray-2);
box-shadow: var(--mantine-shadow-sm);
@mixin dark {
background-color: var(--mantine-color-dark-6);
border-color: var(--mantine-color-dark-4);
}
}
.tab {
z-index: 3; /* Higher than the indicator */
padding: 10px;
font-weight: 500;
width: min-content;
transition: color 100ms ease;
color: var(--mantine-color-gray-7);
&[data-active] {
color: var(--mantine-color-black);
}
@mixin dark {
color: var(--mantine-color-dark-1);
&[data-active] {
color: var(--mantine-color-white);
}
}
}

View File

@@ -0,0 +1,107 @@
import {
Burger,
FloatingIndicator,
Tabs as MantineTabs,
Transition,
} from '@mantine/core';
import { useState } from 'react';
import classes from './Tabs.module.css';
export interface Tab {
value: string;
label: string;
icon?: React.ReactNode;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
burger?: boolean;
orientation?: 'vertical' | 'horizontal';
}
export const Tabs = ({
tabs,
burger = false,
orientation = 'vertical',
}: TabsProps): React.JSX.Element => {
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const [controlsRefs, setControlsRefs] = useState<
Record<string, HTMLButtonElement | null>
>({});
const [value, setValue] = useState<string | null>(
tabs.length > 0 ? tabs[0].value : null,
);
const [isTabListVisible, setIsTabListVisible] = useState(true);
const setControlRef =
(val: string) =>
(node: HTMLButtonElement): void => {
controlsRefs[val] = node;
setControlsRefs(controlsRefs);
};
return (
<div style={{ position: 'relative', height: '100%' }}>
{burger && (
<Burger
className={classes.burger}
opened={!isTabListVisible}
onClick={(): void => setIsTabListVisible(prev => !prev)}
/>
)}
<MantineTabs
h={'100%'}
value={value}
variant="none"
onChange={setValue}
orientation={orientation}
>
<Transition
duration={300}
transition="fade"
timingFunction="ease"
mounted={isTabListVisible}
>
{styles => (
<MantineTabs.List
style={styles}
ref={setRootRef}
mt={burger ? '40px' : '0px'}
className={classes.list}
>
{tabs.map(({ value, label, icon }) => (
<MantineTabs.Tab
key={value}
value={value}
leftSection={icon}
className={classes.tab}
ref={setControlRef(value)}
>
{label}
</MantineTabs.Tab>
))}
<FloatingIndicator
parent={rootRef}
className={classes.indicator}
target={value ? controlsRefs[value] : null}
/>
</MantineTabs.List>
)}
</Transition>
{tabs.map(({ value, content }) => (
<MantineTabs.Panel key={value} value={value}>
{content}
</MantineTabs.Panel>
))}
</MantineTabs>
</div>
);
};

View File

@@ -0,0 +1,11 @@
.icon {
border: 1px solid;
@mixin light {
border-color: black;
}
@mixin dark {
border-color: grey;
}
}

View File

@@ -0,0 +1,29 @@
import { ActionIcon } from '@mantine/core';
import styles from './ToggleActionIcon.module.css';
interface ToggleActionIconProps {
isActive?: boolean;
onClick?: () => void;
children: React.ReactElement;
}
/**
* Mantine ActionIcon
*
* @see https://mantine.dev/core/action-icon/
*/
export const ToggleActionIcon = ({
onClick,
isActive,
children,
}: ToggleActionIconProps): React.JSX.Element => (
<ActionIcon
variant="light"
onClick={onClick}
className={styles.icon}
color={isActive ? '' : 'dimmed'}
>
{children}
</ActionIcon>
);

View File

@@ -0,0 +1,21 @@
import { FileInput, Stack } from '@mantine/core';
import { useState } from 'react';
interface UploadFrameProps {
placeholder: string;
children: (file: File | null) => React.ReactNode;
}
export const UploadFrame = ({
children,
placeholder,
}: UploadFrameProps): React.JSX.Element => {
const [file, setFile] = useState<File | null>(null);
return (
<Stack>
<FileInput clearable onChange={setFile} placeholder={placeholder} />
{children(file)}
</Stack>
);
};

54
src/global.css Executable file
View File

@@ -0,0 +1,54 @@
html,
body,
#root {
height: 100% !important;
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
}
:root {
--grid-color-light: rgba(0, 0, 0, 0.1);
--grid-color-dark: rgba(255, 255, 255, 0.1);
--hidden-track: transparent;
--track-bar: rgba(190, 190, 190, 0.4);
--track-bar-hover: rgba(190, 190, 190, 0.6);
}
/* STYLED SCROLLBARS */
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--track-bar) var(--hidden-track);
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 17px;
}
*::-webkit-scrollbar-track {
background: var(--hidden-track);
}
*::-webkit-scrollbar-thumb {
background-color: var(--track-bar);
border-radius: 14px;
border: 3px solid var(--hidden-track);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--track-bar-hover);
}
/* EOF STYLED SCROLLBARS */
.header {
gap: 12px;
width: 100%;
display: flex;
z-index: 1000;
flex-wrap: wrap;
position: fixed;
align-items: center;
justify-content: center;
padding: var(--mantine-padding-lg);
margin-top: 10px;
border-radius: var(--mantine-radius-md);
}

5
src/index.tsx Executable file
View File

@@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root') as HTMLElement).render(<App />);

1
src/lib/hooks/index.ts Executable file
View File

@@ -0,0 +1 @@
export * from './useStrictColorScheme';

View File

@@ -0,0 +1,19 @@
import { useComputedColorScheme, useMantineColorScheme } from '@mantine/core';
interface StrictColorSchemeResponse {
colorScheme: 'light' | 'dark';
toggleColorScheme: () => void;
}
export const useStrictColorScheme = (): StrictColorSchemeResponse => {
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: true,
});
const toggleColorScheme = (): void => {
setColorScheme(computedColorScheme === 'light' ? 'dark' : 'light');
};
return { toggleColorScheme, colorScheme: computedColorScheme };
};

19
src/lib/providers/index.tsx Executable file
View File

@@ -0,0 +1,19 @@
import { MantineProvider } from '@mantine/core';
import { DatesProvider } from '@mantine/dates';
import { theme } from '@/lib/theme';
import { BrowserRouter } from 'react-router-dom';
interface ProvidersProps {
children: React.ReactNode;
}
export const Providers = ({ children }: ProvidersProps): React.JSX.Element => (
<BrowserRouter>
<MantineProvider theme={theme} defaultColorScheme="light">
<DatesProvider settings={{ consistentWeeks: true }}>
{children}
</DatesProvider>
</MantineProvider>
</BrowserRouter>
);

5
src/lib/store/defaultState.ts Executable file
View File

@@ -0,0 +1,5 @@
import { AppState, defaultAppState } from './slices/appSlice';
export type StoreState = AppState;
export const defaultState: AppState = { ...defaultAppState };

9
src/lib/store/index.ts Executable file
View File

@@ -0,0 +1,9 @@
import { create } from 'zustand';
import { AppSlice, createAppSlice } from './slices/appSlice';
export type Store = AppSlice;
export const useStore = create<Store>((set, get, store) => ({
...createAppSlice(set, get, store),
}));

View File

@@ -0,0 +1,24 @@
import { StateCreator } from 'zustand';
import { Store } from '..';
export interface AppState {
isDarkMode: boolean;
}
export type AppActions = {
setIsDarkMode(isDarkMode: AppState['isDarkMode']): void;
};
export type AppSlice = AppState & AppActions;
/** This object enables us to reset state on the fly */
export const defaultAppState: AppState = {
isDarkMode: true,
};
export const createAppSlice: StateCreator<Store, [], [], AppSlice> = set => ({
...defaultAppState,
setIsDarkMode: isDarkMode => set({ isDarkMode }),
});

94
src/lib/theme/index.ts Executable file
View File

@@ -0,0 +1,94 @@
import { createTheme } from '@mantine/core';
export const theme = createTheme({
fontFamily: '"Inter", sans-serif',
colors: {
brandOrange: [
'#ffede3',
'#ffdbcc',
'#ffb59a',
'#ff8d63',
'#ff6a36',
'#ff5418',
'#ff4807',
'#e43900',
'#cc3100',
'#b22500',
],
brandBlue: [
'#f2f8f8',
'#e7eced',
'#c8d9da',
'#a7c4c6',
'#8bb3b5',
'#79a8ab',
'#6ea3a6',
'#5d8e91',
'#4f7f82',
'#3c6e71',
],
brandYellow: [
'#fff9e1',
'#fff1cc',
'#ffe19b',
'#ffd164',
'#ffc238',
'#ffba1c',
'#ffb509',
'#e39f00',
'#ca8d00',
'#af7800',
],
brandBrown: [
'#f8f3f2',
'#e8e5e4',
'#d3c6c4',
'#bea6a1',
'#ad8a84',
'#a37870',
'#9f6f67',
'#8b5e56',
'#7d534c',
'#6f463f',
],
brandGrey: [
'#f5f5f5',
'#e7e7e7',
'#cdcdcd',
'#b2b2b2',
'#9a9a9a',
'#8b8b8b',
'#848484',
'#717171',
'#656565',
'#575757',
],
},
primaryShade: { light: 4, dark: 7 },
primaryColor: 'brandOrange',
headings: {
fontFamily: '"Bricolage Grotesque", sans-serif',
},
other: {
brand: {
dark: {
primary: '#FF6F3C',
secondary: '#3C6E71',
accent: '#FFD166',
background: '#2D2D2D',
text: '#FFF8E1',
border: '#4A4A4A',
hover: '#464646',
},
light: {
primary: '#FF6F3C',
secondary: '#3C6E71',
accent: '#FFD166',
background: '#FFF8E1',
text: '#3E2723',
border: '#DADADA',
hover: '#e9e6df',
},
},
},
});

View File

@@ -0,0 +1,54 @@
// useLogin.ts
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface AuthorizeRequest {
redirect_uri: string;
client_id: string;
}
export interface AuthorizeResponse {
token: string;
expires_in: string;
}
interface UseAuthorize {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
authorize: (payload: AuthorizeRequest) => Promise<void>;
}
export const useAuthorize = (): UseAuthorize => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse<AuthorizeResponse> | null>(null);
const authorize = useCallback((payload: AuthorizeRequest): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
return apiClient()
.post<AuthorizeResponse>('v1/auth/authorize', payload)
.then(res => {
if (isApiResponse<AuthorizeResponse>(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, authorize };
};

View File

@@ -0,0 +1,62 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface CreateClientRequest {
client_name: string;
redirect_uri: string;
}
export interface CreateClientResponse {
client_id: string;
client_name: string;
redirect_uri: string;
client_secret: string;
}
interface UseCreateClient {
isLoading: boolean;
error: ApiError | null;
data: APIResponse<CreateClientResponse> | null;
createClient: (payload: CreateClientRequest) => Promise<void>;
}
export const useCreateClient = (): UseCreateClient => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse<CreateClientResponse> | null>(
null,
);
const createClient = useCallback(
(payload: CreateClientRequest): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
const url = 'v1/clients';
return apiClient()
.post<CreateClientResponse>(url, payload)
.then(res => {
if (isApiResponse<CreateClientResponse>(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
},
[],
);
return { isLoading, error, data, createClient };
};

View File

@@ -0,0 +1,50 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
interface UseDeleteAdminClient {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
deleteClient: (clientId: string) => Promise<void>;
}
export const useDeleteAdminClient = (): UseDeleteAdminClient => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const deleteClient = useCallback((clientId: string): Promise<void> => {
if (!clientId) {
setError({ message: 'Client ID is required.' });
return Promise.resolve();
}
setIsLoading(true);
setError(null);
setData(null);
const url = `v1/admin/clients/${clientId}`;
return apiClient()
.delete(url)
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, deleteClient };
};

View File

@@ -0,0 +1,50 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
interface UseDeleteAdminUser {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
deleteUser: (userId: string) => Promise<void>;
}
export const useDeleteAdminUser = (): UseDeleteAdminUser => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const deleteUser = useCallback((userId: string): Promise<void> => {
if (!userId) {
setError({ message: 'User ID is required.' });
return Promise.resolve();
}
setIsLoading(true);
setError(null);
setData(null);
const url = `v1/admin/users/${userId}`;
return apiClient()
.delete(url)
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, deleteUser };
};

View File

@@ -0,0 +1,57 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface UserDeleteRequest {
code: string;
}
interface UseDeleteUserData {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
deleteUserData: (payload: UserDeleteRequest) => Promise<void>;
}
export const useDeleteUserData = (): UseDeleteUserData => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const deleteUserData = useCallback(
(payload: UserDeleteRequest): Promise<void> => {
if (!payload?.code) {
setError({ message: 'Verification code is required.' });
return Promise.resolve();
}
setIsLoading(true);
setError(null);
setData(null);
const url = 'v1/user/me/data';
return apiClient()
.delete(url, payload)
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
},
[],
);
return { isLoading, error, data, deleteUserData };
};

View File

@@ -0,0 +1,66 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface OauthClient {
client_id: string;
owner_id: string;
client_name: string;
redirect_uri: string;
created_at: number;
updated_at: number;
}
export interface FetchClientsResponse {
clients: OauthClient[];
page: number;
limit: number;
total_items: number;
total_pages: number;
has_next_page: boolean;
has_prev_page: boolean;
}
interface UseFetchAdminClients {
isLoading: boolean;
error: ApiError | null;
data: APIResponse<FetchClientsResponse> | null;
fetchClients: (limit?: number, offset?: number) => Promise<void>;
}
export const useFetchAdminClients = (): UseFetchAdminClients => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse<FetchClientsResponse> | null>(
null,
);
const fetchClients = useCallback((limit = 10, offset = 0): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
const url = `v1/admin/clients?limit=${limit}&offset=${offset}`;
return apiClient()
.get<FetchClientsResponse>(url)
.then(res => {
if (isApiResponse<FetchClientsResponse>(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, fetchClients };
};

View File

@@ -0,0 +1,71 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface User {
user_id: string;
email: string;
nickname: string;
last_logged_in?: number;
last_password_change?: number;
created_at: number;
updated_at: number;
is_active: boolean;
role: string;
last_data_request?: number;
deleted_at?: number;
}
export interface FetchUsersResponse {
users: User[];
page: number;
limit: number;
total_items: number;
total_pages: number;
has_next_page: boolean;
has_prev_page: boolean;
}
interface UseFetchAdminUsers {
isLoading: boolean;
error: ApiError | null;
data: APIResponse<FetchUsersResponse> | null;
fetchUsers: (limit?: number, offset?: number) => Promise<void>;
}
export const useFetchAdminUsers = (): UseFetchAdminUsers => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse<FetchUsersResponse> | null>(
null,
);
const fetchUsers = useCallback((limit = 10, offset = 0): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
const url = `v1/admin/users?limit=${limit}&offset=${offset}`;
return apiClient()
.get<FetchUsersResponse>(url)
.then(res => {
if (isApiResponse<FetchUsersResponse>(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, fetchUsers };
};

View File

@@ -0,0 +1,53 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface FetchClientResponse {
client_id: string;
client_name: string;
redirect_uri: string;
}
interface UseFetchClientById {
isLoading: boolean;
error: ApiError | null;
data: APIResponse<FetchClientResponse> | null;
fetchClientById: (clientId: string) => Promise<void>;
}
export const useFetchClientById = (): UseFetchClientById => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse<FetchClientResponse> | null>(
null,
);
const fetchClientById = useCallback((clientId: string): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
const url = `v1/client/${clientId}`;
return apiClient()
.get<FetchClientResponse>(url)
.then(res => {
if (isApiResponse<FetchClientResponse>(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, fetchClientById };
};

View File

@@ -0,0 +1,55 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface Client {
client_id: string;
client_name: string;
redirect_uri: string;
}
export type FetchClientsResponse = Client[];
interface UseFetchClients {
isLoading: boolean;
error: ApiError | null;
data: APIResponse<FetchClientsResponse> | null;
fetchClients: () => Promise<void>;
}
export const useFetchClients = (): UseFetchClients => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse<FetchClientsResponse> | null>(
null,
);
const fetchClients = useCallback((): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
const url = 'v1/clients';
return apiClient()
.get<FetchClientsResponse>(url)
.then(res => {
if (isApiResponse<FetchClientsResponse>(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, fetchClients };
};

View File

@@ -0,0 +1,54 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface FetchUserResponse {
user_id: string;
email: string;
nickname: string;
role: string;
created_at: number;
last_data_request: number;
}
interface UseFetchCurrentUser {
isLoading: boolean;
error: ApiError | null;
data: APIResponse<FetchUserResponse> | null;
fetchCurrentUser: () => Promise<void>;
}
export const useFetchCurrentUser = (): UseFetchCurrentUser => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse<FetchUserResponse> | null>(null);
const fetchCurrentUser = useCallback((): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
const url = 'v1/user/me';
return apiClient()
.get<FetchUserResponse>(url)
.then(res => {
if (isApiResponse<FetchUserResponse>(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, fetchCurrentUser };
};

View File

@@ -0,0 +1,49 @@
// useLogin.ts
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface LoginRequest {
email: string;
password: string;
}
interface UseLoginReturn {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
login: (payload: LoginRequest) => Promise<void>;
}
export const useLogin = (): UseLoginReturn => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const login = useCallback((payload: LoginRequest): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
return apiClient()
.post('v1/auth/login', payload)
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, login };
};

View File

@@ -0,0 +1,44 @@
// useLogin.ts
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
interface UseLogout {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
logout: () => Promise<void>;
}
export const useLogout = (): UseLogout => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const logout = useCallback((): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
return apiClient()
.get('v1/auth/logout')
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, logout };
};

View File

@@ -0,0 +1,45 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
interface UsePostUserData {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
postUserData: () => Promise<void>;
}
export const usePostUserData = (): UsePostUserData => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const postUserData = useCallback((): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
const url = 'v1/user/me/data';
return apiClient()
.post(url)
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, postUserData };
};

View File

@@ -0,0 +1,44 @@
// useLogin.ts
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
interface UseRefresh {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
refresh: () => Promise<void>;
}
export const useRefresh = (): UseRefresh => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const refresh = useCallback((): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
return apiClient()
.post('v1/auth/token/refresh')
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, refresh };
};

View File

@@ -0,0 +1,50 @@
// useLogin.ts
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface RegisterRequest {
email: string;
code: string;
password: string;
}
interface UseRegister {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
register: (payload: RegisterRequest) => Promise<void>;
}
export const useRegister = (): UseRegister => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const register = useCallback((payload: RegisterRequest): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
return apiClient()
.post('v1/auth/register', payload)
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, register };
};

View File

@@ -0,0 +1,50 @@
// useLogin.ts
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface ResetRequest {
email: string;
password: string;
code: string;
}
interface UseResetReturn {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
reset: (payload: ResetRequest) => Promise<void>;
}
export const useReset = (): UseResetReturn => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const reset = useCallback((payload: ResetRequest): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
return apiClient()
.patch('v1/auth/reset', payload)
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
}, []);
return { isLoading, error, data, reset };
};

View File

@@ -0,0 +1,55 @@
// useLogin.ts
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
OTPTypes,
} from '..';
export interface SendEmailCodeRequest {
email: string;
kind: number;
}
interface UseSendEmailCode {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
sendEmailCode: (email: string, kind: number) => Promise<void>;
}
export const useSendEmailCode = (): UseSendEmailCode => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const sendEmailCode = useCallback(
(email: string, kind: OTPTypes): Promise<void> => {
const payload: SendEmailCodeRequest = { email, kind };
setIsLoading(true);
setError(null);
setData(null);
return apiClient()
.post('v1/auth/send-email-code', payload)
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
},
[],
);
return { isLoading, error, data, sendEmailCode };
};

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
interface UseSession {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
isAuthenticated: boolean;
}
/**
* useSession
* ----------
* Automatically checks the user's session on mount.
* Returns only state — no internal navigation or manual triggers.
*/
export const useSession = (): UseSession => {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
useEffect(() => {
let isMounted = true;
apiClient()
.get('v1/auth/session')
.then(res => {
if (!isMounted) return;
if (isApiResponse(res)) {
setData(res);
setError(null);
} else if (isApiError(res)) {
setError(res);
setData(null);
} else {
setError({ message: 'Received unknown response structure.' });
setData(null);
}
})
.catch(() => {
if (isMounted) {
setError({ message: 'Network or unexpected error' });
setData(null);
}
})
.finally(() => {
if (isMounted) setIsLoading(false);
});
return () => {
isMounted = false;
};
}, []);
const isAuthenticated = !!data && !error;
return { isLoading, error, data, isAuthenticated };
};

View File

@@ -0,0 +1,63 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface UpdateClientRequest {
client_name: string;
redirect_uri: string;
regenerate_secret: boolean;
}
export interface UpdateClientResponse {
client_secret?: string;
}
interface UseUpdateClient {
isLoading: boolean;
error: ApiError | null;
data: APIResponse<UpdateClientResponse> | null;
updateClient: (
clientId: string,
payload: UpdateClientRequest,
) => Promise<void>;
}
export const useUpdateClient = (): UseUpdateClient => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse<UpdateClientResponse> | null>(
null,
);
const updateClient = useCallback(
(clientId: string, payload: UpdateClientRequest): Promise<void> => {
setIsLoading(true);
setError(null);
setData(null);
const url = `v1/client/${clientId}`;
return apiClient()
.patch<UpdateClientResponse>(url, payload)
.then(res => {
if (isApiResponse<UpdateClientResponse>(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
},
[],
);
return { isLoading, error, data, updateClient };
};

View File

@@ -0,0 +1,69 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface UpdateUserRequest {
nickname: string;
code?: string;
email?: string;
old_password?: string;
password?: string;
}
interface UseUpdateCurrentUser {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
updateCurrentUser: (payload: UpdateUserRequest) => Promise<void>;
}
export const useUpdateCurrentUser = (): UseUpdateCurrentUser => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const updateCurrentUser = useCallback(
(payload: UpdateUserRequest): Promise<void> => {
if (payload.email && !payload.code) {
setError({
message: 'Verification code is required when updating email.',
});
return Promise.resolve();
}
if (payload.password && !payload.old_password) {
setError({
message: 'Old password is required when updating password.',
});
return Promise.resolve();
}
setIsLoading(true);
setError(null);
setData(null);
const url = 'v1/user/me';
return apiClient()
.patch(url, payload)
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
},
[],
);
return { isLoading, error, data, updateCurrentUser };
};

View File

@@ -0,0 +1,65 @@
import { useState, useCallback } from 'react';
import {
ApiError,
APIResponse,
apiClient,
isApiResponse,
isApiError,
} from '..';
export interface HandleUpdateRoleRequest {
role: string;
}
interface UseUpdateUserRole {
isLoading: boolean;
error: ApiError | null;
data: APIResponse | null;
updateUserRole: (
userId: string,
payload: HandleUpdateRoleRequest,
) => Promise<void>;
}
export const useUpdateUserRole = (): UseUpdateUserRole => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [data, setData] = useState<APIResponse | null>(null);
const updateUserRole = useCallback(
(userId: string, payload: HandleUpdateRoleRequest): Promise<void> => {
if (!userId) {
setError({ message: 'User ID is required.' });
return Promise.resolve();
}
if (!payload?.role) {
setError({ message: 'Role field is required.' });
return Promise.resolve();
}
setIsLoading(true);
setError(null);
setData(null);
const url = `v1/admin/users/${userId}/role`;
return apiClient()
.patch(url, payload)
.then(res => {
if (isApiResponse(res)) {
setData(res);
} else if (isApiError(res)) {
setError(res);
} else {
setError({ message: 'Received unknown response structure.' });
}
})
.catch(() => setError({ message: 'Network or unexpected error' }))
.finally(() => setIsLoading(false));
},
[],
);
return { isLoading, error, data, updateUserRole };
};

193
src/lib/utils/auth/index.ts Normal file
View File

@@ -0,0 +1,193 @@
// apiClient.ts
export type APIResponseType = 'A' | 'B' | 'C' | 'S' | 'V';
export enum APIStatusCode {
// Generic
OK = 0,
Default = 1000,
InvalidRequest = 1001,
NotFound = 1002,
Conflict = 1003,
RateLimited = 1004,
ValidationFailed = 1005,
// Authentication / Authorization
Unauthorized = 2000,
Forbidden = 2001,
TokenExpired = 2002,
TokenInvalid = 2003,
SessionExpired = 2004,
// Server Errors
InternalError = 3000,
DBError = 3001,
CacheError = 3002,
ServiceDown = 3003,
}
export enum OTPTypes {
OTPRegister = 3,
OTPResetPassword = 7,
OTPNewEmail = 9,
OTPDataRequest = 15,
OTPDeleteRequest = 19,
}
export interface APIResponse<T = any> {
data?: T;
msg: string;
status: APIStatusCode;
type: APIResponseType;
}
export interface ApiError {
message: string;
status?: number;
code?: APIStatusCode;
type?: APIResponseType;
}
export interface ApiClientOptions {
baseUrl?: string;
headers?: Record<string, string>;
token?: string;
}
// ---------- Type Guards ----------
/**
* Runtime type guard for checking if a value is a valid APIResponse<T>.
*/
export function isApiResponse<T>(obj: any): obj is APIResponse<T> {
return (
typeof obj === 'object' &&
obj !== null &&
typeof obj.msg === 'string' &&
typeof obj.status === 'number' &&
typeof obj.type === 'string' &&
['A', 'B', 'C', 'S', 'V'].includes(obj.type)
);
}
/**
* Runtime type guard for checking if a value is an ApiError.
*/
export function isApiError(obj: any): obj is ApiError {
return (
typeof obj === 'object' && obj !== null && typeof obj.message === 'string'
);
}
// ---------- API Client ----------
export class ApiClient {
private baseUrl: string;
private headers: Record<string, string>;
constructor(options: ApiClientOptions = {}) {
this.baseUrl = options.baseUrl || 'https://auth.etwo.dev/api/';
this.headers = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (options.token) {
this.headers['Authorization'] = `Bearer ${options.token}`;
}
}
private async handleResponse<T>(
res: Response,
): Promise<APIResponse<T> | ApiError> {
return res
.json()
.then((json: any) => {
if (isApiResponse<T>(json)) {
if (!res.ok || json.status !== APIStatusCode.OK) {
return {
message: json.msg || res.statusText,
status: res.status,
code: json.status,
type: json.type,
} as ApiError;
}
return json;
}
// Response was not a valid APIResponse<T>
return {
message: 'Unexpected response structure from server.',
status: res.status,
} as ApiError;
})
.catch(() => ({
message: 'Invalid JSON response from server.',
status: res.status,
}));
}
get<T = any>(
path: string,
config?: RequestInit,
): Promise<APIResponse<T> | ApiError> {
return fetch(`${this.baseUrl}${path}`, {
...config,
method: 'GET',
headers: this.headers,
}).then(res => this.handleResponse<T>(res));
}
post<T = any>(
path: string,
body?: any,
config?: RequestInit,
): Promise<APIResponse<T> | ApiError> {
return fetch(`${this.baseUrl}${path}`, {
...config,
method: 'POST',
headers: this.headers,
body: body ? JSON.stringify(body) : undefined,
}).then(res => this.handleResponse<T>(res));
}
patch<T = any>(
path: string,
body?: any,
config?: RequestInit,
): Promise<APIResponse<T> | ApiError> {
return fetch(`${this.baseUrl}${path}`, {
...config,
method: 'PATCH',
headers: this.headers,
body: body ? JSON.stringify(body) : undefined,
}).then(res => this.handleResponse<T>(res));
}
put<T = any>(
path: string,
body?: any,
config?: RequestInit,
): Promise<APIResponse<T> | ApiError> {
return fetch(`${this.baseUrl}${path}`, {
...config,
method: 'PUT',
headers: this.headers,
body: body ? JSON.stringify(body) : undefined,
}).then(res => this.handleResponse<T>(res));
}
delete<T = any>(
path: string,
body?: any,
config?: RequestInit,
): Promise<APIResponse<T> | ApiError> {
return fetch(`${this.baseUrl}${path}`, {
...config,
method: 'DELETE',
headers: this.headers,
body: body ? JSON.stringify(body) : undefined,
}).then(res => this.handleResponse<T>(res));
}
}
export const apiClient = (options?: ApiClientOptions) => new ApiClient(options);

View File

@@ -0,0 +1,39 @@
/**
* Copies a given string to the system clipboard.
*
* This function first attempts to use the modern Clipboard API (`navigator.clipboard.writeText`)
* if it is available and the context is secure (i.e., HTTPS). If that is not possible,
* it falls back to creating a temporary `<textarea>` element and using `document.execCommand('copy')`.
*
* @param text - The string to be copied to the clipboard.
* @returns A Promise that resolves to `true` if the copy succeeded, or `false` otherwise.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (!text) return false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
if (!successful) throw new Error('Fallback copy command failed.');
}
return true;
} catch (err) {
// eslint-disable-next-line no-console
console.error('Copy to clipboard failed:', err);
return false;
}
}

26
src/lib/utils/debounce/index.ts Executable file
View File

@@ -0,0 +1,26 @@
/**
* Debounce helper util
*
* This method will debounce calls made to the callback using the timeout
* provided. Set immediate to `true` to run the function immediately, then
* debounce further calls.
*/
export const debounce = <T extends (...args: unknown[]) => unknown>(
callBack: T,
wait: number,
immediate = false,
): VoidFunction => {
let timeout: NodeJS.Timeout | null;
return (...params: unknown[]): void => {
const args = params;
const later = (): void => {
timeout = null;
if (!immediate) callBack(...args);
};
const callNow = immediate && !timeout;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) callBack(...args);
};
};

1
src/setupTests.ts Executable file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

116
tsconfig.json Normal file
View File

@@ -0,0 +1,116 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"dom",
"es2020"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react-jsx" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
// "module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [],
"paths": {
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"]
} /* Allow multiple folders to be treated as one when resolving modules. */,
"typeRoots": [
"./types",
"./node_modules/@types"
] /* Specify multiple folders that act like `./node_modules/@types`. */,
"types": [
"node",
"jest",
"react"
] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
"noEmit": true /* Disable emitting files from a compilation. */,
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"exclude": ["node_modules", "types"]
}

13
vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});

5927
yarn.lock Normal file

File diff suppressed because it is too large Load Diff