st: 기본 셋팅
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { Client } from "@common/network";
|
||||
import { BASE_URL } from "../config/api";
|
||||
import { SITE_ID } from "../config/env";
|
||||
import { setCookie } from "../lib";
|
||||
|
||||
setCookie("site-id-key", SITE_ID);
|
||||
|
||||
export const client = Client.getInstance({
|
||||
baseURL: BASE_URL,
|
||||
siteId: SITE_ID,
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { client } from "./client";
|
||||
@@ -0,0 +1,72 @@
|
||||
import { PROXY_HOST } from "./env";
|
||||
|
||||
export const BASE_URL = `${PROXY_HOST}/server`;
|
||||
|
||||
export const ENDPOINT = {
|
||||
ATTACH_SERVICE: {
|
||||
TUS_UPLOAD: "/attach-service/api/v1/tus/upload",
|
||||
EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor",
|
||||
IMAGES: "/attach-service/api/v1/images",
|
||||
MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail",
|
||||
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
|
||||
DOWNLOAD: "/attach-service/api/v1/atflcd/download",
|
||||
STREAM_VIDEO: "/attach-service/api/v1/stream/video",
|
||||
},
|
||||
USER_SERVICE: {
|
||||
LOGIN: "/user-service/login",
|
||||
USERS: "/user-service/api/v1/users",
|
||||
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
|
||||
AUTHORIZATIONS: "/user-service/api/v1/authorizations",
|
||||
ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations",
|
||||
ROLES: "/user-service/api/v1/roles",
|
||||
LOGINLOG: "/user-service/api/v1/login-logs",
|
||||
PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs",
|
||||
},
|
||||
CMS_SERVICE: {
|
||||
DASHBOARD: "/cms-service/api/v1/dashboard",
|
||||
SITES: "/cms-service/api/v1/sites",
|
||||
IPS: "/cms-service/api/v1/site-ips",
|
||||
DOMAINS: "/cms-service/api/v1/site-dmns",
|
||||
POSTINGS: "/cms-service/api/v1/site-pstgs",
|
||||
CODE: "/cms-service/api/v1/code/cache",
|
||||
MENUS: "/cms-service/api/v1/menus",
|
||||
MENU_ROLES: "/cms-service/api/v1/menu-roles",
|
||||
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
|
||||
MENU_TEMPORARY_STORAGE: "/cms-service/api/v1/menu-tmpr-strg",
|
||||
MENU_HISTORY: "/cms-service/api/v1/menu-hstry",
|
||||
CONTENTS: "/cms-service/api/v1/contents",
|
||||
CONTENTS_DETAILS: "/cms-service/api/v1/contents-details",
|
||||
ORGANIZATIONS: "/cms-service/api/v1/ognzs",
|
||||
USER_ORGANIZATION: "/cms-service/api/v1/user-ognz",
|
||||
IMAGES: "/cms-service/api/v1/images",
|
||||
BANNERS: "/cms-service/api/v1/bnrs",
|
||||
TEMPLATE: "/cms-service/api/v1/tmplt",
|
||||
TERMS: "/cms-service/api/v1/trms",
|
||||
LWPO_CODE: "/cms-service/api/v1/lwpo-cds",
|
||||
UP_CODE: "/cms-service/api/v1/up-codes",
|
||||
POPUPS: "/cms-service/api/v1/popups",
|
||||
SCHEDULES: "/cms-service/api/v1/schdls",
|
||||
SURVEY: "/cms-service/api/v1/srvy",
|
||||
SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem",
|
||||
SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl",
|
||||
ZIPS: "/cms-service/api/v1/zips",
|
||||
GROUPS: "/cms-service/api/v1/groups",
|
||||
GROUP_USERS: "/cms-service/api/v1/group-users",
|
||||
MANAGER_IP: "/cms-service/api/v1/mgrip",
|
||||
APILOG: "/cms-service/api/v1/api-logs",
|
||||
SITE_AUTHORITY: "/cms-service/api/v1/site-authrt",
|
||||
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
|
||||
},
|
||||
BOARD_SERVICE: {
|
||||
BBS: "/board-service/api/v1/bbs",
|
||||
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
|
||||
POSTS: "/board-service/api/v1/posts",
|
||||
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
|
||||
POST_EVALUATION: "/board-service/api/v1/pstEvl",
|
||||
},
|
||||
ST_SERVICE: {
|
||||
COMMON: "/st-service/api/v1/common",
|
||||
}
|
||||
};
|
||||
|
||||
export const JWT_EXPIRATION_TIME = 1800000;
|
||||
@@ -0,0 +1,32 @@
|
||||
// CSP 설정을 위한 환경별 구성 (img-src만)
|
||||
export const CSP_CONFIG = {
|
||||
development: {
|
||||
"img-src": ["'self'", "data:", "https:", "blob:", "http://localhost:*"]
|
||||
},
|
||||
production: {
|
||||
"img-src": ["'self'", "data:", "https:", "blob:"]
|
||||
}
|
||||
};
|
||||
|
||||
// CSP 헤더 값을 생성하는 함수
|
||||
export function generateCSPHeader(environment: 'development' | 'production' = 'development'): string {
|
||||
const config = CSP_CONFIG[environment];
|
||||
|
||||
return Object.entries(config)
|
||||
.map(([key, values]) => {
|
||||
return `${key} ${values.join(' ')}`;
|
||||
})
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
// img-src에 특정 도메인 추가하는 함수
|
||||
export function addImageDomain(domain: string, environment: 'development' | 'production' = 'development'): string {
|
||||
const config = CSP_CONFIG[environment];
|
||||
const currentImgSrc = config["img-src"];
|
||||
|
||||
if (!currentImgSrc.includes(domain)) {
|
||||
currentImgSrc.push(domain);
|
||||
}
|
||||
|
||||
return `img-src ${currentImgSrc.join(' ')};`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
|
||||
export const PROXY_HOST =
|
||||
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
|
||||
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "ST";
|
||||
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN =
|
||||
process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
|
||||
export const REFRESH_TOKEN =
|
||||
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
|
||||
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
export const ST_SERVICE_URL = process.env.NEXT_PUBLIC_ST_SERVICE_URL || "http://localhost:8080";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./api";
|
||||
export * from "./env";
|
||||
@@ -0,0 +1,29 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export function getCookie(name: string): string | undefined {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
return Cookies.get(name);
|
||||
}
|
||||
|
||||
export function setCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
options?: Cookies.CookieAttributes,
|
||||
): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
Cookies.set(name, value, options);
|
||||
}
|
||||
|
||||
export function removeCookie(
|
||||
name: string,
|
||||
options?: Cookies.CookieAttributes,
|
||||
): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
Cookies.remove(name, options);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// CSP 위반 모니터링 및 보고 스크립트
|
||||
|
||||
interface CSPViolation {
|
||||
documentURI: string;
|
||||
blockedURI: string;
|
||||
violatedDirective: string;
|
||||
originalPolicy: string;
|
||||
sourceFile?: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
}
|
||||
|
||||
class CSPMonitor {
|
||||
private reportEndpoint: string;
|
||||
|
||||
constructor(reportEndpoint: string = '/api/csp-violation') {
|
||||
this.reportEndpoint = reportEndpoint;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
// CSP 위반 이벤트 리스너 등록
|
||||
document.addEventListener('securitypolicyviolation', (event) => {
|
||||
this.handleViolation(event);
|
||||
});
|
||||
|
||||
// 페이지 로드 시 CSP 정책 확인
|
||||
this.checkCSPPolicy();
|
||||
}
|
||||
|
||||
private handleViolation(event: SecurityPolicyViolationEvent): void {
|
||||
const violation: CSPViolation = {
|
||||
documentURI: event.documentURI,
|
||||
blockedURI: event.blockedURI,
|
||||
violatedDirective: event.violatedDirective,
|
||||
originalPolicy: event.originalPolicy,
|
||||
sourceFile: event.sourceFile,
|
||||
lineNumber: event.lineNumber,
|
||||
columnNumber: event.columnNumber
|
||||
};
|
||||
|
||||
// 콘솔에 위반 정보 출력
|
||||
console.warn('CSP Violation detected:', violation);
|
||||
|
||||
// 서버로 위반 정보 전송
|
||||
this.reportViolation(violation);
|
||||
}
|
||||
|
||||
private async reportViolation(violation: CSPViolation): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(this.reportEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(violation),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to report CSP violation:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reporting CSP violation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private checkCSPPolicy(): void {
|
||||
// CSP 헤더 확인
|
||||
const cspHeader = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
|
||||
if (cspHeader) {
|
||||
console.log('CSP Policy found in meta tag:', cspHeader.getAttribute('content'));
|
||||
} else {
|
||||
console.log('No CSP meta tag found, relying on HTTP headers');
|
||||
}
|
||||
}
|
||||
|
||||
// CSP 정책 테스트 함수
|
||||
public testCSPPolicy(): void {
|
||||
console.log('Testing CSP policy...');
|
||||
|
||||
// 인라인 스크립트 테스트
|
||||
try {
|
||||
eval('console.log("CSP test: inline script execution")');
|
||||
} catch {
|
||||
console.log('CSP blocked inline script execution');
|
||||
}
|
||||
|
||||
// 외부 스크립트 로드 테스트
|
||||
const testScript = document.createElement('script');
|
||||
testScript.src = 'https://example.com/test.js';
|
||||
testScript.onload = () => console.log('External script loaded successfully');
|
||||
testScript.onerror = () => console.log('CSP blocked external script');
|
||||
document.head.appendChild(testScript);
|
||||
}
|
||||
|
||||
// CSP 위반 통계 수집
|
||||
public getViolationStats(): Promise<unknown> {
|
||||
return fetch(`${this.reportEndpoint}/stats`)
|
||||
.then(response => response.json())
|
||||
.catch(error => {
|
||||
console.error('Error fetching CSP violation stats:', error);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 CSP 모니터 인스턴스 생성
|
||||
export const cspMonitor = new CSPMonitor();
|
||||
|
||||
// 개발 환경에서만 CSP 테스트 실행
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// 페이지 로드 후 CSP 정책 테스트
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
cspMonitor.testCSPPolicy();
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
export default CSPMonitor;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { generateCSPHeader } from '../config/csp';
|
||||
|
||||
// CSP 위반 인터페이스 정의
|
||||
interface CSPViolationData {
|
||||
documentURI: string;
|
||||
blockedURI: string;
|
||||
violatedDirective: string;
|
||||
originalPolicy: string;
|
||||
sourceFile?: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
}
|
||||
|
||||
// CSP 헤더를 설정하는 유틸리티 함수 (img-src만)
|
||||
export function setCSPHeaders(response: Response): Response {
|
||||
const environment = process.env.NODE_ENV as 'development' | 'production';
|
||||
const cspValue = generateCSPHeader(environment);
|
||||
|
||||
response.headers.set('Content-Security-Policy', cspValue);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// CSP 위반을 로깅하는 함수
|
||||
export function logCSPViolation(violation: CSPViolationData): void {
|
||||
console.warn('CSP Violation:', {
|
||||
documentURI: violation.documentURI,
|
||||
blockedURI: violation.blockedURI,
|
||||
violatedDirective: violation.violatedDirective,
|
||||
originalPolicy: violation.originalPolicy,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// img-src 정책을 검증하는 함수
|
||||
export function validateImgSrcPolicy(policy: string): boolean {
|
||||
return policy.includes('img-src');
|
||||
}
|
||||
|
||||
// 개발 환경에서 CSP를 비활성화하는 함수 (디버깅용)
|
||||
export function disableCSPForDevelopment(): boolean {
|
||||
return process.env.NODE_ENV === 'development' &&
|
||||
process.env.DISABLE_CSP === 'true';
|
||||
}
|
||||
|
||||
// img-src에 도메인 추가하는 함수
|
||||
export function addDomainToImgSrc(
|
||||
currentPolicy: string,
|
||||
domain: string
|
||||
): string {
|
||||
if (currentPolicy.includes('img-src')) {
|
||||
// 기존 img-src에 도메인 추가
|
||||
return currentPolicy.replace(
|
||||
/img-src ([^;]+)/,
|
||||
`img-src $1 ${domain}`
|
||||
);
|
||||
} else {
|
||||
// img-src가 없으면 새로 생성
|
||||
return `${currentPolicy}; img-src 'self' ${domain};`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { getCookie, setCookie, removeCookie } from "./cookie";
|
||||
export { useMutationWrapper } from "./use-mutation-wrapper";
|
||||
export { useAlertStore } from "./use-alert-store";
|
||||
export { useDataTable } from "./use-data-table";
|
||||
export { useMenuStore } from "./use-menu-store";
|
||||
export { useUserStore } from "./use-user-store";
|
||||
export { usePrompt } from "./use-prompt";
|
||||
export { useSelectPrompt } from "./use-select-prompt";
|
||||
@@ -0,0 +1,65 @@
|
||||
import { type UseQueryResult } from "@tanstack/react-query";
|
||||
import type { PageableList } from "../model";
|
||||
|
||||
/**
|
||||
* Match the state of a query to a set of components.
|
||||
*
|
||||
* Useful for rendering different UI based on the state of a query.
|
||||
*/
|
||||
export function matchQueryStatus<T>(
|
||||
query: UseQueryResult<PageableList<T>>,
|
||||
options: {
|
||||
Loading?: JSX.Element;
|
||||
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
|
||||
Empty?: JSX.Element;
|
||||
Success: (
|
||||
data: UseQueryResult<PageableList<T>> & {
|
||||
data: NonNullable<UseQueryResult<T>["data"]>;
|
||||
},
|
||||
) => JSX.Element;
|
||||
},
|
||||
): JSX.Element;
|
||||
export function matchQueryStatus<T>(
|
||||
query: UseQueryResult<PageableList<T>>,
|
||||
options: {
|
||||
Loading?: JSX.Element;
|
||||
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
|
||||
Success: (data: UseQueryResult<PageableList<T>>) => JSX.Element;
|
||||
},
|
||||
): JSX.Element;
|
||||
export function matchQueryStatus<T>(
|
||||
query: UseQueryResult<PageableList<T>>,
|
||||
{
|
||||
Loading,
|
||||
Errored,
|
||||
Empty,
|
||||
Success,
|
||||
}: {
|
||||
Loading?: JSX.Element;
|
||||
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
|
||||
Empty?: JSX.Element;
|
||||
Success: (data: UseQueryResult<PageableList<T>>) => JSX.Element;
|
||||
},
|
||||
): JSX.Element {
|
||||
if (query.isLoading && Loading) {
|
||||
return Loading;
|
||||
}
|
||||
|
||||
if (query.isError && Errored) {
|
||||
if (typeof Errored === "function") {
|
||||
return Errored(query.error);
|
||||
}
|
||||
return Errored;
|
||||
}
|
||||
|
||||
const isEmpty =
|
||||
query.data?.content === undefined ||
|
||||
query.data?.content === null ||
|
||||
(Array.isArray(query.data?.content) && query.data?.content.length === 0);
|
||||
|
||||
if (isEmpty && Empty) {
|
||||
return Empty;
|
||||
}
|
||||
|
||||
return Success(query);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useStore } from "zustand";
|
||||
import { useContext } from "react";
|
||||
import type { AlertStore } from "../store/alert-store";
|
||||
import { StoreContext } from "../providers/store-provider";
|
||||
|
||||
export const useAlertStore = <T>(selector: (store: AlertStore) => T): T => {
|
||||
const storeContext = useContext(StoreContext);
|
||||
|
||||
if (!storeContext) {
|
||||
throw new Error("useAlertStore must be used within StoreProvider");
|
||||
}
|
||||
|
||||
return useStore(storeContext.alertStore, selector);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useStore } from "zustand";
|
||||
import { useContext } from "react";
|
||||
import type { AttachmentStore } from "../store/attachment-store";
|
||||
import { StoreContext } from "../providers/store-provider";
|
||||
|
||||
export const useAttachmentStore = <T>(
|
||||
selector: (store: AttachmentStore) => T,
|
||||
): T => {
|
||||
const store = useContext(StoreContext);
|
||||
if (!store) {
|
||||
throw new Error("useAttachmentStore must be used within StoreProvider");
|
||||
}
|
||||
return useStore(store.attachmentStore, selector);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { useReactTable, type TableOptions } from "@tanstack/react-table";
|
||||
import { DataTable } from "@common/components";
|
||||
import { NoData } from "../ui/no-data";
|
||||
import type { PageableList } from "../model";
|
||||
import { matchQueryStatus } from "./match-query-status";
|
||||
|
||||
type UseDataTableOptions<T> = {
|
||||
query: UseQueryResult<PageableList<T>>;
|
||||
tableOptions: Omit<TableOptions<T>, "data" | "rowCount">;
|
||||
dataTableOptions?: DataTableOptions<T>;
|
||||
};
|
||||
|
||||
type DataTableOptions<T> = {
|
||||
variant?: "default" | "secondary" | "line";
|
||||
className?: HTMLAttributes<HTMLTableElement>["className"];
|
||||
viewPagination?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
};
|
||||
|
||||
export const useDataTable = <T,>({
|
||||
query,
|
||||
tableOptions,
|
||||
dataTableOptions,
|
||||
}: UseDataTableOptions<T>) => {
|
||||
const table = useReactTable({
|
||||
...tableOptions,
|
||||
data: query.data?.content ?? [],
|
||||
rowCount: query.data?.totalElements ?? 0,
|
||||
});
|
||||
|
||||
return {
|
||||
tableComponent: matchQueryStatus(query, {
|
||||
Empty: <NoData />,
|
||||
Success: () => {
|
||||
return <DataTable table={table} {...dataTableOptions} />;
|
||||
},
|
||||
}),
|
||||
tableInstance: table,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useStore } from "zustand";
|
||||
import { useContext } from "react";
|
||||
import type { MenuStore } from "../store/menu-store";
|
||||
import { StoreContext } from "../providers/store-provider";
|
||||
|
||||
export const useMenuStore = <T>(selector: (store: MenuStore) => T): T => {
|
||||
const storeContext = useContext(StoreContext);
|
||||
|
||||
if (!storeContext) {
|
||||
throw new Error("useMenuStore must be used within StoreProvider");
|
||||
}
|
||||
|
||||
return useStore(storeContext.menuStore, selector);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { useMutation, type UseMutationOptions } from "@tanstack/react-query";
|
||||
import { useAlertStore } from "./use-alert-store";
|
||||
|
||||
export const useMutationWrapper = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
TVariables = void,
|
||||
TContext = unknown,
|
||||
>(
|
||||
mutationOptions: UseMutationOptions<TData, TError, TVariables, TContext>,
|
||||
) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
return useMutation({
|
||||
...mutationOptions,
|
||||
onError: (error) => {
|
||||
let errorMessage = "";
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.data?.message)
|
||||
errorMessage += `${error.response.data.message}\n`;
|
||||
if (error.response?.data?.errors[0]?.message)
|
||||
errorMessage += `${error.response.data.errors[0].message}\n`;
|
||||
}
|
||||
|
||||
alert(`${errorMessage}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useContext } from "react";
|
||||
import { PromptContext } from "../providers/prompt-provider";
|
||||
|
||||
export const usePrompt = () => {
|
||||
const { message, resolve, setMessage, setResolve } =
|
||||
useContext(PromptContext);
|
||||
|
||||
const prompt = (message: string) => {
|
||||
setMessage(message);
|
||||
return new Promise<{ resolved: boolean; value?: string }>((resolve) => {
|
||||
setResolve(() => resolve);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (value: string) => {
|
||||
resolve?.({ resolved: true, value });
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
resolve?.({ resolved: false });
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
return { message, isOpen: !!message, prompt, onSubmit, onCancel };
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useContext, useEffect } from "react";
|
||||
import { PromptContext } from "../providers/prompt-provider";
|
||||
|
||||
type SelectData = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const useSelectPrompt = (data?: SelectData[]) => {
|
||||
const {
|
||||
selectData,
|
||||
selectionGuide,
|
||||
resolve,
|
||||
setResolve,
|
||||
setSelectData,
|
||||
setSelectionGuide,
|
||||
} = useContext(PromptContext);
|
||||
|
||||
const prompt = (message: string) => {
|
||||
setSelectionGuide(message);
|
||||
return new Promise<{ resolved: boolean; value?: string }>((resolve) => {
|
||||
setResolve(() => resolve);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (value: string) => {
|
||||
resolve?.({ resolved: true, value });
|
||||
setSelectionGuide("");
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
resolve?.({ resolved: false });
|
||||
setSelectionGuide("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setSelectData(data);
|
||||
}, [data]);
|
||||
|
||||
return {
|
||||
selectData,
|
||||
selectionGuide,
|
||||
isOpen: !!selectionGuide,
|
||||
prompt,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { type HttpResponse, Upload } from "tus-js-client";
|
||||
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "../config";
|
||||
import { useAttachmentStore } from "./use-attachment-store";
|
||||
import { useAlertStore } from "./use-alert-store";
|
||||
import { getCookie } from "./cookie";
|
||||
|
||||
type TusProps = {
|
||||
accept?: string;
|
||||
};
|
||||
|
||||
export const useTus = ({ accept }: TusProps = {}) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { attachmentFileCode, setAttachmentFileCode } = useAttachmentStore(
|
||||
(state) => state,
|
||||
);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [upload, setUpload] = useState<Upload | null>(null);
|
||||
const [response, setResponse] = useState<HttpResponse | null>(null);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [isValid, setIsValid] = useState<boolean>(false);
|
||||
|
||||
const metadata = {
|
||||
filename: file?.name ?? "",
|
||||
filetype: file?.type ?? "",
|
||||
atflCd: attachmentFileCode,
|
||||
};
|
||||
|
||||
const validFileType = (file: File) => {
|
||||
const allowedExtensions = accept?.split(",") ?? [];
|
||||
const fileName = file.name;
|
||||
const fileExtension = fileName
|
||||
.substring(fileName.lastIndexOf("."))
|
||||
.toLowerCase();
|
||||
|
||||
if (!allowedExtensions.includes(fileExtension)) {
|
||||
alert("파일 형식이 올바르지 않습니다.");
|
||||
setIsValid(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsValid(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
const createUpload = () => {
|
||||
return new Upload(file!, {
|
||||
endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
|
||||
chunkSize: 10 * 1024 * 1024,
|
||||
headers: {
|
||||
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
|
||||
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
|
||||
},
|
||||
metadata,
|
||||
onError(error) {
|
||||
throw new Error(`Failed because: ${error}`);
|
||||
},
|
||||
onProgress(bytesUploaded, bytesTotal) {
|
||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
|
||||
setProgress(parseInt(percentage));
|
||||
},
|
||||
onAfterResponse(req, res) {
|
||||
const fileInfo = res.getHeader("X-File-Info");
|
||||
if (fileInfo === undefined || fileInfo === null) return;
|
||||
|
||||
setAttachmentFileCode(JSON.parse(fileInfo)?.atflCd ?? "");
|
||||
setResponse(res);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) return;
|
||||
if (!validFileType(file)) return;
|
||||
|
||||
const upload = createUpload();
|
||||
upload.start();
|
||||
setUpload(upload);
|
||||
}, [file]);
|
||||
|
||||
return {
|
||||
file,
|
||||
upload,
|
||||
response,
|
||||
progress,
|
||||
isValid,
|
||||
setFile,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useStore } from "zustand";
|
||||
import { useContext } from "react";
|
||||
import type { UserStore } from "../store/user-store";
|
||||
import { StoreContext } from "../providers/store-provider";
|
||||
|
||||
export const useUserStore = <T>(selector: (store: UserStore) => T): T => {
|
||||
const storeContext = useContext(StoreContext);
|
||||
|
||||
if (!storeContext) {
|
||||
throw new Error("useUserStore must be used within StoreProvider");
|
||||
}
|
||||
|
||||
return useStore(storeContext.userStore, selector);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
interface SortConfig {
|
||||
empty: boolean;
|
||||
sorted: boolean;
|
||||
unsorted: boolean;
|
||||
}
|
||||
|
||||
interface PageableConfig {
|
||||
sort: SortConfig;
|
||||
offset: number;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
paged: boolean;
|
||||
unpaged: boolean;
|
||||
}
|
||||
|
||||
export interface PageableList<T> {
|
||||
content: T[];
|
||||
pageable: PageableConfig;
|
||||
sort: SortConfig;
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
number: number;
|
||||
numberOfElements: number;
|
||||
size: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
empty: boolean;
|
||||
}
|
||||
|
||||
export interface PageableParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./api-interface";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StoreProvider } from "./store-provider";
|
||||
export { PromptProvider } from "./prompt-provider";
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createContext, useState } from "react";
|
||||
|
||||
type SelectData = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface PromptContext {
|
||||
message?: string;
|
||||
selectData?: SelectData[];
|
||||
selectionGuide?: string;
|
||||
resolve?: ({
|
||||
resolved,
|
||||
value,
|
||||
}: {
|
||||
resolved: boolean;
|
||||
value?: string;
|
||||
}) => void;
|
||||
setMessage: (message: string) => void;
|
||||
setSelectData: (selectData: SelectData[]) => void;
|
||||
setSelectionGuide: (message: string) => void;
|
||||
setResolve: (resolve: () => void) => void;
|
||||
}
|
||||
|
||||
export const PromptContext = createContext<PromptContext>({
|
||||
message: "",
|
||||
selectData: [],
|
||||
selectionGuide: "",
|
||||
resolve() {},
|
||||
setMessage() {},
|
||||
setSelectData() {},
|
||||
setSelectionGuide() {},
|
||||
setResolve() {},
|
||||
});
|
||||
|
||||
export const PromptProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [message, setMessage] = useState<string>();
|
||||
const [selectData, setSelectData] = useState<SelectData[]>([]);
|
||||
const [selectionGuide, setSelectionGuide] = useState<string>();
|
||||
const [resolve, setResolve] =
|
||||
useState<
|
||||
({ resolved, value }: { resolved: boolean; value?: string }) => void
|
||||
>();
|
||||
|
||||
return (
|
||||
<PromptContext.Provider
|
||||
value={{
|
||||
message,
|
||||
selectData,
|
||||
selectionGuide,
|
||||
resolve,
|
||||
setMessage,
|
||||
setSelectData,
|
||||
setSelectionGuide,
|
||||
setResolve,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PromptContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useRef } from "react";
|
||||
import {
|
||||
createAlertStore,
|
||||
createAttachmentStore,
|
||||
createMenuStore,
|
||||
createUserStore,
|
||||
} from "../store";
|
||||
|
||||
type StoreContextType = {
|
||||
alertStore: ReturnType<typeof createAlertStore>;
|
||||
attachmentStore: ReturnType<typeof createAttachmentStore>;
|
||||
menuStore: ReturnType<typeof createMenuStore>;
|
||||
userStore: ReturnType<typeof createUserStore>;
|
||||
};
|
||||
|
||||
export const StoreContext = createContext<StoreContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const storeRef = useRef<StoreContextType | null>(null);
|
||||
if (storeRef.current === null) {
|
||||
storeRef.current = {
|
||||
alertStore: createAlertStore(),
|
||||
attachmentStore: createAttachmentStore(),
|
||||
menuStore: createMenuStore(),
|
||||
userStore: createUserStore(),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</StoreContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export type AlertState = {
|
||||
message: string;
|
||||
callback?: () => void;
|
||||
};
|
||||
|
||||
export type AlertActions = {
|
||||
setMessage: (message: string, callback?: () => void) => void;
|
||||
};
|
||||
|
||||
export type AlertStore = AlertState & AlertActions;
|
||||
|
||||
export const defaultInitState: AlertState = {
|
||||
message: "",
|
||||
callback: undefined,
|
||||
};
|
||||
|
||||
export const createAlertStore = (initState: AlertState = defaultInitState) => {
|
||||
return createStore<AlertStore>()((set) => ({
|
||||
...initState,
|
||||
setMessage: (message: string, callback?: () => void) =>
|
||||
set(() => ({ message, callback })),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export type AttachmentState = {
|
||||
attachmentFileCode: string;
|
||||
};
|
||||
|
||||
export type AttachmentActions = {
|
||||
setAttachmentFileCode: (attachmentFileCode: string) => void;
|
||||
};
|
||||
|
||||
export type AttachmentStore = AttachmentState & AttachmentActions;
|
||||
|
||||
export const defaultInitState: AttachmentState = {
|
||||
attachmentFileCode: "",
|
||||
};
|
||||
|
||||
export const createAttachmentStore = (
|
||||
initState: AttachmentState = defaultInitState,
|
||||
) => {
|
||||
return createStore<AttachmentStore>()((set) => ({
|
||||
...initState,
|
||||
setAttachmentFileCode: (attachmentFileCode: string) =>
|
||||
set({ attachmentFileCode }),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { createAlertStore } from "./alert-store";
|
||||
export { createAttachmentStore } from "./attachment-store";
|
||||
export { createMenuStore, type BbsType } from "./menu-store";
|
||||
export { createUserStore, type IUserInfo } from "./user-store";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export const BBS_TYPE = {
|
||||
BBSNOTICE: "BBSNOTICE",
|
||||
BBSNORMAL: "BBSNORMAL",
|
||||
BBSFAQ: "BBSFAQ",
|
||||
BBSIMAGE: "BBSIMAGE",
|
||||
BBSMOVIE: "BBSMOVIE",
|
||||
} as const;
|
||||
|
||||
export type BbsType = (typeof BBS_TYPE)[keyof typeof BBS_TYPE];
|
||||
|
||||
export interface IBbs {
|
||||
bbsId: number;
|
||||
bbsTypeCd: BbsType;
|
||||
bbsTypeNm: string;
|
||||
bbsNm: string;
|
||||
bbsExpln: string;
|
||||
rfrncUseYn: string;
|
||||
atflUseYn: string;
|
||||
atflMaxCnt: number;
|
||||
atflMaxFileSz: number;
|
||||
ansUseYn: string;
|
||||
ansUseAuthrt: string;
|
||||
cmntUseYn: string;
|
||||
dgstfnEvlUseYn: string;
|
||||
smsUseYn: string;
|
||||
upndFixUseYn: string;
|
||||
pstgPrdUseYn: string;
|
||||
popupUseYn: string;
|
||||
delYn: string;
|
||||
useYn: string;
|
||||
mdfcnDt: string;
|
||||
mdfrNm: string;
|
||||
}
|
||||
|
||||
interface IMenu {
|
||||
menuCd: number;
|
||||
menuKornNm: string;
|
||||
menuEngNm: string;
|
||||
parentId: number | null;
|
||||
sortSeq: number;
|
||||
iconNm?: string;
|
||||
children: IMenu[] | null;
|
||||
levelNo: number;
|
||||
urlPath: string;
|
||||
menuLnkgTypeCd: string;
|
||||
menuLnkgSn: number;
|
||||
menuPath: string;
|
||||
qrcd: string;
|
||||
useYn: string;
|
||||
otptYn: string;
|
||||
newWndwYn: string;
|
||||
requiredUrlPath: boolean;
|
||||
}
|
||||
|
||||
interface IAuthority {
|
||||
mngAuthrtYn: string;
|
||||
inqAuthrtYn: string;
|
||||
regAuthrtYn: string;
|
||||
mdfcnAuthrtYn: string;
|
||||
delAuthrtYn: string;
|
||||
}
|
||||
|
||||
export type MenuState = {
|
||||
bbsInfo: IBbs | null;
|
||||
currentMenuInfo: IMenu | null;
|
||||
pageMenuAuthority: IAuthority | null;
|
||||
};
|
||||
|
||||
export type MenuActions = {
|
||||
setBbsInfo: (bbs: IBbs) => void;
|
||||
setCurrentMenuInfo: (menu: IMenu) => void;
|
||||
setPageMenuAuthority: (authority: IAuthority) => void;
|
||||
};
|
||||
|
||||
export type MenuStore = MenuState & MenuActions;
|
||||
|
||||
export const defaultInitState: MenuState = {
|
||||
currentMenuInfo: null,
|
||||
bbsInfo: null,
|
||||
pageMenuAuthority: null,
|
||||
};
|
||||
|
||||
export const createMenuStore = (initState: MenuState = defaultInitState) => {
|
||||
return createStore<MenuStore>()((set) => ({
|
||||
...initState,
|
||||
setBbsInfo: (bbs: IBbs) => set({ bbsInfo: bbs }),
|
||||
setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }),
|
||||
setPageMenuAuthority: (authority: IAuthority) =>
|
||||
set({ pageMenuAuthority: authority }),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export interface IUserInfo {
|
||||
regDt: string;
|
||||
mdfcnDt: string;
|
||||
rgtr: string;
|
||||
mdfr: string;
|
||||
adminRoleYn: string;
|
||||
userUnqId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
email: string;
|
||||
encryptedPassword: string;
|
||||
refreshToken: string;
|
||||
userStateCode: string;
|
||||
lastLoginDate: string;
|
||||
loginFailCount: number;
|
||||
pswdHnt: string;
|
||||
pswdAns: string;
|
||||
mphn: string;
|
||||
telno: string;
|
||||
zip: string;
|
||||
addr: string;
|
||||
daddr: string;
|
||||
laeSeCd: string;
|
||||
ogdpSeCd: string;
|
||||
aprvYn: string;
|
||||
autzrId: string;
|
||||
expryYmd: string;
|
||||
lastPswdChgDt: string;
|
||||
googleId: string;
|
||||
kakaoId: string;
|
||||
naverId: string;
|
||||
isSocialUser: boolean;
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
export type UserState = {
|
||||
userInfo: IUserInfo | null;
|
||||
};
|
||||
|
||||
export type UserActions = {
|
||||
setUserInfo: (userInfo: IUserInfo | null) => void;
|
||||
};
|
||||
|
||||
export type UserStore = UserState & UserActions;
|
||||
|
||||
export const defaultInitState: UserState = {
|
||||
userInfo: null,
|
||||
};
|
||||
|
||||
export const createUserStore = (initState: UserState = defaultInitState) => {
|
||||
return createStore<UserStore>()((set) => ({
|
||||
...initState,
|
||||
setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Button,
|
||||
} from "@common/components";
|
||||
import { useAlertStore } from "../lib/use-alert-store";
|
||||
|
||||
function AlertDialog() {
|
||||
const { message, callback, setMessage } = useAlertStore((state) => state);
|
||||
const handleOpenChange = () => {
|
||||
setMessage("");
|
||||
callback?.();
|
||||
};
|
||||
return (
|
||||
<Dialog open={!!message} onOpenChange={handleOpenChange}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<div className="py-10">
|
||||
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center">
|
||||
<DialogClose asChild>
|
||||
<Button>확인</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { AlertDialog };
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Button,
|
||||
type ButtonProps,
|
||||
} from "@common/components";
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
isOpen?: boolean;
|
||||
confirmButtonProps?: ButtonProps & { label?: string };
|
||||
cancelButtonProps?: ButtonProps & { label?: string };
|
||||
};
|
||||
|
||||
function ConfirmDialog({
|
||||
isOpen,
|
||||
confirmButtonProps,
|
||||
cancelButtonProps,
|
||||
}: ConfirmDialogProps) {
|
||||
const { message, isOpen: isGlobalOpen, onConfirm, onCancel } = useConfirm();
|
||||
return (
|
||||
<Dialog open={isOpen ?? isGlobalOpen} onOpenChange={onCancel}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<div className="py-10">
|
||||
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center">
|
||||
<DialogClose asChild>
|
||||
<Button onClick={onConfirm} {...confirmButtonProps}>
|
||||
{confirmButtonProps?.label ?? "확인"}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button onClick={onCancel} color="white" {...cancelButtonProps}>
|
||||
{cancelButtonProps?.label ?? "취소"}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { ConfirmDialog };
|
||||
@@ -0,0 +1,175 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form";
|
||||
import { cn, Label } from "@common/components";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = "FormLabel";
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = "FormControl";
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = "FormDescription";
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[1rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = "FormMessage";
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import {
|
||||
flexRender,
|
||||
type RowData,
|
||||
type Table as TableInstance,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { Button, Checkbox, Label, cn } from "@common/components";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
align?: "left" | "center" | "right";
|
||||
rowSpan?: number;
|
||||
}
|
||||
}
|
||||
|
||||
type GridViewDataTableProps<T> = {
|
||||
table: TableInstance<T>;
|
||||
variant?: "default" | "secondary" | "line";
|
||||
className?: HTMLAttributes<HTMLTableElement>["className"];
|
||||
viewPagination?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
};
|
||||
|
||||
function GridViewDataTable<T>({
|
||||
table,
|
||||
className,
|
||||
viewPagination,
|
||||
onRowClick,
|
||||
}: GridViewDataTableProps<T>) {
|
||||
return (
|
||||
<div className="bg-secondary-background pb-10">
|
||||
<div className="flex items-center gap-2 rounded-sm rounded-bl-none rounded-br-none border border-border bg-background p-4">
|
||||
<Checkbox
|
||||
id="all-select"
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCheckedChange={(checked) =>
|
||||
table.toggleAllPageRowsSelected(!!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="all-select" className="text-2xl">
|
||||
전체선택
|
||||
</Label>
|
||||
</div>
|
||||
<div className={cn("w-full p-4", className)}>
|
||||
<div className="grid grid-cols-5 gap-6">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-md border border-border bg-background p-4 shadow-md",
|
||||
onRowClick && "cursor-pointer hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const { meta } = cell.column.columnDef;
|
||||
return (
|
||||
<div key={cell.id} {...meta}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{viewPagination && <Pagination table={table} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pagination<T>({ table }: { table: TableInstance<T> }) {
|
||||
const pageCount = table.getPageCount();
|
||||
const currentPage = table.getState().pagination.pageIndex;
|
||||
const maxVisiblePages = 10; // 한 번에 보여줄 최대 페이지 수
|
||||
|
||||
let startPage = Math.max(0, currentPage - maxVisiblePages / 2); // 시작 페이지
|
||||
const endPage = Math.min(pageCount, startPage + maxVisiblePages); // 끝 페이지
|
||||
|
||||
if (endPage - startPage < maxVisiblePages) {
|
||||
startPage = Math.max(0, endPage - maxVisiblePages);
|
||||
}
|
||||
|
||||
const pageNumbers = Array(endPage - startPage)
|
||||
.fill(0)
|
||||
.map((_, i) => startPage + i);
|
||||
|
||||
return (
|
||||
<div className="mt-[24px] flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="white"
|
||||
className="hidden size-10 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">첫 페이지로</span>
|
||||
<DoubleArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="white"
|
||||
className="size-10 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">이전 페이지로</span>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center justify-center text-sm font-medium">
|
||||
{pageNumbers.map((pageNumber) => (
|
||||
<Button
|
||||
key={pageNumber}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"inline-block size-10 !bg-transparent p-0 text-foreground hover:!bg-accent",
|
||||
currentPage === pageNumber &&
|
||||
"!bg-primary !font-bold !text-primary-foreground hover:!bg-primary",
|
||||
)}
|
||||
onClick={() => table.setPageIndex(pageNumber)}
|
||||
>
|
||||
{pageNumber + 1}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="white"
|
||||
className="size-10 p-0"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">다음 페이지로</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="white"
|
||||
className="hidden size-10 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">마지막 페이지로</span>
|
||||
<DoubleArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { GridViewDataTable };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
|
||||
|
||||
function InfoTooltip({ text }: { text: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<i className="diveicon di-info-circle" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{text.split("\n").map((line, idx) => (
|
||||
<span key={idx}>
|
||||
{line}
|
||||
{idx !== text.split("\n").length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export { InfoTooltip };
|
||||
@@ -0,0 +1,47 @@
|
||||
import { InputDatePicker } from "@common/components";
|
||||
import { useEffect } from "react";
|
||||
import { useAlertStore } from "../lib";
|
||||
|
||||
type DateRange = {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
};
|
||||
|
||||
type InputDateRangePickerProps = {
|
||||
dateRange: DateRange;
|
||||
setDateRange: (dateRange: DateRange) => void;
|
||||
};
|
||||
|
||||
function InputDateRangePicker({
|
||||
dateRange,
|
||||
setDateRange,
|
||||
}: InputDateRangePickerProps) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
useEffect(() => {
|
||||
if (dateRange.from && dateRange.to && dateRange.from > dateRange.to) {
|
||||
alert("종료일이 시작일보다 이전일 수 없습니다.");
|
||||
setDateRange({ ...dateRange, to: dateRange.from });
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputDatePicker
|
||||
value={dateRange.from}
|
||||
onChange={(date) => {
|
||||
setDateRange({ ...dateRange, from: date });
|
||||
}}
|
||||
/>
|
||||
~
|
||||
<InputDatePicker
|
||||
value={dateRange.to}
|
||||
onChange={(date) => {
|
||||
setDateRange({ ...dateRange, to: date });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputDateRangePicker };
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cn } from "@common/components";
|
||||
|
||||
function NoData({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-96 flex-col items-center justify-center gap-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<i className="diveicon di-info-circle text-3xl" />
|
||||
조회된 데이터가 없습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { NoData };
|
||||
@@ -0,0 +1,22 @@
|
||||
import { PageTitle } from "@common/components";
|
||||
import { useMenuStore } from "../lib/use-menu-store";
|
||||
|
||||
function PageLayout({ children }: { children: React.ReactNode }) {
|
||||
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
|
||||
|
||||
const menuPaths = currentMenuInfo?.menuPath
|
||||
?.split(">")
|
||||
.map((path) => path.trim());
|
||||
|
||||
return (
|
||||
<div className="h-auto min-h-full bg-container pb-2">
|
||||
<PageTitle
|
||||
title={currentMenuInfo?.menuKornNm ?? ""}
|
||||
menuPaths={menuPaths ?? []}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PageLayout };
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Input,
|
||||
} from "@common/components";
|
||||
import { usePrompt } from "@/shared/lib/use-prompt";
|
||||
|
||||
function PromptDialog() {
|
||||
const { message, isOpen, onSubmit, onCancel } = usePrompt();
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setValue("");
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onCancel}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<div className="flex flex-col gap-6 pb-4 pt-8">
|
||||
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
|
||||
{message}
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
className="!text-[1.3rem]"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center">
|
||||
<DialogClose asChild>
|
||||
<Button onClick={() => onSubmit(value)}>확인</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button onClick={onCancel} color="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { PromptDialog };
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
import { useSelectPrompt } from "../lib/use-select-prompt";
|
||||
|
||||
function SelectPromptDialog() {
|
||||
const { selectData, selectionGuide, isOpen, onSubmit, onCancel } =
|
||||
useSelectPrompt();
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setValue("");
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onCancel}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<div className="flex flex-col gap-6 pb-4 pt-8">
|
||||
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
|
||||
{selectionGuide}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select onValueChange={setValue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectData?.map((data) => (
|
||||
<SelectItem key={data.value} value={data.value}>
|
||||
{data.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
value={value}
|
||||
className="!text-[1.3rem] !leading-6"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center">
|
||||
<DialogClose asChild>
|
||||
<Button onClick={() => onSubmit(value)}>확인</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button onClick={onCancel} color="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { SelectPromptDialog };
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
function SkeletonPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<Skeleton className="h-[6rem] rounded-xl" />
|
||||
<Skeleton className="h-[60rem] rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SkeletonPage };
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
@@ -0,0 +1,26 @@
|
||||
import { PageTitle } from "@common/components";
|
||||
import { useTabStore } from "@/store/tab-store";
|
||||
import { TabBar } from "./tab-bar";
|
||||
|
||||
function StatisticsPageLayout({ children }: { children: React.ReactNode }) {
|
||||
const { activeTabId, tabs } = useTabStore();
|
||||
|
||||
const pageTitle = tabs.find((t) => t.menuItem.menuEngNm === activeTabId);
|
||||
|
||||
const menuPaths = pageTitle?.path
|
||||
?.split(">")
|
||||
.map((path) => path.trim());
|
||||
|
||||
return (
|
||||
<div className="h-auto min-h-full pb-2">
|
||||
<TabBar />
|
||||
<PageTitle
|
||||
title={pageTitle?.label ?? ""}
|
||||
menuPaths={menuPaths ?? []}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { StatisticsPageLayout };
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useTabStore } from "@/store/tab-store";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@common/components";
|
||||
|
||||
const TabBar = () => {
|
||||
const { tabs, activeTabId, setActiveTab, removeTab } = useTabStore();
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCloseTab = (e: React.MouseEvent | React.KeyboardEvent, tabId: string) => {
|
||||
e.stopPropagation();
|
||||
removeTab(tabId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTabId || undefined}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="!px-4 !flex !items-center !justify-start !gap-2 !w-[140px]"
|
||||
title={tab.label}
|
||||
>
|
||||
<span className="text-xl flex-1 leading-none truncate">
|
||||
{tab.label}
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => handleCloseTab(e, tab.id)}
|
||||
aria-label={`${tab.label} 탭 닫기`}
|
||||
className="flex items-center justify-center flex-shrink-0 leading-none hover:text-[#2094fa]"
|
||||
>
|
||||
<i className="ri-close-line" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export { TabBar };
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@common/components";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"origin-(--radix-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md bg-background px-3 py-1.5 text-lg text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[-50%] rotate-45 rounded-[2px] bg-background fill-background" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
Reference in New Issue
Block a user