Update package layout
This commit is contained in:
@@ -47,6 +47,7 @@ export { default as DragAndDropFileInput } from "./file/DragAndDropFileInput";
|
||||
export { default as FileInput } from "./file/FileInput";
|
||||
export { default as NumberInput } from "./number/NumberInput";
|
||||
export { default as OptionInput } from "./text/OptionInput";
|
||||
export { default as PhoneInput } from "./text/PhoneInput";
|
||||
export { default as SelectInput } from "./text/SelectInput";
|
||||
export { default as TextArea } from "./text/TextArea";
|
||||
export { default as TextInput } from "./text/TextInput";
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function TextArea({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
disabled
|
||||
}: Readonly<TextAreaProps>){
|
||||
const componentId = useId();
|
||||
@@ -44,14 +45,15 @@ export default function TextArea({
|
||||
rows={rows}
|
||||
cols={cols}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
spellCheck={spellCheck}
|
||||
/>
|
||||
<label
|
||||
className={clsx(
|
||||
"absolute ml-2 -top-3 left-0 text-sm rounded-md px-1 select-none cursor-default",
|
||||
"peer-placeholder-shown:top-0 peer-placeholder-shown:-left-1 peer-placeholder-shown:text-inherit peer-placeholder-shown:text-base peer-placeholder-shown:bg-transparent peer-placeholder-shown:cursor-text peer-placeholder-shown:w-[99%]",
|
||||
"peer-placeholder-shown:top-0 peer-placeholder-shown:-left-1 peer-placeholder-shown:text-inherit peer-placeholder-shown:text-base peer-placeholder-shown:cursor-text peer-placeholder-shown:w-[99%]",
|
||||
"peer-focus:-top-3 peer-focus:left-0 peer-focus:text-sm peer-focus:w-auto peer-focus:h-auto",
|
||||
"flex items-center",
|
||||
labelClassName
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function TextInput({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
disabled
|
||||
}: Readonly<TextInputProps>){
|
||||
const componentId = useId();
|
||||
@@ -41,14 +42,15 @@ export default function TextInput({
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
spellCheck={spellCheck}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<label
|
||||
className={clsx(
|
||||
"absolute ml-2 -top-3 left-0 text-sm rounded-md px-1 select-none cursor-default",
|
||||
"peer-placeholder-shown:top-0 peer-placeholder-shown:-left-1 peer-placeholder-shown:text-inherit peer-placeholder-shown:text-base peer-placeholder-shown:bg-transparent peer-placeholder-shown:h-full peer-placeholder-shown:cursor-text peer-placeholder-shown:w-[99%]",
|
||||
"peer-placeholder-shown:top-0 peer-placeholder-shown:-left-1 peer-placeholder-shown:text-inherit peer-placeholder-shown:text-base peer-placeholder-shown:h-full peer-placeholder-shown:cursor-text peer-placeholder-shown:w-[99%]",
|
||||
"peer-focus:-top-3 peer-focus:left-0 peer-focus:text-sm peer-focus:w-auto peer-focus:h-auto",
|
||||
"flex items-center",
|
||||
labelClassName
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTheme } from "$/providers/theme/ThemeProvider";
|
||||
import { useTheme } from "$/provider/theme/ThemeProvider";
|
||||
import { BsLightbulb, BsLightbulbFill } from "react-icons/bs";
|
||||
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ThemeProvider, useTheme } from "$/providers/theme/ThemeProvider";
|
||||
@@ -1,3 +1,2 @@
|
||||
export { default as ToasterProvider, useToaster } from "$/providers/toaster/ToasterProvider";
|
||||
export { default as Toaster } from "./Toaster";
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ export * from "$/component/modal";
|
||||
export * from "$/component/nav";
|
||||
export * from "$/component/progress";
|
||||
export * from "$/component/tab";
|
||||
export * from "$/component/theme";
|
||||
export * from "$/component/toaster";
|
||||
export * from "$/provider/axios";
|
||||
export * from "$/provider/theme";
|
||||
export * from "$/provider/toaster";
|
||||
export * from "$/provider/token";
|
||||
|
||||
|
||||
93
lib/provider/axios/AxiosProvider.tsx
Normal file
93
lib/provider/axios/AxiosProvider.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { AxiosError, AxiosInstance } from "axios";
|
||||
import axios from "axios";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { useToken } from "../token";
|
||||
|
||||
|
||||
export interface AxiosState {
|
||||
publicApi: AxiosInstance;
|
||||
authorizedApi: AxiosInstance;
|
||||
}
|
||||
|
||||
const initialState: AxiosState = {
|
||||
publicApi: {} as AxiosInstance,
|
||||
authorizedApi: {} as AxiosInstance
|
||||
}
|
||||
|
||||
const AxiosContext = createContext<AxiosState>(initialState);
|
||||
|
||||
|
||||
export default function AxiosProvider({
|
||||
apiUrl,
|
||||
children
|
||||
}: Readonly<{
|
||||
apiUrl: string;
|
||||
children: React.ReactNode;
|
||||
}>){
|
||||
const { getToken } = useToken();
|
||||
|
||||
const publicApi = useMemo(() => {
|
||||
const api = axios.create({
|
||||
baseURL: apiUrl
|
||||
});
|
||||
return api;
|
||||
}, [apiUrl]);
|
||||
const authorizedApi = useMemo(() => {
|
||||
const api = axios.create({
|
||||
baseURL: apiUrl
|
||||
});
|
||||
api.interceptors.request.use(async (config) => {
|
||||
try{
|
||||
const token = await getToken();
|
||||
if(token){
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
catch(error){
|
||||
return Promise.reject(error as Error);
|
||||
}
|
||||
});
|
||||
api.interceptors.response.use(r => r, async (error: AxiosError) => {
|
||||
const original = error.config;
|
||||
if(!original){
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if(error.response?.status === 401 && !original._retry){
|
||||
original._retry = true;
|
||||
try{
|
||||
const newToken = await getToken();
|
||||
original.headers.Authorization = `Bearer ${newToken}`;
|
||||
return api(original);
|
||||
}
|
||||
catch(refreshError){
|
||||
return Promise.reject(refreshError as Error);
|
||||
}
|
||||
}
|
||||
});
|
||||
return api;
|
||||
}, [apiUrl, getToken]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
publicApi,
|
||||
authorizedApi
|
||||
}), [authorizedApi, publicApi]);
|
||||
|
||||
return (
|
||||
<AxiosContext.Provider value={value}>
|
||||
{children}
|
||||
</AxiosContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useAxios(){
|
||||
const context = useContext(AxiosContext);
|
||||
|
||||
if(!context){
|
||||
throw new Error("useAxios must be called inside an AxiosProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
8
lib/provider/axios/axios.d.ts
vendored
Normal file
8
lib/provider/axios/axios.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import "axios";
|
||||
|
||||
|
||||
declare module "axios" {
|
||||
interface InternalAxiosRequestConfig {
|
||||
_retry?: boolean;
|
||||
}
|
||||
}
|
||||
1
lib/provider/axios/index.ts
Normal file
1
lib/provider/axios/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AxiosProvider, useAxios } from "./AxiosProvider";
|
||||
1
lib/provider/theme/index.ts
Normal file
1
lib/provider/theme/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ThemeProvider, useTheme } from "./ThemeProvider";
|
||||
1
lib/provider/toaster/index.ts
Normal file
1
lib/provider/toaster/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ToasterProvider, useToaster } from "./ToasterProvider";
|
||||
77
lib/provider/token/TokenProvider.tsx
Normal file
77
lib/provider/token/TokenProvider.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef } from "react";
|
||||
import { defaultTokenData, fetchToken, parseToken, type TokenData } from "./TokenUtils";
|
||||
|
||||
export interface TokenState {
|
||||
getToken: () => Promise<string | null | undefined>;
|
||||
}
|
||||
|
||||
const initialState: TokenState = {
|
||||
getToken: () => new Promise(() => {})
|
||||
}
|
||||
|
||||
const TokenContext = createContext<TokenState>(initialState);
|
||||
|
||||
|
||||
export default function TokenProvider({
|
||||
apiUrl,
|
||||
children
|
||||
}: Readonly<{
|
||||
apiUrl: string;
|
||||
children: React.ReactNode;
|
||||
}>){
|
||||
const tokenRef = useRef<TokenData>(defaultTokenData);
|
||||
const refreshPromise = useRef<Promise<string | null | undefined>>(null);
|
||||
|
||||
const getToken = useCallback(async () => {
|
||||
if(refreshPromise.current){
|
||||
return refreshPromise.current;
|
||||
}
|
||||
|
||||
const { accessToken, expires } = tokenRef.current;
|
||||
|
||||
const isExpired = Date.now() > (expires - 5000); //Give a 5 second buffer
|
||||
|
||||
if(!accessToken || isExpired){
|
||||
refreshPromise.current = (async () => {
|
||||
try {
|
||||
const rawToken = (await fetchToken(apiUrl)).token;
|
||||
const parsedToken = parseToken(rawToken);
|
||||
tokenRef.current = parsedToken;
|
||||
return rawToken;
|
||||
}
|
||||
catch(error){
|
||||
tokenRef.current = defaultTokenData;
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
refreshPromise.current = null;
|
||||
}
|
||||
})();
|
||||
return refreshPromise.current;
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}, [apiUrl]);
|
||||
|
||||
const value: TokenState = useMemo(() => ({
|
||||
getToken
|
||||
}), [getToken]);
|
||||
|
||||
return (
|
||||
<TokenContext.Provider value={value}>
|
||||
{children}
|
||||
</TokenContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useToken(){
|
||||
const context = useContext(TokenContext);
|
||||
|
||||
if(!context){
|
||||
throw new Error("useTOken must be called inside a TokenProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
52
lib/provider/token/TokenUtils.ts
Normal file
52
lib/provider/token/TokenUtils.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
interface TokenResponse {
|
||||
token: string | null | undefined;
|
||||
}
|
||||
|
||||
interface TokenBody {
|
||||
token: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface LoginBody {
|
||||
login: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface TokenData {
|
||||
accessToken: string | null | undefined;
|
||||
issued: number;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
export const defaultTokenData: TokenData = {
|
||||
accessToken: "",
|
||||
issued: 0,
|
||||
expires: 0
|
||||
}
|
||||
|
||||
export async function fetchToken(apiUrl: string){
|
||||
const res = await fetch(`${apiUrl}/refresh`);
|
||||
return await res.json() as TokenResponse;
|
||||
}
|
||||
|
||||
export function parseToken(rawToken: string | null | undefined): TokenData {
|
||||
if(!rawToken){
|
||||
return defaultTokenData;
|
||||
}
|
||||
|
||||
const payloads = rawToken.split(".");
|
||||
if(payloads.length !== 3){
|
||||
return defaultTokenData;
|
||||
}
|
||||
|
||||
const payload = payloads[1];
|
||||
|
||||
const tokenBody = JSON.parse(atob(payload)) as TokenBody;
|
||||
|
||||
return ({
|
||||
accessToken: rawToken,
|
||||
issued: tokenBody.iat * 1000,
|
||||
expires: tokenBody.exp * 1000
|
||||
});
|
||||
}
|
||||
1
lib/provider/token/index.ts
Normal file
1
lib/provider/token/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TokenProvider, useToken } from "./TokenProvider";
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from "react";
|
||||
import type { ChangeEventHandler, ComponentProps } from "react";
|
||||
import type { ComponentProps, KeyboardEventHandler } from "react";
|
||||
|
||||
|
||||
export interface TextInputProps {
|
||||
@@ -11,8 +11,9 @@ export interface TextInputProps {
|
||||
maxLength?: number;
|
||||
spellCheck?: boolean;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
value: string;
|
||||
onChange?: (newValue: string) => void;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -25,11 +26,12 @@ export interface TextAreaProps {
|
||||
maxLength?: number;
|
||||
spellCheck?: boolean;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
value: string;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
onChange?: ChangeEventHandler<HTMLTextAreaElement>;
|
||||
onChange?: (newValue: string) => void;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectInputProps {
|
||||
|
||||
Reference in New Issue
Block a user