Commit 6cd6f674 by PLN (Algolia)

feat(paradiso): V1

parent 58c84ea4
ALGOLIA_ADMIN_KEY=""
ALGOLIA_APP_ID=""
TMDB_API_READ_TOKEN=""
TMDB_API_KEY=""
\ No newline at end of file
import React, { useState } from 'react';
import Image from 'next/image';
import styles from '@/styles/paradiso.module.css';
const MovieCard = ({ movie, onVote }) => {
const [isHovered, setIsHovered] = useState(false);
const [isVoting, setIsVoting] = useState(false);
const handleVote = async (e) => {
e.preventDefault();
e.stopPropagation();
if (isVoting) return;
setIsVoting(true);
await onVote(movie.objectID);
setIsVoting(false);
};
return (
<div
className={styles.movieCard}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className={styles.posterContainer}>
{movie.poster ? (
<Image
src={movie.poster}
alt={movie.title}
width={200}
height={300}
className={styles.poster}
unoptimized
/>
) : (
<div className={styles.noPoster}>
<span>{movie.title}</span>
</div>
)}
{isHovered && (
<div className={styles.movieOverlay}>
<h3 className={styles.movieTitle}>{movie.title}</h3>
<div className={styles.movieYear}>{movie.year}</div>
<div className={styles.movieRating}>
{movie.imdbRating || 'N/A'}
</div>
<div className={styles.movieGenres}>
{movie.genre && movie.genre.slice(0, 3).join(' • ')}
</div>
<p className={styles.moviePlot}>
{movie.plot && movie.plot.length > 150
? `${movie.plot.substring(0, 150)}...`
: movie.plot}
</p>
<div className={styles.movieActions}>
<button
className={styles.voteButton}
onClick={handleVote}
disabled={isVoting}
>
{isVoting ? 'Voting...' : `Vote (${movie.votes || 0})`}
</button>
</div>
</div>
)}
</div>
<div className={styles.movieInfo}>
<h4>{movie.title}</h4>
<div className={styles.movieMeta}>
<span>{movie.year}</span>
<span className={styles.votes}>{movie.votes || 0} votes</span>
</div>
</div>
</div>
);
};
export default MovieCard;
\ No newline at end of file
import React, { useState } from 'react';
import styles from '../styles/paradiso.module.css';
const MovieSearch = ({ onAddMovie }) => {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [searching, setSearching] = useState(false);
const [error, setError] = useState(null);
// Function to search for movies via TMDB
const searchMovies = async (e) => {
e.preventDefault();
if (!searchQuery.trim()) return;
setSearching(true);
setError(null);
try {
const response = await fetch(
`https://api.themoviedb.org/3/search/movie?api_key=3e1dd2bcd5e1265d986c9a1501d6f8c0&query=${encodeURIComponent(searchQuery)}&language=en-US&page=1&include_adult=false`
);
if (!response.ok) {
throw new Error('Failed to search for movies');
}
const data = await response.json();
setSearchResults(data.results || []);
} catch (error) {
console.error('Error searching for movies:', error);
setError('Failed to search for movies. Please try again later.');
setSearchResults([]);
} finally {
setSearching(false);
}
};
// Function to add a movie from search results
const handleAddMovie = (movie) => {
const newMovieObj = {
id: movie.id.toString(),
title: movie.title,
votes: 0,
addedDate: new Date().toISOString(),
poster: movie.poster_path ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` : null,
description: movie.overview || null,
year: movie.release_date ? movie.release_date.substring(0, 4) : null
};
onAddMovie(newMovieObj);
setSearchResults([]);
setSearchQuery('');
};
return (
<div className={styles.searchContainer}>
<h3>Find a Movie</h3>
{/* Search Form */}
<form onSubmit={searchMovies} className={styles.searchBar}>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search for a movie..."
/>
<button
type="submit"
disabled={searching || !searchQuery.trim()}
>
{searching ? 'Searching...' : 'Search'}
</button>
</form>
{/* Error Message */}
{error && (
<div className={styles.errorMessage}>
{error}
</div>
)}
{/* Search Results */}
{searchResults.length > 0 && (
<div className={styles.searchResults}>
<h4>Search Results</h4>
<div className={styles.movieGrid}>
{searchResults.slice(0, 5).map(movie => (
<div key={movie.id} className={styles.searchResultItem}>
{movie.poster_path ? (
<img
src={`https://image.tmdb.org/t/p/w200${movie.poster_path}`}
alt={movie.title}
className={styles.searchResultPoster}
/>
) : (
<div className={styles.searchResultPoster} style={{
backgroundColor: '#eee',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999'
}}>
No Image
</div>
)}
<div className={styles.searchResultInfo}>
<h5>{movie.title} {movie.release_date && `(${movie.release_date.substring(0, 4)})`}</h5>
{movie.overview && (
<p>{movie.overview.length > 100 ? `${movie.overview.substring(0, 100)}...` : movie.overview}</p>
)}
<button
onClick={() => handleAddMovie(movie)}
className={styles.addButton}
>
Add to List
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* No Results Message */}
{searchResults.length === 0 && searchQuery && !searching && !error && (
<div className={styles.noResults}>
No results found for "{searchQuery}". Try a different search term.
</div>
)}
</div>
);
};
export default MovieSearch;
\ No newline at end of file
......@@ -19,6 +19,8 @@
"next": "^15.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-instantsearch": "^7.15.7",
"react-instantsearch-dom": "^6.40.4",
"react-player": "^2.14.1",
"react-syntax-highlighter": "^15.5.0",
"remark": "^14.0.0",
......
import { createDefaultMovieService } from './lib/movie-service';
export default async function handler(req, res) {
// Only allow POST requests
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { title, userToken } = req.body;
// Validate required parameters
if (!title || !userToken) {
return res.status(400).json({ error: 'Missing required parameters' });
}
// Create movie service instance
const movieService = createDefaultMovieService();
// Add the movie
const movieData = await movieService.addMovie(title, userToken);
// Return success response
return res.status(200).json({ success: true, movie: movieData });
} catch (error) {
console.error('Error adding movie:', error);
// Return appropriate error messages
if (error.message === 'Movie not found') {
return res.status(404).json({ error: 'Movie not found' });
} else if (error.message === 'Movie already exists') {
return res.status(400).json({ error: 'Movie already exists' });
}
return res.status(500).json({ error: 'Internal server error', message: error.message });
}
}
\ No newline at end of file
/**
* Movie Repository Interface Abstraction
*
* This file defines repository interfaces for storing movie data and votes
* Currently implements Algolia, but could be extended to other storage mechanisms
*/
import { algoliasearch } from 'algoliasearch';
/**
* Base MovieRepository interface
* Any movie repository should implement this interface
*/
class MovieRepository {
/**
* Add a movie to the repository
* @param {object} movieData - Normalized movie data
* @param {string} userId - ID of the user adding the movie
* @returns {Promise<object>} - The stored movie
*/
async addMovie(movieData, userId) {
throw new Error('Method not implemented');
}
/**
* Check if a movie exists in the repository
* @param {string} movieId - The movie ID
* @returns {Promise<boolean>} - True if exists, false otherwise
*/
async movieExists(movieId) {
throw new Error('Method not implemented');
}
/**
* Vote for a movie
* @param {string} movieId - The movie ID
* @param {string} userId - ID of the user voting
* @returns {Promise<boolean>} - True if vote was successful, false otherwise
*/
async voteForMovie(movieId, userId) {
throw new Error('Method not implemented');
}
/**
* Get a movie by ID
* @param {string} movieId - The movie ID
* @returns {Promise<object|null>} - The movie data or null if not found
*/
async getMovie(movieId) {
throw new Error('Method not implemented');
}
/**
* Get top voted movies
* @param {number} count - Number of movies to return
* @returns {Promise<Array<object>>} - Array of movie data
*/
async getTopMovies(count = 5) {
throw new Error('Method not implemented');
}
/**
* Search for movies
* @param {string} query - Search query
* @param {number} limit - Maximum number of results to return
* @returns {Promise<Array<object>>} - Array of movie data
*/
async searchMovies(query, limit = 10) {
throw new Error('Method not implemented');
}
/**
* Get all movies
* @param {number} limit - Maximum number of results to return
* @returns {Promise<Array<object>>} - Array of movie data
*/
async getAllMovies(limit = 100) {
throw new Error('Method not implemented');
}
/**
* Remove a movie
* @param {string} movieId - The movie ID
* @returns {Promise<boolean>} - True if removal was successful
*/
async removeMovie(movieId) {
throw new Error('Method not implemented');
}
}
/**
* Algolia implementation of MovieRepository
*/
class AlgoliaMovieRepository extends MovieRepository {
constructor(appId, apiKey, moviesIndex, votesIndex) {
super();
this.client = algoliasearch(appId, apiKey);
this.moviesIndex = this.client.initIndex(moviesIndex);
this.votesIndex = this.client.initIndex(votesIndex);
}
/**
* Generate a user token for Algolia
* @param {string} userId - User ID
* @returns {string} - User token
*/
generateUserToken(userId) {
return userId.startsWith('discord_') ? userId : `user_${userId}`;
}
async addMovie(movieData, userId) {
// Check if movie already exists
if (await this.movieExists(movieData.id)) {
throw new Error('Movie already exists');
}
// Format the movie data for Algolia
const algoliaMovie = {
objectID: movieData.id,
title: movieData.title,
originalTitle: movieData.originalTitle || movieData.title,
year: movieData.year,
director: movieData.director || 'Unknown',
actors: movieData.actors || [],
genre: movieData.genre || [],
plot: movieData.plot || '',
poster: movieData.poster,
imdbRating: movieData.imdbRating,
imdbID: movieData.imdbID,
tmdbID: movieData.tmdbID,
votes: 0,
addedDate: Date.now(),
addedBy: this.generateUserToken(userId),
source: movieData.source || 'unknown'
};
// Save to Algolia
await this.moviesIndex.saveObject(algoliaMovie);
return algoliaMovie;
}
async movieExists(movieId) {
const searchResponse = await this.moviesIndex.search('', {
filters: `objectID:${movieId}`,
});
return searchResponse.hits.length > 0;
}
async voteForMovie(movieId, userId) {
const userToken = this.generateUserToken(userId);
// Check if user already voted for this movie
const searchResult = await this.votesIndex.search('', {
filters: `userToken:${userToken} AND movieId:${movieId}`
});
if (searchResult.nbHits > 0) {
return false; // User already voted
}
// Record the vote
await this.votesIndex.saveObject({
objectID: `${userToken}_${movieId}`,
userToken: userToken,
movieId: movieId,
timestamp: Date.now()
});
// Increment the movie's vote count
await this.moviesIndex.partialUpdateObject({
objectID: movieId,
votes: {
_operation: 'Increment',
value: 1
}
});
return true;
}
async getMovie(movieId) {
try {
const movie = await this.moviesIndex.getObject(movieId);
return movie;
} catch (error) {
return null;
}
}
async getTopMovies(count = 5) {
const searchResult = await this.moviesIndex.search('', {
filters: 'votes > 0',
hitsPerPage: count,
sortCriteria: ['votes:desc']
});
return searchResult.hits;
}
async searchMovies(query, limit = 10) {
const searchResult = await this.moviesIndex.search(query, {
hitsPerPage: limit
});
return searchResult.hits;
}
async getAllMovies(limit = 100) {
const searchResult = await this.moviesIndex.search('', {
hitsPerPage: limit
});
return searchResult.hits;
}
async removeMovie(movieId) {
try {
await this.moviesIndex.deleteObject(movieId);
// Delete all votes for this movie
const votesResponse = await this.votesIndex.search('', {
filters: `movieId:${movieId}`,
hitsPerPage: 100
});
const voteIds = votesResponse.hits.map(hit => hit.objectID);
if (voteIds.length > 0) {
await this.votesIndex.deleteObjects(voteIds);
}
return true;
} catch (error) {
console.error('Error removing movie:', error);
return false;
}
}
}
/**
* Repository Factory to create the appropriate repository
*/
class MovieRepositoryFactory {
static createRepository(type, config) {
switch (type.toLowerCase()) {
case 'algolia':
return new AlgoliaMovieRepository(
config.appId,
config.apiKey,
config.moviesIndex,
config.votesIndex
);
default:
throw new Error(`Unsupported repository type: ${type}`);
}
}
}
export { MovieRepository, AlgoliaMovieRepository, MovieRepositoryFactory };
\ No newline at end of file
/**
* Movie Service Layer
*
* This service provides a unified interface for interacting with movie data sources
* and repositories, abstracting the underlying implementation details.
*/
import { MovieSourceFactory } from './movie-sources';
import { MovieRepositoryFactory } from './movie-repository';
class MovieService {
/**
* Create a new MovieService instance
* @param {Object} config - Service configuration
* @param {Object} config.dataSource - Data source configuration
* @param {string} config.dataSource.type - Data source type ('omdb', 'tmdb')
* @param {string} config.dataSource.apiKey - API key for the data source
* @param {Object} config.repository - Repository configuration
* @param {string} config.repository.type - Repository type ('algolia')
* @param {Object} config.repository.config - Repository-specific configuration
*/
constructor(config) {
// Set up data sources with fallback cascade
const dataSources = MovieSourceFactory.createDataSourceCascade(config);
this.dataSource = dataSources.primary;
this.fallbackDataSources = dataSources.fallbacks;
// Set up repository
this.repository = MovieRepositoryFactory.createRepository(
config.repository.type,
config.repository.config
);
}
/**
* Search for a movie by title
* @param {string} title - Movie title to search for
* @returns {Promise<Object|null>} - Movie data or null if not found
*/
async searchMovie(title) {
// Try primary data source first
let movieData = await this.dataSource.searchByTitle(title);
// Try each fallback source in order until we find a result
if (!movieData) {
for (const fallbackSource of this.fallbackDataSources) {
movieData = await fallbackSource.searchByTitle(title);
if (movieData) {
console.info(`Found movie using fallback source: ${fallbackSource.constructor.name}`);
break;
}
}
}
return movieData;
}
/**
* Add a movie to the repository
* @param {string} title - Movie title to search and add
* @param {string} userId - ID of the user adding the movie
* @returns {Promise<Object>} - The added movie data
*/
async addMovie(title, userId) {
// First, search for the movie in the data source
const movieData = await this.searchMovie(title);
if (!movieData) {
throw new Error('Movie not found');
}
// Check if the movie already exists in the repository
const movieExists = await this.repository.movieExists(movieData.id);
if (movieExists) {
throw new Error('Movie already exists');
}
// Add the movie to the repository
return await this.repository.addMovie(movieData, userId);
}
/**
* Vote for a movie
* @param {string} movieId - ID of the movie to vote for
* @param {string} userId - ID of the user voting
* @returns {Promise<boolean>} - True if vote was successful, false otherwise
*/
async voteForMovie(movieId, userId) {
return await this.repository.voteForMovie(movieId, userId);
}
/**
* Get top voted movies
* @param {number} count - Number of movies to return
* @returns {Promise<Array<Object>>} - Array of movie data
*/
async getTopMovies(count = 5) {
return await this.repository.getTopMovies(count);
}
/**
* Get all movies
* @param {number} limit - Maximum number of movies to return
* @returns {Promise<Array<Object>>} - Array of movie data
*/
async getAllMovies(limit = 100) {
return await this.repository.getAllMovies(limit);
}
/**
* Search for movies in the repository
* @param {string} query - Search query
* @param {number} limit - Maximum number of results
* @returns {Promise<Array<Object>>} - Array of movie data
*/
async searchMoviesInRepository(query, limit = 10) {
return await this.repository.searchMovies(query, limit);
}
/**
* Remove a movie from the repository
* @param {string} movieId - ID of the movie to remove
* @returns {Promise<boolean>} - True if removal was successful
*/
async removeMovie(movieId) {
return await this.repository.removeMovie(movieId);
}
}
/**
* Create a movie service with the default configuration from environment variables
* @returns {MovieService} - Configured movie service
*/
function createDefaultMovieService() {
// Determine preferred data source
const movieDataSource = process.env.MOVIE_DATA_SOURCE || 'tmdb';
return new MovieService({
dataSource: {
type: movieDataSource,
apiKey: movieDataSource === 'tmdb' ? process.env.TMDB_API_KEY : process.env.OMDB_API_KEY,
fallback: {
type: movieDataSource === 'tmdb' ? 'omdb' : 'tmdb',
apiKey: movieDataSource === 'tmdb' ? process.env.OMDB_API_KEY : process.env.TMDB_API_KEY
}
},
repository: {
type: 'algolia',
config: {
appId: process.env.NEXT_PUBLIC_ALGOLIA_APP_ID,
apiKey: process.env.ALGOLIA_ADMIN_API_KEY,
moviesIndex: process.env.NEXT_PUBLIC_ALGOLIA_INDEX,
votesIndex: process.env.ALGOLIA_VOTES_INDEX || 'paradiso_votes'
}
}
});
}
export { MovieService, createDefaultMovieService };
\ No newline at end of file
import { createDefaultMovieService } from './lib/movie-service';
export default async function handler(req, res) {
// Only allow GET requests
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { title } = req.query;
// Validate required parameters
if (!title) {
return res.status(400).json({ error: 'Missing title parameter' });
}
// Create movie service instance
const movieService = createDefaultMovieService();
// Search for the movie
const movieData = await movieService.searchMovie(title);
if (!movieData) {
return res.status(404).json({ error: 'Movie not found' });
}
// Return the movie data
return res.status(200).json(movieData);
} catch (error) {
console.error('Error searching for movie:', error);
return res.status(500).json({ error: 'Internal server error', message: error.message });
}
}
\ No newline at end of file
import { createDefaultMovieService } from './lib/movie-service';
export default async function handler(req, res) {
// Only allow POST requests
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { movieId, userToken } = req.body;
// Validate required parameters
if (!movieId || !userToken) {
return res.status(400).json({ error: 'Missing required parameters' });
}
// Create movie service instance
const movieService = createDefaultMovieService();
// Vote for the movie
const success = await movieService.voteForMovie(movieId, userToken);
if (!success) {
return res.status(400).json({ error: 'User already voted for this movie' });
}
// Return success response
return res.status(200).json({ success: true });
} catch (error) {
console.error('Error voting for movie:', error);
return res.status(500).json({ error: 'Internal server error', message: error.message });
}
}
\ No newline at end of file
# Paradiso Bot Deployment Guide
This guide will help you deploy the Paradiso movie voting bot on your Debian server or using Replit, as you prefer.
## Environment Variables
The bot requires the following environment variables:
- `DISCORD_TOKEN`: Your Discord bot token
- `ALGOLIA_APP_ID`: Your Algolia application ID
- `ALGOLIA_API_KEY`: Your Algolia API key
- `ALGOLIA_MOVIES_INDEX`: The Algolia index for movies (e.g., `paradiso_movies`)
- `ALGOLIA_VOTES_INDEX`: The Algolia index for votes (e.g., `paradiso_votes`)
- `MOVIE_DATA_SOURCE`: Preferred movie data source (`tmdb`, `omdb`, or `fallback`, defaults to `tmdb`)
At least one of the following API keys is required:
- `TMDB_API_KEY`: Your TMDB API key
- `OMDB_API_KEY`: Your OMDB API key
## Option 1: Deploy on Your Debian Server
### Prerequisites
- Debian 9+ (tested on Debian 9.13 Stretch)
- Python 3.7+ (Python 3.5+ should work, but 3.7+ is recommended)
- pip (Python package manager)
- systemd for service management
### Installation Steps
1. Log in to your server and create a directory for the bot:
```bash
mkdir -p /opt/paradiso-bot
cd /opt/paradiso-bot
```
2. Download the bot files or clone the repository:
```bash
# If you have git installed
git clone https://your-repo-url.git .
# Or manually download and upload the files
```
3. Set up a Python virtual environment (recommended):
```bash
# Install venv if not already installed
apt-get update
apt-get install -y python3-venv
# Create and activate virtual environment
python3 -m venv venv
source venv/bin/activate
```
4. Install the required dependencies:
```bash
pip install discord.py python-dotenv algoliasearch requests
```
5. Create a `.env` file:
```bash
nano .env
```
6. Add your environment variables to the `.env` file:
```
DISCORD_TOKEN=your_discord_token
ALGOLIA_APP_ID=your_algolia_app_id
ALGOLIA_API_KEY=your_algolia_api_key
ALGOLIA_MOVIES_INDEX=paradiso_movies
ALGOLIA_VOTES_INDEX=paradiso_votes
MOVIE_DATA_SOURCE=tmdb
TMDB_API_KEY=your_tmdb_api_key
OMDB_API_KEY=your_omdb_api_key
```
7. Create a systemd service file:
```bash
sudo nano /etc/systemd/system/paradiso-bot.service
```
8. Add the following content to the service file:
```
[Unit]
Description=Paradiso Discord Bot
After=network.target
[Service]
User=your_username
WorkingDirectory=/opt/paradiso-bot
ExecStart=/opt/paradiso-bot/venv/bin/python bot.py
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
```
9. Enable and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable paradiso-bot.service
sudo systemctl start paradiso-bot.service
```
10. Check the status of the service:
```bash
sudo systemctl status paradiso-bot.service
```
### Changing Your Server Hostname (from erable.plnech.fr to nech.pl)
To change your server hostname on Debian:
1. Edit the hostname file:
```bash
sudo nano /etc/hostname
```
2. Replace the current hostname with the new one:
```
nech.pl
```
3. Edit the hosts file:
```bash
sudo nano /etc/hosts
```
4. Update the relevant line:
```
127.0.1.1 nech.pl
```
5. Apply the changes:
```bash
sudo hostname nech.pl
```
6. Restart networking and related services:
```bash
sudo systemctl restart networking
```
7. If you have configured DNS records, update them by:
- Logging into your domain registrar or DNS provider
- Updating the A/AAAA record for `nech.pl` to point to your server's IP
- If using SSL certificates, you may need to renew them for the new domain
8. Reboot your server to ensure all services are using the new hostname:
```bash
sudo reboot
```
## Option 2: Deploy on Replit
### Prerequisites
- A Replit account
- A UptimeRobot account (to keep the bot awake)
### Installation Steps
1. Go to [Replit](https://replit.com) and sign up or log in
2. Click the "+ Create" button
3. Select "Python" as the template
4. Name your repl "ParadisoBot" or similar
5. Click "Create Repl"
6. Upload the `bot.py` file to your Repl
7. Create a `keep_alive.py` file with the following content:
```python
from flask import Flask
from threading import Thread
app = Flask('')
@app.route('/')
def home():
return "Paradiso Bot is alive!"
def run():
app.run(host='0.0.0.0', port=8080)
def keep_alive():
t = Thread(target=run)
t.start()
```
8. Modify the end of your `bot.py` file to use the keep_alive function:
```python
# At the top of the file, add:
from keep_alive import keep_alive
# At the bottom of your file, replace:
if __name__ == "__main__":
client.run(DISCORD_TOKEN)
# With:
if __name__ == "__main__":
keep_alive() # Keep the bot alive
client.run(DISCORD_TOKEN)
```
9. Add the environment variables in Replit:
- Click on the 🔒 icon in the sidebar (or find "Secrets" in the "Tools" menu)
- Add each of the environment variables listed above
10. Create a `pyproject.toml` file for dependencies:
```toml
[tool.poetry]
name = "paradiso-bot"
version = "0.1.0"
description = "Discord bot for Paradiso movie night voting"
authors = ["Your Name <your.email@example.com>"]
[tool.poetry.dependencies]
python = "^3.8"
discord = "^2.0.0"
python-dotenv = "^0.21.0"
algoliasearch = "^2.6.2"
requests = "^2.28.1"
Flask = "^2.2.2"
wikipedia = "^1.4.0" # For fallback movie data
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
```
11. Click the "Run" button to start the bot
12. To keep the bot running 24/7, set up UptimeRobot:
- Go to [UptimeRobot](https://uptimerobot.com/)
- Create a free account
- Click "Add New Monitor"
- Select "HTTP(s)" as the monitor type
- Enter a friendly name like "Paradiso Bot"
- Enter the URL of your Repl webview (e.g., `https://paradisobot.yourusername.repl.co`)
- Set the monitoring interval to 5 minutes
- Click "Create Monitor"
## Movie Data Source Configuration
The bot supports multiple movie data sources in a fallback cascade:
1. Configure which API to use as primary with the `MOVIE_DATA_SOURCE` environment variable:
- `tmdb`: Use The Movie Database API (recommended)
- `omdb`: Use Open Movie Database API
- `fallback`: Attempt to use all available sources in priority order
2. The system will work with any of these configurations:
- TMDB API only: Set `TMDB_API_KEY` and `MOVIE_DATA_SOURCE=tmdb`
- OMDB API only: Set `OMDB_API_KEY` and `MOVIE_DATA_SOURCE=omdb`
- Both APIs: Set both keys and choose your preferred order with `MOVIE_DATA_SOURCE`
3. If both APIs fail, the bot will make a best effort to find information using public sources.
## Verifying the Bot is Working
1. Invite the bot to your Discord server using the OAuth2 URL from the Discord Developer Portal
2. Try using the `/help` command in your server to see all available commands
3. Test adding a movie with `/add [movie title]`
## Monitoring and Troubleshooting
### Checking Logs
On your Debian server, you can check the bot logs using:
```bash
sudo journalctl -u paradiso-bot.service
```
For recent logs only:
```bash
sudo journalctl -u paradiso-bot.service -n 50 --no-pager
```
To follow logs in real-time:
```bash
sudo journalctl -u paradiso-bot.service -f
```
### Common Issues
- **Bot not responding to commands**:
- Check if the bot is running: `systemctl status paradiso-bot`
- Verify Discord token is correct
- Ensure the bot has the proper permissions in Discord
- **Movie search not working**:
- Check if TMDB/OMDB API keys are correct
- Look for API rate limiting errors in logs
- Try switching between data sources
- **Algolia operations failing**:
- Verify Algolia credentials and index names
- Check if indices have been properly created
- Ensure you have the right permissions set for your API key
## Updating the Bot
When you want to update the bot:
1. Stop the current running instance:
```bash
sudo systemctl stop paradiso-bot.service
```
2. Update the code files:
```bash
cd /opt/paradiso-bot
# Pull updates from git or upload new files
```
3. Restart the service:
```bash
sudo systemctl start paradiso-bot.service
```
## Additional Resources
- [Discord.py Documentation](https://discordpy.readthedocs.io/)
- [Algolia Documentation](https://www.algolia.com/doc/)
- [TMDB API Documentation](https://developers.themoviedb.org/3)
- [OMDB API Documentation](http://www.omdbapi.com/)
- [Debian Service Management](https://wiki.debian.org/systemd)
- [Replit Documentation](https://docs.replit.com/)
- [UptimeRobot Documentation](https://uptimerobot.com/help/)
\ No newline at end of file
# Paradiso - Movie Night Voting System
Paradiso is a complete movie night voting system consisting of:
1. A Next.js web interface integrated with your personal site
2. A Discord bot for interaction through Discord
3. Algolia as a database and search engine
4. OMDb API for movie metadata
This system allows you and your friends to vote on movies for your next movie night, ensuring a fair and fun selection process.
## System Architecture
- **Data Storage**: [Algolia](https://www.algolia.com/) (Search & Database)
- **Movie Data**: [OMDb API](https://www.omdbapi.com/) (Open Movie Database)
- **Frontend**: Next.js component on your personal site
- **Bot**: Python Discord bot using discord.py
## Setup Instructions
### Step 1: Set Up Algolia
1. Create a free [Algolia](https://www.algolia.com/) account
2. Create a new application (or use an existing one)
3. Get your Application ID and Admin API Key from the dashboard
4. Run the setup script to configure indices and generate API keys:
```bash
# Install the required package
pip install algoliasearch
# Run the setup script
python setup.py --admin-key YOUR_ADMIN_API_KEY --app-id YOUR_APP_ID
```
The setup script creates:
- A `paradiso_movies` index for storing movie data
- A `paradiso_votes` index for tracking votes
- Necessary API keys with appropriate permissions
- Configuration files for the web app and Discord bot
### Step 2: Get an OMDb API Key
1. Go to [OMDb API](https://www.omdbapi.com/apikey.aspx)
2. Sign up for a free API key (1,000 daily requests)
3. Check your email and activate your key
4. Add your key to the `.env.frontend` and `.env.bot` files generated by the setup script
### Step 3: Set Up the Next.js Frontend
1. Create the API endpoints in your Next.js project:
- `pages/api/paradiso/vote.js` - For voting on movies
- `pages/api/paradiso/search-movie.js` - For searching movies via OMDb API
- `pages/api/paradiso/add-movie.js` - For adding movies to Algolia
2. Create the Paradiso page:
- `pages/paradiso/index.js` - The main movie voting interface
3. Install the required packages:
```bash
npm install algoliasearch react-instantsearch-dom
```
4. Add the environment variables from `.env.frontend` to your Next.js project:
```bash
# .env.local in your Next.js project
NEXT_PUBLIC_ALGOLIA_APP_ID=YOUR_APP_ID
NEXT_PUBLIC_ALGOLIA_SEARCH_KEY=YOUR_SEARCH_KEY
NEXT_PUBLIC_ALGOLIA_INDEX=paradiso_movies
ALGOLIA_ADMIN_API_KEY=YOUR_ADMIN_API_KEY
OMDB_API_KEY=YOUR_OMDB_API_KEY
```
### Step 4: Set Up the Discord Bot
1. Create a new Discord application and bot at the [Discord Developer Portal](https://discord.com/developers/applications)
2. Get your bot token
3. Add the bot to your Discord server with appropriate permissions:
- Read Messages/View Channels
- Send Messages
- Use Slash Commands
- Embed Links
4. Install the required Python packages:
```bash
pip install discord.py python-dotenv algoliasearch requests
```
5. Create a `.env` file for the bot with the environment variables from `.env.bot`:
```bash
# .env for the Discord bot
DISCORD_TOKEN=YOUR_DISCORD_BOT_TOKEN
ALGOLIA_APP_ID=YOUR_APP_ID
ALGOLIA_API_KEY=YOUR_SECURED_API_KEY
ALGOLIA_MOVIES_INDEX=paradiso_movies
ALGOLIA_VOTES_INDEX=paradiso_votes
OMDB_API_KEY=YOUR_OMDB_API_KEY
```
6. Run the bot:
```bash
python bot.py
```
## Usage
### Web Interface
The web interface will be available at `https://your-site.com/paradiso`. Here, users can:
- Search for movies
- Add new movies
- Vote for movies
- See the top voted movies
### Discord Bot
The Discord bot provides the following slash commands:
- `/movies` - List all movies in the voting queue
- `/add [title]` - Add a movie to the voting queue
- `/vote [title]` - Vote for a movie in the queue
- `/remove [title]` - Remove a movie from the voting queue (admin only)
- `/top [count]` - Show the top voted movies (default: top 5)
- `/random` - Suggest a random movie from the list
- `/help` - Show help for all commands
## Security Considerations
The system is secured in the following ways:
1. **Frontend**: Uses a secured API key with restricted permissions
2. **Bot**: Uses a different secured API key with its own permissions
3. **Rate Limiting**: API calls are rate-limited to prevent abuse
4. **User Tokens**: Uses tokens to identify users and prevent duplicate votes
## Limitations and Notes
- The free tier of Algolia provides 10,000 records and 10,000 operations per month, more than enough for a personal movie voting system
- The free tier of OMDb API allows 1,000 requests per day
- The secured API keys generated by the setup script are valid for 1 year, after which you'll need to generate new ones
## Customization
You can customize various aspects of the system:
- Change the index prefix in the setup script
- Modify the web interface styling to match your site
- Adjust the Discord bot's embed colors and messages
- Add additional commands to the bot
## Troubleshooting
- If votes aren't being recorded, check your Algolia API keys and permissions
- If movie searches fail, verify your OMDb API key is active
- If the Discord bot isn't responding, ensure it has the correct permissions in your server
## Future Improvements
- Add user authentication for the web interface
- Implement more advanced voting mechanics (e.g., ranked voting)
- Add movie night scheduling features
- Create a shared watchlist for watched movies
## Credits
- Movie data provided by [OMDb API](https://www.omdbapi.com/)
- Search and database powered by [Algolia](https://www.algolia.com/)
- Discord integration using [discord.py](https://discordpy.readthedocs.io/)
\ No newline at end of file
# Hosting the Paradiso Discord Bot for Free
This guide explains how to host your Paradiso Discord bot for free using Replit, ensuring it runs 24/7 without any costs.
## What is Replit?
[Replit](https://replit.com) is a browser-based IDE that allows you to write, run, and host code in the cloud. It's perfect for hosting Discord bots because:
1. It offers a free tier with no credit card required
2. It can keep your bot running 24/7 (with some setup)
3. It's easy to use and doesn't require server management
## Step 1: Create a Replit Account
1. Go to [Replit](https://replit.com)
2. Sign up for a free account
3. Verify your email address
## Step 2: Create a New Repl
1. Click the "+ Create" button
2. Select "Python" as the template
3. Name your repl "ParadisoBot" or something similar
4. Click "Create Repl"
## Step 3: Set Up Your Bot Files
1. Upload the `paradiso_bot.py` file to your Repl
2. Create a new file called `keep_alive.py` with the following code:
```python
from flask import Flask
from threading import Thread
app = Flask('')
@app.route('/')
def home():
return "Paradiso Bot is alive!"
def run():
app.run(host='0.0.0.0', port=8080)
def keep_alive():
t = Thread(target=run)
t.start()
```
3. Modify the end of your `paradiso_bot.py` file to use the keep_alive function:
```python
# At the top of the file, add:
from keep_alive import keep_alive
# At the bottom of your file, replace:
if __name__ == "__main__":
client.run(DISCORD_TOKEN)
# With:
if __name__ == "__main__":
keep_alive() # Keep the bot alive
client.run(DISCORD_TOKEN)
```
## Step 4: Set Up Environment Variables
Replit provides a secure way to store sensitive information like API keys.
1. In your Repl, click on the 🔒 icon in the sidebar (or find "Secrets" in the "Tools" menu)
2. Add the following secrets:
- Key: `DISCORD_TOKEN`, Value: `your-discord-bot-token`
- Key: `ALGOLIA_APP_ID`, Value: `your-algolia-app-id`
- Key: `ALGOLIA_API_KEY`, Value: `your-algolia-api-key`
- Key: `ALGOLIA_MOVIES_INDEX`, Value: `paradiso_movies`
- Key: `ALGOLIA_VOTES_INDEX`, Value: `paradiso_votes`
- Key: `OMDB_API_KEY`, Value: `your-omdb-api-key`
## Step 5: Install Dependencies
1. Create a new file called `pyproject.toml` with the following content:
```toml
[tool.poetry]
name = "paradiso-bot"
version = "0.1.0"
description = "Discord bot for Paradiso movie night voting"
authors = ["Your Name <your.email@example.com>"]
[tool.poetry.dependencies]
python = "^3.8"
discord = "^2.0.0"
python-dotenv = "^0.21.0"
algoliasearch = "^2.6.2"
requests = "^2.28.1"
Flask = "^2.2.2"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
```
2. Replit will automatically install the dependencies when you run the bot
## Step 6: Run Your Bot
1. Click the "Run" button at the top of the page
2. Your bot should start up and connect to Discord
3. You'll see a webview showing "Paradiso Bot is alive!"
## Step 7: Keep Your Bot Running 24/7
By default, Replit will stop your bot after some time of inactivity. To keep it running:
1. Go to [UptimeRobot](https://uptimerobot.com/)
2. Create a free account
3. Click "Add New Monitor"
4. Select "HTTP(s)" as the monitor type
5. Enter a friendly name like "Paradiso Bot"
6. Enter the URL of your Repl webview (e.g., `https://paradisobot.yourusername.repl.co`)
7. Set the monitoring interval to 5 minutes
8. Click "Create Monitor"
UptimeRobot will now ping your bot every 5 minutes, keeping it alive 24/7.
## Step 8: Update Your Bot
To update your bot when you make changes:
1. Make changes to the files in your Repl
2. Click the "Stop" button if your bot is running
3. Click the "Run" button to restart your bot with the changes
## Alternative Free Hosting Options
If you prefer not to use Replit, here are some alternatives:
### Railway
[Railway](https://railway.app/) offers a generous free tier:
- 5 projects
- 500 hours of runtime per month
- 1GB memory per container
### Render
[Render](https://render.com/) offers a free tier for web services:
- Free for web services (sleeps after 15 minutes of inactivity)
- Wakes up when receiving a request
### Oracle Cloud Free Tier
[Oracle Cloud](https://www.oracle.com/cloud/free/) offers always-free services:
- 2 AMD-based Compute VMs
- 4 ARM-based Ampere A1 cores and 24 GB memory
- 200 GB of storage
## Troubleshooting
- **Bot crashes or doesn't respond**: Check the console output in Replit for error messages
- **UptimeRobot says the site is down**: Make sure your Flask app is running on port 8080
- **Bot doesn't respond to commands**: Ensure your bot has the correct permissions in your Discord server
- **Algolia operations fail**: Check that your API keys and indices are correctly configured
## Notes
- Replit's free tier may occasionally experience slowdowns during high-traffic periods
- The bot might briefly go offline when Replit performs maintenance updates
- For a more robust solution, consider upgrading to Replit's paid plan or hosting on a VPS
## Additional Resources
- [Replit Documentation](https://docs.replit.com/)
- [Discord.py Documentation](https://discordpy.readthedocs.io/)
- [UptimeRobot Documentation](https://uptimerobot.com/help/)
\ No newline at end of file
import React, { useState, useEffect } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import algoliasearch from 'algoliasearch';
import { InstantSearch, SearchBox, Hits, Configure } from 'react-instantsearch-dom';
import Image from 'next/image';
import Layout from '@/components/layout';
import utilStyles from '@/styles/utils.module.css';
import styles from '@/styles/paradiso.module.css';
// Initialize the Algolia client
// TODO CONFIRM .env IS LOADED
const searchClient = algoliasearch(
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID,
process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY
);
// Unique user token for this session - in a real app, this would be tied to user authentication
const getUserToken = () => {
if (typeof window === 'undefined') return null;
let userToken = localStorage.getItem('paradiso_user_token');
if (!userToken) {
// Generate a random user token
userToken = 'user_' + Math.random().toString(36).substring(2, 15);
localStorage.setItem('paradiso_user_token', userToken);
}
return userToken;
};
// Movie Hit component - Displays a single movie
const MovieHit = ({ hit, onVote }) => {
return (
<div className={styles.movieCard}>
{hit.poster ? (
<div className={styles.moviePoster}>
<Image
src={hit.poster}
alt={hit.title}
width={200}
height={300}
layout="responsive"
/>
</div>
) : (
<div className={styles.noImagePlaceholder}>No Image</div>
)}
<div className={styles.movieInfo}>
<h3 className={styles.movieTitle}>
{hit.title} {hit.year && <span className={styles.movieYear}>({hit.year})</span>}
</h3>
{hit.director && (
<p className={styles.movieDirector}>Director: {hit.director}</p>
)}
{hit.actors && hit.actors.length > 0 && (
<p className={styles.movieActors}>Starring: {hit.actors.join(', ')}</p>
)}
{hit.plot && (
<p className={styles.moviePlot}>{hit.plot}</p>
)}
<div className={styles.movieMeta}>
{hit.imdbRating && (
<span className={styles.movieRating}> {hit.imdbRating}/10</span>
)}
{hit.source && (
<span className={styles.movieSource}>Source: {hit.source.toUpperCase()}</span>
)}
</div>
<div className={styles.movieActions}>
<button
onClick={() => onVote(hit.objectID)}
className={styles.voteButton}
>
👍 Vote ({hit.votes || 0})
</button>
</div>
</div>
</div>
);
};
// Empty results component
const EmptyResults = () => (
<div className={styles.emptyResults}>
<h3>No movies found</h3>
<p>Try a different search or add a new movie below</p>
</div>
);
export default function Paradiso() {
const router = useRouter();
const [userToken, setUserToken] = useState(null);
const [isSearching, setIsSearching] = useState(false);
const [newMovieTitle, setNewMovieTitle] = useState('');
const [isAdding, setIsAdding] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
// Set user token on client-side
useEffect(() => {
setUserToken(getUserToken());
}, []);
// Function to vote for a movie
const handleVote = async (movieId) => {
if (!userToken) return;
try {
// Use Algolia's partial update to increment the votes counter
const response = await fetch('/api/paradiso/vote', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
movieId,
userToken,
}),
});
if (!response.ok) {
throw new Error('Failed to vote for movie');
}
setSuccess('Vote recorded successfully!');
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
console.error('Error voting for movie:', err);
setError('Failed to vote for movie. Please try again.');
setTimeout(() => setError(null), 5000);
}
};
// Function to add a new movie
const handleAddMovie = async (e) => {
e.preventDefault();
if (!newMovieTitle.trim() || !userToken) return;
setIsAdding(true);
setError(null);
try {
// Add the movie directly by title
const addResponse = await fetch('/api/paradiso/add-movie', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: newMovieTitle,
userToken,
}),
});
if (!addResponse.ok) {
const errorData = await addResponse.json();
throw new Error(errorData.error || 'Failed to add movie');
}
setNewMovieTitle('');
setSuccess('Movie added successfully!');
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
console.error('Error adding movie:', err);
setError(err.message || 'Failed to add movie. Please try again.');
setTimeout(() => setError(null), 5000);
} finally {
setIsAdding(false);
}
};
return (
<Layout>
<Head>
<title>Paradiso - Movie Night Voting</title>
<meta name="description" content="Vote for the next movie night film" />
</Head>
<div className={styles.container}>
<h1 className={styles.title}>🎬 Paradiso</h1>
<p className={styles.subtitle}>Vote pour <b>notre</b> film et écris l'avenir du <i>Cinéma Paradiso</i>.</p>
{/* Error and success messages */}
{error && <div className={styles.errorMessage}>{error}</div>}
{success && <div className={styles.successMessage}>{success}</div>}
{/* InstantSearch component */}
{userToken && (
<InstantSearch
searchClient={searchClient}
indexName={process.env.NEXT_PUBLIC_ALGOLIA_INDEX}
>
<div className={styles.searchContainer}>
<SearchBox
className={styles.searchBox}
translations={{
placeholder: 'Search for movies...',
}}
onFocus={() => setIsSearching(true)}
onBlur={() => setTimeout(() => setIsSearching(false), 200)}
/>
<Configure
hitsPerPage={12}
distinct={true}
/>
{isSearching && (
<div className={styles.searchResults}>
<Hits
hitComponent={({ hit }) => (
<MovieHit hit={hit} onVote={handleVote} />
)}
classNames={{
list: styles.hitsList,
item: styles.hitItem,
empty: styles.noResults,
}}
emptyComponent={EmptyResults}
/>
</div>
)}
</div>
{/* Add movie form */}
<div className={styles.addMovieSection}>
<h2>Can't find the movie? Add it</h2>
<form onSubmit={handleAddMovie} className={styles.addMovieForm}>
<input
type="text"
value={newMovieTitle}
onChange={(e) => setNewMovieTitle(e.target.value)}
placeholder="Enter movie title..."
disabled={isAdding}
required
/>
<button
type="submit"
disabled={isAdding || !newMovieTitle.trim()}
>
{isAdding ? 'Adding...' : 'Add Movie'}
</button>
</form>
<p className={styles.infoText}>
Movie data is fetched from TMDB, OMDB, or Wikipedia as a fallback
</p>
</div>
{/* Top voted movies */}
<div className={styles.topMoviesSection}>
<h2>Top Voted Movies</h2>
<Configure
hitsPerPage={5}
filters="votes>0"
sortCriteria={['votes:desc', 'title:asc']}
/>
<Hits
hitComponent={({ hit }) => (
<MovieHit hit={hit} onVote={handleVote} />
)}
classNames={{
list: styles.hitsList,
item: styles.hitItem,
empty: styles.noResults,
}}
/>
</div>
</InstantSearch>
)}
</div>
<style jsx>{`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.title {
font-size: 2.5rem;
text-align: center;
margin-bottom: 1rem;
}
.subtitle {
font-size: 1.2rem;
text-align: center;
margin-bottom: 2rem;
color: #666;
}
`}</style>
</Layout>
);
}
\ No newline at end of file
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.title {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #333;
}
.subtitle {
font-size: 1.5rem;
margin-bottom: 2rem;
color: #666;
}
.searchContainer {
background-color: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.searchBar {
display: flex;
margin-bottom: 20px;
}
.searchBar input {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px 0 0 4px;
font-size: 16px;
}
.searchBar button {
background-color: #2196F3;
color: white;
border: none;
padding: 12px 20px;
border-radius: 0 4px 4px 0;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.searchBar button:hover {
background-color: #0b7dda;
}
.searchBar button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.movieGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
margin-top: 20px;
}
.movieCard {
position: relative;
border-radius: 8px;
overflow: hidden;
transition: transform 0.3s ease;
background-color: #1a1a1a;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.movieCard:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
}
.posterContainer {
position: relative;
width: 100%;
height: 270px;
overflow: hidden;
}
.poster {
width: 100%;
height: 100%;
object-fit: cover;
}
.noPoster {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #2a2a2a;
color: #999;
text-align: center;
padding: 10px;
}
.movieOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.9) 100%);
color: white;
padding: 15px;
display: flex;
flex-direction: column;
opacity: 0;
transition: opacity 0.3s ease;
}
.movieCard:hover .movieOverlay {
opacity: 1;
}
.movieTitle {
font-size: 16px;
font-weight: 600;
margin: 0 0 5px 0;
}
.movieYear {
font-size: 14px;
color: #ccc;
margin-bottom: 5px;
}
.movieRating {
font-size: 14px;
color: #ffcc00;
margin-bottom: 5px;
}
.movieGenres {
font-size: 12px;
color: #aaa;
margin-bottom: 10px;
}
.moviePlot {
font-size: 12px;
line-height: 1.4;
color: #ddd;
flex-grow: 1;
overflow: hidden;
}
.movieActions {
margin-top: auto;
}
.voteButton {
background-color: #e50914;
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease;
width: 100%;
}
.voteButton:hover {
background-color: #f40612;
}
.voteButton:disabled {
background-color: #666;
cursor: not-allowed;
}
.movieInfo {
padding: 10px;
}
.movieInfo h4 {
margin: 0;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.movieMeta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #999;
margin-top: 5px;
}
.votes {
color: #e50914;
}
.searchSection {
margin-bottom: 30px;
}
.searchForm {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.searchInput {
flex-grow: 1;
padding: 12px;
border-radius: 4px;
border: 1px solid #333;
background-color: #222;
color: white;
}
.searchButton {
background-color: #e50914;
color: white;
border: none;
border-radius: 4px;
padding: 0 20px;
cursor: pointer;
}
.filterSection {
display: flex;
gap: 15px;
margin-top: 10px;
}
.filterButton {
background-color: transparent;
border: 1px solid #333;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
}
.winner {
position: relative;
}
.winnerBadge {
position: absolute;
top: -10px;
right: -10px;
background-color: gold;
color: black;
padding: 5px 10px;
border-radius: 20px;
font-weight: bold;
z-index: 1;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.manualAdd {
margin-top: 20px;
}
.manualAdd form {
display: flex;
}
.manualAdd input {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px 0 0 4px;
font-size: 16px;
}
.manualAdd button {
background-color: #2196F3;
color: white;
border: none;
padding: 12px 20px;
border-radius: 0 4px 4px 0;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.manualAdd button:hover {
background-color: #0b7dda;
}
.manualAdd button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.searchResults {
margin: 20px 0;
}
.noResults {
text-align: center;
padding: 30px;
background-color: #f9f9f9;
border-radius: 4px;
color: #666;
}
/* Responsive styles */
@media (max-width: 768px) {
.movieGrid {
grid-template-columns: 1fr;
}
.searchBar {
flex-direction: column;
}
.searchBar input {
border-radius: 4px;
margin-bottom: 10px;
}
.searchBar button {
border-radius: 4px;
}
.manualAdd form {
flex-direction: column;
}
.manualAdd input {
border-radius: 4px;
margin-bottom: 10px;
}
.manualAdd button {
border-radius: 4px;
}
}
\ No newline at end of file
......@@ -365,7 +365,6 @@
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Hover Popup Styles */
.hoverReveal {
position: relative;
......@@ -480,6 +479,8 @@
min-width: 380px;
max-width: 500px;
width: max-content;
max-width: calc(100vw - 40px); /* Ensure popup doesn't exceed viewport width */
animation: fadeIn 0.2s ease-in-out;
}
......@@ -573,12 +574,25 @@
/* Mobile responsiveness */
@media (max-width: 768px) {
.hoverPopup {
min-width: 280px;
max-width: 340px;
min-width: auto;
max-width: calc(100vw - 40px); /* 20px margin on each side */
width: max-content;
left: 50%;
transform: translateX(-50%);
}
.popupContent {
padding: 0.8rem;
font-size: 0.9rem;
}
.popupLinks {
flex-direction: column;
align-items: stretch;
}
.popupLinks a {
text-align: center;
width: 100%;
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment