Spaces:
Configuration error
Configuration error
Upload 21 files
Browse files- .env.local +1 -0
- .gitignore +24 -0
- App.tsx +149 -0
- README.md +20 -14
- components/GenreSelector.tsx +104 -0
- components/MediaTypeSelector.tsx +56 -0
- components/RecommendationsDisplay.tsx +101 -0
- components/StepIndicator.tsx +36 -0
- components/TitleSelector.tsx +120 -0
- components/shared/Button.tsx +33 -0
- components/shared/Card.tsx +23 -0
- components/shared/LoadingSpinner.tsx +31 -0
- constants.ts +14 -0
- index.html +33 -0
- index.tsx +16 -0
- metadata.json +5 -0
- package.json +21 -0
- services/geminiService.ts +147 -0
- tsconfig.json +29 -0
- types.ts +15 -0
- vite.config.ts +17 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
});
|