강민균 commited on
Commit
b46ff5d
·
1 Parent(s): 9f03b39

Fix: Remove embedded git from web folder

Browse files
web DELETED
@@ -1 +0,0 @@
1
- Subproject commit a4271215feae73d4660de0c761bffa3bd3f2f20a
 
 
web/.env ADDED
@@ -0,0 +1 @@
 
 
1
+ VITE_API_URL=https://nneans-k-recipe2vec.hf.space
web/.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
25
+ .vercel
26
+ .env*.local
web/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
web/eslint.config.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs.flat.recommended,
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ ecmaVersion: 2020,
18
+ globals: globals.browser,
19
+ parserOptions: {
20
+ ecmaVersion: 'latest',
21
+ ecmaFeatures: { jsx: true },
22
+ sourceType: 'module',
23
+ },
24
+ },
25
+ rules: {
26
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27
+ },
28
+ },
29
+ ])
web/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>recipe-ai-web</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
web/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
web/package.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "recipe-ai-web",
3
+ "homepage": "https://nneans.github.io/k-recipe2vec",
4
+ "private": true,
5
+ "version": "0.0.0",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "vite build",
10
+ "predeploy": "npm run build",
11
+ "deploy": "gh-pages -d dist",
12
+ "lint": "eslint .",
13
+ "preview": "vite preview"
14
+ },
15
+ "dependencies": {
16
+ "axios": "^1.13.4",
17
+ "framer-motion": "^12.29.2",
18
+ "lucide-react": "^0.563.0",
19
+ "react": "^19.2.0",
20
+ "react-dom": "^19.2.0",
21
+ "styled-components": "^6.3.8"
22
+ },
23
+ "devDependencies": {
24
+ "@eslint/js": "^9.39.1",
25
+ "@types/react": "^19.2.5",
26
+ "@types/react-dom": "^19.2.3",
27
+ "@vitejs/plugin-react": "^5.1.1",
28
+ "eslint": "^9.39.1",
29
+ "eslint-plugin-react-hooks": "^7.0.1",
30
+ "eslint-plugin-react-refresh": "^0.4.24",
31
+ "gh-pages": "^6.3.0",
32
+ "globals": "^16.5.0",
33
+ "vite": "^7.2.4"
34
+ }
35
+ }
web/public/vite.svg ADDED
web/src/App.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
web/src/App.jsx ADDED
@@ -0,0 +1,1178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react'
2
+ import styled, { keyframes } from 'styled-components'
3
+ import { searchRecipes, listRecipes, recommendDbSingle, recommendDbMulti } from './services/api'
4
+ import { Search, ChefHat, ArrowLeft, Utensils, Sparkles, SlidersHorizontal, HelpCircle, Zap, BookOpen, ChevronDown, ChevronUp, Check, X, Home } from 'lucide-react'
5
+ import { motion, AnimatePresence } from 'framer-motion'
6
+
7
+ // =========================
8
+ // 🎨 스타일 정의
9
+ // =========================
10
+
11
+ const Container = styled.div`
12
+ max-width: 900px;
13
+ margin: 0 auto;
14
+ padding: 1.5rem;
15
+ min-height: 100vh;
16
+
17
+ @media (max-width: 768px) {
18
+ padding: 1rem;
19
+ }
20
+ `
21
+
22
+ const Header = styled.header`
23
+ margin-bottom: 2rem;
24
+ cursor: pointer;
25
+ text-align: center;
26
+ `
27
+
28
+ const Title = styled.h1`
29
+ font-size: 2.5rem;
30
+ margin: 0;
31
+ background: linear-gradient(135deg, #3b82f6, #8b5cf6);
32
+ -webkit-background-clip: text;
33
+ -webkit-text-fill-color: transparent;
34
+ background-clip: text;
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ gap: 12px;
39
+
40
+ @media (max-width: 768px) {
41
+ font-size: 1.8rem;
42
+ }
43
+ `
44
+
45
+ const Subtitle = styled.p`
46
+ font-size: 1.1rem;
47
+ color: #64748b;
48
+ margin-top: 0.5rem;
49
+ `
50
+
51
+ const Card = styled(motion.div)`
52
+ background: white;
53
+ border-radius: 20px;
54
+ padding: 1.5rem;
55
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
56
+ margin-bottom: 1.5rem;
57
+
58
+ @media (max-width: 768px) {
59
+ padding: 1.2rem;
60
+ border-radius: 16px;
61
+ }
62
+ `
63
+
64
+ const SectionTitle = styled.h3`
65
+ font-size: 1rem;
66
+ margin: 0 0 1rem 0;
67
+ color: #334155;
68
+ display: flex;
69
+ align-items: center;
70
+ gap: 8px;
71
+ `
72
+
73
+ const SearchBar = styled.div`
74
+ display: flex;
75
+ gap: 10px;
76
+ position: relative;
77
+ `
78
+
79
+ const Input = styled.input`
80
+ width: 100%;
81
+ padding: 0.9rem 3rem 0.9rem 1rem;
82
+ border-radius: 12px;
83
+ border: 2px solid #e2e8f0;
84
+ font-size: 1rem;
85
+ font-family: inherit;
86
+ transition: all 0.2s;
87
+
88
+ &:focus {
89
+ outline: none;
90
+ border-color: #3b82f6;
91
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
92
+ }
93
+ `
94
+
95
+ const SearchBtn = styled.button`
96
+ position: absolute;
97
+ right: 6px;
98
+ top: 50%;
99
+ transform: translateY(-50%);
100
+ background: #3b82f6;
101
+ border: none;
102
+ color: white;
103
+ cursor: pointer;
104
+ padding: 0.5rem;
105
+ border-radius: 8px;
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+
110
+ &:hover {
111
+ background: #2563eb;
112
+ }
113
+ `
114
+
115
+ const RecipeList = styled.div`
116
+ display: grid;
117
+ gap: 0.6rem;
118
+ margin-top: 1rem;
119
+ max-height: 400px;
120
+ overflow-y: auto;
121
+ `
122
+
123
+ const RecipeItem = styled(motion.div)`
124
+ background: ${props => props.selected ? '#eff6ff' : '#f8fafc'};
125
+ padding: 1rem 1.2rem;
126
+ border-radius: 12px;
127
+ border: 2px solid ${props => props.selected ? '#3b82f6' : 'transparent'};
128
+ cursor: pointer;
129
+ display: flex;
130
+ justify-content: space-between;
131
+ align-items: center;
132
+ transition: all 0.2s;
133
+
134
+ &:hover {
135
+ border-color: #3b82f6;
136
+ background: #eff6ff;
137
+ }
138
+ `
139
+
140
+ const RecipeId = styled.span`
141
+ font-size: 0.75rem;
142
+ color: #94a3b8;
143
+ background: #f1f5f9;
144
+ padding: 0.2rem 0.5rem;
145
+ border-radius: 6px;
146
+ `
147
+
148
+ const IngredientGrid = styled.div`
149
+ display: flex;
150
+ flex-wrap: wrap;
151
+ gap: 0.5rem;
152
+ margin-top: 1rem;
153
+ `
154
+
155
+ const IngredientChip = styled.button`
156
+ background: ${props => props.selected ? '#3b82f6' : '#f1f5f9'};
157
+ color: ${props => props.selected ? 'white' : '#475569'};
158
+ border: none;
159
+ padding: 0.4rem 0.8rem;
160
+ border-radius: 999px;
161
+ font-size: 0.9rem;
162
+ font-weight: 500;
163
+ cursor: pointer;
164
+ transition: all 0.2s;
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 4px;
168
+
169
+ &:hover {
170
+ background: ${props => props.selected ? '#2563eb' : '#e2e8f0'};
171
+ }
172
+ `
173
+
174
+ const ActionButton = styled.button`
175
+ width: 100%;
176
+ margin-top: 1.2rem;
177
+ padding: 0.9rem;
178
+ border-radius: 12px;
179
+ background: linear-gradient(135deg, #3b82f6, #8b5cf6);
180
+ color: white;
181
+ font-size: 1rem;
182
+ border: none;
183
+ cursor: pointer;
184
+ font-family: inherit;
185
+ font-weight: 600;
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ gap: 8px;
190
+
191
+ &:disabled {
192
+ background: #cbd5e1;
193
+ cursor: not-allowed;
194
+ }
195
+ &:hover:not(:disabled) {
196
+ transform: translateY(-2px);
197
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
198
+ }
199
+ `
200
+
201
+ const SliderContainer = styled.div`
202
+ margin-top: 1.2rem;
203
+ padding: 1rem;
204
+ background: #f8fafc;
205
+ border-radius: 12px;
206
+ `
207
+
208
+ const SliderRow = styled.div`
209
+ display: flex;
210
+ align-items: center;
211
+ gap: 0.8rem;
212
+ margin-bottom: 0.8rem;
213
+
214
+ &:last-child {
215
+ margin-bottom: 0;
216
+ }
217
+ `
218
+
219
+ const SliderLabel = styled.div`
220
+ min-width: 100px;
221
+ font-size: 0.85rem;
222
+ color: #475569;
223
+ display: flex;
224
+ align-items: center;
225
+ gap: 4px;
226
+ `
227
+
228
+ const Slider = styled.input`
229
+ flex: 1;
230
+ -webkit-appearance: none;
231
+ height: 5px;
232
+ border-radius: 3px;
233
+ background: #e2e8f0;
234
+ outline: none;
235
+
236
+ &::-webkit-slider-thumb {
237
+ -webkit-appearance: none;
238
+ width: 16px;
239
+ height: 16px;
240
+ border-radius: 50%;
241
+ background: #3b82f6;
242
+ cursor: pointer;
243
+ }
244
+ `
245
+
246
+ const SliderValue = styled.span`
247
+ min-width: 35px;
248
+ text-align: right;
249
+ font-weight: 600;
250
+ color: #3b82f6;
251
+ font-size: 0.9rem;
252
+ `
253
+
254
+ const ResultCard = styled(motion.div)`
255
+ background: white;
256
+ border: 1px solid #e2e8f0;
257
+ border-radius: 14px;
258
+ padding: 1.2rem;
259
+ margin-bottom: 0.8rem;
260
+ `
261
+
262
+ const ResultHeader = styled.div`
263
+ display: flex;
264
+ justify-content: space-between;
265
+ align-items: center;
266
+ margin-bottom: 0.8rem;
267
+ `
268
+
269
+ const ResultName = styled.span`
270
+ font-size: 1.1rem;
271
+ font-weight: 700;
272
+ color: #1e293b;
273
+ `
274
+
275
+ const ScoreBadge = styled.span`
276
+ background: linear-gradient(135deg, #3b82f6, #8b5cf6);
277
+ color: white;
278
+ padding: 0.25rem 0.5rem;
279
+ border-radius: 12px;
280
+ font-weight: 600;
281
+ font-size: 0.8rem;
282
+ `
283
+
284
+ const ResultGrid = styled.div`
285
+ display: grid;
286
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
287
+ gap: 0.8rem;
288
+ margin-top: 0.8rem;
289
+ `
290
+
291
+ const CompactResultCard = styled(motion.div)`
292
+ background: white;
293
+ border: 1px solid #e2e8f0;
294
+ border-radius: 12px;
295
+ padding: 0.8rem 1rem;
296
+ cursor: pointer;
297
+ transition: all 0.2s;
298
+
299
+ &:hover {
300
+ border-color: #3b82f6;
301
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
302
+ }
303
+ `
304
+
305
+ const CompactHeader = styled.div`
306
+ display: flex;
307
+ justify-content: space-between;
308
+ align-items: center;
309
+ `
310
+
311
+ const MedalIcon = styled.span`
312
+ font-size: 1.2rem;
313
+ margin-right: 4px;
314
+ `
315
+
316
+ const ScoreBarMini = styled.div`
317
+ display: flex;
318
+ gap: 2px;
319
+ margin-top: 0.5rem;
320
+ `
321
+
322
+ const ScoreSegment = styled.div`
323
+ height: 4px;
324
+ flex: 1;
325
+ border-radius: 2px;
326
+ background: ${props => props.color};
327
+ opacity: ${props => props.value > 0.3 ? 1 : 0.3};
328
+ `
329
+
330
+ const TabPills = styled.div`
331
+ display: flex;
332
+ gap: 0.4rem;
333
+ flex-wrap: wrap;
334
+ margin-bottom: 1rem;
335
+ `
336
+
337
+ const TabPill = styled.button`
338
+ padding: 0.4rem 0.8rem;
339
+ border-radius: 999px;
340
+ border: 2px solid ${props => props.active ? '#3b82f6' : '#e2e8f0'};
341
+ background: ${props => props.active ? '#eff6ff' : 'white'};
342
+ color: ${props => props.active ? '#3b82f6' : '#64748b'};
343
+ font-weight: 500;
344
+ font-size: 0.85rem;
345
+ cursor: pointer;
346
+ transition: all 0.2s;
347
+
348
+ &:hover {
349
+ border-color: #3b82f6;
350
+ }
351
+ `
352
+
353
+ const ScoreRow = styled.div`
354
+ display: flex;
355
+ align-items: center;
356
+ gap: 0.6rem;
357
+ margin-bottom: 0.4rem;
358
+ `
359
+
360
+ const ScoreLabel = styled.span`
361
+ min-width: 90px;
362
+ font-size: 0.8rem;
363
+ color: #64748b;
364
+ `
365
+
366
+ const ProgressBar = styled.div`
367
+ flex: 1;
368
+ height: 6px;
369
+ background: #e2e8f0;
370
+ border-radius: 3px;
371
+ overflow: hidden;
372
+ `
373
+
374
+ const ProgressFill = styled.div`
375
+ height: 100%;
376
+ background: ${props => props.color || '#3b82f6'};
377
+ width: ${props => props.value}%;
378
+ transition: width 0.5s ease;
379
+ `
380
+
381
+ const Tooltip = styled.div`
382
+ position: relative;
383
+ display: inline-flex;
384
+ cursor: help;
385
+
386
+ &:hover > div {
387
+ display: block;
388
+ }
389
+ `
390
+
391
+ const TooltipContent = styled.div`
392
+ display: none;
393
+ position: absolute;
394
+ bottom: 100%;
395
+ left: 50%;
396
+ transform: translateX(-50%);
397
+ background: #1e293b;
398
+ color: white;
399
+ padding: 0.4rem 0.6rem;
400
+ border-radius: 6px;
401
+ font-size: 0.75rem;
402
+ white-space: nowrap;
403
+ z-index: 100;
404
+ margin-bottom: 4px;
405
+ `
406
+
407
+ const InfoBox = styled.div`
408
+ background: ${props => props.variant === 'purple' ? '#f5f3ff' : '#eff6ff'};
409
+ border-left: 4px solid ${props => props.variant === 'purple' ? '#8b5cf6' : '#3b82f6'};
410
+ padding: 1rem;
411
+ border-radius: 0 10px 10px 0;
412
+ margin: 0.8rem 0;
413
+ font-size: 0.85rem;
414
+ color: ${props => props.variant === 'purple' ? '#5b21b6' : '#1e40af'};
415
+ line-height: 1.6;
416
+ `
417
+
418
+ const pulse = keyframes`
419
+ 0%, 100% { opacity: 1; }
420
+ 50% { opacity: 0.5; }
421
+ `
422
+
423
+ const LoadingText = styled.span`
424
+ animation: ${pulse} 1.5s ease-in-out infinite;
425
+ `
426
+
427
+ const BackButton = styled.button`
428
+ background: none;
429
+ border: none;
430
+ padding: 0;
431
+ color: #64748b;
432
+ display: flex;
433
+ align-items: center;
434
+ gap: 5px;
435
+ cursor: pointer;
436
+ font-size: 0.9rem;
437
+
438
+ &:hover {
439
+ color: #3b82f6;
440
+ }
441
+ `
442
+
443
+ const NavButtons = styled.div`
444
+ display: flex;
445
+ gap: 1rem;
446
+ margin-bottom: 1rem;
447
+ `
448
+
449
+ const HomeButton = styled.button`
450
+ background: #f1f5f9;
451
+ border: none;
452
+ padding: 0.5rem 0.8rem;
453
+ color: #64748b;
454
+ display: flex;
455
+ align-items: center;
456
+ gap: 5px;
457
+ cursor: pointer;
458
+ font-size: 0.9rem;
459
+ border-radius: 8px;
460
+
461
+ &:hover {
462
+ background: #e2e8f0;
463
+ color: #3b82f6;
464
+ }
465
+ `
466
+
467
+ const ToggleHeader = styled.div`
468
+ display: flex;
469
+ align-items: center;
470
+ justify-content: space-between;
471
+ cursor: pointer;
472
+ padding: 0.5rem 0;
473
+ `
474
+
475
+ const TabContainer = styled.div`
476
+ display: flex;
477
+ gap: 0.5rem;
478
+ margin-bottom: 1rem;
479
+ `
480
+
481
+ const Tab = styled.button`
482
+ flex: 1;
483
+ padding: 0.7rem;
484
+ border-radius: 10px;
485
+ border: 2px solid ${props => props.active ? '#3b82f6' : '#e2e8f0'};
486
+ background: ${props => props.active ? '#eff6ff' : 'white'};
487
+ color: ${props => props.active ? '#3b82f6' : '#64748b'};
488
+ font-weight: 600;
489
+ font-size: 0.9rem;
490
+ cursor: pointer;
491
+ transition: all 0.2s;
492
+
493
+ &:hover {
494
+ border-color: #3b82f6;
495
+ }
496
+ `
497
+
498
+ const MultiResultSection = styled.div`
499
+ margin-bottom: 1.5rem;
500
+ padding-bottom: 1rem;
501
+ border-bottom: 1px solid #e2e8f0;
502
+
503
+ &:last-child {
504
+ border-bottom: none;
505
+ margin-bottom: 0;
506
+ padding-bottom: 0;
507
+ }
508
+ `
509
+
510
+ const TargetLabel = styled.div`
511
+ font-size: 1rem;
512
+ font-weight: 600;
513
+ color: #1e293b;
514
+ margin-bottom: 0.8rem;
515
+ display: flex;
516
+ align-items: center;
517
+ gap: 6px;
518
+ `
519
+
520
+ // =========================
521
+ // 🧠 메인 앱 컴포넌트
522
+ // =========================
523
+
524
+ function App() {
525
+ const [step, setStep] = useState('main') // main, detail, result
526
+ const [query, setQuery] = useState('')
527
+ const [searchResults, setSearchResults] = useState([])
528
+ const [allRecipes, setAllRecipes] = useState([])
529
+ const [totalRecipes, setTotalRecipes] = useState(0)
530
+ const [selectedRecipe, setSelectedRecipe] = useState(null)
531
+ const [selectedIngs, setSelectedIngs] = useState([]) // 다중 선택
532
+ const [recommendations, setRecommendations] = useState([])
533
+ const [multiRecommendations, setMultiRecommendations] = useState([]) // 다중 대체 조합 결과
534
+ const [loading, setLoading] = useState(false)
535
+ const [showWeights, setShowWeights] = useState(false)
536
+ const [showAlgorithm, setShowAlgorithm] = useState(false)
537
+ const [activeTab, setActiveTab] = useState('search') // search, browse
538
+ const [activeResultTab, setActiveResultTab] = useState(0) // 다중 재료 결과 탭
539
+ const [expandedCard, setExpandedCard] = useState(null) // 점수 상세 보기
540
+
541
+ // 가중치 상태
542
+ const [weights, setWeights] = useState({
543
+ w2v: 0.5,
544
+ d2v: 0.5,
545
+ method: 0.0,
546
+ cat: 0.0
547
+ })
548
+
549
+ useEffect(() => {
550
+ // 초기 레시피 목록 로드
551
+ listRecipes(30, 0).then(res => {
552
+ setAllRecipes(res.recipes || [])
553
+ setTotalRecipes(res.total || 0)
554
+ })
555
+ }, [])
556
+
557
+ const handleSearch = async (e) => {
558
+ e?.preventDefault()
559
+ if (!query.trim()) return
560
+ setLoading(true)
561
+ const res = await searchRecipes(query)
562
+ setSearchResults(res)
563
+ setLoading(false)
564
+ }
565
+
566
+ const handleSelectRecipe = (recipe) => {
567
+ setSelectedRecipe(recipe)
568
+ setStep('detail')
569
+ setSelectedIngs([])
570
+ setRecommendations([])
571
+ setMultiRecommendations([])
572
+ }
573
+
574
+ const toggleIngredient = (ing) => {
575
+ if (selectedIngs.includes(ing)) {
576
+ setSelectedIngs(selectedIngs.filter(i => i !== ing))
577
+ } else {
578
+ setSelectedIngs([...selectedIngs, ing])
579
+ }
580
+ }
581
+
582
+ const handleRecommend = async () => {
583
+ if (!selectedRecipe || selectedIngs.length === 0) return
584
+ setLoading(true)
585
+
586
+ if (selectedIngs.length === 1) {
587
+ // 단일 추천
588
+ const res = await recommendDbSingle(
589
+ selectedRecipe.id,
590
+ selectedIngs[0],
591
+ weights.w2v,
592
+ weights.d2v,
593
+ weights.method,
594
+ weights.cat
595
+ )
596
+ setRecommendations(res)
597
+ setMultiRecommendations([])
598
+ } else {
599
+ // 다중 추천 - Beam Search 기반 Multi API 사용
600
+ const res = await recommendDbMulti(
601
+ selectedRecipe.id,
602
+ selectedIngs,
603
+ weights.w2v,
604
+ weights.d2v,
605
+ weights.method,
606
+ weights.cat
607
+ )
608
+ setMultiRecommendations(res)
609
+ setRecommendations([])
610
+ }
611
+
612
+ setLoading(false)
613
+ setStep('result')
614
+ }
615
+
616
+ const goBack = () => {
617
+ if (step === 'result') setStep('detail')
618
+ else if (step === 'detail') setStep('main')
619
+ }
620
+
621
+ const resetAll = () => {
622
+ setStep('main')
623
+ setSearchResults([])
624
+ setQuery('')
625
+ setSelectedRecipe(null)
626
+ setSelectedIngs([])
627
+ setRecommendations([])
628
+ setMultiRecommendations([])
629
+ }
630
+
631
+ const loadMoreRecipes = async () => {
632
+ const res = await listRecipes(30, allRecipes.length)
633
+ setAllRecipes([...allRecipes, ...(res.recipes || [])])
634
+ }
635
+
636
+ return (
637
+ <Container>
638
+ <Header onClick={resetAll}>
639
+ <Title><ChefHat size={32} color="#3b82f6" /> K-Recipe2Vec</Title>
640
+ <Subtitle>AI가 추천하는 최적의 대체 재료</Subtitle>
641
+ </Header>
642
+
643
+ <AnimatePresence mode="wait">
644
+ {/* ========== 메인 화면 ========== */}
645
+ {step === 'main' && (
646
+ <motion.div
647
+ key="main"
648
+ initial={{ opacity: 0, y: 20 }}
649
+ animate={{ opacity: 1, y: 0 }}
650
+ exit={{ opacity: 0, y: -20 }}
651
+ >
652
+ {/* 알고리즘 설명 섹션 */}
653
+ <Card>
654
+ <ToggleHeader onClick={() => setShowAlgorithm(!showAlgorithm)}>
655
+ <SectionTitle style={{ margin: 0 }}>
656
+ <BookOpen size={16} /> 이 서비스는 어떻게 작동하나요?
657
+ </SectionTitle>
658
+ {showAlgorithm ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
659
+ </ToggleHeader>
660
+
661
+ {showAlgorithm && (
662
+ <motion.div
663
+ initial={{ opacity: 0, height: 0 }}
664
+ animate={{ opacity: 1, height: 'auto' }}
665
+ >
666
+ <InfoBox variant="purple" style={{ marginTop: '1rem' }}>
667
+ <strong>🧠 K-Recipe2Vec이란?</strong><br />
668
+ 약 8만개의 한식 레시피 데이터를 기반으로 학습된 AI 모델입니다.
669
+ Word2Vec과 Doc2Vec을 활용하여 재료 간의 의미적 유사도와
670
+ 레시피 문맥에서의 상호 대체 가능성을 분석합니다.
671
+ </InfoBox>
672
+
673
+ <div style={{ fontSize: '0.85rem', color: '#475569', lineHeight: 1.8 }}>
674
+ <p><strong>📊 점수 구성 요소:</strong></p>
675
+ <ul style={{ margin: '0.5rem 0', paddingLeft: '1.2rem' }}>
676
+ <li><strong>재료 유사도 (W2V)</strong>: Word2Vec으로 학습한 재료 간 의미적 거리. 예) 돼지고기 ↔ 소고기</li>
677
+ <li><strong>문맥 유사도 (D2V)</strong>: Doc2Vec으로 학습한 레시피 문맥. 같은 요리에서 함께 쓰이는 빈도 반영</li>
678
+ <li><strong>조리법 적합 (Method)</strong>: 찜, 볶음, 구이 등 같은 조리법에서 자주 사용되는 정도</li>
679
+ <li><strong>카테고리 적합 (Category)</strong>: 찌개, 반찬 등 같은 요리 종류에서의 사용 빈도</li>
680
+ </ul>
681
+ <p style={{ marginTop: '0.8rem' }}>
682
+ ⚙️ <strong>고급 설정</strong>에서 각 점수의 가중치를 조절하여 원하는 방향으로 추천 결과를 커스터마이즈할 수 있습니다.
683
+ </p>
684
+ </div>
685
+ </motion.div>
686
+ )}
687
+ </Card>
688
+
689
+ {/* 레시피 선택 */}
690
+ <Card>
691
+ <TabContainer>
692
+ <Tab active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
693
+ <Search size={14} style={{ marginRight: 4 }} /> 요리명 검색
694
+ </Tab>
695
+ <Tab active={activeTab === 'browse'} onClick={() => setActiveTab('browse')}>
696
+ <Utensils size={14} style={{ marginRight: 4 }} /> 전체 레시피
697
+ </Tab>
698
+ </TabContainer>
699
+
700
+ {activeTab === 'search' && (
701
+ <>
702
+ <form onSubmit={handleSearch}>
703
+ <SearchBar>
704
+ <Input
705
+ placeholder="요리 이름 검색 (예: 김치찌개, 된장찌개)"
706
+ value={query}
707
+ onChange={e => setQuery(e.target.value)}
708
+ />
709
+ <SearchBtn type="submit">
710
+ <Search size={16} />
711
+ </SearchBtn>
712
+ </SearchBar>
713
+ </form>
714
+
715
+ <RecipeList>
716
+ {loading && <LoadingText style={{ textAlign: 'center', padding: '1rem' }}>🔍 검색 중...</LoadingText>}
717
+ {searchResults.map(recipe => (
718
+ <RecipeItem
719
+ key={recipe.id}
720
+ onClick={() => handleSelectRecipe(recipe)}
721
+ whileTap={{ scale: 0.98 }}
722
+ >
723
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
724
+ <Utensils size={16} color="#64748b" />
725
+ <span style={{ fontWeight: '600' }}>{recipe.name}</span>
726
+ <RecipeId>#{recipe.id}</RecipeId>
727
+ </div>
728
+ <span style={{ color: '#94a3b8', fontSize: '0.85rem' }}>
729
+ 재료 {recipe.ingredients.length}개
730
+ </span>
731
+ </RecipeItem>
732
+ ))}
733
+ {searchResults.length === 0 && !loading && query && (
734
+ <div style={{ textAlign: 'center', padding: '1.5rem', color: '#94a3b8' }}>
735
+ 검색 결과가 없습니다
736
+ </div>
737
+ )}
738
+ </RecipeList>
739
+ </>
740
+ )}
741
+
742
+ {activeTab === 'browse' && (
743
+ <>
744
+ <div style={{ fontSize: '0.85rem', color: '#64748b', marginBottom: '0.8rem' }}>
745
+ 전체 {totalRecipes.toLocaleString()}개 레시피 중 {allRecipes.length}개 표시
746
+ </div>
747
+ <RecipeList>
748
+ {allRecipes.map(recipe => (
749
+ <RecipeItem
750
+ key={recipe.id}
751
+ onClick={() => handleSelectRecipe(recipe)}
752
+ whileTap={{ scale: 0.98 }}
753
+ >
754
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
755
+ <Utensils size={16} color="#64748b" />
756
+ <span style={{ fontWeight: '600' }}>{recipe.name}</span>
757
+ <RecipeId>#{recipe.id}</RecipeId>
758
+ </div>
759
+ <span style={{ color: '#94a3b8', fontSize: '0.85rem' }}>
760
+ 재료 {recipe.ingredients.length}개
761
+ </span>
762
+ </RecipeItem>
763
+ ))}
764
+ </RecipeList>
765
+ {allRecipes.length < totalRecipes && (
766
+ <ActionButton
767
+ onClick={loadMoreRecipes}
768
+ style={{ marginTop: '1rem', background: '#64748b' }}
769
+ >
770
+ 더 불러오기
771
+ </ActionButton>
772
+ )}
773
+ </>
774
+ )}
775
+ </Card>
776
+ </motion.div>
777
+ )}
778
+
779
+ {/* ========== 재료 선택 단계 ========== */}
780
+ {step === 'detail' && selectedRecipe && (
781
+ <motion.div
782
+ key="detail"
783
+ initial={{ opacity: 0, x: 20 }}
784
+ animate={{ opacity: 1, x: 0 }}
785
+ exit={{ opacity: 0, x: -20 }}
786
+ >
787
+ <Card>
788
+ <NavButtons>
789
+ <BackButton onClick={goBack}>
790
+ <ArrowLeft size={16} /> 뒤로
791
+ </BackButton>
792
+ <HomeButton onClick={resetAll}>
793
+ <Home size={16} /> 홈으로
794
+ </HomeButton>
795
+ </NavButtons>
796
+
797
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '0.3rem' }}>
798
+ <h2 style={{ margin: 0, fontSize: '1.5rem', color: '#1e293b' }}>
799
+ {selectedRecipe.name}
800
+ </h2>
801
+ <RecipeId>#{selectedRecipe.id}</RecipeId>
802
+ </div>
803
+ <p style={{ color: '#64748b', margin: '0 0 0.5rem 0', fontSize: '0.9rem' }}>
804
+ 대체할 재료를 선택하세요 (여러 개 선택 가능)
805
+ </p>
806
+
807
+ <IngredientGrid>
808
+ {selectedRecipe.ingredients.map((ing, idx) => (
809
+ <IngredientChip
810
+ key={idx}
811
+ selected={selectedIngs.includes(ing)}
812
+ onClick={() => toggleIngredient(ing)}
813
+ >
814
+ {selectedIngs.includes(ing) && <Check size={14} />}
815
+ {ing}
816
+ </IngredientChip>
817
+ ))}
818
+ </IngredientGrid>
819
+
820
+ {selectedIngs.length > 0 && (
821
+ <div style={{ marginTop: '1rem', padding: '0.8rem', background: '#f8fafc', borderRadius: '10px' }}>
822
+ <div style={{ fontSize: '0.85rem', color: '#64748b', marginBottom: '0.5rem' }}>
823
+ 선택된 재료 ({selectedIngs.length}개):
824
+ </div>
825
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
826
+ {selectedIngs.map((ing, idx) => (
827
+ <span
828
+ key={idx}
829
+ style={{
830
+ background: '#3b82f6',
831
+ color: 'white',
832
+ padding: '0.3rem 0.6rem',
833
+ borderRadius: '999px',
834
+ fontSize: '0.85rem',
835
+ display: 'flex',
836
+ alignItems: 'center',
837
+ gap: '4px',
838
+ cursor: 'pointer'
839
+ }}
840
+ onClick={() => toggleIngredient(ing)}
841
+ >
842
+ {ing} <X size={12} />
843
+ </span>
844
+ ))}
845
+ </div>
846
+ </div>
847
+ )}
848
+
849
+ {/* 가중치 설정 */}
850
+ <SliderContainer>
851
+ <ToggleHeader onClick={() => setShowWeights(!showWeights)}>
852
+ <SectionTitle style={{ margin: 0 }}>
853
+ <SlidersHorizontal size={14} /> 고급 설정 (가중치 조절)
854
+ </SectionTitle>
855
+ {showWeights ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
856
+ </ToggleHeader>
857
+
858
+ {showWeights && (
859
+ <motion.div
860
+ initial={{ opacity: 0 }}
861
+ animate={{ opacity: 1 }}
862
+ style={{ marginTop: '0.8rem' }}
863
+ >
864
+ <SliderRow>
865
+ <SliderLabel>
866
+ <Tooltip>
867
+ <HelpCircle size={12} />
868
+ <TooltipContent>재료 간 의미적 유사도</TooltipContent>
869
+ </Tooltip>
870
+ 재료 유사도
871
+ </SliderLabel>
872
+ <Slider
873
+ type="range" min="0" max="1" step="0.1"
874
+ value={weights.w2v}
875
+ onChange={e => setWeights({ ...weights, w2v: parseFloat(e.target.value) })}
876
+ />
877
+ <SliderValue>{weights.w2v.toFixed(1)}</SliderValue>
878
+ </SliderRow>
879
+ <SliderRow>
880
+ <SliderLabel>
881
+ <Tooltip>
882
+ <HelpCircle size={12} />
883
+ <TooltipContent>레시피 문맥 유사도</TooltipContent>
884
+ </Tooltip>
885
+ 문맥 유사도
886
+ </SliderLabel>
887
+ <Slider
888
+ type="range" min="0" max="1" step="0.1"
889
+ value={weights.d2v}
890
+ onChange={e => setWeights({ ...weights, d2v: parseFloat(e.target.value) })}
891
+ />
892
+ <SliderValue>{weights.d2v.toFixed(1)}</SliderValue>
893
+ </SliderRow>
894
+ <SliderRow>
895
+ <SliderLabel>
896
+ <Tooltip>
897
+ <HelpCircle size={12} />
898
+ <TooltipContent>조리 방법 적합도</TooltipContent>
899
+ </Tooltip>
900
+ 조리법 적합
901
+ </SliderLabel>
902
+ <Slider
903
+ type="range" min="0" max="1" step="0.1"
904
+ value={weights.method}
905
+ onChange={e => setWeights({ ...weights, method: parseFloat(e.target.value) })}
906
+ />
907
+ <SliderValue>{weights.method.toFixed(1)}</SliderValue>
908
+ </SliderRow>
909
+ <SliderRow>
910
+ <SliderLabel>
911
+ <Tooltip>
912
+ <HelpCircle size={12} />
913
+ <TooltipContent>요리 카테고리 적합도</TooltipContent>
914
+ </Tooltip>
915
+ 카테고리 적합
916
+ </SliderLabel>
917
+ <Slider
918
+ type="range" min="0" max="1" step="0.1"
919
+ value={weights.cat}
920
+ onChange={e => setWeights({ ...weights, cat: parseFloat(e.target.value) })}
921
+ />
922
+ <SliderValue>{weights.cat.toFixed(1)}</SliderValue>
923
+ </SliderRow>
924
+ </motion.div>
925
+ )}
926
+ </SliderContainer>
927
+
928
+ <ActionButton onClick={handleRecommend} disabled={selectedIngs.length === 0 || loading}>
929
+ {loading ? (
930
+ <LoadingText>분석 중...</LoadingText>
931
+ ) : (
932
+ <>
933
+ <Zap size={16} />
934
+ {selectedIngs.length > 0
935
+ ? `${selectedIngs.length}개 재료 대체 추천받기`
936
+ : '재료를 선택해주세요'}
937
+ </>
938
+ )}
939
+ </ActionButton>
940
+ </Card>
941
+ </motion.div>
942
+ )}
943
+
944
+ {/* ========== 결과 단계 ========== */}
945
+ {step === 'result' && (
946
+ <motion.div
947
+ key="result"
948
+ initial={{ opacity: 0, scale: 0.95 }}
949
+ animate={{ opacity: 1, scale: 1 }}
950
+ >
951
+ <Card>
952
+ <NavButtons>
953
+ <BackButton onClick={goBack}>
954
+ <ArrowLeft size={16} /> 뒤로
955
+ </BackButton>
956
+ <HomeButton onClick={resetAll}>
957
+ <Home size={16} /> 홈으로
958
+ </HomeButton>
959
+ </NavButtons>
960
+
961
+ <h2 style={{ fontSize: '1.4rem', display: 'flex', alignItems: 'center', gap: '8px', margin: '0 0 0.3rem 0' }}>
962
+ <Sparkles color="#eab308" fill="#eab308" size={20} /> 이런 재료로 대체해보세요
963
+ </h2>
964
+ <p style={{ color: '#64748b', margin: '0 0 1rem 0', fontSize: '0.9rem' }}>
965
+ <strong>{selectedRecipe.name}</strong> (#{selectedRecipe.id})
966
+ </p>
967
+
968
+ {/* 단일 재료 결과 - 그리드 레이아웃 */}
969
+ {recommendations.length > 0 && (
970
+ <>
971
+ <TargetLabel>
972
+ "{selectedIngs[0]}" → {expandedCard !== null && recommendations[expandedCard]
973
+ ? <span style={{ color: '#3b82f6', fontWeight: '600' }}>{recommendations[expandedCard]['대체재료']}</span>
974
+ : '대체 추천'}
975
+ </TargetLabel>
976
+ <ResultGrid>
977
+ {recommendations.map((rec, idx) => (
978
+ <CompactResultCard
979
+ key={idx}
980
+ initial={{ opacity: 0, y: 10 }}
981
+ animate={{ opacity: 1, y: 0 }}
982
+ transition={{ delay: idx * 0.05 }}
983
+ onClick={() => setExpandedCard(expandedCard === idx ? null : idx)}
984
+ >
985
+ <CompactHeader>
986
+ <ResultName>
987
+ <MedalIcon>
988
+ {idx === 0 && '🥇'}
989
+ {idx === 1 && '🥈'}
990
+ {idx === 2 && '🥉'}
991
+ {idx > 2 && `${idx + 1}.`}
992
+ </MedalIcon>
993
+ {rec['대체재료']}
994
+ </ResultName>
995
+ <ScoreBadge>{(rec['최종점수'] * 100).toFixed(0)}점</ScoreBadge>
996
+ </CompactHeader>
997
+
998
+ <ScoreBarMini>
999
+ <ScoreSegment color="#3b82f6" value={rec['W2V'] || 0} title="W2V" />
1000
+ <ScoreSegment color="#8b5cf6" value={rec['D2V'] || 0} title="D2V" />
1001
+ <ScoreSegment color="#10b981" value={rec['Method'] || 0} title="Method" />
1002
+ <ScoreSegment color="#f59e0b" value={rec['Category'] || 0} title="Category" />
1003
+ </ScoreBarMini>
1004
+
1005
+ {expandedCard === idx && (
1006
+ <motion.div
1007
+ initial={{ opacity: 0, height: 0 }}
1008
+ animate={{ opacity: 1, height: 'auto' }}
1009
+ style={{ marginTop: '0.8rem', paddingTop: '0.8rem', borderTop: '1px solid #e2e8f0' }}
1010
+ >
1011
+ <ScoreRow>
1012
+ <ScoreLabel>재료 유사도</ScoreLabel>
1013
+ <ProgressBar><ProgressFill value={(rec['W2V'] || 0) * 100} color="#3b82f6" /></ProgressBar>
1014
+ <span style={{ minWidth: '30px', textAlign: 'right', fontSize: '0.75rem', color: '#64748b' }}>
1015
+ {((rec['W2V'] || 0) * 100).toFixed(0)}%
1016
+ </span>
1017
+ </ScoreRow>
1018
+ <ScoreRow>
1019
+ <ScoreLabel>문맥 유사도</ScoreLabel>
1020
+ <ProgressBar><ProgressFill value={(rec['D2V'] || 0) * 100} color="#8b5cf6" /></ProgressBar>
1021
+ <span style={{ minWidth: '30px', textAlign: 'right', fontSize: '0.75rem', color: '#64748b' }}>
1022
+ {((rec['D2V'] || 0) * 100).toFixed(0)}%
1023
+ </span>
1024
+ </ScoreRow>
1025
+ <ScoreRow>
1026
+ <ScoreLabel>조리법 적합</ScoreLabel>
1027
+ <ProgressBar><ProgressFill value={(rec['Method'] || 0) * 100} color="#10b981" /></ProgressBar>
1028
+ <span style={{ minWidth: '30px', textAlign: 'right', fontSize: '0.75rem', color: '#64748b' }}>
1029
+ {((rec['Method'] || 0) * 100).toFixed(0)}%
1030
+ </span>
1031
+ </ScoreRow>
1032
+ <ScoreRow>
1033
+ <ScoreLabel>카테고리 적합</ScoreLabel>
1034
+ <ProgressBar><ProgressFill value={(rec['Category'] || 0) * 100} color="#f59e0b" /></ProgressBar>
1035
+ <span style={{ minWidth: '30px', textAlign: 'right', fontSize: '0.75rem', color: '#64748b' }}>
1036
+ {((rec['Category'] || 0) * 100).toFixed(0)}%
1037
+ </span>
1038
+ </ScoreRow>
1039
+ </motion.div>
1040
+ )}
1041
+ </CompactResultCard>
1042
+ ))}
1043
+ </ResultGrid>
1044
+ <p style={{ fontSize: '0.8rem', color: '#94a3b8', marginTop: '0.8rem', textAlign: 'center' }}>
1045
+ 카드를 클릭하면 상세 점수를 확인할 수 있어요
1046
+ </p>
1047
+ </>
1048
+ )}
1049
+
1050
+ {/* 다중 재료 결과 - Beam Search 조합 표시 */}
1051
+ {multiRecommendations.length > 0 && (
1052
+ <>
1053
+ <TargetLabel>
1054
+ {selectedIngs.join(' + ')} → {expandedCard !== null && multiRecommendations[expandedCard]
1055
+ ? <span style={{ color: '#3b82f6', fontWeight: '600' }}>
1056
+ {multiRecommendations[expandedCard].substitutes.join(' + ')}
1057
+ </span>
1058
+ : '최적 대체 조합'}
1059
+ </TargetLabel>
1060
+
1061
+ <div style={{ marginBottom: '1rem' }}>
1062
+ {multiRecommendations.map((combo, idx) => (
1063
+ <CompactResultCard
1064
+ key={idx}
1065
+ initial={{ opacity: 0, y: 10 }}
1066
+ animate={{ opacity: 1, y: 0 }}
1067
+ transition={{ delay: idx * 0.1 }}
1068
+ style={{
1069
+ marginBottom: '0.8rem',
1070
+ border: expandedCard === idx ? '2px solid #3b82f6' : '1px solid #e2e8f0'
1071
+ }}
1072
+ onClick={() => setExpandedCard(expandedCard === idx ? null : idx)}
1073
+ >
1074
+ <CompactHeader>
1075
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1076
+ <MedalIcon>
1077
+ {idx === 0 && '🥇'}
1078
+ {idx === 1 && '🥈'}
1079
+ {idx === 2 && '🥉'}
1080
+ </MedalIcon>
1081
+ <span style={{ fontWeight: '600', color: '#1e293b' }}>
1082
+ 조합 {idx + 1}
1083
+ </span>
1084
+ </div>
1085
+ <ScoreBadge>{(combo.score * 100).toFixed(0)}점</ScoreBadge>
1086
+ </CompactHeader>
1087
+
1088
+ <div style={{
1089
+ marginTop: '0.8rem',
1090
+ display: 'flex',
1091
+ flexWrap: 'wrap',
1092
+ gap: '0.5rem'
1093
+ }}>
1094
+ {selectedIngs.map((origIng, i) => (
1095
+ <div
1096
+ key={i}
1097
+ style={{
1098
+ display: 'flex',
1099
+ alignItems: 'center',
1100
+ gap: '6px',
1101
+ padding: '0.4rem 0.8rem',
1102
+ background: expandedCard === idx ? '#dbeafe' : '#f1f5f9',
1103
+ borderRadius: '8px',
1104
+ fontSize: '0.9rem'
1105
+ }}
1106
+ >
1107
+ <span style={{ color: '#64748b', textDecoration: 'line-through' }}>
1108
+ {origIng}
1109
+ </span>
1110
+ <span style={{ color: '#94a3b8' }}>→</span>
1111
+ <span style={{ fontWeight: '600', color: '#3b82f6' }}>
1112
+ {combo.substitutes[i]}
1113
+ </span>
1114
+ </div>
1115
+ ))}
1116
+ </div>
1117
+
1118
+ {expandedCard === idx && (
1119
+ <motion.div
1120
+ initial={{ opacity: 0, height: 0 }}
1121
+ animate={{ opacity: 1, height: 'auto' }}
1122
+ style={{
1123
+ marginTop: '1rem',
1124
+ paddingTop: '1rem',
1125
+ borderTop: '1px solid #e2e8f0',
1126
+ fontSize: '0.85rem',
1127
+ color: '#475569'
1128
+ }}
1129
+ >
1130
+ <div style={{ marginBottom: '0.5rem', fontWeight: '600', color: '#1e293b' }}>
1131
+ 추천 기준
1132
+ </div>
1133
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
1134
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
1135
+ <span>평균 유사도 점수</span>
1136
+ <span style={{ fontWeight: '600', color: '#3b82f6' }}>{(combo.score * 100).toFixed(1)}%</span>
1137
+ </div>
1138
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
1139
+ <span>조합 순위</span>
1140
+ <span style={{ fontWeight: '600' }}>{idx + 1}위 / {multiRecommendations.length}개</span>
1141
+ </div>
1142
+ <div style={{
1143
+ marginTop: '0.5rem',
1144
+ padding: '0.5rem',
1145
+ background: '#f8fafc',
1146
+ borderRadius: '6px',
1147
+ fontSize: '0.8rem',
1148
+ color: '#64748b'
1149
+ }}>
1150
+ Beam Search가 각 재료의 W2V, D2V, Method, Category 점수를 종합하여 최적의 조합을 선택했습니다.
1151
+ </div>
1152
+ </div>
1153
+ </motion.div>
1154
+ )}
1155
+ </CompactResultCard>
1156
+ ))}
1157
+ </div>
1158
+
1159
+ <p style={{ fontSize: '0.8rem', color: '#94a3b8', textAlign: 'center' }}>
1160
+ 카드를 클릭하면 상세 정보를 확인할 수 있어요
1161
+ </p>
1162
+ </>
1163
+ )}
1164
+
1165
+ {recommendations.length === 0 && multiRecommendations.length === 0 && (
1166
+ <div style={{ textAlign: 'center', padding: '2rem', color: '#94a3b8' }}>
1167
+ 추천 결과가 없습니다. 다른 재료를 선택해 주세요.
1168
+ </div>
1169
+ )}
1170
+ </Card>
1171
+ </motion.div>
1172
+ )}
1173
+ </AnimatePresence>
1174
+ </Container>
1175
+ )
1176
+ }
1177
+
1178
+ export default App
web/src/assets/react.svg ADDED
web/src/index.css ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap');
2
+
3
+ :root {
4
+ font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
5
+ line-height: 1.6;
6
+ font-weight: 400;
7
+
8
+ color-scheme: light;
9
+ color: #1e293b;
10
+
11
+ font-synthesis: none;
12
+ text-rendering: optimizeLegibility;
13
+ -webkit-font-smoothing: antialiased;
14
+ -moz-osx-font-smoothing: grayscale;
15
+ }
16
+
17
+ * {
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ body {
22
+ margin: 0;
23
+ min-width: 320px;
24
+ min-height: 100vh;
25
+ background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 50%, #cbd5e1 100%);
26
+ color: #1e293b;
27
+ font-size: 16px;
28
+ }
29
+
30
+ h1,
31
+ h2,
32
+ h3,
33
+ h4,
34
+ h5,
35
+ h6 {
36
+ line-height: 1.3;
37
+ color: #1e293b;
38
+ margin: 0;
39
+ }
40
+
41
+ button {
42
+ border-radius: 12px;
43
+ border: none;
44
+ padding: 0.6em 1.2em;
45
+ font-size: 1em;
46
+ font-weight: 500;
47
+ font-family: inherit;
48
+ background-color: #3b82f6;
49
+ color: white;
50
+ cursor: pointer;
51
+ transition: all 0.2s ease;
52
+ }
53
+
54
+ button:hover {
55
+ background-color: #2563eb;
56
+ transform: translateY(-1px);
57
+ }
58
+
59
+ button:focus,
60
+ button:focus-visible {
61
+ outline: 3px solid rgba(59, 130, 246, 0.3);
62
+ outline-offset: 2px;
63
+ }
64
+
65
+ input {
66
+ font-family: inherit;
67
+ }
68
+
69
+ div#root {
70
+ width: 100%;
71
+ }
72
+
73
+ /* 스크롤바 스타일 */
74
+ ::-webkit-scrollbar {
75
+ width: 8px;
76
+ }
77
+
78
+ ::-webkit-scrollbar-track {
79
+ background: #f1f5f9;
80
+ }
81
+
82
+ ::-webkit-scrollbar-thumb {
83
+ background: #cbd5e1;
84
+ border-radius: 4px;
85
+ }
86
+
87
+ ::-webkit-scrollbar-thumb:hover {
88
+ background: #94a3b8;
89
+ }
web/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.jsx'
5
+
6
+ createRoot(document.getElementById('root')).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
web/src/services/api.js ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+
3
+ const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
4
+
5
+ const api = axios.create({
6
+ baseURL: API_BASE_URL,
7
+ headers: {
8
+ 'Content-Type': 'application/json',
9
+ },
10
+ });
11
+
12
+ export const healthCheck = async () => {
13
+ try {
14
+ const res = await api.get('/');
15
+ return res.data;
16
+ } catch (error) {
17
+ console.error("Health check failed:", error);
18
+ return null;
19
+ }
20
+ };
21
+
22
+ // 전체 레시피 목록 조회 (페이지네이션)
23
+ export const listRecipes = async (limit = 50, offset = 0) => {
24
+ try {
25
+ const res = await api.get('/recipes', { params: { limit, offset } });
26
+ return res.data;
27
+ } catch (error) {
28
+ console.error("List recipes failed:", error);
29
+ return { total: 0, recipes: [] };
30
+ }
31
+ };
32
+
33
+ export const searchRecipes = async (query) => {
34
+ try {
35
+ const res = await api.get('/recipes/search', { params: { q: query } });
36
+ return res.data;
37
+ } catch (error) {
38
+ console.error("Recipe search failed:", error);
39
+ return [];
40
+ }
41
+ };
42
+
43
+ export const getRecipeDetail = async (id) => {
44
+ try {
45
+ const res = await api.get(`/recipes/${id}`);
46
+ return res.data;
47
+ } catch (error) {
48
+ console.error("Get recipe detail failed:", error);
49
+ return null;
50
+ }
51
+ };
52
+
53
+ // 단일 재료 대체 추천
54
+ export const recommendDbSingle = async (recipeId, target, w2v = 0.5, d2v = 0.5, method = 0.0, cat = 0.0) => {
55
+ try {
56
+ const res = await api.post('/recommend/db/single', {
57
+ recipe_id: recipeId,
58
+ target: [target],
59
+ stopwords: [],
60
+ w_w2v: w2v,
61
+ w_d2v: d2v,
62
+ w_method: method,
63
+ w_cat: cat
64
+ });
65
+ return res.data;
66
+ } catch (error) {
67
+ console.error("DB Single Rec failed:", error);
68
+ return [];
69
+ }
70
+ };
71
+
72
+ // 다중 재료 대체 추천
73
+ export const recommendDbMulti = async (recipeId, targets, w2v = 0.5, d2v = 0.5, method = 0.0, cat = 0.0) => {
74
+ try {
75
+ const res = await api.post('/recommend/db/multi', {
76
+ recipe_id: recipeId,
77
+ target: targets,
78
+ stopwords: [],
79
+ w_w2v: w2v,
80
+ w_d2v: d2v,
81
+ w_method: method,
82
+ w_cat: cat
83
+ });
84
+ return res.data;
85
+ } catch (error) {
86
+ console.error("DB Multi Rec failed:", error);
87
+ return [];
88
+ }
89
+ };
90
+
91
+ // Custom recommendation (사용자 정의 재료)
92
+ export const recommendCustomSingle = async (target, contextIngs) => {
93
+ try {
94
+ const res = await api.post('/recommend/custom/single', {
95
+ context_ings: contextIngs,
96
+ target: [target],
97
+ stopwords: [],
98
+ excluded: []
99
+ });
100
+ return res.data;
101
+ } catch (error) {
102
+ return [];
103
+ }
104
+ };
105
+
106
+ export default api;
web/vite.config.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vite.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ base: '/k-recipe2vec/',
8
+ })