k-recipe2vec / web /src /App.jsx
๊ฐ•๋ฏผ๊ท 
Fix: Remove embedded git from web folder
b46ff5d
import { useState, useEffect } from 'react'
import styled, { keyframes } from 'styled-components'
import { searchRecipes, listRecipes, recommendDbSingle, recommendDbMulti } from './services/api'
import { Search, ChefHat, ArrowLeft, Utensils, Sparkles, SlidersHorizontal, HelpCircle, Zap, BookOpen, ChevronDown, ChevronUp, Check, X, Home } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
// =========================
// ๐ŸŽจ ์Šคํƒ€์ผ ์ •์˜
// =========================
const Container = styled.div`
max-width: 900px;
margin: 0 auto;
padding: 1.5rem;
min-height: 100vh;
@media (max-width: 768px) {
padding: 1rem;
}
`
const Header = styled.header`
margin-bottom: 2rem;
cursor: pointer;
text-align: center;
`
const Title = styled.h1`
font-size: 2.5rem;
margin: 0;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
@media (max-width: 768px) {
font-size: 1.8rem;
}
`
const Subtitle = styled.p`
font-size: 1.1rem;
color: #64748b;
margin-top: 0.5rem;
`
const Card = styled(motion.div)`
background: white;
border-radius: 20px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
margin-bottom: 1.5rem;
@media (max-width: 768px) {
padding: 1.2rem;
border-radius: 16px;
}
`
const SectionTitle = styled.h3`
font-size: 1rem;
margin: 0 0 1rem 0;
color: #334155;
display: flex;
align-items: center;
gap: 8px;
`
const SearchBar = styled.div`
display: flex;
gap: 10px;
position: relative;
`
const Input = styled.input`
width: 100%;
padding: 0.9rem 3rem 0.9rem 1rem;
border-radius: 12px;
border: 2px solid #e2e8f0;
font-size: 1rem;
font-family: inherit;
transition: all 0.2s;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
`
const SearchBtn = styled.button`
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: #3b82f6;
border: none;
color: white;
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #2563eb;
}
`
const RecipeList = styled.div`
display: grid;
gap: 0.6rem;
margin-top: 1rem;
max-height: 400px;
overflow-y: auto;
`
const RecipeItem = styled(motion.div)`
background: ${props => props.selected ? '#eff6ff' : '#f8fafc'};
padding: 1rem 1.2rem;
border-radius: 12px;
border: 2px solid ${props => props.selected ? '#3b82f6' : 'transparent'};
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
&:hover {
border-color: #3b82f6;
background: #eff6ff;
}
`
const RecipeId = styled.span`
font-size: 0.75rem;
color: #94a3b8;
background: #f1f5f9;
padding: 0.2rem 0.5rem;
border-radius: 6px;
`
const IngredientGrid = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
`
const IngredientChip = styled.button`
background: ${props => props.selected ? '#3b82f6' : '#f1f5f9'};
color: ${props => props.selected ? 'white' : '#475569'};
border: none;
padding: 0.4rem 0.8rem;
border-radius: 999px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
&:hover {
background: ${props => props.selected ? '#2563eb' : '#e2e8f0'};
}
`
const ActionButton = styled.button`
width: 100%;
margin-top: 1.2rem;
padding: 0.9rem;
border-radius: 12px;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: white;
font-size: 1rem;
border: none;
cursor: pointer;
font-family: inherit;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&:disabled {
background: #cbd5e1;
cursor: not-allowed;
}
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
`
const SliderContainer = styled.div`
margin-top: 1.2rem;
padding: 1rem;
background: #f8fafc;
border-radius: 12px;
`
const SliderRow = styled.div`
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 0.8rem;
&:last-child {
margin-bottom: 0;
}
`
const SliderLabel = styled.div`
min-width: 100px;
font-size: 0.85rem;
color: #475569;
display: flex;
align-items: center;
gap: 4px;
`
const Slider = styled.input`
flex: 1;
-webkit-appearance: none;
height: 5px;
border-radius: 3px;
background: #e2e8f0;
outline: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
}
`
const SliderValue = styled.span`
min-width: 35px;
text-align: right;
font-weight: 600;
color: #3b82f6;
font-size: 0.9rem;
`
const ResultCard = styled(motion.div)`
background: white;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 1.2rem;
margin-bottom: 0.8rem;
`
const ResultHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.8rem;
`
const ResultName = styled.span`
font-size: 1.1rem;
font-weight: 700;
color: #1e293b;
`
const ScoreBadge = styled.span`
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-weight: 600;
font-size: 0.8rem;
`
const ResultGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.8rem;
margin-top: 0.8rem;
`
const CompactResultCard = styled(motion.div)`
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.8rem 1rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #3b82f6;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
}
`
const CompactHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`
const MedalIcon = styled.span`
font-size: 1.2rem;
margin-right: 4px;
`
const ScoreBarMini = styled.div`
display: flex;
gap: 2px;
margin-top: 0.5rem;
`
const ScoreSegment = styled.div`
height: 4px;
flex: 1;
border-radius: 2px;
background: ${props => props.color};
opacity: ${props => props.value > 0.3 ? 1 : 0.3};
`
const TabPills = styled.div`
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 1rem;
`
const TabPill = styled.button`
padding: 0.4rem 0.8rem;
border-radius: 999px;
border: 2px solid ${props => props.active ? '#3b82f6' : '#e2e8f0'};
background: ${props => props.active ? '#eff6ff' : 'white'};
color: ${props => props.active ? '#3b82f6' : '#64748b'};
font-weight: 500;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #3b82f6;
}
`
const ScoreRow = styled.div`
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.4rem;
`
const ScoreLabel = styled.span`
min-width: 90px;
font-size: 0.8rem;
color: #64748b;
`
const ProgressBar = styled.div`
flex: 1;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
`
const ProgressFill = styled.div`
height: 100%;
background: ${props => props.color || '#3b82f6'};
width: ${props => props.value}%;
transition: width 0.5s ease;
`
const Tooltip = styled.div`
position: relative;
display: inline-flex;
cursor: help;
&:hover > div {
display: block;
}
`
const TooltipContent = styled.div`
display: none;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: #1e293b;
color: white;
padding: 0.4rem 0.6rem;
border-radius: 6px;
font-size: 0.75rem;
white-space: nowrap;
z-index: 100;
margin-bottom: 4px;
`
const InfoBox = styled.div`
background: ${props => props.variant === 'purple' ? '#f5f3ff' : '#eff6ff'};
border-left: 4px solid ${props => props.variant === 'purple' ? '#8b5cf6' : '#3b82f6'};
padding: 1rem;
border-radius: 0 10px 10px 0;
margin: 0.8rem 0;
font-size: 0.85rem;
color: ${props => props.variant === 'purple' ? '#5b21b6' : '#1e40af'};
line-height: 1.6;
`
const pulse = keyframes`
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
`
const LoadingText = styled.span`
animation: ${pulse} 1.5s ease-in-out infinite;
`
const BackButton = styled.button`
background: none;
border: none;
padding: 0;
color: #64748b;
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-size: 0.9rem;
&:hover {
color: #3b82f6;
}
`
const NavButtons = styled.div`
display: flex;
gap: 1rem;
margin-bottom: 1rem;
`
const HomeButton = styled.button`
background: #f1f5f9;
border: none;
padding: 0.5rem 0.8rem;
color: #64748b;
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-size: 0.9rem;
border-radius: 8px;
&:hover {
background: #e2e8f0;
color: #3b82f6;
}
`
const ToggleHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0.5rem 0;
`
const TabContainer = styled.div`
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
`
const Tab = styled.button`
flex: 1;
padding: 0.7rem;
border-radius: 10px;
border: 2px solid ${props => props.active ? '#3b82f6' : '#e2e8f0'};
background: ${props => props.active ? '#eff6ff' : 'white'};
color: ${props => props.active ? '#3b82f6' : '#64748b'};
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #3b82f6;
}
`
const MultiResultSection = styled.div`
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e2e8f0;
&:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
`
const TargetLabel = styled.div`
font-size: 1rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.8rem;
display: flex;
align-items: center;
gap: 6px;
`
// =========================
// ๐Ÿง  ๋ฉ”์ธ ์•ฑ ์ปดํฌ๋„ŒํŠธ
// =========================
function App() {
const [step, setStep] = useState('main') // main, detail, result
const [query, setQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [allRecipes, setAllRecipes] = useState([])
const [totalRecipes, setTotalRecipes] = useState(0)
const [selectedRecipe, setSelectedRecipe] = useState(null)
const [selectedIngs, setSelectedIngs] = useState([]) // ๋‹ค์ค‘ ์„ ํƒ
const [recommendations, setRecommendations] = useState([])
const [multiRecommendations, setMultiRecommendations] = useState([]) // ๋‹ค์ค‘ ๋Œ€์ฒด ์กฐํ•ฉ ๊ฒฐ๊ณผ
const [loading, setLoading] = useState(false)
const [showWeights, setShowWeights] = useState(false)
const [showAlgorithm, setShowAlgorithm] = useState(false)
const [activeTab, setActiveTab] = useState('search') // search, browse
const [activeResultTab, setActiveResultTab] = useState(0) // ๋‹ค์ค‘ ์žฌ๋ฃŒ ๊ฒฐ๊ณผ ํƒญ
const [expandedCard, setExpandedCard] = useState(null) // ์ ์ˆ˜ ์ƒ์„ธ ๋ณด๊ธฐ
// ๊ฐ€์ค‘์น˜ ์ƒํƒœ
const [weights, setWeights] = useState({
w2v: 0.5,
d2v: 0.5,
method: 0.0,
cat: 0.0
})
useEffect(() => {
// ์ดˆ๊ธฐ ๋ ˆ์‹œํ”ผ ๋ชฉ๋ก ๋กœ๋“œ
listRecipes(30, 0).then(res => {
setAllRecipes(res.recipes || [])
setTotalRecipes(res.total || 0)
})
}, [])
const handleSearch = async (e) => {
e?.preventDefault()
if (!query.trim()) return
setLoading(true)
const res = await searchRecipes(query)
setSearchResults(res)
setLoading(false)
}
const handleSelectRecipe = (recipe) => {
setSelectedRecipe(recipe)
setStep('detail')
setSelectedIngs([])
setRecommendations([])
setMultiRecommendations([])
}
const toggleIngredient = (ing) => {
if (selectedIngs.includes(ing)) {
setSelectedIngs(selectedIngs.filter(i => i !== ing))
} else {
setSelectedIngs([...selectedIngs, ing])
}
}
const handleRecommend = async () => {
if (!selectedRecipe || selectedIngs.length === 0) return
setLoading(true)
if (selectedIngs.length === 1) {
// ๋‹จ์ผ ์ถ”์ฒœ
const res = await recommendDbSingle(
selectedRecipe.id,
selectedIngs[0],
weights.w2v,
weights.d2v,
weights.method,
weights.cat
)
setRecommendations(res)
setMultiRecommendations([])
} else {
// ๋‹ค์ค‘ ์ถ”์ฒœ - Beam Search ๊ธฐ๋ฐ˜ Multi API ์‚ฌ์šฉ
const res = await recommendDbMulti(
selectedRecipe.id,
selectedIngs,
weights.w2v,
weights.d2v,
weights.method,
weights.cat
)
setMultiRecommendations(res)
setRecommendations([])
}
setLoading(false)
setStep('result')
}
const goBack = () => {
if (step === 'result') setStep('detail')
else if (step === 'detail') setStep('main')
}
const resetAll = () => {
setStep('main')
setSearchResults([])
setQuery('')
setSelectedRecipe(null)
setSelectedIngs([])
setRecommendations([])
setMultiRecommendations([])
}
const loadMoreRecipes = async () => {
const res = await listRecipes(30, allRecipes.length)
setAllRecipes([...allRecipes, ...(res.recipes || [])])
}
return (
<Container>
<Header onClick={resetAll}>
<Title><ChefHat size={32} color="#3b82f6" /> K-Recipe2Vec</Title>
<Subtitle>AI๊ฐ€ ์ถ”์ฒœํ•˜๋Š” ์ตœ์ ์˜ ๋Œ€์ฒด ์žฌ๋ฃŒ</Subtitle>
</Header>
<AnimatePresence mode="wait">
{/* ========== ๋ฉ”์ธ ํ™”๋ฉด ========== */}
{step === 'main' && (
<motion.div
key="main"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
{/* ์•Œ๊ณ ๋ฆฌ์ฆ˜ ์„ค๋ช… ์„น์…˜ */}
<Card>
<ToggleHeader onClick={() => setShowAlgorithm(!showAlgorithm)}>
<SectionTitle style={{ margin: 0 }}>
<BookOpen size={16} /> ์ด ์„œ๋น„์Šค๋Š” ์–ด๋–ป๊ฒŒ ์ž‘๋™ํ•˜๋‚˜์š”?
</SectionTitle>
{showAlgorithm ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</ToggleHeader>
{showAlgorithm && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
>
<InfoBox variant="purple" style={{ marginTop: '1rem' }}>
<strong>๐Ÿง  K-Recipe2Vec์ด๋ž€?</strong><br />
์•ฝ 8๋งŒ๊ฐœ์˜ ํ•œ์‹ ๋ ˆ์‹œํ”ผ ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ํ•™์Šต๋œ AI ๋ชจ๋ธ์ž…๋‹ˆ๋‹ค.
Word2Vec๊ณผ Doc2Vec์„ ํ™œ์šฉํ•˜์—ฌ ์žฌ๋ฃŒ ๊ฐ„์˜ ์˜๋ฏธ์  ์œ ์‚ฌ๋„์™€
๋ ˆ์‹œํ”ผ ๋ฌธ๋งฅ์—์„œ์˜ ์ƒํ˜ธ ๋Œ€์ฒด ๊ฐ€๋Šฅ์„ฑ์„ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค.
</InfoBox>
<div style={{ fontSize: '0.85rem', color: '#475569', lineHeight: 1.8 }}>
<p><strong>๐Ÿ“Š ์ ์ˆ˜ ๊ตฌ์„ฑ ์š”์†Œ:</strong></p>
<ul style={{ margin: '0.5rem 0', paddingLeft: '1.2rem' }}>
<li><strong>์žฌ๋ฃŒ ์œ ์‚ฌ๋„ (W2V)</strong>: Word2Vec์œผ๋กœ ํ•™์Šตํ•œ ์žฌ๋ฃŒ ๊ฐ„ ์˜๋ฏธ์  ๊ฑฐ๋ฆฌ. ์˜ˆ) ๋ผ์ง€๊ณ ๊ธฐ โ†” ์†Œ๊ณ ๊ธฐ</li>
<li><strong>๋ฌธ๋งฅ ์œ ์‚ฌ๋„ (D2V)</strong>: Doc2Vec์œผ๋กœ ํ•™์Šตํ•œ ๋ ˆ์‹œํ”ผ ๋ฌธ๋งฅ. ๊ฐ™์€ ์š”๋ฆฌ์—์„œ ํ•จ๊ป˜ ์“ฐ์ด๋Š” ๋นˆ๋„ ๋ฐ˜์˜</li>
<li><strong>์กฐ๋ฆฌ๋ฒ• ์ ํ•ฉ (Method)</strong>: ์ฐœ, ๋ณถ์Œ, ๊ตฌ์ด ๋“ฑ ๊ฐ™์€ ์กฐ๋ฆฌ๋ฒ•์—์„œ ์ž์ฃผ ์‚ฌ์šฉ๋˜๋Š” ์ •๋„</li>
<li><strong>์นดํ…Œ๊ณ ๋ฆฌ ์ ํ•ฉ (Category)</strong>: ์ฐŒ๊ฐœ, ๋ฐ˜์ฐฌ ๋“ฑ ๊ฐ™์€ ์š”๋ฆฌ ์ข…๋ฅ˜์—์„œ์˜ ์‚ฌ์šฉ ๋นˆ๋„</li>
</ul>
<p style={{ marginTop: '0.8rem' }}>
โš™๏ธ <strong>๊ณ ๊ธ‰ ์„ค์ •</strong>์—์„œ ๊ฐ ์ ์ˆ˜์˜ ๊ฐ€์ค‘์น˜๋ฅผ ์กฐ์ ˆํ•˜์—ฌ ์›ํ•˜๋Š” ๋ฐฉํ–ฅ์œผ๋กœ ์ถ”์ฒœ ๊ฒฐ๊ณผ๋ฅผ ์ปค์Šคํ„ฐ๋งˆ์ด์ฆˆํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
</p>
</div>
</motion.div>
)}
</Card>
{/* ๋ ˆ์‹œํ”ผ ์„ ํƒ */}
<Card>
<TabContainer>
<Tab active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
<Search size={14} style={{ marginRight: 4 }} /> ์š”๋ฆฌ๋ช… ๊ฒ€์ƒ‰
</Tab>
<Tab active={activeTab === 'browse'} onClick={() => setActiveTab('browse')}>
<Utensils size={14} style={{ marginRight: 4 }} /> ์ „์ฒด ๋ ˆ์‹œํ”ผ
</Tab>
</TabContainer>
{activeTab === 'search' && (
<>
<form onSubmit={handleSearch}>
<SearchBar>
<Input
placeholder="์š”๋ฆฌ ์ด๋ฆ„ ๊ฒ€์ƒ‰ (์˜ˆ: ๊น€์น˜์ฐŒ๊ฐœ, ๋œ์žฅ์ฐŒ๊ฐœ)"
value={query}
onChange={e => setQuery(e.target.value)}
/>
<SearchBtn type="submit">
<Search size={16} />
</SearchBtn>
</SearchBar>
</form>
<RecipeList>
{loading && <LoadingText style={{ textAlign: 'center', padding: '1rem' }}>๐Ÿ” ๊ฒ€์ƒ‰ ์ค‘...</LoadingText>}
{searchResults.map(recipe => (
<RecipeItem
key={recipe.id}
onClick={() => handleSelectRecipe(recipe)}
whileTap={{ scale: 0.98 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Utensils size={16} color="#64748b" />
<span style={{ fontWeight: '600' }}>{recipe.name}</span>
<RecipeId>#{recipe.id}</RecipeId>
</div>
<span style={{ color: '#94a3b8', fontSize: '0.85rem' }}>
์žฌ๋ฃŒ {recipe.ingredients.length}๊ฐœ
</span>
</RecipeItem>
))}
{searchResults.length === 0 && !loading && query && (
<div style={{ textAlign: 'center', padding: '1.5rem', color: '#94a3b8' }}>
๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค
</div>
)}
</RecipeList>
</>
)}
{activeTab === 'browse' && (
<>
<div style={{ fontSize: '0.85rem', color: '#64748b', marginBottom: '0.8rem' }}>
์ „์ฒด {totalRecipes.toLocaleString()}๊ฐœ ๋ ˆ์‹œํ”ผ ์ค‘ {allRecipes.length}๊ฐœ ํ‘œ์‹œ
</div>
<RecipeList>
{allRecipes.map(recipe => (
<RecipeItem
key={recipe.id}
onClick={() => handleSelectRecipe(recipe)}
whileTap={{ scale: 0.98 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<Utensils size={16} color="#64748b" />
<span style={{ fontWeight: '600' }}>{recipe.name}</span>
<RecipeId>#{recipe.id}</RecipeId>
</div>
<span style={{ color: '#94a3b8', fontSize: '0.85rem' }}>
์žฌ๋ฃŒ {recipe.ingredients.length}๊ฐœ
</span>
</RecipeItem>
))}
</RecipeList>
{allRecipes.length < totalRecipes && (
<ActionButton
onClick={loadMoreRecipes}
style={{ marginTop: '1rem', background: '#64748b' }}
>
๋” ๋ถˆ๋Ÿฌ์˜ค๊ธฐ
</ActionButton>
)}
</>
)}
</Card>
</motion.div>
)}
{/* ========== ์žฌ๋ฃŒ ์„ ํƒ ๋‹จ๊ณ„ ========== */}
{step === 'detail' && selectedRecipe && (
<motion.div
key="detail"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
>
<Card>
<NavButtons>
<BackButton onClick={goBack}>
<ArrowLeft size={16} /> ๋’ค๋กœ
</BackButton>
<HomeButton onClick={resetAll}>
<Home size={16} /> ํ™ˆ์œผ๋กœ
</HomeButton>
</NavButtons>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '0.3rem' }}>
<h2 style={{ margin: 0, fontSize: '1.5rem', color: '#1e293b' }}>
{selectedRecipe.name}
</h2>
<RecipeId>#{selectedRecipe.id}</RecipeId>
</div>
<p style={{ color: '#64748b', margin: '0 0 0.5rem 0', fontSize: '0.9rem' }}>
๋Œ€์ฒดํ•  ์žฌ๋ฃŒ๋ฅผ ์„ ํƒํ•˜์„ธ์š” (์—ฌ๋Ÿฌ ๊ฐœ ์„ ํƒ ๊ฐ€๋Šฅ)
</p>
<IngredientGrid>
{selectedRecipe.ingredients.map((ing, idx) => (
<IngredientChip
key={idx}
selected={selectedIngs.includes(ing)}
onClick={() => toggleIngredient(ing)}
>
{selectedIngs.includes(ing) && <Check size={14} />}
{ing}
</IngredientChip>
))}
</IngredientGrid>
{selectedIngs.length > 0 && (
<div style={{ marginTop: '1rem', padding: '0.8rem', background: '#f8fafc', borderRadius: '10px' }}>
<div style={{ fontSize: '0.85rem', color: '#64748b', marginBottom: '0.5rem' }}>
์„ ํƒ๋œ ์žฌ๋ฃŒ ({selectedIngs.length}๊ฐœ):
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{selectedIngs.map((ing, idx) => (
<span
key={idx}
style={{
background: '#3b82f6',
color: 'white',
padding: '0.3rem 0.6rem',
borderRadius: '999px',
fontSize: '0.85rem',
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer'
}}
onClick={() => toggleIngredient(ing)}
>
{ing} <X size={12} />
</span>
))}
</div>
</div>
)}
{/* ๊ฐ€์ค‘์น˜ ์„ค์ • */}
<SliderContainer>
<ToggleHeader onClick={() => setShowWeights(!showWeights)}>
<SectionTitle style={{ margin: 0 }}>
<SlidersHorizontal size={14} /> ๊ณ ๊ธ‰ ์„ค์ • (๊ฐ€์ค‘์น˜ ์กฐ์ ˆ)
</SectionTitle>
{showWeights ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</ToggleHeader>
{showWeights && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{ marginTop: '0.8rem' }}
>
<SliderRow>
<SliderLabel>
<Tooltip>
<HelpCircle size={12} />
<TooltipContent>์žฌ๋ฃŒ ๊ฐ„ ์˜๋ฏธ์  ์œ ์‚ฌ๋„</TooltipContent>
</Tooltip>
์žฌ๋ฃŒ ์œ ์‚ฌ๋„
</SliderLabel>
<Slider
type="range" min="0" max="1" step="0.1"
value={weights.w2v}
onChange={e => setWeights({ ...weights, w2v: parseFloat(e.target.value) })}
/>
<SliderValue>{weights.w2v.toFixed(1)}</SliderValue>
</SliderRow>
<SliderRow>
<SliderLabel>
<Tooltip>
<HelpCircle size={12} />
<TooltipContent>๋ ˆ์‹œํ”ผ ๋ฌธ๋งฅ ์œ ์‚ฌ๋„</TooltipContent>
</Tooltip>
๋ฌธ๋งฅ ์œ ์‚ฌ๋„
</SliderLabel>
<Slider
type="range" min="0" max="1" step="0.1"
value={weights.d2v}
onChange={e => setWeights({ ...weights, d2v: parseFloat(e.target.value) })}
/>
<SliderValue>{weights.d2v.toFixed(1)}</SliderValue>
</SliderRow>
<SliderRow>
<SliderLabel>
<Tooltip>
<HelpCircle size={12} />
<TooltipContent>์กฐ๋ฆฌ ๋ฐฉ๋ฒ• ์ ํ•ฉ๋„</TooltipContent>
</Tooltip>
์กฐ๋ฆฌ๋ฒ• ์ ํ•ฉ
</SliderLabel>
<Slider
type="range" min="0" max="1" step="0.1"
value={weights.method}
onChange={e => setWeights({ ...weights, method: parseFloat(e.target.value) })}
/>
<SliderValue>{weights.method.toFixed(1)}</SliderValue>
</SliderRow>
<SliderRow>
<SliderLabel>
<Tooltip>
<HelpCircle size={12} />
<TooltipContent>์š”๋ฆฌ ์นดํ…Œ๊ณ ๋ฆฌ ์ ํ•ฉ๋„</TooltipContent>
</Tooltip>
์นดํ…Œ๊ณ ๋ฆฌ ์ ํ•ฉ
</SliderLabel>
<Slider
type="range" min="0" max="1" step="0.1"
value={weights.cat}
onChange={e => setWeights({ ...weights, cat: parseFloat(e.target.value) })}
/>
<SliderValue>{weights.cat.toFixed(1)}</SliderValue>
</SliderRow>
</motion.div>
)}
</SliderContainer>
<ActionButton onClick={handleRecommend} disabled={selectedIngs.length === 0 || loading}>
{loading ? (
<LoadingText>๋ถ„์„ ์ค‘...</LoadingText>
) : (
<>
<Zap size={16} />
{selectedIngs.length > 0
? `${selectedIngs.length}๊ฐœ ์žฌ๋ฃŒ ๋Œ€์ฒด ์ถ”์ฒœ๋ฐ›๊ธฐ`
: '์žฌ๋ฃŒ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”'}
</>
)}
</ActionButton>
</Card>
</motion.div>
)}
{/* ========== ๊ฒฐ๊ณผ ๋‹จ๊ณ„ ========== */}
{step === 'result' && (
<motion.div
key="result"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
>
<Card>
<NavButtons>
<BackButton onClick={goBack}>
<ArrowLeft size={16} /> ๋’ค๋กœ
</BackButton>
<HomeButton onClick={resetAll}>
<Home size={16} /> ํ™ˆ์œผ๋กœ
</HomeButton>
</NavButtons>
<h2 style={{ fontSize: '1.4rem', display: 'flex', alignItems: 'center', gap: '8px', margin: '0 0 0.3rem 0' }}>
<Sparkles color="#eab308" fill="#eab308" size={20} /> ์ด๋Ÿฐ ์žฌ๋ฃŒ๋กœ ๋Œ€์ฒดํ•ด๋ณด์„ธ์š”
</h2>
<p style={{ color: '#64748b', margin: '0 0 1rem 0', fontSize: '0.9rem' }}>
<strong>{selectedRecipe.name}</strong> (#{selectedRecipe.id})
</p>
{/* ๋‹จ์ผ ์žฌ๋ฃŒ ๊ฒฐ๊ณผ - ๊ทธ๋ฆฌ๋“œ ๋ ˆ์ด์•„์›ƒ */}
{recommendations.length > 0 && (
<>
<TargetLabel>
"{selectedIngs[0]}" โ†’ {expandedCard !== null && recommendations[expandedCard]
? <span style={{ color: '#3b82f6', fontWeight: '600' }}>{recommendations[expandedCard]['๋Œ€์ฒด์žฌ๋ฃŒ']}</span>
: '๋Œ€์ฒด ์ถ”์ฒœ'}
</TargetLabel>
<ResultGrid>
{recommendations.map((rec, idx) => (
<CompactResultCard
key={idx}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
onClick={() => setExpandedCard(expandedCard === idx ? null : idx)}
>
<CompactHeader>
<ResultName>
<MedalIcon>
{idx === 0 && '๐Ÿฅ‡'}
{idx === 1 && '๐Ÿฅˆ'}
{idx === 2 && '๐Ÿฅ‰'}
{idx > 2 && `${idx + 1}.`}
</MedalIcon>
{rec['๋Œ€์ฒด์žฌ๋ฃŒ']}
</ResultName>
<ScoreBadge>{(rec['์ตœ์ข…์ ์ˆ˜'] * 100).toFixed(0)}์ </ScoreBadge>
</CompactHeader>
<ScoreBarMini>
<ScoreSegment color="#3b82f6" value={rec['W2V'] || 0} title="W2V" />
<ScoreSegment color="#8b5cf6" value={rec['D2V'] || 0} title="D2V" />
<ScoreSegment color="#10b981" value={rec['Method'] || 0} title="Method" />
<ScoreSegment color="#f59e0b" value={rec['Category'] || 0} title="Category" />
</ScoreBarMini>
{expandedCard === idx && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
style={{ marginTop: '0.8rem', paddingTop: '0.8rem', borderTop: '1px solid #e2e8f0' }}
>
<ScoreRow>
<ScoreLabel>์žฌ๋ฃŒ ์œ ์‚ฌ๋„</ScoreLabel>
<ProgressBar><ProgressFill value={(rec['W2V'] || 0) * 100} color="#3b82f6" /></ProgressBar>
<span style={{ minWidth: '30px', textAlign: 'right', fontSize: '0.75rem', color: '#64748b' }}>
{((rec['W2V'] || 0) * 100).toFixed(0)}%
</span>
</ScoreRow>
<ScoreRow>
<ScoreLabel>๋ฌธ๋งฅ ์œ ์‚ฌ๋„</ScoreLabel>
<ProgressBar><ProgressFill value={(rec['D2V'] || 0) * 100} color="#8b5cf6" /></ProgressBar>
<span style={{ minWidth: '30px', textAlign: 'right', fontSize: '0.75rem', color: '#64748b' }}>
{((rec['D2V'] || 0) * 100).toFixed(0)}%
</span>
</ScoreRow>
<ScoreRow>
<ScoreLabel>์กฐ๋ฆฌ๋ฒ• ์ ํ•ฉ</ScoreLabel>
<ProgressBar><ProgressFill value={(rec['Method'] || 0) * 100} color="#10b981" /></ProgressBar>
<span style={{ minWidth: '30px', textAlign: 'right', fontSize: '0.75rem', color: '#64748b' }}>
{((rec['Method'] || 0) * 100).toFixed(0)}%
</span>
</ScoreRow>
<ScoreRow>
<ScoreLabel>์นดํ…Œ๊ณ ๋ฆฌ ์ ํ•ฉ</ScoreLabel>
<ProgressBar><ProgressFill value={(rec['Category'] || 0) * 100} color="#f59e0b" /></ProgressBar>
<span style={{ minWidth: '30px', textAlign: 'right', fontSize: '0.75rem', color: '#64748b' }}>
{((rec['Category'] || 0) * 100).toFixed(0)}%
</span>
</ScoreRow>
</motion.div>
)}
</CompactResultCard>
))}
</ResultGrid>
<p style={{ fontSize: '0.8rem', color: '#94a3b8', marginTop: '0.8rem', textAlign: 'center' }}>
์นด๋“œ๋ฅผ ํด๋ฆญํ•˜๋ฉด ์ƒ์„ธ ์ ์ˆ˜๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์–ด์š”
</p>
</>
)}
{/* ๋‹ค์ค‘ ์žฌ๋ฃŒ ๊ฒฐ๊ณผ - Beam Search ์กฐํ•ฉ ํ‘œ์‹œ */}
{multiRecommendations.length > 0 && (
<>
<TargetLabel>
{selectedIngs.join(' + ')} โ†’ {expandedCard !== null && multiRecommendations[expandedCard]
? <span style={{ color: '#3b82f6', fontWeight: '600' }}>
{multiRecommendations[expandedCard].substitutes.join(' + ')}
</span>
: '์ตœ์  ๋Œ€์ฒด ์กฐํ•ฉ'}
</TargetLabel>
<div style={{ marginBottom: '1rem' }}>
{multiRecommendations.map((combo, idx) => (
<CompactResultCard
key={idx}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
style={{
marginBottom: '0.8rem',
border: expandedCard === idx ? '2px solid #3b82f6' : '1px solid #e2e8f0'
}}
onClick={() => setExpandedCard(expandedCard === idx ? null : idx)}
>
<CompactHeader>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<MedalIcon>
{idx === 0 && '๐Ÿฅ‡'}
{idx === 1 && '๐Ÿฅˆ'}
{idx === 2 && '๐Ÿฅ‰'}
</MedalIcon>
<span style={{ fontWeight: '600', color: '#1e293b' }}>
์กฐํ•ฉ {idx + 1}
</span>
</div>
<ScoreBadge>{(combo.score * 100).toFixed(0)}์ </ScoreBadge>
</CompactHeader>
<div style={{
marginTop: '0.8rem',
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem'
}}>
{selectedIngs.map((origIng, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '0.4rem 0.8rem',
background: expandedCard === idx ? '#dbeafe' : '#f1f5f9',
borderRadius: '8px',
fontSize: '0.9rem'
}}
>
<span style={{ color: '#64748b', textDecoration: 'line-through' }}>
{origIng}
</span>
<span style={{ color: '#94a3b8' }}>โ†’</span>
<span style={{ fontWeight: '600', color: '#3b82f6' }}>
{combo.substitutes[i]}
</span>
</div>
))}
</div>
{expandedCard === idx && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
style={{
marginTop: '1rem',
paddingTop: '1rem',
borderTop: '1px solid #e2e8f0',
fontSize: '0.85rem',
color: '#475569'
}}
>
<div style={{ marginBottom: '0.5rem', fontWeight: '600', color: '#1e293b' }}>
์ถ”์ฒœ ๊ธฐ์ค€
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>ํ‰๊ท  ์œ ์‚ฌ๋„ ์ ์ˆ˜</span>
<span style={{ fontWeight: '600', color: '#3b82f6' }}>{(combo.score * 100).toFixed(1)}%</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>์กฐํ•ฉ ์ˆœ์œ„</span>
<span style={{ fontWeight: '600' }}>{idx + 1}์œ„ / {multiRecommendations.length}๊ฐœ</span>
</div>
<div style={{
marginTop: '0.5rem',
padding: '0.5rem',
background: '#f8fafc',
borderRadius: '6px',
fontSize: '0.8rem',
color: '#64748b'
}}>
Beam Search๊ฐ€ ๊ฐ ์žฌ๋ฃŒ์˜ W2V, D2V, Method, Category ์ ์ˆ˜๋ฅผ ์ข…ํ•ฉํ•˜์—ฌ ์ตœ์ ์˜ ์กฐํ•ฉ์„ ์„ ํƒํ–ˆ์Šต๋‹ˆ๋‹ค.
</div>
</div>
</motion.div>
)}
</CompactResultCard>
))}
</div>
<p style={{ fontSize: '0.8rem', color: '#94a3b8', textAlign: 'center' }}>
์นด๋“œ๋ฅผ ํด๋ฆญํ•˜๋ฉด ์ƒ์„ธ ์ •๋ณด๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์–ด์š”
</p>
</>
)}
{recommendations.length === 0 && multiRecommendations.length === 0 && (
<div style={{ textAlign: 'center', padding: '2rem', color: '#94a3b8' }}>
์ถ”์ฒœ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์žฌ๋ฃŒ๋ฅผ ์„ ํƒํ•ด ์ฃผ์„ธ์š”.
</div>
)}
</Card>
</motion.div>
)}
</AnimatePresence>
</Container>
)
}
export default App