feat(tarot): 시안 기반 UI 재구성 — 랜딩 좌→우 그라데이션 + Reading 테이블 배경
랜딩(tarot_main_landing_page.png 참고): - hero overlay를 full-screen dark에서 좌→우 그라데이션으로 변경 - 좌측만 어둡게 (텍스트 가독), 우측은 영상 선명히 노출 Reading(tarot_card_select_page.png 참고): - tarot_table.png 배경 fixed (보라 신비 톤 + vignette) - 상단 step indicator (질문 & 설정 → 카드 선택 → 해석) - 패널 backdrop-filter blur + 금색 보더로 시안 느낌 강화 - 하단 남은 카드 row 미리보기 (12장) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
BIN
public/images/tarot/tarot_table.png
Normal file
BIN
public/images/tarot/tarot_table.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -3,15 +3,38 @@ import './Tarot.css';
|
||||
import { TAROT_DECK, CATEGORIES, SPREADS } from './data/cards';
|
||||
import { useTarotShuffle } from './hooks/useTarotShuffle';
|
||||
import { useTarotReading } from './hooks/useTarotReading';
|
||||
import TarotCard from './components/TarotCard';
|
||||
import CardGrid from './components/CardGrid';
|
||||
import SpreadSlots from './components/SpreadSlots';
|
||||
import InterpretationPanel from './components/InterpretationPanel';
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, label: '질문 & 설정' },
|
||||
{ id: 2, label: '카드 선택' },
|
||||
{ id: 3, label: '해석' },
|
||||
];
|
||||
|
||||
function StepIndicator({ current }) {
|
||||
return (
|
||||
<div className="tarot-reading-steps">
|
||||
{STEPS.map((s, i) => (
|
||||
<React.Fragment key={s.id}>
|
||||
<div className={`tarot-reading-steps__item ${s.id < current ? 'is-done' : ''} ${s.id === current ? 'is-active' : ''}`}>
|
||||
<span className="tarot-reading-steps__dot">{s.id < current ? '✓' : s.id}</span>
|
||||
<span>{s.label}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className="tarot-reading-steps__sep" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Reading() {
|
||||
const [category, setCategory] = useState('일반');
|
||||
const [question, setQuestion] = useState('');
|
||||
const [spreadId, setSpreadId] = useState('three_card');
|
||||
const [step, setStep] = useState(1); // 1: 입력, 2: 뽑기, 3: 해석
|
||||
const [step, setStep] = useState(1);
|
||||
const [picks, setPicks] = useState([]);
|
||||
const [focusIdx, setFocusIdx] = useState(null);
|
||||
|
||||
@@ -32,9 +55,7 @@ export default function Reading() {
|
||||
const pos = spread.positions[idx];
|
||||
const next = [...picks, { card, position: pos.label, reversed: card.reversed }];
|
||||
setPicks(next);
|
||||
if (next.length === spread.positions.length) {
|
||||
setFocusIdx(0);
|
||||
}
|
||||
if (next.length === spread.positions.length) setFocusIdx(0);
|
||||
};
|
||||
|
||||
const handleInterpret = async () => {
|
||||
@@ -47,24 +68,32 @@ export default function Reading() {
|
||||
} catch { /* error는 hook state */ }
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
setStep(1); setPicks([]); setFocusIdx(null);
|
||||
};
|
||||
|
||||
const disabledIds = picks.map((p) => p.card.slug);
|
||||
const focusCard = focusIdx !== null && picks[focusIdx] ? picks[focusIdx].card : null;
|
||||
const focusCardId = focusCard?.slug;
|
||||
const allPicked = picks.length === spread.positions.length;
|
||||
const busy = status === 'interpreting' || status === 'saving';
|
||||
const remaining = slice.filter((c) => !disabledIds.includes(c.slug));
|
||||
|
||||
return (
|
||||
<div className="tarot tarot-reading">
|
||||
<div className="tarot tarot-reading-page">
|
||||
<StepIndicator current={step} />
|
||||
|
||||
<div className="tarot-reading">
|
||||
<aside className="tarot-reading__col">
|
||||
<div className="tarot-reading__step-label">1. 질문</div>
|
||||
<div className="tarot-reading__step-label">질문</div>
|
||||
<textarea
|
||||
className="tarot-reading__textarea"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="궁금한 것을 적어주세요"
|
||||
placeholder="질문을 입력하세요"
|
||||
/>
|
||||
|
||||
<div className="tarot-reading__step-label" style={{ marginTop: 16 }}>2. 카테고리</div>
|
||||
<div className="tarot-reading__step-label" style={{ marginTop: 16 }}>카테고리</div>
|
||||
<div className="tarot-reading__chips">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
@@ -75,11 +104,11 @@ export default function Reading() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="tarot-reading__step-label" style={{ marginTop: 16 }}>3. 스프레드</div>
|
||||
<div className="tarot-reading__step-label" style={{ marginTop: 16 }}>스프레드 선택</div>
|
||||
<div className="tarot-reading__radio-row">
|
||||
<label>
|
||||
<input type="radio" checked={spreadId === 'three_card'}
|
||||
onChange={() => setSpreadId('three_card')} /> 3장 (과거/현재/미래)
|
||||
onChange={() => setSpreadId('three_card')} /> 3장 (과거 · 현재 · 미래)
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" checked={spreadId === 'one_card'}
|
||||
@@ -87,18 +116,26 @@ export default function Reading() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<button className="tarot-reading__primary" onClick={startShuffle}>
|
||||
⊃ 카드 셔플하기
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step >= 2 && allPicked && step < 3 && (
|
||||
{step === 2 && !allPicked && (
|
||||
<button className="tarot-reading__primary" onClick={() => reshuffle()}>
|
||||
⊃ 다시 셔플
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step === 2 && allPicked && (
|
||||
<button className="tarot-reading__primary" onClick={handleInterpret} disabled={busy}>
|
||||
{busy ? '해석 중…' : 'AI 해석 시작'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<button className="tarot-reading__primary" onClick={() => { setStep(1); setPicks([]); setFocusIdx(null); }}>
|
||||
<button className="tarot-reading__primary" onClick={restart}>
|
||||
새 리딩
|
||||
</button>
|
||||
)}
|
||||
@@ -106,30 +143,51 @@ export default function Reading() {
|
||||
{error && <p style={{ color: '#f43f5e', marginTop: 12, fontSize: 13 }}>{error}</p>}
|
||||
</aside>
|
||||
|
||||
<div className="tarot-reading__col">
|
||||
{step < 2 && <p style={{ color: 'var(--tarot-text-dim)' }}>좌측에서 질문·카테고리·스프레드를 선택하고 셔플하세요.</p>}
|
||||
<div className="tarot-reading__col tarot-reading__center">
|
||||
{step < 2 && (
|
||||
<p style={{ color: 'var(--tarot-text-dim)', textAlign: 'center', padding: '60px 20px' }}>
|
||||
좌측에서 질문·카테고리·스프레드를 선택하고 셔플하세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
{!allPicked && (
|
||||
{!allPicked ? (
|
||||
<>
|
||||
<p style={{ color: 'var(--tarot-text-dim)', marginBottom: 16 }}>
|
||||
카드 {picks.length + 1}/{spread.positions.length} — {spread.positions[picks.length].label}
|
||||
<p style={{ color: 'var(--tarot-text-dim)', marginBottom: 20, letterSpacing: 1 }}>
|
||||
카드 {picks.length + 1}/{spread.positions.length} — <span style={{ color: 'var(--tarot-gold)' }}>{spread.positions[picks.length].label}</span>
|
||||
</p>
|
||||
<CardGrid slice={slice} onPick={handlePick} disabledIds={disabledIds} />
|
||||
</>
|
||||
)}
|
||||
{picks.length > 0 && (
|
||||
<SpreadSlots
|
||||
spread={spread} picks={picks}
|
||||
onCardClick={(idx) => setFocusIdx(idx)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<SpreadSlots
|
||||
spread={spread} picks={picks}
|
||||
onCardClick={(idx) => setFocusIdx(idx)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<SpreadSlots
|
||||
spread={spread} picks={picks}
|
||||
onCardClick={(idx) => setFocusIdx(idx)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(step === 2 || step === 3) && remaining.length > 0 && allPicked && (
|
||||
<div className="tarot-reading__preview-row">
|
||||
{remaining.slice(0, 12).map((c) => (
|
||||
<TarotCard key={c.slug} card={c} faceDown size="sm" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InterpretationPanel
|
||||
@@ -138,5 +196,6 @@ export default function Reading() {
|
||||
focusCardId={focusCardId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,8 +43,11 @@
|
||||
.tarot__hero-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(15,4,40,.5), rgba(15,4,40,.85));
|
||||
background:
|
||||
linear-gradient(90deg, rgba(10,4,32,.92) 0%, rgba(10,4,32,.55) 35%, rgba(10,4,32,.12) 60%, rgba(10,4,32,0) 100%),
|
||||
linear-gradient(180deg, rgba(10,4,32,0) 0%, rgba(10,4,32,.55) 100%);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tarot__hero-content {
|
||||
@@ -149,20 +152,98 @@
|
||||
}
|
||||
|
||||
/* ===== Reading page (3-step) ===== */
|
||||
.tarot-reading-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(10,4,32,.55), rgba(10,4,32,.75)),
|
||||
url('/images/tarot/tarot_table.png') center / cover no-repeat fixed,
|
||||
#0a0420;
|
||||
}
|
||||
|
||||
.tarot-reading-steps {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
justify-content: center;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
.tarot-reading-steps__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--tarot-text-dim);
|
||||
}
|
||||
.tarot-reading-steps__item.is-done .tarot-reading-steps__dot,
|
||||
.tarot-reading-steps__item.is-active .tarot-reading-steps__dot {
|
||||
border-color: var(--tarot-gold);
|
||||
color: var(--tarot-gold);
|
||||
}
|
||||
.tarot-reading-steps__item.is-done { color: var(--tarot-gold); }
|
||||
.tarot-reading-steps__item.is-active { color: var(--tarot-text); }
|
||||
.tarot-reading-steps__dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--tarot-text-dim);
|
||||
}
|
||||
.tarot-reading-steps__sep {
|
||||
width: 32px;
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,.15);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.tarot-reading {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
padding: 32px 24px 40px;
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr 380px;
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tarot-reading__col {
|
||||
background: rgba(255, 255, 255, .04);
|
||||
border: 1px solid rgba(255, 255, 255, .06);
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 6, 36, .55);
|
||||
border: 1px solid rgba(212, 175, 55, .15);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.tarot-reading__center {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tarot-reading__preview-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 28px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid rgba(212, 175, 55, .15);
|
||||
width: 100%;
|
||||
}
|
||||
.tarot-reading__preview-row .tarot-card {
|
||||
--tarot-card-w: 56px;
|
||||
--tarot-card-h: 84px;
|
||||
opacity: .55;
|
||||
transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.tarot-reading__preview-row .tarot-card:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tarot-reading__step-label {
|
||||
|
||||
Reference in New Issue
Block a user