Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- app.py +237 -0
- assets/favicon.ico +0 -0
- assets/style.css +190 -0
- requirements.txt +4 -0
app.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import dash
|
| 2 |
+
from dash import html, dcc, Output, Input, State
|
| 3 |
+
import pickle
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import numpy as np
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
# Load preprocessed data
|
| 9 |
+
with open('processed_data.pkl', 'rb') as f:
|
| 10 |
+
data = pickle.load(f)
|
| 11 |
+
|
| 12 |
+
df = data['df']
|
| 13 |
+
similarity_matrix = data['similarity_matrix']
|
| 14 |
+
|
| 15 |
+
# Clean genres (remove empty)
|
| 16 |
+
all_genres = sorted({genre for genres in df['genres'] for genre in genres if genre.strip() != ''})
|
| 17 |
+
genre_tabs = [{'label': genre, 'value': genre} for genre in all_genres]
|
| 18 |
+
genre_tabs.insert(0, {'label': 'Most Popular', 'value': '__popular__'})
|
| 19 |
+
|
| 20 |
+
# Helper for recommendations
|
| 21 |
+
def extract_series(name):
|
| 22 |
+
return re.split(r'[:\-]', name)[0].strip().lower()
|
| 23 |
+
|
| 24 |
+
def get_recommendations(game_id):
|
| 25 |
+
idx = df[df['id'] == game_id].index[0]
|
| 26 |
+
sim_scores = list(enumerate(similarity_matrix[idx]))
|
| 27 |
+
sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True)[1:]
|
| 28 |
+
|
| 29 |
+
selected = []
|
| 30 |
+
seen_series = set()
|
| 31 |
+
|
| 32 |
+
for i, score in sim_scores:
|
| 33 |
+
name = df.iloc[i]['name']
|
| 34 |
+
series = extract_series(name)
|
| 35 |
+
if series not in seen_series:
|
| 36 |
+
selected.append({
|
| 37 |
+
'id': int(df.iloc[i]['id']),
|
| 38 |
+
'name': name,
|
| 39 |
+
'image': df.iloc[i]['background_image'],
|
| 40 |
+
'rating': df.iloc[i]['rating'],
|
| 41 |
+
'platforms': df.iloc[i]['platforms'],
|
| 42 |
+
})
|
| 43 |
+
seen_series.add(series)
|
| 44 |
+
if len(selected) >= 5:
|
| 45 |
+
break
|
| 46 |
+
|
| 47 |
+
return selected
|
| 48 |
+
|
| 49 |
+
app = dash.Dash(__name__)
|
| 50 |
+
server = app.server
|
| 51 |
+
app.title = "Game Recommender 🎮"
|
| 52 |
+
|
| 53 |
+
# Layout
|
| 54 |
+
app.layout = html.Div(className="app-background", children=[
|
| 55 |
+
html.Div(className="layout-container", children=[
|
| 56 |
+
|
| 57 |
+
html.Div(className="sidebar-panel", children=[
|
| 58 |
+
html.H2("Discover Games", className="sidebar-title"),
|
| 59 |
+
dcc.Tabs(
|
| 60 |
+
id='genre-tabs',
|
| 61 |
+
value='__popular__',
|
| 62 |
+
vertical=True,
|
| 63 |
+
children=[
|
| 64 |
+
dcc.Tab(label=tab['label'], value=tab['value'],
|
| 65 |
+
className="tab", selected_className="selected-tab")
|
| 66 |
+
for tab in genre_tabs
|
| 67 |
+
],
|
| 68 |
+
className="tabs-container"
|
| 69 |
+
)
|
| 70 |
+
]),
|
| 71 |
+
|
| 72 |
+
html.Div(className="main-container", children=[
|
| 73 |
+
html.H1("🎮 Game Recommender", className="fancy-title"),
|
| 74 |
+
|
| 75 |
+
dcc.Dropdown(
|
| 76 |
+
id='game-dropdown',
|
| 77 |
+
options=[{'label': row['name'], 'value': row['id']} for _, row in df.iterrows()],
|
| 78 |
+
placeholder="Search for a game",
|
| 79 |
+
className="custom-dropdown"
|
| 80 |
+
),
|
| 81 |
+
|
| 82 |
+
dcc.Store(id='mode-store', data='discovery'),
|
| 83 |
+
dcc.Store(id='cards-data-store'),
|
| 84 |
+
dcc.Store(id='recommendations-data-store'),
|
| 85 |
+
|
| 86 |
+
html.Div(id='search-output'),
|
| 87 |
+
html.Div(id='grid-display'),
|
| 88 |
+
])
|
| 89 |
+
])
|
| 90 |
+
])
|
| 91 |
+
|
| 92 |
+
# 1️⃣ Render grid and store grid card metadata
|
| 93 |
+
@app.callback(
|
| 94 |
+
[Output('grid-display', 'children'),
|
| 95 |
+
Output('cards-data-store', 'data')],
|
| 96 |
+
[Input('genre-tabs', 'value'),
|
| 97 |
+
Input('mode-store', 'data')]
|
| 98 |
+
)
|
| 99 |
+
def render_grid(selected_tab, mode):
|
| 100 |
+
if mode != 'discovery':
|
| 101 |
+
return "", dash.no_update
|
| 102 |
+
|
| 103 |
+
if selected_tab == '__popular__':
|
| 104 |
+
filtered = df.sort_values(by='rating', ascending=False).head(20)
|
| 105 |
+
else:
|
| 106 |
+
filtered = df[df['genres'].apply(lambda genres: selected_tab in genres)].sort_values(by='rating', ascending=False).head(20)
|
| 107 |
+
|
| 108 |
+
cards_data = []
|
| 109 |
+
grid_cards = []
|
| 110 |
+
|
| 111 |
+
for idx, row in enumerate(filtered.itertuples()):
|
| 112 |
+
card_info = {
|
| 113 |
+
'id': row.id,
|
| 114 |
+
'name': row.name,
|
| 115 |
+
'image': row.background_image,
|
| 116 |
+
'rating': row.rating
|
| 117 |
+
}
|
| 118 |
+
cards_data.append(card_info)
|
| 119 |
+
|
| 120 |
+
grid_cards.append(
|
| 121 |
+
html.Div([
|
| 122 |
+
html.Img(src=row.background_image, className="rec-image"),
|
| 123 |
+
html.Div([
|
| 124 |
+
html.H4(row.name, className="rec-title"),
|
| 125 |
+
html.P(f"{row.rating} ⭐", className="rec-rating")
|
| 126 |
+
], className="rec-info")
|
| 127 |
+
],
|
| 128 |
+
className="rec-card",
|
| 129 |
+
n_clicks=0,
|
| 130 |
+
id={'type': 'grid-card', 'index': idx})
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
return [
|
| 134 |
+
html.H3("Games", className="subtitle"),
|
| 135 |
+
html.Div(grid_cards, className="recommendations-container")
|
| 136 |
+
], cards_data
|
| 137 |
+
|
| 138 |
+
# 2️⃣ Render search output and store recommendation metadata
|
| 139 |
+
@app.callback(
|
| 140 |
+
[Output('search-output', 'children'),
|
| 141 |
+
Output('recommendations-data-store', 'data')],
|
| 142 |
+
[Input('game-dropdown', 'value'),
|
| 143 |
+
Input('mode-store', 'data')]
|
| 144 |
+
)
|
| 145 |
+
def render_search(selected_id, mode):
|
| 146 |
+
if mode != 'search' or selected_id is None:
|
| 147 |
+
return "", dash.no_update
|
| 148 |
+
|
| 149 |
+
row = df[df['id'] == selected_id].iloc[0]
|
| 150 |
+
platforms_display = row['platforms'][:4]
|
| 151 |
+
platforms_hidden = ', '.join(row['platforms'])
|
| 152 |
+
|
| 153 |
+
main_game = html.Div([
|
| 154 |
+
html.Div([
|
| 155 |
+
html.Img(src=row['background_image'], className='main-image'),
|
| 156 |
+
html.Div([
|
| 157 |
+
html.H2(row['name'], className='main-title'),
|
| 158 |
+
html.P(f"Rating: {row['rating']} ⭐"),
|
| 159 |
+
html.P(f"Metacritic: {row['metacritic']} 🎯"),
|
| 160 |
+
html.P(f"Genres: {', '.join(row['genres'])}"),
|
| 161 |
+
html.P("Platforms:"),
|
| 162 |
+
html.Div([
|
| 163 |
+
html.Span(', '.join(platforms_display), title=platforms_hidden, className="platform-text")
|
| 164 |
+
])
|
| 165 |
+
], className="main-details")
|
| 166 |
+
], className="main-card-inner")
|
| 167 |
+
], className="main-card")
|
| 168 |
+
|
| 169 |
+
recs = get_recommendations(selected_id)
|
| 170 |
+
rec_cards = []
|
| 171 |
+
rec_data = []
|
| 172 |
+
|
| 173 |
+
for idx, rec in enumerate(recs):
|
| 174 |
+
rec_cards.append(
|
| 175 |
+
html.Div([
|
| 176 |
+
html.Img(src=rec['image'], className="rec-image"),
|
| 177 |
+
html.Div([
|
| 178 |
+
html.H4(rec['name'], className="rec-title"),
|
| 179 |
+
html.P(f"{rec['rating']} ⭐", className="rec-rating")
|
| 180 |
+
], className="rec-info")
|
| 181 |
+
], className="rec-card",
|
| 182 |
+
n_clicks=0,
|
| 183 |
+
id={'type': 'rec-card', 'index': idx})
|
| 184 |
+
)
|
| 185 |
+
rec_data.append({'id': rec['id'], 'name': rec['name']})
|
| 186 |
+
|
| 187 |
+
return html.Div([
|
| 188 |
+
main_game,
|
| 189 |
+
html.H3("You May Also Like:", className="subtitle"),
|
| 190 |
+
html.Div(rec_cards, className="recommendations-container")
|
| 191 |
+
]), rec_data
|
| 192 |
+
|
| 193 |
+
# 3️⃣ Unified callback handling all clicks + dropdown + tabs
|
| 194 |
+
@app.callback(
|
| 195 |
+
[Output('game-dropdown', 'value'),
|
| 196 |
+
Output('mode-store', 'data')],
|
| 197 |
+
[Input('game-dropdown', 'value'),
|
| 198 |
+
Input('genre-tabs', 'value'),
|
| 199 |
+
Input({'type': 'grid-card', 'index': dash.ALL}, 'n_clicks'),
|
| 200 |
+
Input({'type': 'rec-card', 'index': dash.ALL}, 'n_clicks')],
|
| 201 |
+
[State('cards-data-store', 'data'),
|
| 202 |
+
State('recommendations-data-store', 'data')]
|
| 203 |
+
)
|
| 204 |
+
def handle_inputs(dropdown_value, tab_value, grid_clicks, rec_clicks, cards_data, rec_data):
|
| 205 |
+
ctx = dash.callback_context
|
| 206 |
+
|
| 207 |
+
if not ctx.triggered:
|
| 208 |
+
raise dash.exceptions.PreventUpdate
|
| 209 |
+
|
| 210 |
+
triggered = ctx.triggered[0]['prop_id']
|
| 211 |
+
|
| 212 |
+
# Handle grid card click
|
| 213 |
+
if "grid-card" in triggered:
|
| 214 |
+
for i, clicks in enumerate(grid_clicks):
|
| 215 |
+
if clicks and clicks > 0:
|
| 216 |
+
clicked_game_id = cards_data[i]['id']
|
| 217 |
+
return clicked_game_id, 'search'
|
| 218 |
+
|
| 219 |
+
# Handle recommendation card click
|
| 220 |
+
if "rec-card" in triggered:
|
| 221 |
+
for i, clicks in enumerate(rec_clicks):
|
| 222 |
+
if clicks and clicks > 0:
|
| 223 |
+
clicked_game_id = rec_data[i]['id']
|
| 224 |
+
return clicked_game_id, 'search'
|
| 225 |
+
|
| 226 |
+
# Handle search dropdown
|
| 227 |
+
if 'game-dropdown' in triggered and dropdown_value is not None:
|
| 228 |
+
return dropdown_value, 'search'
|
| 229 |
+
|
| 230 |
+
# Handle tab change
|
| 231 |
+
if 'genre-tabs' in triggered:
|
| 232 |
+
return dash.no_update, 'discovery'
|
| 233 |
+
|
| 234 |
+
return dash.no_update, dash.no_update
|
| 235 |
+
|
| 236 |
+
if __name__ == '__main__':
|
| 237 |
+
app.run(debug=True)
|
assets/favicon.ico
ADDED
|
|
assets/style.css
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
body {
|
| 2 |
+
margin: 0;
|
| 3 |
+
padding: 0;
|
| 4 |
+
font-family: 'Segoe UI', sans-serif;
|
| 5 |
+
color: #00E0FF;
|
| 6 |
+
background: linear-gradient(135deg, #0D0D0D 10%, #3A3A3A 100%);
|
| 7 |
+
background-attachment: fixed;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.layout-container {
|
| 11 |
+
display: flex;
|
| 12 |
+
flex-direction: row;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.sidebar-panel {
|
| 16 |
+
width: 300px;
|
| 17 |
+
background-color: #1A1A1A;
|
| 18 |
+
padding: 20px;
|
| 19 |
+
border-right: 3px solid #00E0FF;
|
| 20 |
+
height: 100vh;
|
| 21 |
+
position: fixed;
|
| 22 |
+
left: 0;
|
| 23 |
+
top: 0;
|
| 24 |
+
overflow-y: auto;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.sidebar-title {
|
| 28 |
+
text-align: center;
|
| 29 |
+
font-size: 26px;
|
| 30 |
+
margin-bottom: 30px;
|
| 31 |
+
color: #FF007F;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.tabs-container {
|
| 35 |
+
display: flex;
|
| 36 |
+
flex-direction: column;
|
| 37 |
+
gap: 10px;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.tab {
|
| 41 |
+
background-color: #252525;
|
| 42 |
+
color: #9D00FF;
|
| 43 |
+
font-weight: bold;
|
| 44 |
+
padding: 15px;
|
| 45 |
+
font-size: 18px;
|
| 46 |
+
border: 2px solid #9D00FF;
|
| 47 |
+
border-radius: 10px;
|
| 48 |
+
cursor: pointer;
|
| 49 |
+
text-align: center;
|
| 50 |
+
}
|
| 51 |
+
.tab:hover {
|
| 52 |
+
background-color: #3A3A3A;
|
| 53 |
+
}
|
| 54 |
+
.selected-tab {
|
| 55 |
+
background-color: #9D00FF !important;
|
| 56 |
+
color: #00E0FF !important;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.main-container {
|
| 60 |
+
margin-left: 500px;
|
| 61 |
+
padding: 30px;
|
| 62 |
+
max-width: 1100px;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.fancy-title {
|
| 66 |
+
text-align: center;
|
| 67 |
+
font-size: 50px;
|
| 68 |
+
margin-bottom: 40px;
|
| 69 |
+
background: linear-gradient(45deg, #9D00FF, #FF007F);
|
| 70 |
+
-webkit-background-clip: text;
|
| 71 |
+
-webkit-text-fill-color: transparent;
|
| 72 |
+
text-shadow: 2px 2px 8px rgba(157, 0, 255, 0.8);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.custom-dropdown .Select-control {
|
| 76 |
+
background-color: #3A3A3A !important;
|
| 77 |
+
border: 2px solid #00E0FF !important;
|
| 78 |
+
color: #00E0FF !important;
|
| 79 |
+
}
|
| 80 |
+
.custom-dropdown input {
|
| 81 |
+
color: #00E0FF !important;
|
| 82 |
+
font-weight: bold !important;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* Main search card (top game) */
|
| 86 |
+
.main-card {
|
| 87 |
+
background-color: #252525;
|
| 88 |
+
border: 3px solid #9D00FF;
|
| 89 |
+
border-radius: 20px;
|
| 90 |
+
padding: 20px;
|
| 91 |
+
margin-top: 40px;
|
| 92 |
+
box-shadow: 0 8px 20px rgba(0, 224, 255, 0.4);
|
| 93 |
+
transition: transform 0.3s ease;
|
| 94 |
+
}
|
| 95 |
+
.main-card:hover {
|
| 96 |
+
transform: translateY(-8px);
|
| 97 |
+
}
|
| 98 |
+
.main-card-inner {
|
| 99 |
+
display: flex;
|
| 100 |
+
align-items: center;
|
| 101 |
+
}
|
| 102 |
+
.main-image {
|
| 103 |
+
width: 720px;
|
| 104 |
+
height: 340px;
|
| 105 |
+
border-radius: 15px;
|
| 106 |
+
box-shadow: 0 4px 10px rgba(255, 0, 127, 0.6);
|
| 107 |
+
}
|
| 108 |
+
.main-details {
|
| 109 |
+
padding-left: 30px;
|
| 110 |
+
}
|
| 111 |
+
.main-title {
|
| 112 |
+
color: #FF007F;
|
| 113 |
+
font-size: 28px;
|
| 114 |
+
margin-bottom: 15px;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.subtitle {
|
| 118 |
+
text-align: center;
|
| 119 |
+
font-size: 28px;
|
| 120 |
+
margin-top: 60px;
|
| 121 |
+
margin-bottom: 20px;
|
| 122 |
+
color: #9D00FF;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/* Cards container (for both grid and recommendations) */
|
| 126 |
+
.recommendations-container {
|
| 127 |
+
display: flex;
|
| 128 |
+
justify-content: center;
|
| 129 |
+
flex-wrap: wrap;
|
| 130 |
+
gap: 20px;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.rec-card {
|
| 134 |
+
width: 200px;
|
| 135 |
+
height: 340px;
|
| 136 |
+
background-color: #353535;
|
| 137 |
+
border: 2px solid #9D00FF;
|
| 138 |
+
border-radius: 15px;
|
| 139 |
+
overflow: hidden;
|
| 140 |
+
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
| 141 |
+
box-shadow: 0 4px 10px rgba(0, 224, 255, 0.5);
|
| 142 |
+
cursor: pointer;
|
| 143 |
+
}
|
| 144 |
+
.rec-card:hover {
|
| 145 |
+
transform: translateY(-12px);
|
| 146 |
+
box-shadow: 0 8px 16px rgba(0, 224, 255, 0.6);
|
| 147 |
+
}
|
| 148 |
+
.rec-image {
|
| 149 |
+
width: 100%;
|
| 150 |
+
height: 220px;
|
| 151 |
+
object-fit: cover;
|
| 152 |
+
}
|
| 153 |
+
.rec-info {
|
| 154 |
+
padding: 10px;
|
| 155 |
+
}
|
| 156 |
+
.rec-title {
|
| 157 |
+
font-size: 16px;
|
| 158 |
+
color: #FF007F;
|
| 159 |
+
margin-bottom: 5px;
|
| 160 |
+
white-space: nowrap;
|
| 161 |
+
overflow: hidden;
|
| 162 |
+
text-overflow: ellipsis;
|
| 163 |
+
text-align: center;
|
| 164 |
+
}
|
| 165 |
+
.rec-rating {
|
| 166 |
+
font-size: 14px;
|
| 167 |
+
text-align: center;
|
| 168 |
+
margin-top: 10px;
|
| 169 |
+
color: #00E0FF;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.platform-text {
|
| 173 |
+
white-space: nowrap;
|
| 174 |
+
overflow: hidden;
|
| 175 |
+
text-overflow: ellipsis;
|
| 176 |
+
max-width: 400px;
|
| 177 |
+
display: inline-block;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
/* Mobile Responsive Layout */
|
| 181 |
+
@media (max-width: 768px) {
|
| 182 |
+
.main-card-inner {
|
| 183 |
+
flex-direction: column;
|
| 184 |
+
align-items: center;
|
| 185 |
+
}
|
| 186 |
+
.main-details {
|
| 187 |
+
padding-left: 0;
|
| 188 |
+
margin-top: 20px;
|
| 189 |
+
}
|
| 190 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
requests
|
| 2 |
+
pandas
|
| 3 |
+
scikit-learn
|
| 4 |
+
dash
|