From 7b6a821f36db7610f45b65438da64cbade61bd88 Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 13 Apr 2026 19:17:49 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=B4=EC=8A=A4=20=EA=B2=B0=EC=A0=95=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=8E=98=EC=9D=B4=EC=A7=80=20+?= =?UTF-8?q?=20UI/UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 레이아웃: - 풀스크린 모드 컨텍스트 (BossCrystal 페이지에서 푸터 숨김 + viewport 고정) - 캐릭터 패널: 자연 높이 + viewport 한도 + 내부 목록 스크롤 - 보스 패널: 헤더 고정 + 목록 내부 스크롤 - 커스텀 스크롤바 (전역) 캐릭터 패널: - framer-motion Reorder로 드래그앤드롭 정렬 - 가로 캐릭터 행 + 6x2 보스 그리드 + 난이도 영문 첫글자 뱃지 - 총 수익에 ResizeObserver 기반 자동 폰트 fit - 캐릭터 삭제 시 첫번째 자동 선택, 입력 재개 시 에러 메시지 자동 제거 기능: - 공개 보스/캐릭터 API 추가 - API 키 라이브 키로 변경 Co-Authored-By: Claude Opus 4.6 (1M context) --- .env | 2 +- backend/routes/boss-crystal.js | 33 ++ backend/routes/character.js | 42 +++ backend/server.js | 4 + frontend/package-lock.json | 43 +++ frontend/package.json | 1 + frontend/src/components/Layout.jsx | 47 ++- .../src/features/boss-crystal/BossCrystal.jsx | 125 +++++++- .../features/boss-crystal/admin/BossList.jsx | 9 +- .../features/boss-crystal/admin/constants.js | 37 ++- .../boss-crystal/user/BossSelector.jsx | 116 +++++++ .../boss-crystal/user/CharacterPanel.jsx | 302 ++++++++++++++++++ frontend/src/hooks/useFitText.js | 43 +++ frontend/src/hooks/useSmoothSticky.js | 95 ++++++ frontend/src/index.css | 29 ++ 15 files changed, 900 insertions(+), 28 deletions(-) create mode 100644 backend/routes/boss-crystal.js create mode 100644 backend/routes/character.js create mode 100644 frontend/src/features/boss-crystal/user/BossSelector.jsx create mode 100644 frontend/src/features/boss-crystal/user/CharacterPanel.jsx create mode 100644 frontend/src/hooks/useFitText.js create mode 100644 frontend/src/hooks/useSmoothSticky.js diff --git a/.env b/.env index 4381644..366381b 100644 --- a/.env +++ b/.env @@ -13,7 +13,7 @@ S3_SECRET_KEY=u1m508WWLGQsn5ueRXV4qPID8OVqiz0Pnm9QDVeI S3_BUCKET=maplestory # 넥슨 API -NEXON_API_KEY=test_d32f00908105a5803bf0ce5cf717747c0f06152c00f907ea7f9bb68d3541d2b6efe8d04e6d233bd35cf2fabdeb93fb0d +NEXON_API_KEY=live_d32f00908105a5803bf0ce5cf717747c8a9f571e4891660c5b4c69d7c34cbe70efe8d04e6d233bd35cf2fabdeb93fb0d # 앱 NODE_ENV=development diff --git a/backend/routes/boss-crystal.js b/backend/routes/boss-crystal.js new file mode 100644 index 0000000..f4579e2 --- /dev/null +++ b/backend/routes/boss-crystal.js @@ -0,0 +1,33 @@ +import { Router } from 'express'; +import { BossCrystalBoss, BossCrystalBossDifficulty } from '../models/index.js'; +import { getPublicUrl } from '../lib/s3.js'; + +const router = Router(); + +// 공개 보스 목록 +router.get('/bosses', async (_req, res) => { + try { + const bosses = await BossCrystalBoss.findAll({ + order: [['sort_order', 'ASC'], ['id', 'ASC']], + include: [{ model: BossCrystalBossDifficulty, as: 'difficulties' }], + }); + res.json(bosses.map((b) => { + const json = b.toJSON(); + return { + id: json.id, + name: json.name, + image_url: json.image_path ? getPublicUrl(json.image_path) : null, + max_party_size: json.max_party_size, + difficulties: (json.difficulties || []).map((d) => ({ + difficulty: d.difficulty, + crystal_price: Number(d.crystal_price), + })), + }; + })); + } catch (err) { + console.error('보스 목록 조회 오류:', err.message); + res.status(500).json({ error: '보스 목록 조회 실패' }); + } +}); + +export default router; diff --git a/backend/routes/character.js b/backend/routes/character.js new file mode 100644 index 0000000..3ffe994 --- /dev/null +++ b/backend/routes/character.js @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import axios from 'axios'; + +const router = Router(); +const NEXON_API_BASE = 'https://open.api.nexon.com'; + +// 캐릭터 닉네임으로 정보 조회 +router.get('/search', async (req, res) => { + const { name } = req.query; + if (!name?.trim()) return res.status(400).json({ error: '캐릭터 닉네임을 입력해주세요' }); + + try { + // 1) ocid 조회 + const { data: idData } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/id`, { + params: { character_name: name.trim() }, + headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY }, + }); + + // 2) basic 조회 + const { data: basic } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/basic`, { + params: { ocid: idData.ocid }, + headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY }, + }); + + res.json({ + ocid: idData.ocid, + character_name: basic.character_name, + world_name: basic.world_name, + job_name: basic.character_class, + character_level: basic.character_level, + character_image: basic.character_image, + }); + } catch (err) { + if (err.response?.status === 400) { + return res.status(404).json({ error: '존재하지 않는 캐릭터입니다' }); + } + console.error('캐릭터 조회 오류:', err.response?.data || err.message); + res.status(500).json({ error: '캐릭터 조회 실패' }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index 123ee18..7f676dc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,6 +3,8 @@ import cors from 'cors'; import adminRoutes from './routes/admin.js'; import menuRoutes from './routes/menus.js'; import noticeRoutes from './routes/notices.js'; +import bossCrystalRoutes from './routes/boss-crystal.js'; +import characterRoutes from './routes/character.js'; import { sequelize } from './lib/db.js'; import './models/index.js'; @@ -19,6 +21,8 @@ app.use(express.json()); app.use('/api/menus', menuRoutes); app.use('/api/notices', noticeRoutes); +app.use('/api/boss-crystal', bossCrystalRoutes); +app.use('/api/character', characterRoutes); app.use('/api/admin', adminRoutes); app.get('/api/health', (_req, res) => { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9486582..fabdfd9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-query": "^5.91.0", + "framer-motion": "^12.23.22", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.14.0" @@ -1874,6 +1875,33 @@ "dev": true, "license": "ISC" }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2444,6 +2472,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2565050..d942e4a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-query": "^5.91.0", + "framer-motion": "^12.23.22", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.14.0" diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index d2df32d..e1b82a0 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,23 +1,38 @@ +import { createContext, useContext, useState } from 'react' import { Outlet, Link } from 'react-router-dom' import Footer from './Footer' +const LayoutContext = createContext({ setFullscreen: () => {} }) + +export function useLayout() { + return useContext(LayoutContext) +} + export default function Layout() { + const [fullscreen, setFullscreen] = useState(false) + return ( -
-
-
- - - - 메이플스토리 유틸리티 - - -
-
-
- -
-
-
+ +
+
+
+ + + + 메이플스토리 유틸리티 + + +
+
+
+ +
+ {!fullscreen &&
} +
+
) } diff --git a/frontend/src/features/boss-crystal/BossCrystal.jsx b/frontend/src/features/boss-crystal/BossCrystal.jsx index 2737240..cd6d7b7 100644 --- a/frontend/src/features/boss-crystal/BossCrystal.jsx +++ b/frontend/src/features/boss-crystal/BossCrystal.jsx @@ -1,8 +1,127 @@ +import { useState, useEffect } from 'react' +import { useQuery } from '@tanstack/react-query' +import { api } from '../../api/client' +import { useLayout } from '../../components/Layout' +import CharacterPanel from './user/CharacterPanel' +import BossSelector from './user/BossSelector' + +const STORAGE_CHARS = 'maple-bc-characters' +const STORAGE_SELECTIONS = 'maple-bc-selections' +const MAX_PER_CHARACTER = 12 + export default function BossCrystal() { + const [characters, setCharacters] = useState(() => { + const saved = localStorage.getItem(STORAGE_CHARS) + return saved ? JSON.parse(saved) : [] + }) + const [selectedChar, setSelectedChar] = useState(() => { + const saved = localStorage.getItem(STORAGE_CHARS) + const list = saved ? JSON.parse(saved) : [] + return list[0]?.character_name || null + }) + const [allSelections, setAllSelections] = useState(() => { + const saved = localStorage.getItem(STORAGE_SELECTIONS) + return saved ? JSON.parse(saved) : {} + }) + + useEffect(() => { + localStorage.setItem(STORAGE_CHARS, JSON.stringify(characters)) + }, [characters]) + + useEffect(() => { + localStorage.setItem(STORAGE_SELECTIONS, JSON.stringify(allSelections)) + }, [allSelections]) + + // 풀스크린 모드 (푸터 숨김 + 내부 스크롤) + const { setFullscreen } = useLayout() + useEffect(() => { + setFullscreen(true) + return () => setFullscreen(false) + }, [setFullscreen]) + + const { data: bosses = [], isLoading } = useQuery({ + queryKey: ['boss-crystal', 'bosses'], + queryFn: () => api('/api/boss-crystal/bosses').catch(() => []), + }) + + const handleAddCharacter = (char) => { + setCharacters((prev) => [...prev, char]) + setSelectedChar(char.character_name) + } + + const handleRemoveCharacter = (name) => { + setCharacters((prev) => { + const next = prev.filter((c) => c.character_name !== name) + if (selectedChar === name) { + setSelectedChar(next[0]?.character_name || null) + } + return next + }) + setAllSelections((prev) => { + const next = { ...prev } + delete next[name] + return next + }) + } + + const handleReorderCharacters = (next) => { + setCharacters(next) + } + + const handleBossChange = (bossId, sel) => { + if (!selectedChar) return + setAllSelections((prev) => { + const charSel = { ...(prev[selectedChar] || {}) } + if (sel === null) { + delete charSel[bossId] + } else { + charSel[bossId] = sel + } + return { ...prev, [selectedChar]: charSel } + }) + } + + const currentSelections = selectedChar ? (allSelections[selectedChar] || {}) : {} + const currentSelectedCount = Object.values(currentSelections).filter(Boolean).length + const isMaxReached = currentSelectedCount >= MAX_PER_CHARACTER + + return ( -
-

주간 보스 결정 계산기

-

준비 중입니다.

+
+ {isLoading ? ( +
+
+
+ ) : ( +
+ {/* 좌측: 캐릭터 + 결과 통합 (총 수익/추가 고정 + 목록 스크롤) */} +
+ +
+ + {/* 우측: 보스 선택 (헤더 고정 + 목록 스크롤) */} +
+ +
+
+ )}
) } diff --git a/frontend/src/features/boss-crystal/admin/BossList.jsx b/frontend/src/features/boss-crystal/admin/BossList.jsx index 7d42cab..f1e289c 100644 --- a/frontend/src/features/boss-crystal/admin/BossList.jsx +++ b/frontend/src/features/boss-crystal/admin/BossList.jsx @@ -11,7 +11,7 @@ import { } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { api } from '../../../api/client' -import { DIFFICULTIES, formatMeso } from './constants' +import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants' function BossCardContent({ boss, dragging = false }) { return ( @@ -45,7 +45,12 @@ function BossCardContent({ boss, dragging = false }) { {DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => { const bd = boss.difficulties.find((x) => x.difficulty === d.key) return ( - + {d.label} ) diff --git a/frontend/src/features/boss-crystal/admin/constants.js b/frontend/src/features/boss-crystal/admin/constants.js index d7fdba2..2117c8b 100644 --- a/frontend/src/features/boss-crystal/admin/constants.js +++ b/frontend/src/features/boss-crystal/admin/constants.js @@ -1,12 +1,37 @@ -// 난이도 정의 (key, label, color) — 색상은 게임 내 난이도 배지 이미지와 매치 +// 난이도 정의 (key, label, initial, colors) export const DIFFICULTIES = [ - { key: 'easy', label: '이지', color: 'text-slate-300 border-slate-400/40 bg-slate-400/10' }, - { key: 'normal', label: '노말', color: 'text-sky-300 border-sky-400/40 bg-sky-400/10' }, - { key: 'hard', label: '하드', color: 'text-fuchsia-300 border-fuchsia-400/40 bg-fuchsia-400/10' }, - { key: 'chaos', label: '카오스', color: 'text-amber-300 border-amber-500/40 bg-amber-500/10' }, - { key: 'extreme', label: '익스트림', color: 'text-red-400 border-red-500/40 bg-red-500/10' }, + { + key: 'easy', label: '이지', initial: 'E', + colors: { border: '#999999', bg: '#999999', text: '#ffffff' }, + }, + { + key: 'normal', label: '노말', initial: 'N', + colors: { border: '#33aabb', bg: '#33aabb', text: '#ffffff' }, + }, + { + key: 'hard', label: '하드', initial: 'H', + colors: { border: '#dd4489', bg: '#dd4489', text: '#ffffff' }, + }, + { + key: 'chaos', label: '카오스', initial: 'C', + colors: { border: '#ddbb88', bg: '#444444', text: '#ffddbb' }, + }, + { + key: 'extreme', label: '익스트림', initial: 'E', + colors: { border: '#ee3355', bg: '#444444', text: '#ee4455' }, + }, ] +export function getDifficultyBadgeStyle(key) { + const diff = DIFFICULTIES.find((d) => d.key === key) + if (!diff) return {} + return { + borderColor: diff.colors.border, + backgroundColor: diff.colors.bg, + color: diff.colors.text, + } +} + export function formatMeso(n) { if (!n || n < 10000) return (n || 0).toLocaleString() if (n >= 100_000_000) { diff --git a/frontend/src/features/boss-crystal/user/BossSelector.jsx b/frontend/src/features/boss-crystal/user/BossSelector.jsx new file mode 100644 index 0000000..45f45f1 --- /dev/null +++ b/frontend/src/features/boss-crystal/user/BossSelector.jsx @@ -0,0 +1,116 @@ +import Select from '../../../components/Select' +import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from '../admin/constants' + +export default function BossSelector({ characterName, bosses, selections, onChange, maxReached, selectedCount, maxPerCharacter }) { + if (!characterName) { + return ( +
+ 좌측에서 캐릭터를 선택해주세요 +
+ ) + } + + if (bosses.length === 0) { + return ( +
+ 등록된 보스가 없습니다 +
+ ) + } + + return ( +
+ {/* 헤더 (고정) */} +
+
보스
+
난이도
+
파티원 수
+
가격
+
+ {/* 목록 (스크롤) */} +
+
+ {bosses.map((boss) => { + const availableDiffs = DIFFICULTIES.filter((d) => + boss.difficulties.some((bd) => bd.difficulty === d.key) + ) + const sel = selections[boss.id] + const bdInfo = sel ? boss.difficulties.find((bd) => bd.difficulty === sel.difficulty) : null + const partyN = sel?.party || 1 + const revenue = bdInfo ? Math.floor(bdInfo.crystal_price / partyN) : 0 + + const partyOptions = Array.from({ length: boss.max_party_size }, (_, i) => i + 1).map((n) => ({ + value: n, + label: `${n}인`, + })) + + // 한도 도달 + 이 보스가 선택 안 됐으면 비활성화 + const disabled = maxReached && !sel + + return ( +
+ {/* 보스 이미지 + 이름 */} +
+
+ {boss.name} +
+ {boss.name} +
+ + {/* 난이도 - 한 줄 고정 */} +
+ {availableDiffs.map((d) => { + const active = sel?.difficulty === d.key + return ( + + ) + })} +
+ + {/* 파티 인원 - 커스텀 Select */} +
+ {sel ? ( + { setName(e.target.value); if (error) setError('') }} + placeholder="캐릭터 닉네임 입력" + className="flex-1 min-w-0 rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition" + /> + + + {error &&

{error}

} +
+ + {/* 캐릭터 목록 (스크롤) */} + {characters.length > 0 && ( +
+ + {characters.map((char) => ( + + ))} + +
+ )} + + setConfirmRemove(null)} + onConfirm={() => { + onRemove(confirmRemove.character_name) + setConfirmRemove(null) + }} + title="캐릭터 삭제" + description={confirmRemove ? `"${confirmRemove.character_name}" 캐릭터를 목록에서 삭제하시겠습니까?\n\n저장된 보스 선택도 함께 삭제됩니다.` : ''} + confirmText="삭제" + destructive + /> +
+ ) +} diff --git a/frontend/src/hooks/useFitText.js b/frontend/src/hooks/useFitText.js new file mode 100644 index 0000000..640de5c --- /dev/null +++ b/frontend/src/hooks/useFitText.js @@ -0,0 +1,43 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * 텍스트가 컨테이너에 들어가도록 자동으로 폰트 크기 축소 + * @param {number} maxFontSize - 최대 폰트 크기 (px) + * @param {number} minFontSize - 최소 폰트 크기 (px) + * @param {string} value - 텍스트 (변경 감지용) + */ +export function useFitText({ maxFontSize = 30, minFontSize = 12, value }) { + const containerRef = useRef(null) + const textRef = useRef(null) + const [fontSize, setFontSize] = useState(maxFontSize) + + useEffect(() => { + if (!containerRef.current || !textRef.current) return + + const fit = () => { + const container = containerRef.current + const text = textRef.current + if (!container || !text) return + + // 일단 최대 크기로 시도 + let size = maxFontSize + text.style.fontSize = `${size}px` + + // 컨테이너보다 크면 줄여나감 + while (text.scrollWidth > container.clientWidth && size > minFontSize) { + size -= 1 + text.style.fontSize = `${size}px` + } + + setFontSize(size) + } + + fit() + + const ro = new ResizeObserver(fit) + ro.observe(containerRef.current) + return () => ro.disconnect() + }, [value, maxFontSize, minFontSize]) + + return { containerRef, textRef, fontSize } +} diff --git a/frontend/src/hooks/useSmoothSticky.js b/frontend/src/hooks/useSmoothSticky.js new file mode 100644 index 0000000..2a44493 --- /dev/null +++ b/frontend/src/hooks/useSmoothSticky.js @@ -0,0 +1,95 @@ +import { useCallback, useRef } from 'react' + +/** + * 구글폼 사이드 패널처럼 스크롤을 부드럽게 따라오는 sticky 효과 + * - 부모 컨테이너의 위/아래 경계를 넘지 않음 + * - lerp(선형보간)로 부드러운 움직임 + * + * Callback ref 패턴이라 element가 마운트되는 시점에 자동 setup + * + * @returns {Function} ref 콜백 + */ +export function useSmoothSticky({ offsetTop = 80, bottomMargin = 16, lerp = 0.18 } = {}) { + const cleanupRef = useRef(null) + + return useCallback((el) => { + // 이전 element의 cleanup + if (cleanupRef.current) { + cleanupRef.current() + cleanupRef.current = null + } + + if (!el || !el.parentElement) return + + let rafId = null + let target = 0 + let current = 0 + + const calcTarget = () => { + const containerRect = el.parentElement.getBoundingClientRect() + const containerHeight = el.parentElement.offsetHeight + const elementHeight = el.offsetHeight + const viewportHeight = window.innerHeight + const availableSpace = viewportHeight - offsetTop - bottomMargin + const maxOffset = Math.max(0, containerHeight - elementHeight - bottomMargin) + + let desired + if (elementHeight <= availableSpace) { + // 패널이 viewport에 들어감 → 상단에 sticky + desired = Math.max(0, offsetTop - containerRect.top) + } else { + // 패널이 viewport보다 큼 → 자연스럽게 스크롤되다가 하단이 viewport 하단에 닿으면 멈춤 + desired = Math.max(0, viewportHeight - bottomMargin - containerRect.top - elementHeight) + } + + target = Math.min(desired, maxOffset) + } + + const tick = () => { + const diff = target - current + if (Math.abs(diff) > 0.3) { + current += diff * lerp + el.style.transform = `translate3d(0, ${current}px, 0)` + rafId = requestAnimationFrame(tick) + } else { + current = target + el.style.transform = `translate3d(0, ${current}px, 0)` + rafId = null + } + } + + const startTick = () => { + if (rafId === null) rafId = requestAnimationFrame(tick) + } + + const onScroll = () => { + calcTarget() + startTick() + } + + // 초기 위치 설정 + calcTarget() + current = target + el.style.transform = `translate3d(0, ${current}px, 0)` + el.style.willChange = 'transform' + + window.addEventListener('scroll', onScroll, { passive: true }) + window.addEventListener('resize', onScroll) + + const ro = new ResizeObserver(onScroll) + ro.observe(el) + ro.observe(el.parentElement) + + cleanupRef.current = () => { + if (rafId !== null) cancelAnimationFrame(rafId) + window.removeEventListener('scroll', onScroll) + window.removeEventListener('resize', onScroll) + ro.disconnect() + if (el.isConnected) { + el.style.transform = '' + el.style.willChange = '' + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 9d417b4..e2a8b08 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -5,6 +5,11 @@ --font-maple: "Maplestory", "Noto Sans KR", sans-serif; } +html, body, #root { + height: 100%; + background: #030712; +} + html { font-family: "Maplestory", "Noto Sans KR", system-ui, sans-serif; } @@ -34,3 +39,27 @@ input[type="number"]::-webkit-outer-spin-button { input[type="number"] { -moz-appearance: textfield; } + +/* 커스텀 스크롤바 */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.1) transparent; +} +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 4px; + transition: background 0.2s; +} +*::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.18); +} +*::-webkit-scrollbar-corner { + background: transparent; +}