KingCam326 commited on
Commit
9aaa179
·
verified ·
1 Parent(s): 8aec713

Upload 21 files

Browse files
.env.local ADDED
@@ -0,0 +1 @@
 
 
1
+ GEMINI_API_KEY=PLACEHOLDER_API_KEY
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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?
App.tsx ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useCallback } from 'react';
2
+ import { MediaType, Recommendation } from './types';
3
+ import { GENRES } from './constants';
4
+ import { fetchTitles, fetchRecommendations } from './services/geminiService';
5
+ import MediaTypeSelector from './components/MediaTypeSelector';
6
+ import GenreSelector from './components/GenreSelector';
7
+ import TitleSelector from './components/TitleSelector';
8
+ import RecommendationsDisplay from './components/RecommendationsDisplay';
9
+ import LoadingSpinner from './components/shared/LoadingSpinner';
10
+ import StepIndicator from './components/StepIndicator';
11
+
12
+ const App: React.FC = () => {
13
+ const [step, setStep] = useState(1);
14
+ const [mediaType, setMediaType] = useState<MediaType | null>(null);
15
+ const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
16
+ const [suggestedTitles, setSuggestedTitles] = useState<string[]>([]);
17
+ const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
18
+ const [isLoading, setIsLoading] = useState(false);
19
+ const [error, setError] = useState<string | null>(null);
20
+ const [isGamePassOnly, setIsGamePassOnly] = useState(false);
21
+ const [isMultiplayerOnly, setIsMultiplayerOnly] = useState(false);
22
+
23
+ const handleReset = useCallback(() => {
24
+ setStep(1);
25
+ setMediaType(null);
26
+ setSelectedGenres([]);
27
+ setSuggestedTitles([]);
28
+ setRecommendations([]);
29
+ setIsLoading(false);
30
+ setError(null);
31
+ setIsGamePassOnly(false);
32
+ setIsMultiplayerOnly(false);
33
+ }, []);
34
+
35
+ const handleBack = useCallback(() => {
36
+ if (step > 1) {
37
+ setError(null);
38
+ setStep(prev => prev - 1);
39
+ }
40
+ }, [step]);
41
+
42
+ const handleSelectMediaType = useCallback((type: MediaType) => {
43
+ setMediaType(type);
44
+ if (type !== MediaType.VideoGames) {
45
+ setIsGamePassOnly(false);
46
+ setIsMultiplayerOnly(false);
47
+ }
48
+ setStep(2);
49
+ }, []);
50
+
51
+ const handleSelectGenres = useCallback(async (genres: string[]) => {
52
+ if (!mediaType) return;
53
+ setSelectedGenres(genres);
54
+ setStep(3);
55
+ setIsLoading(true);
56
+ setError(null);
57
+ try {
58
+ const titles = await fetchTitles(mediaType, genres, mediaType === MediaType.VideoGames ? isGamePassOnly : undefined, mediaType === MediaType.VideoGames ? isMultiplayerOnly : undefined);
59
+ setSuggestedTitles(titles);
60
+ } catch (err) {
61
+ console.error(err);
62
+ setError('Could not fetch title suggestions. Please go back and try again.');
63
+ setStep(2);
64
+ } finally {
65
+ setIsLoading(false);
66
+ }
67
+ }, [mediaType, isGamePassOnly, isMultiplayerOnly]);
68
+
69
+ const handleSelectTitles = useCallback(async (titles: string[]) => {
70
+ if (!mediaType) return;
71
+ setStep(4);
72
+ setIsLoading(true);
73
+ setError(null);
74
+ try {
75
+ const recs = await fetchRecommendations(mediaType, titles, mediaType === MediaType.VideoGames ? isGamePassOnly : undefined, mediaType === MediaType.VideoGames ? isMultiplayerOnly : undefined);
76
+ setRecommendations(recs);
77
+ } catch (err) {
78
+ console.error(err);
79
+ setError('Could not fetch recommendations. Please go back and try again.');
80
+ setStep(3);
81
+ } finally {
82
+ setIsLoading(false);
83
+ }
84
+ }, [mediaType, isGamePassOnly, isMultiplayerOnly]);
85
+
86
+ const renderStep = () => {
87
+ if (isLoading && step > 2) {
88
+ return (
89
+ <div className="flex flex-col items-center justify-center h-64">
90
+ <LoadingSpinner />
91
+ <p className="mt-4 text-cyan-400">AI is thinking...</p>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ switch (step) {
97
+ case 1:
98
+ return <MediaTypeSelector onSelect={handleSelectMediaType} />;
99
+ case 2:
100
+ return mediaType ? <GenreSelector
101
+ mediaType={mediaType}
102
+ genres={GENRES[mediaType]}
103
+ onNext={handleSelectGenres}
104
+ onBack={handleBack}
105
+ isGamePassOnly={isGamePassOnly}
106
+ onGamePassToggle={setIsGamePassOnly}
107
+ isMultiplayerOnly={isMultiplayerOnly}
108
+ onMultiplayerToggle={setIsMultiplayerOnly}
109
+ /> : null;
110
+ case 3:
111
+ return <TitleSelector titles={suggestedTitles} onNext={handleSelectTitles} onBack={handleBack} />;
112
+ case 4:
113
+ return <RecommendationsDisplay recommendations={recommendations} onReset={handleReset} />;
114
+ default:
115
+ return null;
116
+ }
117
+ };
118
+
119
+ return (
120
+ <div className="min-h-screen bg-slate-900 flex flex-col items-center p-4 sm:p-6 lg:p-8">
121
+ <div className="w-full max-w-4xl mx-auto">
122
+ <header className="text-center mb-8">
123
+ <h1 className="text-4xl sm:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-teal-500">
124
+ FlixFinder
125
+ </h1>
126
+ <p className="text-slate-400 mt-2">Your personal guide to movies, TV, and games.</p>
127
+ </header>
128
+
129
+ <main className="bg-slate-800/50 rounded-xl shadow-2xl shadow-slate-950/50 p-6 sm:p-8 backdrop-blur-sm border border-slate-700">
130
+ <StepIndicator currentStep={step} totalSteps={4} />
131
+ {error && (
132
+ <div className="bg-red-900/50 border border-red-700 text-red-300 p-3 rounded-md mb-6 text-center">
133
+ {error}
134
+ </div>
135
+ )}
136
+ <div className="mt-6">
137
+ {renderStep()}
138
+ </div>
139
+ </main>
140
+
141
+ <footer className="text-center mt-8 text-slate-500 text-sm">
142
+ <p>Powered by Gemini API</p>
143
+ </footer>
144
+ </div>
145
+ </div>
146
+ );
147
+ };
148
+
149
+ export default App;
README.md CHANGED
@@ -1,14 +1,20 @@
1
- ---
2
- title: FlixFinder
3
- emoji: 🌍
4
- colorFrom: green
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 5.44.1
8
- app_file: app.py
9
- pinned: false
10
- license: apache-2.0
11
- short_description: An AI-powered recommendation engine for films, TV shows, and
12
- ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
1
+ <div align="center">
2
+ <img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
3
+ </div>
4
+
5
+ # Run and deploy your AI Studio app
6
+
7
+ This contains everything you need to run your app locally.
8
+
9
+ View your app in AI Studio: https://ai.studio/apps/drive/1g4g1I4cYdhx_-QqsBmvgB0zH0Z3xSOse
10
+
11
+ ## Run Locally
12
+
13
+ **Prerequisites:** Node.js
14
+
15
+
16
+ 1. Install dependencies:
17
+ `npm install`
18
+ 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
19
+ 3. Run the app:
20
+ `npm run dev`
components/GenreSelector.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { MediaType } from '../types';
3
+ import Button from './shared/Button';
4
+
5
+ interface GenreSelectorProps {
6
+ mediaType: MediaType;
7
+ genres: string[];
8
+ onNext: (selectedGenres: string[]) => void;
9
+ onBack: () => void;
10
+ isGamePassOnly: boolean;
11
+ onGamePassToggle: (value: boolean) => void;
12
+ isMultiplayerOnly: boolean;
13
+ onMultiplayerToggle: (value: boolean) => void;
14
+ }
15
+
16
+ const GenreSelector: React.FC<GenreSelectorProps> = ({
17
+ mediaType,
18
+ genres,
19
+ onNext,
20
+ onBack,
21
+ isGamePassOnly,
22
+ onGamePassToggle,
23
+ isMultiplayerOnly,
24
+ onMultiplayerToggle
25
+ }) => {
26
+ const [selectedGenres, setSelectedGenres] = useState<Set<string>>(new Set());
27
+
28
+ const toggleGenre = (genre: string) => {
29
+ setSelectedGenres(prev => {
30
+ const newSet = new Set(prev);
31
+ if (newSet.has(genre)) {
32
+ newSet.delete(genre);
33
+ } else {
34
+ newSet.add(genre);
35
+ }
36
+ return newSet;
37
+ });
38
+ };
39
+
40
+ const handleNext = () => {
41
+ onNext(Array.from(selectedGenres));
42
+ };
43
+
44
+ return (
45
+ <div className="animate-fade-in">
46
+ <h2 className="text-2xl font-bold text-center mb-2 text-slate-200">Select Genres for {mediaType}</h2>
47
+ <p className="text-center text-slate-400 mb-6">Choose one or more genres you're interested in.</p>
48
+
49
+ {mediaType === MediaType.VideoGames && (
50
+ <div className="flex flex-col items-center justify-center my-6 space-y-4 bg-slate-700/50 p-4 rounded-lg border border-slate-700">
51
+ <div className="flex items-center w-full">
52
+ <input
53
+ type="checkbox"
54
+ id="gamepass-toggle"
55
+ className="h-5 w-5 rounded bg-slate-600 border-slate-500 text-cyan-500 focus:ring-cyan-500 cursor-pointer"
56
+ checked={isGamePassOnly}
57
+ onChange={(e) => onGamePassToggle(e.target.checked)}
58
+ />
59
+ <label htmlFor="gamepass-toggle" className="ml-3 text-slate-300 font-medium cursor-pointer select-none">
60
+ Only find games available on Game Pass (PC & Console)
61
+ </label>
62
+ </div>
63
+ <div className="flex items-center w-full">
64
+ <input
65
+ type="checkbox"
66
+ id="multiplayer-toggle"
67
+ className="h-5 w-5 rounded bg-slate-600 border-slate-500 text-cyan-500 focus:ring-cyan-500 cursor-pointer"
68
+ checked={isMultiplayerOnly}
69
+ onChange={(e) => onMultiplayerToggle(e.target.checked)}
70
+ />
71
+ <label htmlFor="multiplayer-toggle" className="ml-3 text-slate-300 font-medium cursor-pointer select-none">
72
+ Only find multiplayer games
73
+ </label>
74
+ </div>
75
+ </div>
76
+ )}
77
+
78
+ <div className="flex flex-wrap justify-center gap-3 mb-8">
79
+ {genres.map(genre => (
80
+ <button
81
+ key={genre}
82
+ onClick={() => toggleGenre(genre)}
83
+ className={`px-4 py-2 rounded-full text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-800 focus:ring-cyan-500 ${
84
+ selectedGenres.has(genre)
85
+ ? 'bg-cyan-500 text-white'
86
+ : 'bg-slate-700 hover:bg-slate-600 text-slate-300'
87
+ }`}
88
+ >
89
+ {genre}
90
+ </button>
91
+ ))}
92
+ </div>
93
+
94
+ <div className="flex justify-between items-center">
95
+ <Button onClick={onBack} variant="secondary">Back</Button>
96
+ <Button onClick={handleNext} disabled={selectedGenres.size === 0}>
97
+ Next: Find Titles
98
+ </Button>
99
+ </div>
100
+ </div>
101
+ );
102
+ };
103
+
104
+ export default GenreSelector;
components/MediaTypeSelector.tsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { MediaType } from '../types';
4
+ import Card from './shared/Card';
5
+
6
+ interface MediaTypeSelectorProps {
7
+ onSelect: (type: MediaType) => void;
8
+ }
9
+
10
+ const MediaOption: React.FC<{ type: MediaType; icon: JSX.Element; onSelect: (type: MediaType) => void }> = ({ type, icon, onSelect }) => (
11
+ <Card
12
+ onClick={() => onSelect(type)}
13
+ className="cursor-pointer text-center transform transition-transform duration-300 hover:scale-105 hover:bg-slate-700/80 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-slate-800"
14
+ isHoverable={true}
15
+ >
16
+ <div className="text-cyan-400 w-16 h-16 mx-auto mb-4">
17
+ {icon}
18
+ </div>
19
+ <h3 className="text-xl font-semibold text-slate-100">{type}</h3>
20
+ </Card>
21
+ );
22
+
23
+ const MovieIcon = () => (
24
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
25
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5m-7.5-13.5h15M3.75 5.25h16.5M3.75 9.75h16.5M3.75 14.25h16.5M3.75 18.75h16.5" />
26
+ </svg>
27
+ );
28
+
29
+ const TvIcon = () => (
30
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
31
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 20.25h12m-7.5-3.75v3.75m-3.75-3.75v3.75m-3.75-3.75h15a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5h-15a1.5 1.5 0 00-1.5 1.5v9a1.5 1.5 0 001.5 1.5z" />
32
+ </svg>
33
+ );
34
+
35
+ const GameIcon = () => (
36
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
37
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
38
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m-3.75-10.5h7.5" />
39
+ </svg>
40
+ );
41
+
42
+
43
+ const MediaTypeSelector: React.FC<MediaTypeSelectorProps> = ({ onSelect }) => {
44
+ return (
45
+ <div className="animate-fade-in">
46
+ <h2 className="text-2xl font-bold text-center mb-6 text-slate-200">What are you in the mood for?</h2>
47
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
48
+ <MediaOption type={MediaType.Movies} icon={<MovieIcon />} onSelect={onSelect} />
49
+ <MediaOption type={MediaType.TVShows} icon={<TvIcon />} onSelect={onSelect} />
50
+ <MediaOption type={MediaType.VideoGames} icon={<GameIcon />} onSelect={onSelect} />
51
+ </div>
52
+ </div>
53
+ );
54
+ };
55
+
56
+ export default MediaTypeSelector;
components/RecommendationsDisplay.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Recommendation } from '../types';
3
+ import Button from './shared/Button';
4
+ import Card from './shared/Card';
5
+
6
+ interface RecommendationsDisplayProps {
7
+ recommendations: Recommendation[];
8
+ onReset: () => void;
9
+ }
10
+
11
+ const Rating: React.FC<{ value: number }> = ({ value }) => {
12
+ const color = value >= 8 ? 'text-green-400' : value >= 6 ? 'text-yellow-400' : 'text-red-400';
13
+ return (
14
+ <div className="flex items-center gap-1 font-bold">
15
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={`w-5 h-5 ${color}`}>
16
+ <path fillRule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.007z" clipRule="evenodd" />
17
+ </svg>
18
+ <span className={`${color} text-lg`}>{value.toFixed(1)}</span>
19
+ <span className="text-slate-400 text-sm">/ 10</span>
20
+ </div>
21
+ );
22
+ };
23
+
24
+ const PlayerCountIcon = () => (
25
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
26
+ <path d="M10 8a3 3 0 100-6 3 3 0 000 6zM3.465 14.493a1.23 1.23 0 00.41 1.412A9.957 9.957 0 0010 18c2.31 0 4.438-.784 6.131-2.095a1.23 1.23 0 00.41-1.412A9.992 9.992 0 0010 12c-2.31 0-4.438.784-6.131 2.095z" />
27
+ </svg>
28
+ );
29
+
30
+ const LivePlayerIcon = () => (
31
+ <svg viewBox="0 0 16 16" fill="currentColor" className="w-4 h-4 text-green-500">
32
+ <circle cx="8" cy="8" r="6" />
33
+ </svg>
34
+ );
35
+
36
+ const StreamingIcon = () => (
37
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-5 h-5">
38
+ <path d="M6.25 3.75a2 2 0 00-2 2v8a2 2 0 002 2h7.5a2 2 0 002-2v-8a2 2 0 00-2-2h-7.5zm.75 2.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-4.5z" />
39
+ </svg>
40
+ );
41
+
42
+
43
+ const RecommendationsDisplay: React.FC<RecommendationsDisplayProps> = ({ recommendations, onReset }) => {
44
+ return (
45
+ <div className="animate-fade-in">
46
+ <h2 className="text-2xl font-bold text-center mb-6 text-slate-200">Here are your recommendations!</h2>
47
+
48
+ <div className="space-y-6 mb-8">
49
+ {recommendations.map((rec, index) => (
50
+ <Card key={index}>
51
+ <div className="flex flex-col sm:flex-row justify-between sm:items-start mb-2">
52
+ <h3 className="text-xl font-bold text-cyan-400 mb-2 sm:mb-0">{rec.title}</h3>
53
+ <div className="flex-shrink-0 sm:text-right">
54
+ <Rating value={rec.rating} />
55
+ {rec.playerCount !== undefined && (
56
+ <div className="flex items-center sm:justify-end gap-1.5 mt-2 text-slate-400 text-sm">
57
+ <PlayerCountIcon />
58
+ <span className="font-medium text-slate-300">{rec.playerCount.toLocaleString()}</span>
59
+ <span>peak players</span>
60
+ </div>
61
+ )}
62
+ {rec.livePlayerCount !== undefined && (
63
+ <div className="flex items-center sm:justify-end gap-1.5 mt-1 text-slate-400 text-sm">
64
+ <LivePlayerIcon />
65
+ <span className="font-medium text-green-400">{rec.livePlayerCount.toLocaleString()}</span>
66
+ <span>live players</span>
67
+ </div>
68
+ )}
69
+ </div>
70
+ </div>
71
+ <div className="flex flex-wrap gap-2 my-3">
72
+ {rec.genres.map(genre => (
73
+ <span key={genre} className="px-2 py-1 bg-slate-700 text-slate-300 text-xs font-medium rounded-full">{genre}</span>
74
+ ))}
75
+ </div>
76
+ <p className="text-slate-300 text-sm">{rec.synopsis}</p>
77
+ {rec.streamingOn && rec.streamingOn.length > 0 && (
78
+ <div className="mt-4 pt-4 border-t border-slate-700/50">
79
+ <div className="flex items-center gap-2 mb-2">
80
+ <StreamingIcon />
81
+ <h4 className="text-sm font-semibold text-slate-300">Available on:</h4>
82
+ </div>
83
+ <div className="flex flex-wrap gap-2">
84
+ {rec.streamingOn.map(service => (
85
+ <span key={service} className="px-2.5 py-1 bg-slate-600 text-slate-200 text-xs font-semibold rounded-md">{service}</span>
86
+ ))}
87
+ </div>
88
+ </div>
89
+ )}
90
+ </Card>
91
+ ))}
92
+ </div>
93
+
94
+ <div className="text-center">
95
+ <Button onClick={onReset} size="large">Start Over</Button>
96
+ </div>
97
+ </div>
98
+ );
99
+ };
100
+
101
+ export default RecommendationsDisplay;
components/StepIndicator.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+
4
+ interface StepIndicatorProps {
5
+ currentStep: number;
6
+ totalSteps: number;
7
+ }
8
+
9
+ const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, totalSteps }) => {
10
+ const getStepName = (step: number) => {
11
+ switch(step) {
12
+ case 1: return "Select Media Type";
13
+ case 2: return "Select Genres";
14
+ case 3: return "Choose Your Favorites";
15
+ case 4: return "Your Recommendations";
16
+ default: return "";
17
+ }
18
+ };
19
+
20
+ return (
21
+ <div className="w-full mb-6">
22
+ <div className="flex items-center justify-between mb-2">
23
+ <span className="text-sm font-medium text-cyan-400">{getStepName(currentStep)}</span>
24
+ <span className="text-sm text-slate-400">Step {currentStep} of {totalSteps}</span>
25
+ </div>
26
+ <div className="w-full bg-slate-700 rounded-full h-2">
27
+ <div
28
+ className="bg-cyan-500 h-2 rounded-full transition-all duration-500 ease-out"
29
+ style={{ width: `${(currentStep / totalSteps) * 100}%` }}
30
+ />
31
+ </div>
32
+ </div>
33
+ );
34
+ };
35
+
36
+ export default StepIndicator;
components/TitleSelector.tsx ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import Button from './shared/Button';
3
+ import Card from './shared/Card';
4
+
5
+ interface TitleSelectorProps {
6
+ titles: string[];
7
+ onNext: (selectedTitles: string[]) => void;
8
+ onBack: () => void;
9
+ }
10
+
11
+ const TitleSelector: React.FC<TitleSelectorProps> = ({ titles, onNext, onBack }) => {
12
+ const [selectedTitles, setSelectedTitles] = useState<Set<string>>(new Set());
13
+ const [userAddedTitles, setUserAddedTitles] = useState<Set<string>>(new Set());
14
+ const [inputValue, setInputValue] = useState('');
15
+
16
+ const toggleTitle = (title: string) => {
17
+ // A title from the suggested list cannot be a user-added title and vice-versa
18
+ if (userAddedTitles.has(title)) return;
19
+
20
+ setSelectedTitles(prev => {
21
+ const newSet = new Set(prev);
22
+ if (newSet.has(title)) {
23
+ newSet.delete(title);
24
+ } else {
25
+ newSet.add(title);
26
+ }
27
+ return newSet;
28
+ });
29
+ };
30
+
31
+ const handleAddTitle = (e: React.FormEvent) => {
32
+ e.preventDefault();
33
+ const newTitle = inputValue.trim();
34
+ if (newTitle) {
35
+ setUserAddedTitles(prev => new Set(prev).add(newTitle));
36
+ setSelectedTitles(prev => new Set(prev).add(newTitle));
37
+ setInputValue('');
38
+ }
39
+ };
40
+
41
+ const handleRemoveUserTitle = (title: string) => {
42
+ setUserAddedTitles(prev => {
43
+ const newSet = new Set(prev);
44
+ newSet.delete(title);
45
+ return newSet;
46
+ });
47
+ setSelectedTitles(prev => {
48
+ const newSet = new Set(prev);
49
+ newSet.delete(title);
50
+ return newSet;
51
+ });
52
+ };
53
+
54
+ const handleNext = () => {
55
+ onNext(Array.from(selectedTitles));
56
+ };
57
+
58
+ return (
59
+ <div className="animate-fade-in">
60
+ <h2 className="text-2xl font-bold text-center mb-2 text-slate-200">Choose Your Favorites</h2>
61
+ <p className="text-center text-slate-400 mb-6">Select titles you've enjoyed, or add your own.</p>
62
+
63
+ <form onSubmit={handleAddTitle} className="flex gap-2 mb-4">
64
+ <input
65
+ type="text"
66
+ value={inputValue}
67
+ onChange={(e) => setInputValue(e.target.value)}
68
+ placeholder="Add a title we missed..."
69
+ className="flex-grow bg-slate-700 border border-slate-600 text-slate-200 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500"
70
+ />
71
+ <Button type="submit" disabled={!inputValue.trim()}>
72
+ Add
73
+ </Button>
74
+ </form>
75
+
76
+ {userAddedTitles.size > 0 && (
77
+ <div className="mb-4 p-3 bg-slate-900/50 rounded-lg">
78
+ <h3 className="text-sm font-semibold text-slate-400 mb-2">Your added titles:</h3>
79
+ <div className="flex flex-wrap gap-2">
80
+ {Array.from(userAddedTitles).map(title => (
81
+ <span key={title} className="flex items-center gap-2 bg-teal-600/50 text-teal-200 px-3 py-1 rounded-full text-sm font-medium">
82
+ {title}
83
+ <button onClick={() => handleRemoveUserTitle(title)} className="text-teal-200 hover:text-white focus:outline-none">
84
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
85
+ <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
86
+ </svg>
87
+ </button>
88
+ </span>
89
+ ))}
90
+ </div>
91
+ </div>
92
+ )}
93
+
94
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 mb-8 max-h-80 overflow-y-auto p-2 bg-slate-900/50 rounded-lg">
95
+ {titles.map(title => (
96
+ <Card
97
+ key={title}
98
+ onClick={() => toggleTitle(title)}
99
+ className={`cursor-pointer h-full flex items-center justify-center p-3 text-center text-sm font-medium transition-all duration-200 ${
100
+ selectedTitles.has(title) && !userAddedTitles.has(title)
101
+ ? 'bg-cyan-600 border-cyan-400 ring-2 ring-cyan-400'
102
+ : 'bg-slate-700 hover:bg-slate-600 border-slate-600'
103
+ }`}
104
+ >
105
+ {title}
106
+ </Card>
107
+ ))}
108
+ </div>
109
+
110
+ <div className="flex justify-between items-center">
111
+ <Button onClick={onBack} variant="secondary">Back</Button>
112
+ <Button onClick={handleNext} disabled={selectedTitles.size === 0}>
113
+ Get Recommendations
114
+ </Button>
115
+ </div>
116
+ </div>
117
+ );
118
+ };
119
+
120
+ export default TitleSelector;
components/shared/Button.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+
4
+ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
5
+ children: React.ReactNode;
6
+ variant?: 'primary' | 'secondary';
7
+ size?: 'normal' | 'large';
8
+ }
9
+
10
+ const Button: React.FC<ButtonProps> = ({ children, variant = 'primary', size = 'normal', className, ...props }) => {
11
+ const baseStyles = 'font-semibold rounded-md shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-800';
12
+
13
+ const variantStyles = {
14
+ primary: 'bg-cyan-600 text-white hover:bg-cyan-500 focus:ring-cyan-500 disabled:bg-slate-600 disabled:text-slate-400 disabled:cursor-not-allowed',
15
+ secondary: 'bg-slate-700 text-slate-200 hover:bg-slate-600 focus:ring-slate-500',
16
+ };
17
+
18
+ const sizeStyles = {
19
+ normal: 'px-4 py-2 text-sm',
20
+ large: 'px-6 py-3 text-base',
21
+ };
22
+
23
+ return (
24
+ <button
25
+ className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
26
+ {...props}
27
+ >
28
+ {children}
29
+ </button>
30
+ );
31
+ };
32
+
33
+ export default Button;
components/shared/Card.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+
4
+ interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
5
+ children: React.ReactNode;
6
+ className?: string;
7
+ isHoverable?: boolean;
8
+ }
9
+
10
+ const Card: React.FC<CardProps> = ({ children, className, isHoverable, ...props }) => {
11
+ const hoverStyles = isHoverable ? 'transition-all duration-300 hover:shadow-cyan-500/20 hover:border-slate-600' : '';
12
+
13
+ return (
14
+ <div
15
+ className={`bg-slate-800/60 p-6 rounded-lg border border-slate-700 ${hoverStyles} ${className}`}
16
+ {...props}
17
+ >
18
+ {children}
19
+ </div>
20
+ );
21
+ };
22
+
23
+ export default Card;
components/shared/LoadingSpinner.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+
4
+ const LoadingSpinner: React.FC = () => {
5
+ return (
6
+ <div className="flex justify-center items-center">
7
+ <svg
8
+ className="animate-spin h-10 w-10 text-cyan-500"
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ fill="none"
11
+ viewBox="0 0 24 24"
12
+ >
13
+ <circle
14
+ className="opacity-25"
15
+ cx="12"
16
+ cy="12"
17
+ r="10"
18
+ stroke="currentColor"
19
+ strokeWidth="4"
20
+ ></circle>
21
+ <path
22
+ className="opacity-75"
23
+ fill="currentColor"
24
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
25
+ ></path>
26
+ </svg>
27
+ </div>
28
+ );
29
+ };
30
+
31
+ export default LoadingSpinner;
constants.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { MediaType } from './types';
3
+
4
+ export const GENRES: Record<MediaType, string[]> = {
5
+ [MediaType.Movies]: [
6
+ "Action", "Adventure", "Comedy", "Drama", "Fantasy", "Horror", "Mystery", "Romance", "Sci-Fi", "Thriller", "Animation", "Crime"
7
+ ],
8
+ [MediaType.TVShows]: [
9
+ "Animation", "Comedy", "Crime", "Documentary", "Drama", "Family", "Fantasy", "Reality", "Sci-Fi", "Thriller", "Mystery", "Action & Adventure"
10
+ ],
11
+ [MediaType.VideoGames]: [
12
+ "Action", "Adventure", "RPG", "Strategy", "Shooter", "Puzzle", "Sports", "Simulation", "Horror", "Platformer", "Fighting", "Stealth"
13
+ ],
14
+ };
index.html ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>FlixFinder</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
12
+ <style>
13
+ body {
14
+ font-family: 'Inter', sans-serif;
15
+ }
16
+ </style>
17
+ <script type="importmap">
18
+ {
19
+ "imports": {
20
+ "@google/genai": "https://aistudiocdn.com/@google/genai@^1.17.0",
21
+ "react-dom/": "https://aistudiocdn.com/react-dom@^19.1.1/",
22
+ "react/": "https://aistudiocdn.com/react@^19.1.1/",
23
+ "react": "https://aistudiocdn.com/react@^19.1.1"
24
+ }
25
+ }
26
+ </script>
27
+ <link rel="stylesheet" href="/index.css">
28
+ </head>
29
+ <body class="bg-slate-900 text-slate-100">
30
+ <div id="root"></div>
31
+ <script type="module" src="/index.tsx"></script>
32
+ </body>
33
+ </html>
index.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import ReactDOM from 'react-dom/client';
4
+ import App from './App';
5
+
6
+ const rootElement = document.getElementById('root');
7
+ if (!rootElement) {
8
+ throw new Error("Could not find root element to mount to");
9
+ }
10
+
11
+ const root = ReactDOM.createRoot(rootElement);
12
+ root.render(
13
+ <React.StrictMode>
14
+ <App />
15
+ </React.StrictMode>
16
+ );
metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "name": "FlixFinder",
3
+ "description": "An AI-powered recommendation engine for films, TV shows, and video games. Select your preferred media type and genres, choose some titles you like, and get personalized suggestions.",
4
+ "requestFramePermissions": []
5
+ }
package.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "flixfinder",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@google/genai": "^1.17.0",
13
+ "react-dom": "^19.1.1",
14
+ "react": "^19.1.1"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.14.0",
18
+ "typescript": "~5.8.2",
19
+ "vite": "^6.2.0"
20
+ }
21
+ }
services/geminiService.ts ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Fix: This file was previously placeholder text. It is now fully implemented
2
+ // to provide the required services for fetching data from the Gemini API,
3
+ // resolving all module-related errors.
4
+ import { GoogleGenAI, Type } from "@google/genai";
5
+ import { MediaType, Recommendation } from "../types";
6
+
7
+ // Initialize the Google Gemini AI client.
8
+ // The API key is sourced from the environment variables, as per guidelines.
9
+ const ai = new GoogleGenAI({ apiKey: process.env.API_KEY! });
10
+
11
+ /**
12
+ * Fetches a list of popular titles for a given media type and genres.
13
+ * @param mediaType - The type of media (Movies, TV Shows, Video Games).
14
+ * @param genres - An array of selected genres.
15
+ * @param isGamePassOnly - Optional flag for video games to filter by Game Pass availability.
16
+ * @param isMultiplayerOnly - Optional flag for video games to filter by multiplayer availability.
17
+ * @returns A promise that resolves to an array of title strings.
18
+ */
19
+ export const fetchTitles = async (
20
+ mediaType: MediaType,
21
+ genres: string[],
22
+ isGamePassOnly?: boolean,
23
+ isMultiplayerOnly?: boolean
24
+ ): Promise<string[]> => {
25
+ const gamePassQuery = isGamePassOnly ? " that are available on PC or Console Game Pass" : "";
26
+ const multiplayerQuery = isMultiplayerOnly ? " multiplayer" : "";
27
+
28
+ const prompt = `
29
+ List 20 popular and highly-rated${multiplayerQuery} ${mediaType}${gamePassQuery} from the following genres: ${genres.join(', ')}.
30
+ Focus on a diverse mix of classic and recent titles.
31
+ Return ONLY a JSON array of strings, where each string is a title. Do not include any other text or explanation.
32
+ `;
33
+
34
+ try {
35
+ const response = await ai.models.generateContent({
36
+ model: 'gemini-2.5-flash',
37
+ contents: prompt,
38
+ config: {
39
+ responseMimeType: 'application/json',
40
+ responseSchema: {
41
+ type: Type.ARRAY,
42
+ items: {
43
+ type: Type.STRING,
44
+ description: "The title of the media.",
45
+ }
46
+ }
47
+ }
48
+ });
49
+
50
+ const jsonString = response.text.trim();
51
+ // The response is expected to be a JSON string array.
52
+ return JSON.parse(jsonString);
53
+ } catch (e) {
54
+ console.error("Error fetching titles from Gemini API:", e);
55
+ // Re-throw a more user-friendly error to be caught by the UI layer.
56
+ throw new Error("Failed to fetch title suggestions from the AI. Please try again.");
57
+ }
58
+ };
59
+
60
+ /**
61
+ * Fetches personalized recommendations based on user's favorite titles.
62
+ * @param mediaType - The type of media (Movies, TV Shows, Video Games).
63
+ * @param titles - An array of titles the user likes.
64
+ * @param isGamePassOnly - Optional flag for video games to filter by Game Pass availability.
65
+ * @param isMultiplayerOnly - Optional flag for video games to filter by multiplayer availability.
66
+ * @returns A promise that resolves to an array of Recommendation objects.
67
+ */
68
+ export const fetchRecommendations = async (
69
+ mediaType: MediaType,
70
+ titles: string[],
71
+ isGamePassOnly?: boolean,
72
+ isMultiplayerOnly?: boolean,
73
+ ): Promise<Recommendation[]> => {
74
+
75
+ const gamePassQuery = isGamePassOnly ? " that are available on PC or Console Game Pass" : "";
76
+ const multiplayerQuery = isMultiplayerOnly ? " multiplayer" : "";
77
+ const isGames = mediaType === MediaType.VideoGames;
78
+ const isStreamable = mediaType === MediaType.Movies || mediaType === MediaType.TVShows;
79
+
80
+ const playerCountDescription = isGames
81
+ ? "Include an estimated 'playerCount' for recent peak concurrent players and a 'livePlayerCount' for the current live players. These should both be numbers."
82
+ : "";
83
+
84
+ const streamingDescription = isStreamable
85
+ ? "- streamingOn: A list of major streaming services where it's available (e.g., Netflix, Hulu, Disney+)."
86
+ : "";
87
+
88
+ // Base properties for the JSON schema for all media types.
89
+ const properties: { [key: string]: object } = {
90
+ title: { type: Type.STRING, description: 'The title of the recommended item.' },
91
+ synopsis: { type: Type.STRING, description: 'A brief, engaging synopsis (2-3 sentences).' },
92
+ genres: { type: Type.ARRAY, items: { type: Type.STRING }, description: 'A list of relevant genres.' },
93
+ rating: { type: Type.NUMBER, description: 'An estimated rating out of 10 (e.g., 8.5).' },
94
+ };
95
+
96
+ // Add a game-specific property to the schema if the media type is Video Games.
97
+ if (isGames) {
98
+ properties.playerCount = { type: Type.NUMBER, description: 'Estimated recent peak concurrent player count.' };
99
+ properties.livePlayerCount = { type: Type.NUMBER, description: 'Estimated current live player count.' };
100
+ }
101
+
102
+ if (isStreamable) {
103
+ properties.streamingOn = { type: Type.ARRAY, items: { type: Type.STRING }, description: 'List of streaming services.' };
104
+ }
105
+
106
+ const prompt = `
107
+ Based on a user's enjoyment of the following ${mediaType}: ${titles.join(', ')}.
108
+
109
+ Please recommend 5 other${multiplayerQuery} ${mediaType}${gamePassQuery} they might like.
110
+
111
+ For each recommendation, provide the following details:
112
+ - title: The title of the recommendation.
113
+ - synopsis: A brief, 2-3 sentence summary.
114
+ - genres: A list of relevant genres.
115
+ - rating: An estimated critical and audience rating out of 10 (e.g., 8.5).
116
+ ${streamingDescription}
117
+ ${playerCountDescription}
118
+
119
+ Return ONLY a JSON array of objects matching the defined schema. Do not include any other text, markdown, or explanation.
120
+ `;
121
+
122
+ try {
123
+ const response = await ai.models.generateContent({
124
+ model: 'gemini-2.5-flash',
125
+ contents: prompt,
126
+ config: {
127
+ responseMimeType: 'application/json',
128
+ responseSchema: {
129
+ type: Type.ARRAY,
130
+ items: {
131
+ type: Type.OBJECT,
132
+ properties: properties,
133
+ required: ['title', 'synopsis', 'genres', 'rating']
134
+ }
135
+ }
136
+ }
137
+ });
138
+
139
+ const jsonString = response.text.trim();
140
+ // The response is expected to be a JSON string representing an array of Recommendation objects.
141
+ return JSON.parse(jsonString);
142
+ } catch (e) {
143
+ console.error("Error fetching recommendations from Gemini API:", e);
144
+ // Re-throw a more user-friendly error to be caught by the UI layer.
145
+ throw new Error("Failed to fetch recommendations from the AI. Please try again.");
146
+ }
147
+ };
tsconfig.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "experimentalDecorators": true,
5
+ "useDefineForClassFields": false,
6
+ "module": "ESNext",
7
+ "lib": [
8
+ "ES2022",
9
+ "DOM",
10
+ "DOM.Iterable"
11
+ ],
12
+ "skipLibCheck": true,
13
+ "types": [
14
+ "node"
15
+ ],
16
+ "moduleResolution": "bundler",
17
+ "isolatedModules": true,
18
+ "moduleDetection": "force",
19
+ "allowJs": true,
20
+ "jsx": "react-jsx",
21
+ "paths": {
22
+ "@/*": [
23
+ "./*"
24
+ ]
25
+ },
26
+ "allowImportingTsExtensions": true,
27
+ "noEmit": true
28
+ }
29
+ }
types.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export enum MediaType {
2
+ Movies = "Movies",
3
+ TVShows = "TV Shows",
4
+ VideoGames = "Video Games",
5
+ }
6
+
7
+ export interface Recommendation {
8
+ title: string;
9
+ synopsis: string;
10
+ genres: string[];
11
+ rating: number;
12
+ playerCount?: number;
13
+ livePlayerCount?: number;
14
+ streamingOn?: string[];
15
+ }
vite.config.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'path';
2
+ import { defineConfig, loadEnv } from 'vite';
3
+
4
+ export default defineConfig(({ mode }) => {
5
+ const env = loadEnv(mode, '.', '');
6
+ return {
7
+ define: {
8
+ 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
9
+ 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
10
+ },
11
+ resolve: {
12
+ alias: {
13
+ '@': path.resolve(__dirname, '.'),
14
+ }
15
+ }
16
+ };
17
+ });