-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathApp.tsx
More file actions
332 lines (292 loc) · 14 KB
/
Copy pathApp.tsx
File metadata and controls
332 lines (292 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
import React, { useState, useCallback } from 'react';
import { Upload, Sparkles, Bookmark, RotateCcw, VibeGoLogo } from './components/Icons';
import { LoadingStatus, VibeResponse, ImageState } from './types';
import { analyzeVibeAndGenerateItinerary, findBudgetDoppelganger, performRealityCheck, refineItinerary } from './services/geminiService';
import LoadingState from './components/LoadingState';
import ResultHeader from './components/ResultHeader';
import ResultCard from './components/ResultCard';
import VibeBreakdown from './components/VibeBreakdown';
import RefineInput from './components/RefineInput';
import RealityCheckWidget from './components/RealityCheckWidget';
const App: React.FC = () => {
const [imageState, setImageState] = useState<ImageState>({
file: null,
previewUrl: null,
base64: null,
mimeType: ''
});
const [status, setStatus] = useState<LoadingStatus>(LoadingStatus.IDLE);
const [result, setResult] = useState<VibeResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<'HOME' | 'CURATED'>('HOME');
const [isSearchingBudget, setIsSearchingBudget] = useState(false);
const [isRefining, setIsRefining] = useState(false);
const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
setError("Please upload a valid image file.");
return;
}
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
// split to get base64 part
const base64Data = result.split(',')[1];
setImageState({
file,
previewUrl: result,
base64: base64Data,
mimeType: file.type
});
setError(null);
setResult(null); // Reset previous result
setStatus(LoadingStatus.IDLE);
};
reader.readAsDataURL(file);
}, []);
const handleAnalyze = async () => {
if (!imageState.base64 || !imageState.mimeType) return;
try {
setStatus(LoadingStatus.ANALYZING);
// Simulate phases for UX (Analysis -> Grounding -> Generating)
setTimeout(() => {
if (status !== LoadingStatus.ERROR && status < LoadingStatus.GROUNDING) setStatus(LoadingStatus.GROUNDING);
}, 1500);
setTimeout(() => {
if (status !== LoadingStatus.ERROR && status < LoadingStatus.GENERATING) setStatus(LoadingStatus.GENERATING);
}, 3000);
// Step 1: Generate Vibe & Itinerary
const data = await analyzeVibeAndGenerateItinerary(imageState.base64, imageState.mimeType);
// Step 2: Perform Reality Check
setStatus(LoadingStatus.REALITY_CHECK);
const realityData = await performRealityCheck(data.location_name, data.itinerary);
// Merge results
const finalResult = { ...data, reality_check: realityData };
setResult(finalResult);
setStatus(LoadingStatus.COMPLETE);
} catch (err) {
console.error(err);
setError("Failed to interpret the vibe. Please try again or use a different image.");
setStatus(LoadingStatus.ERROR);
}
};
const handleBudgetDoppelganger = async () => {
if (!result) return;
try {
setIsSearchingBudget(true);
const budgetData = await findBudgetDoppelganger(result);
// Perform Reality Check on the doppelganger
const realityData = await performRealityCheck(budgetData.location_name, budgetData.itinerary);
setResult({ ...budgetData, reality_check: realityData });
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (err) {
console.error("Doppelganger Error:", err);
// We don't change global error state here to keep the current result visible
} finally {
setIsSearchingBudget(false);
}
};
const handleRefine = async (instruction: string) => {
if (!result) return;
try {
setIsRefining(true);
// Call service to re-plan based on instruction
const refinedData = await refineItinerary(result, instruction);
setResult({
...refinedData,
reality_check: result.reality_check
});
} catch (err) {
console.error("Refine Error:", err);
} finally {
setIsRefining(false);
}
};
const resetApp = () => {
setImageState({ file: null, previewUrl: null, base64: null, mimeType: '' });
setResult(null);
setStatus(LoadingStatus.IDLE);
setError(null);
setView('HOME');
};
const navigateToHome = () => setView('HOME');
const navigateToCurate = () => setView('CURATED');
return (
<div className="min-h-screen bg-[#F8F9FA] text-gray-900 font-sans">
{/* Navigation */}
<nav className="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2 cursor-pointer group" onClick={navigateToHome}>
<div className="group-hover:rotate-12 transition-transform duration-300">
<VibeGoLogo className="w-9 h-9 md:w-10 md:h-10 shadow-sm" />
</div>
<span className="font-bold text-xl tracking-tighter text-black">VibeGo</span>
</div>
<div className="flex items-center gap-4">
<button
className="bg-white text-gray-900 border border-gray-200 px-4 py-2 rounded-full text-sm font-bold hover:bg-gray-50 transition-colors shadow-sm hidden md:block"
>
VibeGo Ranking
</button>
<button
onClick={navigateToCurate}
className={`bg-white text-gray-900 border border-gray-200 px-4 py-2 rounded-full text-sm font-bold hover:bg-gray-50 transition-colors shadow-sm ${view === 'CURATED' ? 'bg-gray-100 ring-2 ring-gray-200' : ''}`}
>
Curate
</button>
<button
onClick={resetApp}
className="bg-white text-gray-900 border border-gray-200 px-4 py-2 rounded-full text-sm font-bold hover:bg-gray-50 transition-colors flex items-center gap-2 shadow-sm"
>
<RotateCcw className="w-3 h-3" />
<span className="hidden md:inline">New Experience</span>
<span className="md:hidden">New</span>
</button>
</div>
</div>
</nav>
{/* Main Content */}
<main className={`mx-auto px-6 py-12 ${result ? 'max-w-[90rem]' : 'max-w-6xl'}`}>
{view === 'CURATED' ? (
<div className="flex flex-col items-center justify-center py-20 text-center space-y-6 animate-in fade-in duration-500">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center">
<Bookmark className="w-10 h-10 text-gray-400" />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-bold text-gray-900">Your Curated Vibes</h2>
<p className="text-gray-500 max-w-md mx-auto">
Save your favorite generated itineraries here to build your personal travel wishlist.
</p>
</div>
<button
onClick={navigateToHome}
className="mt-4 px-6 py-3 bg-gray-900 text-white rounded-xl font-bold hover:bg-indigo-600 transition-colors"
>
Start Exploring
</button>
</div>
) : (
<>
{/* Hero Section (Only visible when no result) */}
{!result && status !== LoadingStatus.COMPLETE && status !== LoadingStatus.REALITY_CHECK && (
<div className="text-center mb-12 space-y-4 animate-in slide-in-from-bottom-4 duration-500">
<h1 className="text-5xl md:text-6xl font-black text-gray-900 tracking-tight leading-tight">
Image-to-<span className="text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-purple-600">Experience</span>
</h1>
<p className="text-xl text-gray-500 max-w-3xl mx-auto font-light leading-relaxed">
Turn any static image—movie scene, painting, or photo—into a bookable, real-world adventure using Gemini’s multimodal reasoning.
</p>
</div>
)}
{/* Interaction Area (Centered when no result) */}
{!result && (
<div className="flex flex-col items-center justify-center w-full max-w-2xl mx-auto">
{/* Upload State */}
{status === LoadingStatus.IDLE && (
<div className="w-full bg-white rounded-3xl shadow-soft border border-gray-100 overflow-hidden transition-all hover:shadow-lg">
{imageState.previewUrl ? (
<div className="relative group">
<img
src={imageState.previewUrl}
alt="Preview"
className="w-full h-80 object-cover"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={resetApp}
className="bg-white text-gray-900 px-6 py-2 rounded-full font-bold hover:bg-gray-100 transition-colors"
>
Change Image
</button>
</div>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-80 cursor-pointer bg-gray-50 hover:bg-gray-100 transition-colors border-2 border-dashed border-gray-200 hover:border-indigo-400 group">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<div className="p-4 bg-white rounded-full shadow-sm mb-4 group-hover:scale-110 transition-transform duration-300">
<Upload className="w-8 h-8 text-indigo-600" />
</div>
<p className="mb-2 text-lg font-semibold text-gray-700">Click to upload visual inspiration</p>
<p className="text-sm text-gray-400">PNG, JPG, WEBP (Max 10MB)</p>
</div>
<input type="file" className="hidden" accept="image/*" onChange={handleFileChange} />
</label>
)}
{/* Action Bar */}
<div className="p-6 bg-white border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-gray-500">
<Sparkles className="w-4 h-4 text-indigo-500" />
<span>AI-Powered Vibe Matching</span>
</div>
<button
onClick={handleAnalyze}
disabled={!imageState.file}
className={`
px-8 py-3 rounded-xl font-bold text-white transition-all transform duration-200
${imageState.file
? 'bg-gray-900 hover:bg-indigo-600 hover:scale-[1.02] shadow-lg hover:shadow-indigo-500/30'
: 'bg-gray-200 cursor-not-allowed text-gray-400'}
`}
>
Scout Location
</button>
</div>
</div>
)}
{/* Loading View */}
{status !== LoadingStatus.IDLE && status !== LoadingStatus.COMPLETE && status !== LoadingStatus.ERROR && (
<div className="w-full bg-white rounded-3xl shadow-soft border border-gray-100 p-8">
<LoadingState status={status} />
</div>
)}
{/* Error View */}
{status === LoadingStatus.ERROR && (
<div className="w-full bg-red-50 rounded-3xl border border-red-100 p-8 text-center">
<p className="text-red-600 font-medium mb-4">{error}</p>
<button
onClick={resetApp}
className="px-6 py-2 bg-white text-red-600 font-bold rounded-lg shadow-sm hover:bg-red-50 border border-red-100"
>
Try Again
</button>
</div>
)}
</div>
)}
{/* Results Grid View */}
{result && status === LoadingStatus.COMPLETE && (
<div className="animate-in slide-in-from-bottom-8 duration-700 space-y-8">
{/* 1. Header Section */}
<ResultHeader
data={result}
originalImage={imageState.previewUrl}
onDoppelganger={handleBudgetDoppelganger}
isDoppelgangerLoading={isSearchingBudget}
/>
{/* 2. Feasibility Scan (Full Width) */}
<div className="w-full">
<RealityCheckWidget realityCheck={result.reality_check} />
</div>
{/* 3. Itinerary Cards Section (Grid containing Days + Refine) */}
<div>
<ResultCard
data={result}
actionCard={
<RefineInput onRefine={handleRefine} isLoading={isRefining} />
}
/>
</div>
{/* 4. Vibe Breakdown (Full Width) */}
<div className="w-full">
<VibeBreakdown data={result} />
</div>
</div>
)}
</>
)}
</main>
</div>
);
};
export default App;