Spaces:
Sleeping
Sleeping
| 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 | |