보스 결정 사용자 페이지 + UI/UX 개선
레이아웃: - 풀스크린 모드 컨텍스트 (BossCrystal 페이지에서 푸터 숨김 + viewport 고정) - 캐릭터 패널: 자연 높이 + viewport 한도 + 내부 목록 스크롤 - 보스 패널: 헤더 고정 + 목록 내부 스크롤 - 커스텀 스크롤바 (전역) 캐릭터 패널: - framer-motion Reorder로 드래그앤드롭 정렬 - 가로 캐릭터 행 + 6x2 보스 그리드 + 난이도 영문 첫글자 뱃지 - 총 수익에 ResizeObserver 기반 자동 폰트 fit - 캐릭터 삭제 시 첫번째 자동 선택, 입력 재개 시 에러 메시지 자동 제거 기능: - 공개 보스/캐릭터 API 추가 - API 키 라이브 키로 변경 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
793903668c
commit
7b6a821f36
15 changed files with 900 additions and 28 deletions
2
.env
2
.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
|
||||
|
|
|
|||
33
backend/routes/boss-crystal.js
Normal file
33
backend/routes/boss-crystal.js
Normal file
|
|
@ -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;
|
||||
42
backend/routes/character.js
Normal file
42
backend/routes/character.js
Normal file
|
|
@ -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;
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
43
frontend/package-lock.json
generated
43
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-950 to-slate-900 text-white flex flex-col">
|
||||
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<Link to="/" className="group flex items-center gap-2.5">
|
||||
<img src="/favicon.ico" alt="" className="w-8 h-8" />
|
||||
<span className="text-lg font-bold tracking-tight">
|
||||
메이플스토리 유틸리티
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 mx-auto w-full max-w-5xl px-6 py-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<LayoutContext.Provider value={{ fullscreen, setFullscreen }}>
|
||||
<div className={`min-w-[1280px] bg-gradient-to-br from-gray-950 via-gray-950 to-slate-900 text-white flex flex-col ${
|
||||
fullscreen ? 'h-dvh' : 'min-h-screen'
|
||||
}`}>
|
||||
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md shrink-0">
|
||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-4">
|
||||
<Link to="/" className="group flex items-center gap-2.5">
|
||||
<img src="/favicon.ico" alt="" className="w-8 h-8" />
|
||||
<span className="text-lg font-bold tracking-tight">
|
||||
메이플스토리 유틸리티
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<main className={`flex-1 mx-auto w-full max-w-[1400px] ${
|
||||
fullscreen ? 'min-h-0 px-6 py-4' : 'px-6 pt-4 pb-10'
|
||||
}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
{!fullscreen && <Footer />}
|
||||
</div>
|
||||
</LayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">주간 보스 결정 계산기</h1>
|
||||
<p className="text-gray-400">준비 중입니다.</p>
|
||||
<div className="h-full">
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-16 text-center">
|
||||
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin mx-auto" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-[420px_1fr] h-full min-h-0">
|
||||
{/* 좌측: 캐릭터 + 결과 통합 (총 수익/추가 고정 + 목록 스크롤) */}
|
||||
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-4 min-h-0 max-h-full self-start overflow-hidden flex flex-col">
|
||||
<CharacterPanel
|
||||
characters={characters}
|
||||
selectedName={selectedChar}
|
||||
allSelections={allSelections}
|
||||
bosses={bosses}
|
||||
onSelect={setSelectedChar}
|
||||
onAdd={handleAddCharacter}
|
||||
onRemove={handleRemoveCharacter}
|
||||
onReorder={handleReorderCharacters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 우측: 보스 선택 (헤더 고정 + 목록 스크롤) */}
|
||||
<div className="min-h-0">
|
||||
<BossSelector
|
||||
characterName={selectedChar}
|
||||
bosses={bosses}
|
||||
selections={currentSelections}
|
||||
onChange={handleBossChange}
|
||||
maxReached={isMaxReached}
|
||||
selectedCount={currentSelectedCount}
|
||||
maxPerCharacter={MAX_PER_CHARACTER}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span key={d.key} className={`text-[10px] px-1.5 py-0.5 rounded border ${d.color}`} title={formatMeso(bd.crystal_price)}>
|
||||
<span
|
||||
key={d.key}
|
||||
className="text-[10px] font-medium px-1.5 py-0.5 rounded border"
|
||||
style={getDifficultyBadgeStyle(d.key)}
|
||||
title={`${d.label} - ${formatMeso(bd.crystal_price)}`}
|
||||
>
|
||||
{d.label}
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
116
frontend/src/features/boss-crystal/user/BossSelector.jsx
Normal file
116
frontend/src/features/boss-crystal/user/BossSelector.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center text-sm text-gray-500">
|
||||
좌측에서 캐릭터를 선택해주세요
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (bosses.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center text-sm text-gray-500">
|
||||
등록된 보스가 없습니다
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/5 bg-gray-900/40 overflow-hidden flex flex-col h-full">
|
||||
{/* 헤더 (고정) */}
|
||||
<div className="flex items-center gap-3 px-3 py-3 bg-gray-950/60 border-b border-white/5 text-base font-semibold text-gray-300 shrink-0">
|
||||
<div className="w-52 shrink-0">보스</div>
|
||||
<div className="flex-1">난이도</div>
|
||||
<div className="w-20 shrink-0 text-center">파티원 수</div>
|
||||
<div className="w-32 shrink-0 text-right">가격</div>
|
||||
</div>
|
||||
{/* 목록 (스크롤) */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="divide-y divide-white/5">
|
||||
{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 (
|
||||
<div
|
||||
key={boss.id}
|
||||
className={`flex items-center gap-3 px-3 py-3 transition ${
|
||||
disabled ? 'opacity-30 pointer-events-none' : ''
|
||||
}`}
|
||||
>
|
||||
{/* 보스 이미지 + 이름 */}
|
||||
<div className="flex items-center gap-2.5 w-52 shrink-0">
|
||||
<div className="shrink-0 w-11 h-11 rounded-lg bg-gray-900 overflow-hidden">
|
||||
<img src={boss.image_url || '/default.png'} alt={boss.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<span className="text-base font-medium leading-tight whitespace-nowrap overflow-hidden text-ellipsis">{boss.name}</span>
|
||||
</div>
|
||||
|
||||
{/* 난이도 - 한 줄 고정 */}
|
||||
<div className="flex-1 flex items-center gap-2 flex-nowrap min-w-0">
|
||||
{availableDiffs.map((d) => {
|
||||
const active = sel?.difficulty === d.key
|
||||
return (
|
||||
<button
|
||||
key={d.key}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.blur()
|
||||
if (active) {
|
||||
onChange(boss.id, null)
|
||||
} else {
|
||||
onChange(boss.id, { difficulty: d.key, party: partyN })
|
||||
}
|
||||
}}
|
||||
className={`shrink-0 transition focus:outline-none ${active ? 'opacity-100 scale-105' : 'opacity-40 hover:opacity-70'}`}
|
||||
title={d.label}
|
||||
>
|
||||
<img src={getDifficultyImageUrl(d.key)} alt={d.label} className="h-5" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 파티 인원 - 커스텀 Select */}
|
||||
<div className="w-20 shrink-0">
|
||||
{sel ? (
|
||||
<Select
|
||||
value={partyN}
|
||||
onChange={(val) => onChange(boss.id, { ...sel, party: val })}
|
||||
options={partyOptions}
|
||||
align="right"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-xs text-gray-700 text-center">-</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 수익 */}
|
||||
<div className={`w-32 shrink-0 text-right text-sm font-medium tabular-nums ${sel ? 'text-emerald-300' : 'text-gray-700'}`}>
|
||||
{sel ? formatMeso(revenue) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
302
frontend/src/features/boss-crystal/user/CharacterPanel.jsx
Normal file
302
frontend/src/features/boss-crystal/user/CharacterPanel.jsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Reorder } from 'framer-motion'
|
||||
import { api } from '../../../api/client'
|
||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||
import { useFitText } from '../../../hooks/useFitText'
|
||||
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
|
||||
|
||||
const MAX_PER_CHARACTER = 12
|
||||
const MAX_PER_ACCOUNT = 90
|
||||
|
||||
function CharacterContent({ char, selections, bosses }) {
|
||||
const selectedBosses = Object.entries(selections || {})
|
||||
.filter(([, sel]) => sel)
|
||||
.map(([bossId, sel]) => {
|
||||
const boss = bosses.find((b) => b.id === Number(bossId))
|
||||
if (!boss) return null
|
||||
const bd = boss.difficulties.find((d) => d.difficulty === sel.difficulty)
|
||||
if (!bd) return null
|
||||
return {
|
||||
boss,
|
||||
difficulty: sel.difficulty,
|
||||
revenue: Math.floor(bd.crystal_price / sel.party),
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.revenue - a.revenue)
|
||||
|
||||
const visibleBosses = selectedBosses.slice(0, MAX_PER_CHARACTER)
|
||||
const totalRevenue = visibleBosses.reduce((s, x) => s + x.revenue, 0)
|
||||
const count = selectedBosses.length
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0 overflow-hidden flex items-center justify-center" style={{ width: 96, height: 96 }}>
|
||||
{char.character_image ? (
|
||||
<img
|
||||
src={char.character_image}
|
||||
alt=""
|
||||
className="w-full h-full object-contain scale-[3] origin-center select-none"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-700 text-4xl">?</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<span className="text-base font-semibold truncate">{char.character_name}</span>
|
||||
<span className="text-xs text-gray-500 truncate">Lv.{char.character_level} · {char.job_name}</span>
|
||||
</div>
|
||||
|
||||
{visibleBosses.length > 0 ? (
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{visibleBosses.map((item) => {
|
||||
const diff = DIFFICULTIES.find((d) => d.key === item.difficulty)
|
||||
return (
|
||||
<div
|
||||
key={item.boss.id}
|
||||
className="space-y-0.5"
|
||||
title={`${item.boss.name} ${diff?.label || ''} - ${formatMeso(item.revenue)}`}
|
||||
>
|
||||
<div className="aspect-square rounded bg-gray-900 overflow-hidden border border-white/5">
|
||||
<img src={item.boss.image_url || '/default.png'} alt="" draggable={false} className="w-full h-full object-cover select-none" />
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="text-[9px] font-bold leading-none rounded border w-3.5 h-3.5 flex items-center justify-center"
|
||||
style={getDifficultyBadgeStyle(item.difficulty)}
|
||||
>
|
||||
{diff?.initial}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-600 italic h-[58px] flex items-center">보스 미선택</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-white/5 pt-2">
|
||||
<div className={`text-sm tabular-nums ${count > 0 ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
{count}<span className="text-gray-700">/{MAX_PER_CHARACTER}</span>
|
||||
</div>
|
||||
<div className={`text-sm font-semibold tabular-nums whitespace-nowrap ${count > 0 ? 'text-emerald-300' : 'text-gray-700'}`}>
|
||||
{count > 0 ? formatMeso(totalRevenue) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemove }) {
|
||||
const [dragged, setDragged] = useState(false)
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={char}
|
||||
onDragStart={() => setDragged(true)}
|
||||
onDragEnd={() => {
|
||||
// 다음 click 이벤트 후에 reset
|
||||
setTimeout(() => setDragged(false), 0)
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (dragged) return
|
||||
if (e.target.closest('button')) return
|
||||
onSelect(char.character_name)
|
||||
}}
|
||||
className={`group relative rounded-xl border cursor-grab active:cursor-grabbing select-none ${
|
||||
isSelected
|
||||
? 'border-emerald-500/40 bg-emerald-500/[0.08]'
|
||||
: 'border-white/5 hover:border-white/15 bg-gray-950/40 hover:bg-gray-950/60'
|
||||
}`}
|
||||
>
|
||||
{/* 드래그 핸들 아이콘 (시각적 표시용) */}
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-600 pointer-events-none">
|
||||
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
|
||||
<circle cx="3" cy="3" r="1.2" />
|
||||
<circle cx="9" cy="3" r="1.2" />
|
||||
<circle cx="3" cy="8" r="1.2" />
|
||||
<circle cx="9" cy="8" r="1.2" />
|
||||
<circle cx="3" cy="13" r="1.2" />
|
||||
<circle cx="9" cy="13" r="1.2" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(char) }}
|
||||
className="absolute top-2 right-2 z-10 w-6 h-6 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition opacity-0 group-hover:opacity-100 flex items-center justify-center text-base"
|
||||
aria-label="삭제"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="pl-8 pr-3 py-2.5">
|
||||
<CharacterContent char={char} selections={selections} bosses={bosses} />
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CharacterPanel({
|
||||
characters, selectedName, allSelections, bosses,
|
||||
onSelect, onAdd, onRemove, onReorder,
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [confirmRemove, setConfirmRemove] = useState(null)
|
||||
|
||||
const searchMutation = useMutation({
|
||||
mutationFn: (n) => api(`/api/character/search?name=${encodeURIComponent(n)}`),
|
||||
onSuccess: (data) => {
|
||||
if (characters.find((c) => c.character_name === data.character_name)) {
|
||||
setError('이미 추가된 캐릭터입니다')
|
||||
return
|
||||
}
|
||||
onAdd(data)
|
||||
setName('')
|
||||
setError('')
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
})
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
setError('')
|
||||
searchMutation.mutate(name.trim())
|
||||
}
|
||||
|
||||
// 총합 계산
|
||||
const charResults = characters.map((char) => {
|
||||
const charSel = allSelections[char.character_name] || {}
|
||||
const items = Object.entries(charSel)
|
||||
.filter(([, sel]) => sel)
|
||||
.map(([bossId, sel]) => {
|
||||
const boss = bosses.find((b) => b.id === Number(bossId))
|
||||
if (!boss) return null
|
||||
const bd = boss.difficulties.find((d) => d.difficulty === sel.difficulty)
|
||||
if (!bd) return null
|
||||
return Math.floor(bd.crystal_price / sel.party)
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b - a)
|
||||
.slice(0, MAX_PER_CHARACTER)
|
||||
|
||||
return { count: items.length, revenue: items.reduce((s, v) => s + v, 0) }
|
||||
})
|
||||
|
||||
const totalCount = charResults.reduce((s, r) => s + r.count, 0)
|
||||
const totalRevenue = charResults.reduce((s, r) => s + r.revenue, 0)
|
||||
const accountUsage = Math.min(totalCount, MAX_PER_ACCOUNT)
|
||||
const usagePct = Math.min((accountUsage / MAX_PER_ACCOUNT) * 100, 100)
|
||||
const totalText = formatMeso(totalRevenue)
|
||||
const { containerRef: totalContainerRef, textRef: totalTextRef } = useFitText({
|
||||
maxFontSize: 32,
|
||||
minFontSize: 14,
|
||||
value: totalText,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 min-h-0 flex-1">
|
||||
{/* 총 수익 카드 (고정) */}
|
||||
<div className="rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-500/15 to-emerald-500/5 p-4 space-y-3 shrink-0">
|
||||
<div>
|
||||
<div className="text-xs text-emerald-200/80">총 주간 수익</div>
|
||||
<div ref={totalContainerRef} className="mt-1 overflow-hidden">
|
||||
<div
|
||||
ref={totalTextRef}
|
||||
className="font-bold text-emerald-300 leading-tight whitespace-nowrap inline-block"
|
||||
>
|
||||
{totalText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">총 결정 개수</span>
|
||||
<span className={`tabular-nums font-semibold ${totalCount > MAX_PER_ACCOUNT ? 'text-amber-400' : 'text-gray-200'}`}>
|
||||
{accountUsage}<span className="text-gray-600 font-normal">/{MAX_PER_ACCOUNT}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-900 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${totalCount > MAX_PER_ACCOUNT ? 'bg-amber-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${usagePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{totalCount > MAX_PER_ACCOUNT && (
|
||||
<p className="text-[10px] text-amber-400">⚠ 한도 {totalCount - MAX_PER_ACCOUNT}개 초과</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 캐릭터 추가 (고정) */}
|
||||
<div className="shrink-0">
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={searchMutation.isPending}
|
||||
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-4 py-2 text-sm font-medium transition shrink-0"
|
||||
>
|
||||
{searchMutation.isPending ? '...' : '추가'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="text-xs text-red-400 mt-1.5">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* 캐릭터 목록 (스크롤) */}
|
||||
{characters.length > 0 && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto -mx-4 px-4">
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={characters}
|
||||
onReorder={onReorder}
|
||||
className="space-y-2"
|
||||
>
|
||||
{characters.map((char) => (
|
||||
<CharacterItem
|
||||
key={char.character_name}
|
||||
char={char}
|
||||
isSelected={selectedName === char.character_name}
|
||||
selections={allSelections[char.character_name] || {}}
|
||||
bosses={bosses}
|
||||
onSelect={onSelect}
|
||||
onRemove={setConfirmRemove}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirmRemove}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
onConfirm={() => {
|
||||
onRemove(confirmRemove.character_name)
|
||||
setConfirmRemove(null)
|
||||
}}
|
||||
title="캐릭터 삭제"
|
||||
description={confirmRemove ? `"${confirmRemove.character_name}" 캐릭터를 목록에서 삭제하시겠습니까?\n\n저장된 보스 선택도 함께 삭제됩니다.` : ''}
|
||||
confirmText="삭제"
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
frontend/src/hooks/useFitText.js
Normal file
43
frontend/src/hooks/useFitText.js
Normal file
|
|
@ -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 }
|
||||
}
|
||||
95
frontend/src/hooks/useSmoothSticky.js
Normal file
95
frontend/src/hooks/useSmoothSticky.js
Normal file
|
|
@ -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
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue