feat(frontend): Phase 2 - 유틸리티 및 상수 정의

- constants/index.js: 카테고리, SNS 링크, 앨범 타입, 타임존 상수
- utils/cn.js: clsx 기반 className 유틸리티
- utils/date.js: dayjs 기반 날짜 유틸리티 (KST)
- utils/format.js: HTML 디코딩, 숫자/시간 포맷팅
- utils/index.js: 통합 export

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 17:07:56 +09:00
parent 4ec368c936
commit dc63a91f4f
8 changed files with 368 additions and 9 deletions

View file

@ -1,17 +1,21 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { isMobile } from "react-device-detect";
import { cn, getTodayKST, formatFullDate, formatXDateTime } from "@/utils";
import { CATEGORY_NAMES, SOCIAL_LINKS } from "@/constants";
/**
* 프로미스나인 팬사이트 메인
*
* Phase 1: 프로젝트 셋업 완료
* - 기본 구조 설정 파일 생성
* - React Query, React Router 설정
* - Tailwind CSS 설정
*
* 다음 단계에서 유틸리티, 스토어, API, 컴포넌트들이 추가될 예정
* Phase 2: 유틸리티 상수 완료
* - constants/index.js: 상수 정의 (카테고리, SNS 링크 )
* - utils/cn.js: className 유틸리티 (clsx 기반)
* - utils/date.js: 날짜 관련 유틸리티
* - utils/format.js: 포맷팅 유틸리티
* - utils/index.js: 통합 export
*/
function App() {
const today = getTodayKST();
return (
<BrowserRouter>
<Routes>
@ -19,16 +23,24 @@ function App() {
path="/"
element={
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold text-primary mb-2">
fromis_9 Frontend Refactoring
</h1>
<p className="text-gray-600">
Phase 1 완료 - 프로젝트 셋업
Phase 2 완료 - 유틸리티 상수
</p>
<p className="text-sm text-gray-500 mt-2">
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
디바이스: {isMobile ? "모바일" : "PC"}
</p>
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-2">
<p><strong>오늘 날짜:</strong> {today}</p>
<p><strong>포맷된 날짜:</strong> {formatFullDate(today)}</p>
<p><strong>X 스타일:</strong> {formatXDateTime(today, "19:00")}</p>
<p><strong>카테고리:</strong> {Object.values(CATEGORY_NAMES).join(", ")}</p>
<p><strong>SNS 개수:</strong> {Object.keys(SOCIAL_LINKS).length}</p>
</div>
</div>
</div>
}

View file

@ -0,0 +1,47 @@
/**
* 상수 정의
*/
/** 카테고리 ID */
export const CATEGORY_ID = {
DEFAULT: 1,
YOUTUBE: 2,
X: 3,
};
/** 카테고리 이름 매핑 */
export const CATEGORY_NAMES = {
[CATEGORY_ID.DEFAULT]: '기본',
[CATEGORY_ID.YOUTUBE]: 'YouTube',
[CATEGORY_ID.X]: 'X (Twitter)',
};
/** 공식 SNS 링크 */
export const SOCIAL_LINKS = {
youtube: 'https://www.youtube.com/@fromis9_official',
instagram: 'https://www.instagram.com/officialfromis_9',
twitter: 'https://twitter.com/realfromis_9',
tiktok: 'https://www.tiktok.com/@officialfromis_9',
fancafe: 'https://cafe.daum.net/officialfromis9',
};
/** 앨범 타입 */
export const ALBUM_TYPES = {
FULL: '정규',
MINI: '미니',
SINGLE: '싱글',
DIGITAL: '디지털',
OST: 'OST',
};
/** 타임존 */
export const TIMEZONE = 'Asia/Seoul';
/** 요일 이름 */
export const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'];
/** 기본 페이지 크기 */
export const DEFAULT_PAGE_SIZE = 20;
/** API 기본 URL */
export const API_BASE_URL = '/api';

View file

@ -0,0 +1,16 @@
import { clsx } from 'clsx';
/**
* className 유틸리티
* clsx를 래핑하여 조건부 클래스 조합을 쉽게 처리
*
* @example
* cn('base-class', isActive && 'active', { 'error': hasError })
* // => 'base-class active error' (조건에 따라)
*
* @param {...(string|object|array|boolean|null|undefined)} inputs - 클래스 입력
* @returns {string} 조합된 클래스 문자열
*/
export function cn(...inputs) {
return clsx(inputs);
}

View file

@ -0,0 +1,156 @@
/**
* 날짜 관련 유틸리티 함수
* dayjs를 사용하여 KST(한국 표준시) 기준으로 날짜 처리
*/
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { TIMEZONE, WEEKDAYS } from '@/constants';
// 플러그인 확장
dayjs.extend(utc);
dayjs.extend(timezone);
/**
* KST 기준 오늘 날짜 (YYYY-MM-DD)
* @returns {string} 오늘 날짜 문자열
*/
export const getTodayKST = () => {
return dayjs().tz(TIMEZONE).format('YYYY-MM-DD');
};
/**
* KST 기준 현재 시각
* @returns {dayjs.Dayjs} dayjs 객체
*/
export const nowKST = () => {
return dayjs().tz(TIMEZONE);
};
/**
* 날짜 문자열 포맷팅
* @param {string|Date} date - 날짜
* @param {string} format - 포맷 (기본: 'YYYY-MM-DD')
* @returns {string} 포맷된 날짜 문자열
*/
export const formatDate = (date, format = 'YYYY-MM-DD') => {
if (!date) return '';
return dayjs(date).tz(TIMEZONE).format(format);
};
/**
* 날짜에서 , , , 요일 추출
* @param {string|Date} date - 날짜
* @returns {{ year: number, month: number, day: number, weekday: string }}
*/
export const parseDateKST = (date) => {
const d = dayjs(date).tz(TIMEZONE);
return {
year: d.year(),
month: d.month() + 1,
day: d.date(),
weekday: WEEKDAYS[d.day()],
};
};
/**
* 날짜 비교 (같은 날인지)
* @param {string|Date} date1
* @param {string|Date} date2
* @returns {boolean}
*/
export const isSameDay = (date1, date2) => {
return (
dayjs(date1).tz(TIMEZONE).format('YYYY-MM-DD') ===
dayjs(date2).tz(TIMEZONE).format('YYYY-MM-DD')
);
};
/**
* 날짜가 오늘인지 확인
* @param {string|Date} date
* @returns {boolean}
*/
export const isToday = (date) => {
return isSameDay(date, dayjs());
};
/**
* 날짜가 과거인지 확인
* @param {string|Date} date
* @returns {boolean}
*/
export const isPast = (date) => {
return dayjs(date).tz(TIMEZONE).isBefore(dayjs().tz(TIMEZONE), 'day');
};
/**
* 날짜가 미래인지 확인
* @param {string|Date} date
* @returns {boolean}
*/
export const isFuture = (date) => {
return dayjs(date).tz(TIMEZONE).isAfter(dayjs().tz(TIMEZONE), 'day');
};
/**
* 전체 날짜 포맷 (YYYY. M. D. (요일))
* @param {string|Date} date - 날짜
* @returns {string} 포맷된 문자열
*/
export const formatFullDate = (date) => {
if (!date) return '';
const d = dayjs(date).tz(TIMEZONE);
return `${d.year()}. ${d.month() + 1}. ${d.date()}. (${WEEKDAYS[d.day()]})`;
};
/**
* X(트위터) 스타일 날짜/시간 포맷팅
* @param {string} date - 날짜 문자열 (YYYY-MM-DD)
* @param {string} [time] - 시간 문자열 (HH:mm 또는 HH:mm:ss)
* @returns {string} "오후 7:00 · 2026년 1월 18일" 또는 "2026년 1월 18일"
*/
export const formatXDateTime = (date, time) => {
if (!date) return '';
const d = dayjs(date).tz(TIMEZONE);
const datePart = `${d.year()}${d.month() + 1}${d.date()}`;
if (time) {
const [hours, minutes] = time.split(':').map(Number);
const period = hours < 12 ? '오전' : '오후';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
return `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${datePart}`;
}
return datePart;
};
/**
* datetime 문자열에서 date 추출
* @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DD"
* @returns {string} "YYYY-MM-DD"
*/
export const extractDate = (datetime) => {
if (!datetime) return '';
return datetime.split(' ')[0].split('T')[0];
};
/**
* datetime 문자열에서 time 추출
* @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DDTHH:mm"
* @returns {string|null} "HH:mm" 또는 null
*/
export const extractTime = (datetime) => {
if (!datetime) return null;
if (datetime.includes(' ')) {
return datetime.split(' ')[1]?.slice(0, 5) || null;
}
if (datetime.includes('T')) {
return datetime.split('T')[1]?.slice(0, 5) || null;
}
return null;
};
// dayjs 인스턴스도 export (고급 사용용)
export { dayjs };

View file

@ -0,0 +1,94 @@
/**
* 포맷팅 관련 유틸리티 함수
*/
/**
* HTML 엔티티 디코딩
* @param {string} text - HTML 엔티티가 포함된 텍스트
* @returns {string} 디코딩된 텍스트
*/
export const decodeHtmlEntities = (text) => {
if (!text) return '';
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
};
/**
* 시간 문자열 포맷팅 (HH:mm 형식으로)
* @param {string} time - 시간 문자열 (HH:mm:ss 또는 HH:mm)
* @returns {string|null} 포맷된 시간 또는 null
*/
export const formatTime = (time) => {
if (!time) return null;
return time.slice(0, 5);
};
/**
* 숫자에 단위 콤마 추가
* @param {number} num - 숫자
* @returns {string} 콤마가 추가된 문자열
*/
export const formatNumber = (num) => {
if (num === null || num === undefined) return '0';
return num.toLocaleString('ko-KR');
};
/**
* 조회수 포맷팅 (1 이상이면 '만' 단위로)
* @param {number} count - 조회수
* @returns {string} 포맷된 조회수
*/
export const formatViewCount = (count) => {
if (!count) return '0';
if (count >= 10000) {
return `${(count / 10000).toFixed(1)}`;
}
return formatNumber(count);
};
/**
* 파일 크기 포맷팅
* @param {number} bytes - 바이트 단위 크기
* @returns {string} 포맷된 크기 (: "1.5 MB")
*/
export const formatFileSize = (bytes) => {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
/**
* 재생 시간 포맷팅 ( -> MM:SS 또는 HH:MM:SS)
* @param {number} seconds - 단위 시간
* @returns {string} 포맷된 시간
*/
export const formatDuration = (seconds) => {
if (!seconds) return '0:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
return `${minutes}:${String(secs).padStart(2, '0')}`;
};
/**
* 텍스트 말줄임 처리
* @param {string} text - 원본 텍스트
* @param {number} maxLength - 최대 길이
* @returns {string} 말줄임 처리된 텍스트
*/
export const truncateText = (text, maxLength) => {
if (!text) return '';
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength)}...`;
};

View file

@ -0,0 +1,34 @@
/**
* 유틸리티 함수 통합 export
*/
// className 유틸리티
export { cn } from './cn';
// 날짜 관련
export {
getTodayKST,
nowKST,
formatDate,
parseDateKST,
isSameDay,
isToday,
isPast,
isFuture,
formatFullDate,
formatXDateTime,
extractDate,
extractTime,
dayjs,
} from './date';
// 포맷팅 관련
export {
decodeHtmlEntities,
formatTime,
formatNumber,
formatViewCount,
formatFileSize,
formatDuration,
truncateText,
} from './format';