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

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useTheme } from "$/providers/theme/ThemeProvider";
import { useTheme } from "$/provider/theme/ThemeProvider";
import { BsLightbulb, BsLightbulbFill } from "react-icons/bs";

View File

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

View File

@@ -1,3 +1,2 @@
export { default as ToasterProvider, useToaster } from "$/providers/toaster/ToasterProvider";
export { default as Toaster } from "./Toaster";

View File

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

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 @@
export { default as ThemeProvider, useTheme } from "./ThemeProvider";

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";

View File

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