feat(frontend): Phase 3 - Zustand 스토어 구현
- useAuthStore: 인증 상태 관리 (localStorage persist) - useScheduleStore: 스케줄 페이지 상태 (검색, 필터, 날짜, 뷰) - useUIStore: UI 상태 (토스트, 모달, 라이트박스, 확인 다이얼로그) - stores/index.js: 통합 export Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
dc63a91f4f
commit
cba7e4b522
6 changed files with 359 additions and 17 deletions
|
|
@ -1,20 +1,31 @@
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { cn, getTodayKST, formatFullDate, formatXDateTime } from "@/utils";
|
import { cn, getTodayKST, formatFullDate } from "@/utils";
|
||||||
import { CATEGORY_NAMES, SOCIAL_LINKS } from "@/constants";
|
import { useAuthStore, useScheduleStore, useUIStore } from "@/stores";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로미스나인 팬사이트 메인 앱
|
* 프로미스나인 팬사이트 메인 앱
|
||||||
*
|
*
|
||||||
* Phase 2: 유틸리티 및 상수 완료
|
* Phase 3: 스토어 완료
|
||||||
* - constants/index.js: 상수 정의 (카테고리, SNS 링크 등)
|
* - useAuthStore: 인증 상태 (localStorage 지속)
|
||||||
* - utils/cn.js: className 유틸리티 (clsx 기반)
|
* - useScheduleStore: 스케줄 페이지 상태
|
||||||
* - utils/date.js: 날짜 관련 유틸리티
|
* - useUIStore: UI 상태 (모달, 토스트, 라이트박스 등)
|
||||||
* - utils/format.js: 포맷팅 유틸리티
|
|
||||||
* - utils/index.js: 통합 export
|
|
||||||
*/
|
*/
|
||||||
function App() {
|
function App() {
|
||||||
const today = getTodayKST();
|
const today = getTodayKST();
|
||||||
|
const { isAuthenticated, login, logout } = useAuthStore();
|
||||||
|
const { viewMode, setViewMode, selectedCategories, toggleCategory } = useScheduleStore();
|
||||||
|
const { showSuccess, showError, toasts } = useUIStore();
|
||||||
|
|
||||||
|
const handleTestLogin = () => {
|
||||||
|
login("test-token", { name: "테스트 유저" });
|
||||||
|
showSuccess("로그인 성공!");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestLogout = () => {
|
||||||
|
logout();
|
||||||
|
showError("로그아웃됨");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|
@ -27,21 +38,64 @@ function App() {
|
||||||
<h1 className="text-2xl font-bold text-primary mb-2">
|
<h1 className="text-2xl font-bold text-primary mb-2">
|
||||||
fromis_9 Frontend Refactoring
|
fromis_9 Frontend Refactoring
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">Phase 3 완료 - 스토어</p>
|
||||||
Phase 2 완료 - 유틸리티 및 상수
|
|
||||||
</p>
|
|
||||||
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
|
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
|
||||||
디바이스: {isMobile ? "모바일" : "PC"}
|
디바이스: {isMobile ? "모바일" : "PC"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-2">
|
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-3">
|
||||||
<p><strong>오늘 날짜:</strong> {today}</p>
|
<p><strong>오늘:</strong> {formatFullDate(today)}</p>
|
||||||
<p><strong>포맷된 날짜:</strong> {formatFullDate(today)}</p>
|
|
||||||
<p><strong>X 스타일:</strong> {formatXDateTime(today, "19:00")}</p>
|
<div className="border-t pt-3">
|
||||||
<p><strong>카테고리:</strong> {Object.values(CATEGORY_NAMES).join(", ")}</p>
|
<p className="font-semibold mb-2">useAuthStore 테스트</p>
|
||||||
<p><strong>SNS 개수:</strong> {Object.keys(SOCIAL_LINKS).length}개</p>
|
<p>인증 상태: {isAuthenticated ? "✅ 로그인됨" : "❌ 로그아웃"}</p>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button onClick={handleTestLogin} className="px-3 py-1 bg-primary text-white rounded text-xs">로그인</button>
|
||||||
|
<button onClick={handleTestLogout} className="px-3 py-1 bg-gray-500 text-white rounded text-xs">로그아웃</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<p className="font-semibold mb-2">useScheduleStore 테스트</p>
|
||||||
|
<p>뷰 모드: {viewMode}</p>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button onClick={() => setViewMode("list")} className={cn("px-3 py-1 rounded text-xs", viewMode === "list" ? "bg-primary text-white" : "bg-gray-200")}>리스트</button>
|
||||||
|
<button onClick={() => setViewMode("calendar")} className={cn("px-3 py-1 rounded text-xs", viewMode === "calendar" ? "bg-primary text-white" : "bg-gray-200")}>캘린더</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2">선택된 카테고리: {selectedCategories.length > 0 ? selectedCategories.join(", ") : "없음"}</p>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
{[1, 2, 3].map((id) => (
|
||||||
|
<button key={id} onClick={() => toggleCategory(id)} className={cn("px-3 py-1 rounded text-xs", selectedCategories.includes(id) ? "bg-primary text-white" : "bg-gray-200")}>
|
||||||
|
카테고리 {id}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<p className="font-semibold mb-2">useUIStore 토스트</p>
|
||||||
|
<p>활성 토스트: {toasts.length}개</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 토스트 표시 */}
|
||||||
|
<div className="fixed bottom-4 right-4 space-y-2">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 rounded shadow-lg text-white text-sm",
|
||||||
|
toast.type === "success" && "bg-green-500",
|
||||||
|
toast.type === "error" && "bg-red-500",
|
||||||
|
toast.type === "warning" && "bg-yellow-500",
|
||||||
|
toast.type === "info" && "bg-blue-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
6
frontend-temp/src/stores/index.js
Normal file
6
frontend-temp/src/stores/index.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/**
|
||||||
|
* 스토어 통합 export
|
||||||
|
*/
|
||||||
|
export { default as useAuthStore } from './useAuthStore';
|
||||||
|
export { default as useScheduleStore } from './useScheduleStore';
|
||||||
|
export { default as useUIStore } from './useUIStore';
|
||||||
54
frontend-temp/src/stores/useAuthStore.js
Normal file
54
frontend-temp/src/stores/useAuthStore.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 상태 스토어
|
||||||
|
* localStorage에 지속되어 새로고침 후에도 유지
|
||||||
|
*/
|
||||||
|
const useAuthStore = create(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// 상태
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
// 로그인
|
||||||
|
login: (token, user) => {
|
||||||
|
set({
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 로그아웃
|
||||||
|
logout: () => {
|
||||||
|
set({
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 토큰 가져오기
|
||||||
|
getToken: () => get().token,
|
||||||
|
|
||||||
|
// 인증 여부 확인
|
||||||
|
checkAuth: () => {
|
||||||
|
const { token } = get();
|
||||||
|
return !!token;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
token: state.token,
|
||||||
|
user: state.user,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useAuthStore;
|
||||||
107
frontend-temp/src/stores/useScheduleStore.js
Normal file
107
frontend-temp/src/stores/useScheduleStore.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 페이지 상태 스토어
|
||||||
|
* 메모리 기반 - SPA 내 페이지 이동 시 유지, 새로고침 시 초기화
|
||||||
|
*/
|
||||||
|
const useScheduleStore = create((set, get) => ({
|
||||||
|
// ===== 검색 관련 =====
|
||||||
|
searchInput: '',
|
||||||
|
searchTerm: '',
|
||||||
|
isSearchMode: false,
|
||||||
|
|
||||||
|
// ===== 필터 관련 =====
|
||||||
|
selectedCategories: [],
|
||||||
|
selectedMembers: [],
|
||||||
|
|
||||||
|
// ===== 날짜 관련 =====
|
||||||
|
selectedDate: undefined, // undefined: 오늘, null: 전체, Date: 특정 날짜
|
||||||
|
currentDate: new Date(),
|
||||||
|
|
||||||
|
// ===== 뷰 관련 =====
|
||||||
|
viewMode: 'list', // 'list' | 'calendar'
|
||||||
|
scrollPosition: 0,
|
||||||
|
|
||||||
|
// ===== 검색 액션 =====
|
||||||
|
setSearchInput: (value) => set({ searchInput: value }),
|
||||||
|
setSearchTerm: (value) => set({ searchTerm: value }),
|
||||||
|
setIsSearchMode: (value) => set({ isSearchMode: value }),
|
||||||
|
|
||||||
|
startSearch: (term) => {
|
||||||
|
set({
|
||||||
|
searchTerm: term,
|
||||||
|
isSearchMode: true,
|
||||||
|
selectedDate: null, // 검색 시 날짜 필터 해제
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSearch: () => {
|
||||||
|
set({
|
||||||
|
searchInput: '',
|
||||||
|
searchTerm: '',
|
||||||
|
isSearchMode: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 필터 액션 =====
|
||||||
|
setSelectedCategories: (value) => set({ selectedCategories: value }),
|
||||||
|
setSelectedMembers: (value) => set({ selectedMembers: value }),
|
||||||
|
|
||||||
|
toggleCategory: (categoryId) => {
|
||||||
|
const { selectedCategories } = get();
|
||||||
|
const isSelected = selectedCategories.includes(categoryId);
|
||||||
|
set({
|
||||||
|
selectedCategories: isSelected
|
||||||
|
? selectedCategories.filter((id) => id !== categoryId)
|
||||||
|
: [...selectedCategories, categoryId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMember: (memberId) => {
|
||||||
|
const { selectedMembers } = get();
|
||||||
|
const isSelected = selectedMembers.includes(memberId);
|
||||||
|
set({
|
||||||
|
selectedMembers: isSelected
|
||||||
|
? selectedMembers.filter((id) => id !== memberId)
|
||||||
|
: [...selectedMembers, memberId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFilters: () => {
|
||||||
|
set({
|
||||||
|
selectedCategories: [],
|
||||||
|
selectedMembers: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 날짜 액션 =====
|
||||||
|
setSelectedDate: (value) => set({ selectedDate: value }),
|
||||||
|
setCurrentDate: (value) => set({ currentDate: value }),
|
||||||
|
|
||||||
|
goToToday: () => {
|
||||||
|
set({
|
||||||
|
selectedDate: undefined,
|
||||||
|
currentDate: new Date(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 뷰 액션 =====
|
||||||
|
setViewMode: (mode) => set({ viewMode: mode }),
|
||||||
|
setScrollPosition: (value) => set({ scrollPosition: value }),
|
||||||
|
|
||||||
|
// ===== 전체 초기화 =====
|
||||||
|
reset: () =>
|
||||||
|
set({
|
||||||
|
searchInput: '',
|
||||||
|
searchTerm: '',
|
||||||
|
isSearchMode: false,
|
||||||
|
selectedCategories: [],
|
||||||
|
selectedMembers: [],
|
||||||
|
selectedDate: undefined,
|
||||||
|
currentDate: new Date(),
|
||||||
|
viewMode: 'list',
|
||||||
|
scrollPosition: 0,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useScheduleStore;
|
||||||
121
frontend-temp/src/stores/useUIStore.js
Normal file
121
frontend-temp/src/stores/useUIStore.js
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI 상태 스토어
|
||||||
|
* 모달, 토스트, 사이드바 등 전역 UI 상태 관리
|
||||||
|
*/
|
||||||
|
const useUIStore = create((set, get) => ({
|
||||||
|
// ===== 모달 =====
|
||||||
|
modalOpen: false,
|
||||||
|
modalContent: null,
|
||||||
|
modalProps: {},
|
||||||
|
|
||||||
|
openModal: (content, props = {}) => {
|
||||||
|
set({
|
||||||
|
modalOpen: true,
|
||||||
|
modalContent: content,
|
||||||
|
modalProps: props,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
closeModal: () => {
|
||||||
|
set({
|
||||||
|
modalOpen: false,
|
||||||
|
modalContent: null,
|
||||||
|
modalProps: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 토스트 =====
|
||||||
|
toasts: [],
|
||||||
|
|
||||||
|
addToast: (message, type = 'info', duration = 3000) => {
|
||||||
|
const id = Date.now();
|
||||||
|
const toast = { id, message, type, duration };
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
toasts: [...state.toasts, toast],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 자동 제거
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
get().removeToast(id);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeToast: (id) => {
|
||||||
|
set((state) => ({
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearToasts: () => set({ toasts: [] }),
|
||||||
|
|
||||||
|
// 편의 메서드
|
||||||
|
showSuccess: (message, duration) => get().addToast(message, 'success', duration),
|
||||||
|
showError: (message, duration) => get().addToast(message, 'error', duration),
|
||||||
|
showWarning: (message, duration) => get().addToast(message, 'warning', duration),
|
||||||
|
showInfo: (message, duration) => get().addToast(message, 'info', duration),
|
||||||
|
|
||||||
|
// ===== 사이드바 (모바일) =====
|
||||||
|
sidebarOpen: false,
|
||||||
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
|
openSidebar: () => set({ sidebarOpen: true }),
|
||||||
|
closeSidebar: () => set({ sidebarOpen: false }),
|
||||||
|
|
||||||
|
// ===== 라이트박스 =====
|
||||||
|
lightboxOpen: false,
|
||||||
|
lightboxImages: [],
|
||||||
|
lightboxIndex: 0,
|
||||||
|
|
||||||
|
openLightbox: (images, index = 0) => {
|
||||||
|
set({
|
||||||
|
lightboxOpen: true,
|
||||||
|
lightboxImages: Array.isArray(images) ? images : [images],
|
||||||
|
lightboxIndex: index,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
closeLightbox: () => {
|
||||||
|
set({
|
||||||
|
lightboxOpen: false,
|
||||||
|
lightboxImages: [],
|
||||||
|
lightboxIndex: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setLightboxIndex: (index) => set({ lightboxIndex: index }),
|
||||||
|
|
||||||
|
// ===== 로딩 =====
|
||||||
|
globalLoading: false,
|
||||||
|
setGlobalLoading: (value) => set({ globalLoading: value }),
|
||||||
|
|
||||||
|
// ===== 확인 다이얼로그 =====
|
||||||
|
confirmDialog: null,
|
||||||
|
|
||||||
|
showConfirm: (options) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
set({
|
||||||
|
confirmDialog: {
|
||||||
|
...options,
|
||||||
|
onConfirm: () => {
|
||||||
|
set({ confirmDialog: null });
|
||||||
|
resolve(true);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
set({ confirmDialog: null });
|
||||||
|
resolve(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
closeConfirm: () => set({ confirmDialog: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useUIStore;
|
||||||
Loading…
Add table
Reference in a new issue