client.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. 'use client';
  2. import { clsx, type ClassValue } from 'clsx';
  3. import { twMerge } from 'tailwind-merge';
  4. import { TokenData, ResultDto, ApiError } from '@/types/response/common';
  5. import { CLAIM_NAME_IDENTIFIER, CLAIM_EMAIL, CLAIM_NAME } from '@/constants/common';
  6. export function cn(...inputs: ClassValue[]) {
  7. return twMerge(clsx(inputs))
  8. }
  9. // 클라이언트용 fetch 유틸 (Route Handler 호출용)
  10. interface FetchApiOptions extends Omit<RequestInit, 'body'> {
  11. body?: unknown;
  12. /** true로 설정 시 에러 응답을 throw하지 않고 ResultDto를 그대로 반환 */
  13. silent?: boolean;
  14. }
  15. // 토큰 갱신 중복 방지
  16. let refreshPromise: Promise<boolean> | null = null;
  17. async function tryRefreshToken(): Promise<boolean> {
  18. if (refreshPromise) {
  19. return refreshPromise;
  20. }
  21. refreshPromise = fetch('/api/auth/refresh-token', { method: 'POST' })
  22. .then(res => res.json())
  23. .then((res: ResultDto<unknown>) => res.success)
  24. .catch(() => false)
  25. .finally(() => { refreshPromise = null; });
  26. return refreshPromise;
  27. }
  28. export async function fetchApi<T>(url: string, options?: FetchApiOptions): Promise<ResultDto<T>> {
  29. const isFormData = options?.body instanceof FormData;
  30. const hasBody = options?.body !== undefined;
  31. const fetchOptions: RequestInit = {
  32. ...options,
  33. headers: {
  34. ...(!isFormData && hasBody ? { 'Content-Type': 'application/json' } : {}),
  35. ...options?.headers
  36. },
  37. body: isFormData ? options.body as BodyInit : (options?.body ? JSON.stringify(options.body) : undefined)
  38. };
  39. let res: ResultDto<T> = await (await fetch(url, fetchOptions)).json();
  40. // 401 시 토큰 갱신 후 재시도
  41. if (!res.success && res.status === 401) {
  42. const refreshed = await tryRefreshToken();
  43. if (refreshed) {
  44. res = await (await fetch(url, fetchOptions)).json();
  45. }
  46. }
  47. // 기본: 에러 시 throw (silent 옵션으로 비활성화)
  48. if (!options?.silent) {
  49. throwError(res);
  50. }
  51. return res;
  52. }
  53. // JWT 토큰에서 사용자 정보 추출
  54. export function decodeAccessToken(token: string): TokenData|null {
  55. try {
  56. const base64URL = token.split('.')[1]; // JWT의 Payload 부분
  57. const base64 = base64URL.replace(/-/g, '+').replace(/_/g, '/'); // Base64 형식 변환
  58. const jsonPayload = decodeURIComponent(
  59. atob(base64).split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')
  60. );
  61. const payload = JSON.parse(jsonPayload);
  62. return {
  63. id: payload[CLAIM_NAME_IDENTIFIER] || null,
  64. email: payload[CLAIM_EMAIL] || null,
  65. name: payload[CLAIM_NAME] || null
  66. };
  67. } catch {
  68. return null;
  69. }
  70. }
  71. // 날짜 형식 변환 표시
  72. export function formatDate(dateString: string|null): string {
  73. if (!dateString) {
  74. return '-';
  75. }
  76. const date = new Date(dateString);
  77. const now = new Date();
  78. const diffMs = now.getTime() - date.getTime();
  79. const diffSec = Math.floor(diffMs / 1000);
  80. const diffMin = Math.floor(diffSec / 60);
  81. const diffHour = Math.floor(diffMin / 60);
  82. const diffDay = Math.floor(diffHour / 24);
  83. const diffWeek = Math.floor(diffDay / 7);
  84. const diffMonth = Math.floor(diffDay / 30);
  85. const diffYear = Math.floor(diffDay / 365);
  86. if (diffSec < 60) return '방금 전';
  87. if (diffMin < 60) return `${diffMin}분 전`;
  88. if (diffHour < 24) return `${diffHour}시간 전`;
  89. if (diffDay < 7) return `${diffDay}일 전`;
  90. if (diffWeek < 5) return `${diffWeek}주 전`;
  91. if (diffMonth < 12) return `${diffMonth}개월 전`;
  92. return `${diffYear}년 전`;
  93. }
  94. // 2025.01.01. 12:00:00 으로 조회
  95. export function getDateTime(dateString: string|null) {
  96. if (!dateString) {
  97. return '-';
  98. }
  99. const date = new Date(dateString);
  100. return date.toLocaleString('ko-KR', {
  101. year: 'numeric',
  102. month: '2-digit',
  103. day: '2-digit',
  104. hour: '2-digit',
  105. minute: '2-digit',
  106. second: '2-digit',
  107. hour12: false
  108. }).replace(/\. /g, '.').replace(/\.(\d{2}:)/, ' $1');
  109. }
  110. // 파일 이름 생성
  111. export function getDateFilename(prefix: string): string {
  112. if (!prefix) {
  113. return '';
  114. }
  115. const now = new Date();
  116. const yyyy = now.getFullYear();
  117. const MM = String(now.getMonth() + 1).padStart(2, '0');
  118. const dd = String(now.getDate()).padStart(2, '0');
  119. const hh = String(now.getHours()).padStart(2, '0');
  120. const mm = String(now.getMinutes()).padStart(2, '0');
  121. const ss = String(now.getSeconds()).padStart(2, '0');
  122. return `${prefix}-${yyyy}${MM}${dd}${hh}${mm}${ss}.png`;
  123. }
  124. // HTML 태그 제거
  125. export function stripHtmlTags(str?: string|null): string {
  126. if (!str) {
  127. return '';
  128. }
  129. const div = document.createElement('div');
  130. div.innerHTML = str;
  131. return div.textContent || div.innerText || '';
  132. }
  133. // 비밀번호 정책 안내 문구 생성성
  134. export function getPasswordPolicyMessage(policy: {
  135. passwordUppercaseLength: number;
  136. passwordNumbersLength: number;
  137. passwordSpecialcharsLength: number;
  138. }) {
  139. const parts: string[] = [];
  140. if (policy.passwordUppercaseLength > 0) {
  141. parts.push(`영문 대문자 ${policy.passwordUppercaseLength}자 이상`);
  142. }
  143. if (policy.passwordNumbersLength > 0) {
  144. parts.push(`숫자 ${policy.passwordNumbersLength}자 이상`);
  145. }
  146. if (policy.passwordSpecialcharsLength > 0) {
  147. parts.push(`특수문자 ${policy.passwordSpecialcharsLength}자 이상`);
  148. }
  149. if (parts.length > 0) {
  150. return `비밀번호는 ${parts.join(', ')}를 포함해야 합니다.`;
  151. }
  152. return '';
  153. }
  154. // 특수문자 제거
  155. export function filterSpecialCharacters(str: string) {
  156. return str.replace(/[^\p{L}\p{N}\s]/gu, '');
  157. }
  158. // ResultDto에서 첫 번째 오류 조회
  159. export function getError(errors: ApiError[]|null|undefined, index = 0): string|undefined {
  160. if (!errors || errors.length === 0) {
  161. return undefined;
  162. }
  163. return errors[index]?.description;
  164. }
  165. // 서버 응답 메시지 분석
  166. export function throwError<T>(res: ResultDto<T>): void {
  167. if (res.success) {
  168. return;
  169. }
  170. let message: string|null|undefined = getError(res.errors);
  171. if (!message) {
  172. message = res.message;
  173. }
  174. if (!message) {
  175. switch (res.status) {
  176. case 400: message = '잘못된 요청입니다.'; break;
  177. case 401:
  178. message = '로그인 후 이용해 주세요.';
  179. // 인증 만료 이벤트 발행
  180. window.dispatchEvent(new CustomEvent('auth:unauthorized'));;
  181. break;
  182. case 403: message = '권한이 없습니다.'; break;
  183. case 404: message = '요청하신 페이지를 찾을 수 없습니다.'; break;
  184. case 500: message = '서버 오류가 발생했습니다. 다시 시도해 주세요.'; break;
  185. }
  186. }
  187. if (message) {
  188. throw new Error(message);
  189. }
  190. }
  191. // 1000회 이상 조회된 게시글인지 확인
  192. export function isHotPost(showHotIcon: boolean, post: { views: number; createdAt: string }): boolean {
  193. return (showHotIcon && post.views >= 1000 && Date.now() - new Date(post.createdAt).getTime() < 7 * 24 * 60 * 60 * 1000);
  194. }
  195. // 24시간 이내에 작성된 게시글인지 확인
  196. export function isNewPost(showNewIcon: boolean, post: { createdAt: string }): boolean {
  197. return (showNewIcon && Date.now() - new Date(post.createdAt).getTime() < 24 * 60 * 60 * 1000);
  198. }
  199. // a 태그에 target="_blank" 추가
  200. export function forceTargetBlank(html: string) {
  201. return html.replace(/<a\s+(?![^>]*target=)([^>]*href=["'][^"']+["'])/gi, '<a target="_blank" rel="noopener noreferrer" $1');
  202. };
  203. // 날짜가 현재 날짜보다 지났는지 확인
  204. export function isDateOverdue(dateString: string|null, days = 0): boolean {
  205. if (!dateString) {
  206. return false;
  207. }
  208. const limitDate = new Date(new Date(dateString));
  209. limitDate.setDate(limitDate.getDate() + days);
  210. return (new Date()) > limitDate;
  211. }