st: 기본 셋팅

This commit is contained in:
2025-12-18 17:55:20 +09:00
parent 85c1a7a5e3
commit c37f4f15f9
240 changed files with 32045 additions and 1 deletions
+11
View File
@@ -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,
});
+1
View File
@@ -0,0 +1 @@
export { client } from "./client";
+72
View File
@@ -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;
+32
View File
@@ -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(' ')};`;
}
+12
View File
@@ -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";
+2
View File
@@ -0,0 +1,2 @@
export * from "./api";
export * from "./env";
+29
View File
@@ -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);
}
+120
View File
@@ -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;
+61
View File
@@ -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};`;
}
}
+8
View File
@@ -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);
}
+14
View File
@@ -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);
};
+42
View File
@@ -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,
};
};
+14
View File
@@ -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}`);
},
});
};
+26
View File
@@ -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,
};
};
+90
View File
@@ -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,
};
};
+14
View File
@@ -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);
};
+34
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
export * from "./api-interface";
+2
View File
@@ -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>
);
};
+25
View File
@@ -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 }),
}));
};
+4
View File
@@ -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";
+93
View File
@@ -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 }),
}));
};
+57
View File
@@ -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 })),
}));
};
+34
View File
@@ -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 };
+48
View File
@@ -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 };
+175
View File
@@ -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 };
+23
View File
@@ -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 };
+17
View File
@@ -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 };
+22
View File
@@ -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 };
+49
View File
@@ -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 };
+14
View File
@@ -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 };
+25
View File
@@ -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 };
+52
View File
@@ -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 };
+61
View File
@@ -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 };