Update package layout

This commit is contained in:
2026-03-16 23:36:38 -04:00
parent b345982ab1
commit 8fe121951b
24 changed files with 383 additions and 39 deletions

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

@@ -0,0 +1,8 @@
import "axios";
declare module "axios" {
interface InternalAxiosRequestConfig {
_retry?: boolean;
}
}

View File

@@ -0,0 +1 @@
export { default as AxiosProvider, useAxios } from "./AxiosProvider";

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

View File

@@ -0,0 +1 @@
export { default as ThemeProvider, useTheme } from "./ThemeProvider";

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

View File

@@ -0,0 +1 @@
export { default as ToasterProvider, useToaster } from "./ToasterProvider";

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

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

View File

@@ -0,0 +1 @@
export { default as TokenProvider, useToken } from "./TokenProvider";