구조 개편 2단계: features 내부에 pc/ 폴더 생성 + 이동

- features/boss-crystal/pc/: BossCrystal, BossCrystalAdmin, admin/, user/
- features/symbol/pc/: Symbol, SymbolAdmin, admin/
- features/liberation/pc/: Liberation, components/
- store.js, data.js는 feature 루트에 유지 (device 공용)
- registry.js: import.meta.glob 패턴을 './*/\{pc,mobile\}/*.jsx' 로 변경
- getMobileComponent 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-19 11:26:12 +09:00
parent 4789c56dfa
commit b423d0ac82
18 changed files with 62 additions and 56 deletions

View file

@ -1,10 +1,10 @@
import { useEffect, useLayoutEffect } from 'react' import { useEffect, useLayoutEffect } from 'react'
import { useQuery, useQueries } from '@tanstack/react-query' import { useQuery, useQueries } from '@tanstack/react-query'
import { api } from '../../api/client' import { api } from '../../../api/client'
import { useLayout } from '../../components/pc/Layout' import { useLayout } from '../../../components/pc/Layout'
import CharacterPanel from './user/CharacterPanel' import CharacterPanel from './user/CharacterPanel'
import BossSelector from './user/BossSelector' import BossSelector from './user/BossSelector'
import { useBossStore } from './store' import { useBossStore } from '../store'
const MAX_PER_CHARACTER = 12 const MAX_PER_CHARACTER = 12

View file

@ -1,11 +1,11 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../../api/client' import { api } from '../../../../api/client'
import ConfirmDialog from '../../../components/common/ConfirmDialog' import ConfirmDialog from '../../../../components/common/ConfirmDialog'
import Checkbox from '../../../components/common/Checkbox' import Checkbox from '../../../../components/common/Checkbox'
import Select from '../../../components/common/Select' import Select from '../../../../components/common/Select'
import { useAuthStore } from '../../../stores/auth' import { useAuthStore } from '../../../../stores/auth'
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants' import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants'
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}` })) const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}` }))

View file

@ -10,8 +10,8 @@ import {
arrayMove, arrayMove,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { api } from '../../../api/client' import { api } from '../../../../api/client'
import Tooltip from '../../../components/common/Tooltip' import Tooltip from '../../../../components/common/Tooltip'
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants' import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants'
function BossCardContent({ boss, dragging = false }) { function BossCardContent({ boss, dragging = false }) {

View file

@ -1,4 +1,4 @@
import Select from '../../../components/common/Select' import Select from '../../../../components/common/Select'
import { DIFFICULTIES, formatMeso } from '../admin/constants' import { DIFFICULTIES, formatMeso } from '../admin/constants'
const LABEL_EN = { easy: 'EASY', normal: 'NORMAL', hard: 'HARD', chaos: 'CHAOS', extreme: 'EXTREME' } const LABEL_EN = { easy: 'EASY', normal: 'NORMAL', hard: 'HARD', chaos: 'CHAOS', extreme: 'EXTREME' }

View file

@ -2,11 +2,11 @@ import { useState } from 'react'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { Reorder, useDragControls } from 'framer-motion' import { Reorder, useDragControls } from 'framer-motion'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react' import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import { api } from '../../../api/client' import { api } from '../../../../api/client'
import ConfirmDialog from '../../../components/common/ConfirmDialog' import ConfirmDialog from '../../../../components/common/ConfirmDialog'
import Tooltip from '../../../components/common/Tooltip' import Tooltip from '../../../../components/common/Tooltip'
import CharacterSuggestDropdown from '../../../components/common/CharacterSuggestDropdown' import CharacterSuggestDropdown from '../../../../components/common/CharacterSuggestDropdown'
import { useFitText } from '../../../hooks/useFitText' import { useFitText } from '../../../../hooks/useFitText'
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants' import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
const MAX_PER_CHARACTER = 12 const MAX_PER_CHARACTER = 12

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useLayoutEffect } from 'react' import { useState, useEffect, useLayoutEffect } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { api } from '../../api/client' import { api } from '../../../api/client'
import { import {
GENESIS_CHAPTERS, GENESIS_CHAPTERS,
GENESIS_TOTAL, GENESIS_TOTAL,
@ -10,15 +10,15 @@ import {
calcPoints, calcPoints,
formatDate, formatDate,
todayKST, todayKST,
} from './data' } from '../data'
import { useLiberationStore } from './store' import { useLiberationStore } from '../store'
import QuestSelector from './components/QuestSelector' import QuestSelector from './components/QuestSelector'
import PointsInput from './components/PointsInput' import PointsInput from './components/PointsInput'
import ProgressBar from './components/ProgressBar' import ProgressBar from './components/ProgressBar'
import WeeklyDefault from './components/WeeklyDefault' import WeeklyDefault from './components/WeeklyDefault'
import DatePicker from '../../components/common/DatePicker' import DatePicker from '../../../components/common/DatePicker'
import ConfirmDialog from '../../components/common/ConfirmDialog' import ConfirmDialog from '../../../components/common/ConfirmDialog'
import { useLayout } from '../../components/pc/Layout' import { useLayout } from '../../../components/pc/Layout'
function makeEmptyWeekly() { function makeEmptyWeekly() {
const bosses = {} const bosses = {}

View file

@ -1,4 +1,4 @@
import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../data' import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../../data'
const DOW = ['일', '월', '화', '수', '목', '금', '토'] const DOW = ['일', '월', '화', '수', '목', '금', '토']
function formatKoreanDate(s) { function formatKoreanDate(s) {

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { GENESIS_CHAPTERS, QUEST_BOSS_IMAGE_BASE } from '../data' import { GENESIS_CHAPTERS, QUEST_BOSS_IMAGE_BASE } from '../../data'
/** /**
* 진행 중인 퀘스트 드롭다운 * 진행 중인 퀘스트 드롭다운

View file

@ -1,7 +1,7 @@
import Select from '../../../components/common/Select' import Select from '../../../../components/common/Select'
import Tooltip from '../../../components/common/Tooltip' import Tooltip from '../../../../components/common/Tooltip'
import WeeklyScheduler from './WeeklyScheduler' import WeeklyScheduler from './WeeklyScheduler'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, calcPoints } from '../data' import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, calcPoints } from '../../data'
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}` })) const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}` }))
const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 } const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 }

View file

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES, calcPoints } from '../data' import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES, calcPoints } from '../../data'
import { BossRow } from './WeeklyDefault' import { BossRow } from './WeeklyDefault'
function bossEarn(boss, sel) { function bossEarn(boss, sel) {

View file

@ -1,18 +1,18 @@
/** /**
* 기능 자동 등록 시스템 * 기능 자동 등록 시스템
* *
* - features/{kebab-case}/{PascalCase}.jsx : 사용자 페이지 * - features/{kebab-case}/pc/{PascalCase}.jsx : PC 사용자 페이지
* - features/{kebab-case}/{PascalCase}Admin.jsx : 관리자 페이지 * - features/{kebab-case}/pc/{PascalCase}Admin.jsx : PC 관리자 페이지
* - features/{kebab-case}/mobile/{PascalCase}.jsx : 모바일 사용자 페이지
* *
* 예시: * 예시:
* /boss-crystal features/boss-crystal/BossCrystal.jsx * /boss-crystal features/boss-crystal/pc/BossCrystal.jsx
* /admin/boss-crystal features/boss-crystal/BossCrystalAdmin.jsx * /admin/boss-crystal features/boss-crystal/pc/BossCrystalAdmin.jsx
*/ */
import { lazy } from 'react' import { lazy } from 'react'
// Vite의 import.meta.glob으로 features 폴더 전체를 스캔 const pages = import.meta.glob('./*/{pc,mobile}/*.jsx')
const userPages = import.meta.glob('./*/*.jsx')
function slugToPascal(slug) { function slugToPascal(slug) {
return slug return slug
@ -21,33 +21,39 @@ function slugToPascal(slug) {
.join('') .join('')
} }
// 컴포넌트 캐시 - 동일 slug에 대해 항상 같은 컴포넌트 인스턴스 반환 const userPcCache = new Map()
// (매 렌더마다 새 lazy() 생성하면 React가 unmount/remount하면서 화면 갱신이 깨짐) const adminPcCache = new Map()
const userCache = new Map() const userMobileCache = new Map()
const adminCache = new Map()
function loadCached(cache, slug, suffix) { function loadCached(cache, slug, device, suffix) {
if (cache.has(slug)) return cache.get(slug) if (cache.has(slug)) return cache.get(slug)
const pascal = slugToPascal(slug) const pascal = slugToPascal(slug)
const path = `./${slug}/${pascal}${suffix}.jsx` const path = `./${slug}/${device}/${pascal}${suffix}.jsx`
const loader = userPages[path] const loader = pages[path]
const component = loader ? lazy(loader) : null const component = loader ? lazy(loader) : null
cache.set(slug, component) cache.set(slug, component)
return component return component
} }
/** /**
* slug에 해당하는 사용자 페이지 컴포넌트 반환 * slug에 해당하는 PC 사용자 페이지 컴포넌트 반환
*/ */
export function getUserComponent(slug) { export function getUserComponent(slug) {
return loadCached(userCache, slug, '') return loadCached(userPcCache, slug, 'pc', '')
} }
/** /**
* slug에 해당하는 관리자 페이지 컴포넌트 반환 * slug에 해당하는 관리자 페이지 컴포넌트 반환 (PC 전용)
*/ */
export function getAdminComponent(slug) { export function getAdminComponent(slug) {
return loadCached(adminCache, slug, 'Admin') return loadCached(adminPcCache, slug, 'pc', 'Admin')
}
/**
* slug에 해당하는 모바일 사용자 페이지 컴포넌트 반환
*/
export function getMobileComponent(slug) {
return loadCached(userMobileCache, slug, 'mobile', '')
} }
/** /**

View file

@ -3,12 +3,12 @@ import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc' import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone' import timezone from 'dayjs/plugin/timezone'
import { api } from '../../api/client' import { api } from '../../../api/client'
import { useLayout } from '../../components/pc/Layout' import { useLayout } from '../../../components/pc/Layout'
import Select from '../../components/common/Select' import Select from '../../../components/common/Select'
import Tooltip from '../../components/common/Tooltip' import Tooltip from '../../../components/common/Tooltip'
import CharacterSuggestDropdown from '../../components/common/CharacterSuggestDropdown' import CharacterSuggestDropdown from '../../../components/common/CharacterSuggestDropdown'
import { useSymbolStore } from './store' import { useSymbolStore } from '../store'
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)

View file

@ -1,10 +1,10 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../../api/client' import { api } from '../../../../api/client'
import Select from '../../../components/common/Select' import Select from '../../../../components/common/Select'
import ConfirmDialog from '../../../components/common/ConfirmDialog' import ConfirmDialog from '../../../../components/common/ConfirmDialog'
import { useAuthStore } from '../../../stores/auth' import { useAuthStore } from '../../../../stores/auth'
const TYPE_OPTIONS = [ const TYPE_OPTIONS = [
{ value: '아케인', label: '아케인' }, { value: '아케인', label: '아케인' },

View file

@ -10,7 +10,7 @@ import {
arrayMove, arrayMove,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { api } from '../../../api/client' import { api } from '../../../../api/client'
const TYPE_STYLE = { const TYPE_STYLE = {
'아케인': { '아케인': {