Update package layout
This commit is contained in:
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";
|
||||
62
lib/provider/theme/ThemeProvider.tsx
Normal file
62
lib/provider/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Theme, ThemeProviderProps, ThemeProviderState } from "$/types/ThemeTypes";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
|
||||
const themeInitialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(themeInitialState);
|
||||
|
||||
|
||||
export default function ThemeProvider(props: Readonly<ThemeProviderProps>){
|
||||
const {
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "mattrixwv-ui-theme"
|
||||
} = props;
|
||||
|
||||
const [ theme, setTheme ] = useState<Theme>((localStorage.getItem(storageKey) as Theme) || defaultTheme);
|
||||
|
||||
useEffect(() => {
|
||||
const root = globalThis.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if(theme === "system"){
|
||||
const systemTheme = globalThis.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
}
|
||||
else{
|
||||
root.classList.add(theme);
|
||||
}
|
||||
}, [ theme ]);
|
||||
|
||||
const value: ThemeProviderState = useMemo(() => ({
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
}
|
||||
}), [storageKey, theme]);
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useTheme(){
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if(!context){
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
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";
|
||||
95
lib/provider/toaster/ToasterProvider.tsx
Normal file
95
lib/provider/toaster/ToasterProvider.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { DangerMessageBlock, SuccessMessageBlock, WarningMessageBlock } from "$/component/message";
|
||||
import Toaster from "$/component/toaster/Toaster";
|
||||
import type { Toast, ToastProviderProps, ToastProviderState } from "$/types/ToasterTypes";
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
|
||||
|
||||
const toastInitialState: ToastProviderState = {
|
||||
toast: [],
|
||||
hideToast: () => {},
|
||||
addToast: () => "",
|
||||
addSuccess: () => "",
|
||||
addWarning: () => "",
|
||||
addDanger: () => ""
|
||||
};
|
||||
|
||||
const ToasterProviderContext = createContext<ToastProviderState>(toastInitialState);
|
||||
|
||||
|
||||
export default function ToasterProvider({
|
||||
className,
|
||||
children
|
||||
}: Readonly<ToastProviderProps>){
|
||||
const [ toast, setToast ] = useState<Toast[]>([]);
|
||||
|
||||
|
||||
const hideToast = useCallback(function hide(id: string){
|
||||
setToast((prev) => {
|
||||
if(prev.length === 1 && prev[0].id === id){
|
||||
const current = prev[0].hideTime > new Date(Date.now() - 600) ? [...prev] : [];
|
||||
if(current.length > 0){
|
||||
setTimeout(() => hide(id), 600);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
else{
|
||||
return prev.filter((toast) => toast.id !== id);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback((message: React.ReactNode, duration?: number) => {
|
||||
if(!duration){
|
||||
duration = 5000;
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
setToast((prev) => [ ...prev, { id, message, duration, hideTime: new Date(Date.now() + duration) } ]);
|
||||
setTimeout(() => hideToast(id), duration);
|
||||
|
||||
return id;
|
||||
}, [hideToast]);
|
||||
|
||||
const addSuccess = useCallback((message: React.ReactNode, duration?: number) => {
|
||||
return addToast(<SuccessMessageBlock>{message}</SuccessMessageBlock>, duration);
|
||||
}, [ addToast ]);
|
||||
|
||||
const addWarning = useCallback((message: React.ReactNode, duration?: number) => {
|
||||
return addToast(<WarningMessageBlock>{message}</WarningMessageBlock>, duration);
|
||||
}, [ addToast ]);
|
||||
|
||||
const addDanger = useCallback((message: React.ReactNode, duration?: number) => {
|
||||
return addToast(<DangerMessageBlock>{message}</DangerMessageBlock>, duration);
|
||||
}, [ addToast ]);
|
||||
|
||||
const value: ToastProviderState = useMemo(() => ({
|
||||
toast,
|
||||
hideToast,
|
||||
addToast,
|
||||
addSuccess,
|
||||
addWarning,
|
||||
addDanger
|
||||
}), [ toast, hideToast, addToast, addSuccess, addWarning, addDanger ]);
|
||||
|
||||
return (
|
||||
<ToasterProviderContext.Provider value={value}>
|
||||
<Toaster
|
||||
toast={toast}
|
||||
className={className}
|
||||
/>
|
||||
{children}
|
||||
</ToasterProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useToaster(){
|
||||
const context = useContext(ToasterProviderContext);
|
||||
|
||||
if(!context){
|
||||
throw new Error("useToaster must be used within a ToasterProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
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";
|
||||
Reference in New Issue
Block a user