client.ts 6.7 KB

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