Merge branch 'reactShows' into 'master'

React shows first draft

See merge request !27
This commit is contained in:
Lucas 2017-01-03 14:45:03 +00:00
commit 5e426fffc8
14 changed files with 500 additions and 130 deletions

View File

@ -80,12 +80,12 @@ var (
type Show struct { type Show struct {
sqly.BaseModel sqly.BaseModel
polochon.Show polochon.Show
Episodes []*Episode Episodes []*Episode `json:"episodes"`
TrackedSeason int TrackedSeason int `json:"tracked_season"`
TrackedEpisode int TrackedEpisode int `json:"tracked_episode"`
BannerURL string `json:"banner_url"` BannerURL string `json:"banner_url"`
FanartURL string `json:"fanart_url"` FanartURL string `json:"fanart_url"`
PosterURL string `json:"poster_url"` PosterURL string `json:"poster_url"`
} }
// ShowDB represents the Show in the DB // ShowDB represents the Show in the DB
@ -100,7 +100,6 @@ type ShowDB struct {
FirstAired time.Time `db:"first_aired"` FirstAired time.Time `db:"first_aired"`
Created time.Time `db:"created_at"` Created time.Time `db:"created_at"`
Updated time.Time `db:"updated_at"` Updated time.Time `db:"updated_at"`
// URL string `json:"-"`
} }
// NewShowDB returns a Show ready to be put in DB from a // NewShowDB returns a Show ready to be put in DB from a
@ -248,7 +247,7 @@ func (s *Show) GetDetails(env *web.Env, force bool) error {
// GetPosterURL returns the image URL or the default image if the poster is not yet downloaded // GetPosterURL returns the image URL or the default image if the poster is not yet downloaded
func (s *Show) GetPosterURL(env *web.Env) string { func (s *Show) GetPosterURL(env *web.Env) string {
// Check if the movie image exists // Check if the movie image exists
if _, err := os.Stat(s.imgURL(env, "poster")); os.IsNotExist(err) { if _, err := os.Stat(s.imgFile(env, "poster")); os.IsNotExist(err) {
// TODO image in the config ? // TODO image in the config ?
return "img/noimage.png" return "img/noimage.png"
} }
@ -273,11 +272,16 @@ func (s *Show) downloadImages(env *web.Env) {
} }
// imgFile returns the image location on disk // imgFile returns the image location on disk
func (s *Show) imgURL(env *web.Env, imgType string) string { func (s *Show) imgFile(env *web.Env, imgType string) string {
fileURL := fmt.Sprintf("img/shows/%s-%s.jpg", s.ImdbID, imgType) fileURL := fmt.Sprintf("img/shows/%s-%s.jpg", s.ImdbID, imgType)
return filepath.Join(env.Config.PublicDir, fileURL) return filepath.Join(env.Config.PublicDir, fileURL)
} }
// imgURL returns the default image url
func (s *Show) imgURL(env *web.Env, imgType string) string {
return fmt.Sprintf("img/shows/%s-%s.jpg", s.ImdbID, imgType)
}
// GetDetailsAsUser like GetDetails but with User context // GetDetailsAsUser like GetDetails but with User context
func (s *Show) GetDetailsAsUser(db *sqlx.DB, user *users.User, log *logrus.Entry) error { func (s *Show) GetDetailsAsUser(db *sqlx.DB, user *users.User, log *logrus.Entry) error {
var err error var err error

View File

@ -1,18 +1,8 @@
import { configureAxios, request } from '../requests' import { configureAxios, request } from '../requests'
// Select Movie // ======================
export function selectMovie(imdbId) { // Errors
return { // ======================
type: 'SELECT_MOVIE',
imdbId
}
}
export function isUserLoggedIn() {
return {
type: 'IS_USER_LOGGED_IN',
}
}
export function addError(message) { export function addError(message) {
return { return {
@ -29,12 +19,22 @@ export function dismissError() {
} }
} }
// ======================
// Users
// ======================
export function userLogout() { export function userLogout() {
return { return {
type: 'USER_LOGOUT', type: 'USER_LOGOUT',
} }
} }
export function isUserLoggedIn() {
return {
type: 'IS_USER_LOGGED_IN',
}
}
export function loginUser(username, password) { export function loginUser(username, password) {
return request( return request(
'USER_LOGIN', 'USER_LOGIN',
@ -69,6 +69,17 @@ export function getUserInfos() {
) )
} }
// ======================
// Movies
// ======================
export function selectMovie(imdbId) {
return {
type: 'SELECT_MOVIE',
imdbId
}
}
export function getMovieDetails(imdbId) { export function getMovieDetails(imdbId) {
return request( return request(
'MOVIE_GET_DETAILS', 'MOVIE_GET_DETAILS',
@ -82,3 +93,28 @@ export function fetchMovies(url) {
configureAxios().get(url) configureAxios().get(url)
) )
} }
// ======================
// Shows
// ======================
export function fetchShows(url) {
return request(
'SHOW_LIST_FETCH',
configureAxios().get(url)
)
}
export function fetchShowDetails(imdbId) {
return request(
'SHOW_FETCH_DETAILS',
configureAxios().get(`/shows/${imdbId}`)
)
}
export function selectShow(imdbId) {
return {
type: 'SELECT_SHOW',
imdbId
}
}

View File

@ -29,6 +29,8 @@ import store, { history } from './store'
import NavBar from './components/navbar' import NavBar from './components/navbar'
import Error from './components/errors' import Error from './components/errors'
import MovieList from './components/movies/list' import MovieList from './components/movies/list'
import ShowList from './components/shows/list'
import ShowDetails from './components/shows/details'
import UserLoginForm from './components/users/login' import UserLoginForm from './components/users/login'
import UserEdit from './components/users/edit' import UserEdit from './components/users/edit'
import UserSignUp from './components/users/signup' import UserSignUp from './components/users/signup'
@ -53,6 +55,7 @@ class Main extends React.Component {
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
movieStore: state.movieStore, movieStore: state.movieStore,
showStore: state.showStore,
userStore: state.userStore, userStore: state.userStore,
errors: state.errors, errors: state.errors,
} }
@ -81,6 +84,14 @@ const MovieListPolochon = (props) => (
<MovieList {...props} moviesUrl='/movies/polochon'/> <MovieList {...props} moviesUrl='/movies/polochon'/>
) )
const ShowListPopular = (props) => (
<ShowList {...props} showsUrl='/shows/explore'/>
)
const ShowDetailsView = (props) => (
<ShowDetails {...props} />
)
ReactDOM.render(( ReactDOM.render((
<Provider store={store}> <Provider store={store}>
<Router history={history}> <Router history={history}>
@ -91,6 +102,8 @@ ReactDOM.render((
<Route path="/users/edit" component={UserIsAuthenticated(UserEdit)} /> <Route path="/users/edit" component={UserIsAuthenticated(UserEdit)} />
<Route path="/movies/popular" component={UserIsAuthenticated(MovieListPopular)} /> <Route path="/movies/popular" component={UserIsAuthenticated(MovieListPopular)} />
<Route path="/movies/polochon(/:page)" component={UserIsAuthenticated(MovieListPolochon)} /> <Route path="/movies/polochon(/:page)" component={UserIsAuthenticated(MovieListPolochon)} />
<Route path="/shows/popular" component={UserIsAuthenticated(ShowListPopular)} />
<Route path="/shows/details/:imdbId" component={UserIsAuthenticated(ShowDetailsView)} />
</Route> </Route>
</Router> </Router>
</Provider> </Provider>

View File

@ -0,0 +1,24 @@
import React from 'react'
export default function ListDetails(props) {
return (
<div className="col-xs-7 col-md-4">
<div className="show-detail affix">
<h1 className="hidden-xs">{props.data.title}</h1>
<h3 className="visible-xs">{props.data.title}</h3>
{props.data.runtime &&
<p>
<i className="fa fa-clock-o"></i>
&nbsp;{props.data.runtime} min
</p>
}
<p>
<i className="fa fa-star-o"></i>
&nbsp;{props.data.rating} <small>({props.data.votes} counts)</small>
</p>
<p className="plot">{props.data.plot}</p>
</div>
{props.children}
</div>
);
}

View File

@ -0,0 +1,22 @@
import React from 'react'
import { Control, Form } from 'react-redux-form';
export default function ListFilter(props) {
return (
<div className="col-xs-12 col-md-12 list-filter">
<div className="row">
<Form model={props.formModel} className="input-group" >
<Control.text
model={props.controlModel}
className="form-control input-sm"
placeholder={props.controlPlaceHolder}
updateOn="change"
/>
<span className="input-group-btn">
<button className="btn btn-default btn-sm" type="button">Filter</button>
</span>
</Form>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
import React from 'react'
export default function ListPoster(props) {
const selected = props.selected ? ' thumbnail-selected' : '';
const imgClass = 'thumbnail' + selected;
return (
<div className="col-xs-12 col-sm-6 col-md-3 col-lg-2">
<a className={imgClass}>
<img
src={props.data.poster_url}
onClick={props.onClick}
/>
</a>
</div>
);
}

View File

@ -0,0 +1,45 @@
import React from 'react'
import fuzzy from 'fuzzy';
import ListFilter from './filter'
import ListPoster from './poster'
export default function ListPosters(props) {
let elmts = props.data.slice();
// Filter the list of elements
if (props.filter !== "") {
const filtered = fuzzy.filter(props.filter, elmts, {
extract: (el) => el.title
});
elmts = filtered.map((el) => el.original);
}
// Limit the number of results
if (elmts.length > props.perPage) {
elmts = elmts.slice(0, props.perPage);
}
return (
<div className="col-xs-5 col-md-8">
<ListFilter
formModel={props.formModel}
controlModel={props.controlModel}
controlPlaceHolder={props.controlPlaceHolder}
/>
<div className="row">
{elmts.map(function(el, index) {
const selected = (el.imdb_id === props.selectedImdbId) ? true : false;
return (
<ListPoster
data={el}
key={el.imdb_id}
selected={selected}
onClick={() => props.onClick(el.imdb_id)}
/>
)}
)}
</div>
</div>
);
}

View File

@ -1,101 +1,7 @@
import React from 'react' import React from 'react'
import axios from 'axios'
import { Control, Form } from 'react-redux-form';
import fuzzy from 'fuzzy';
function MoviePosters(props) { import ListPosters from '../list/posters'
let movies = props.movies.slice(); import ListDetails from '../list/details'
// Filter the movies
if (props.filter !== "") {
const filtered = fuzzy.filter(props.filter, movies, {
extract: (el) => el.title
});
movies = filtered.map((el) => el.original);
}
// Limit the number of results
if (movies.length > props.perPage) {
movies = movies.slice(0, props.perPage);
}
return (
<div className="col-xs-5 col-md-8">
<MovieListFilter />
<div className="row">
{movies.map(function(movie, index) {
const selected = (movie.imdb_id === props.selectedMovieId) ? true : false;
return (
<MoviePoster
data={movie}
key={movie.imdb_id}
selected={selected}
onClick={() => props.onClick(movie.imdb_id)}
/>
)}
)}
</div>
</div>
);
}
class MovieListFilter extends React.Component {
render() {
return (
<div className="col-xs-12 col-md-12 movie-list-filter">
<div className="row">
<Form model="movieStore" className="input-group" >
<Control.text
model="movieStore.filter"
className="form-control input-sm"
placeholder="Filter movies..."
updateOn="change"
/>
<span className="input-group-btn">
<button className="btn btn-default btn-sm" type="button">Filter</button>
</span>
</Form>
</div>
</div>
);
}
}
function MoviePoster(props) {
const selected = props.selected ? ' thumbnail-selected' : '';
const imgClass = 'thumbnail' + selected;
return (
<div className="col-xs-12 col-sm-6 col-md-3 col-lg-2">
<a className={imgClass}>
<img
src={props.data.poster_url}
onClick={props.onClick}
/>
</a>
</div>
);
}
function MovieDetails(props) {
return (
<div className="col-xs-7 col-md-4">
<div className="movie-detail affix">
<h1 className="hidden-xs">{props.movie.title}</h1>
<h3 className="visible-xs">{props.movie.title}</h3>
<p>
<i className="fa fa-clock-o"></i>
&nbsp;{props.movie.runtime} min
</p>
<p>
<i className="fa fa-star-o"></i>
&nbsp;{props.movie.rating} <small>({props.movie.votes} counts)</small>
</p>
<p className="movie-plot">{props.movie.plot}</p>
</div>
<MovieButtons {...props} />
</div>
);
}
class MovieButtons extends React.Component { class MovieButtons extends React.Component {
constructor(props) { constructor(props) {
@ -112,7 +18,7 @@ class MovieButtons extends React.Component {
render() { render() {
const imdb_link = `http://www.imdb.com/title/${this.props.movie.imdb_id}`; const imdb_link = `http://www.imdb.com/title/${this.props.movie.imdb_id}`;
return ( return (
<div className="movie-details-buttons btn-toolbar"> <div className="list-details-buttons btn-toolbar">
<a type="button" className="btn btn-default btn-sm" onClick={this.handleClick}> <a type="button" className="btn btn-default btn-sm" onClick={this.handleClick}>
{this.props.fetching || {this.props.fetching ||
<span> <span>
@ -159,19 +65,24 @@ export default class MovieList extends React.Component {
const selectedMovie = movies[index]; const selectedMovie = movies[index];
return ( return (
<div className="row" id="container"> <div className="row" id="container">
<MoviePosters <ListPosters
movies={movies} data={movies}
selectedMovieId={selectedMovieId} formModel="movieStore"
controlModel="movieStore.filter"
controlPlaceHolder="Filter movies..."
selectedImdbId={selectedMovieId}
filter={this.props.movieStore.filter} filter={this.props.movieStore.filter}
perPage={this.props.movieStore.perPage} perPage={this.props.movieStore.perPage}
onClick={this.props.selectMovie} onClick={this.props.selectMovie}
/> />
{selectedMovie && {selectedMovie &&
<MovieDetails <ListDetails data={selectedMovie}>
movie={selectedMovie} <MovieButtons
fetching={this.props.movieStore.fetchingDetails} movie={selectedMovie}
getMovieDetails={this.props.getMovieDetails} fetching={this.props.movieStore.fetchingDetails}
/> getMovieDetails={this.props.getMovieDetails}
/>
</ListDetails>
} }
</div> </div>
); );

View File

@ -27,6 +27,9 @@ export default class NavBar extends React.Component {
<LinkContainer to="/movies/polochon"> <LinkContainer to="/movies/polochon">
<NavItem>Polochon movies</NavItem> <NavItem>Polochon movies</NavItem>
</LinkContainer> </LinkContainer>
<LinkContainer to="/shows/popular">
<NavItem>Polochon shows</NavItem>
</LinkContainer>
</Nav> </Nav>
<Nav pullRight> <Nav pullRight>
{isLoggedIn || {isLoggedIn ||

View File

@ -0,0 +1,145 @@
import React from 'react'
export default class ShowDetails extends React.Component {
componentWillMount() {
this.props.fetchShowDetails(this.props.params.imdbId);
}
render() {
return (
<div className="row" id="container">
<Header data={this.props.showStore.show} />
<SeasonsList data={this.props.showStore.show} />
</div>
);
}
}
function Header(props){
return (
<div className="col-xs-12 col-sm-10 col-sm-offset-1 col-md-10 col-md-offset-1">
<div className="panel panel-default">
<div className="panel-body">
<HeaderThumbnail data={props.data} />
<HeaderDetails data={props.data} />
</div>
</div>
</div>
);
}
function HeaderThumbnail(props){
return (
<div className="col-xs-12 col-sm-2 text-center">
<img src={props.data.poster_url} className="show-thumbnail thumbnail-selected img-thumbnail img-responsive"/>
</div>
);
}
function HeaderDetails(props){
const imdbLink = `http://www.imdb.com/title/${props.data.imdb_id}`;
return (
<div className="col-xs-12 col-sm-10">
<dl className="dl-horizontal">
<dt>Title</dt>
<dd>{props.data.title}</dd>
<dt>Plot</dt>
<dd className="plot">{props.data.plot}</dd>
<dt>IMDB</dt>
<dd>
<a type="button" className="btn btn-warning btn-xs" href={imdbLink}>
<i className="fa fa-external-link"></i> Open in IMDB
</a>
</dd>
<dt>Year</dt>
<dd>{props.data.year}</dd>
<dt>Rating</dt>
<dd>{props.data.rating}</dd>
</dl>
</div>
);
}
function SeasonsList(props){
return (
<div>
{props.data.seasons.length > 0 && props.data.seasons.map(function(season, index) {
return (
<div className="col-xs-12 col-sm-10 col-sm-offset-1 col-md-10 col-md-offset-1" key={index}>
<Season data={season} />
</div>
)
})}
</div>
)
}
class Season extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.state = { colapsed: true };
}
handleClick(e) {
e.preventDefault();
this.setState({ colapsed: !this.state.colapsed });
}
render() {
return (
<div className="panel panel-default">
<div className="panel-heading clickable" onClick={(e) => this.handleClick(e)}>
Season {this.props.data.season}
<small className="text-primary"> ({this.props.data.episodes.length} episodes)</small>
<span className="pull-right">
{this.state.colapsed ||
<i className="fa fa-chevron-down"></i>
}
{this.state.colapsed &&
<i className="fa fa-chevron-left"></i>
}
</span>
</div>
{this.state.colapsed ||
<table className="table table-striped">
<tbody>
{this.props.data.episodes.map(function(episode, index) {
let key = `${episode.season}-${episode.episode}`;
return (
<Episode key={key} data={episode} />
)
})}
</tbody>
</table>
}
</div>
)
}
}
function Episode(props) {
return (
<tr>
<th scope="row" className="col-xs-1">{props.data.episode}</th>
<td className="col-xs-8">{props.data.title}</td>
<td className="col-xs-3">
<span className="pull-right">
{props.data.torrents && props.data.torrents.map(function(torrent, index) {
let key = `${props.data.season}-${props.data.episode}-${torrent.source}-${torrent.quality}`;
return (
<Torrent data={torrent} key={key} />
)
})}
</span>
</td>
</tr>
)
}
function Torrent(props) {
return (
<span className="episode-button">
<a type="button" className="btn btn-primary btn-xs" href={props.data.url}>
<i className="fa fa-download"></i> {props.data.quality}
</a>
</span>
)
}

View File

@ -0,0 +1,53 @@
import React from 'react'
import { Link } from 'react-router'
import ListDetails from '../list/details'
import ListPosters from '../list/posters'
function ShowButtons(props) {
const imdbLink = `http://www.imdb.com/title/${props.show.imdb_id}`;
return (
<div className="list-details-buttons btn-toolbar">
<a type="button" className="btn btn-warning btn-sm" href={imdbLink}>
<i className="fa fa-external-link"></i> IMDB
</a>
<Link type="button" className="btn btn-primary btn-sm" to={"/shows/details/" + props.show.imdb_id}>
<i className="fa fa-external-link"></i> Details
</Link>
</div>
);
}
export default class ShowList extends React.Component {
componentWillMount() {
this.props.fetchShows(this.props.showsUrl);
}
render() {
const shows = this.props.showStore.shows;
const selectedShowId = this.props.showStore.selectedImdbId;
let index = shows.map((el) => el.imdb_id).indexOf(selectedShowId);
if (index === -1) {
index = 0;
}
const selectedShow = shows[index];
return (
<div className="row" id="container">
<ListPosters
data={shows}
formModel="showStore"
controlModel="showStore.filter"
controlPlaceHolder="Filter shows..."
selectedImdbId={selectedShowId}
filter={this.props.showStore.filter}
perPage={this.props.showStore.perPage}
onClick={this.props.selectShow}
/>
{selectedShow &&
<ListDetails data={selectedShow} >
<ShowButtons show={selectedShow} />
</ListDetails>
}
</div>
);
}
}

View File

@ -2,6 +2,7 @@ import { combineForms } from 'react-redux-form'
import { routerReducer } from 'react-router-redux' import { routerReducer } from 'react-router-redux'
import movieStore from './movies' import movieStore from './movies'
import showStore from './shows'
import userStore from './users' import userStore from './users'
import errors from './errors' import errors from './errors'
@ -11,6 +12,7 @@ import errors from './errors'
const rootReducer = combineForms({ const rootReducer = combineForms({
routing: routerReducer, routing: routerReducer,
movieStore, movieStore,
showStore,
userStore, userStore,
errors, errors,
}) })

View File

@ -0,0 +1,84 @@
const defaultState = {
shows: [],
filter: "",
perPage: 30,
selectedImdbId: "",
show: {
seasons: [],
},
};
export default function showStore(state = defaultState, action) {
switch (action.type) {
case 'SHOW_LIST_FETCH_FULFILLED':
let selectedImdbId = "";
// Select the first show
if (action.payload.data.length > 0) {
selectedImdbId = action.payload.data[0].imdb_id;
}
return Object.assign({}, state, {
shows: action.payload.data,
selectedImdbId: selectedImdbId,
})
case 'SHOW_FETCH_DETAILS_FULFILLED':
return Object.assign({}, state, {
show: sortEpisodes(action.payload.data),
})
return state;
case 'SELECT_SHOW':
// Don't select the show if we're fetching another show's details
if (state.fetchingDetails) {
return state
}
return Object.assign({}, state, {
selectedImdbId: action.imdbId,
})
default:
return state
}
}
function sortEpisodes(show) {
let episodes = show.episodes;
delete show["episodes"];
if (episodes.length == 0) {
return show;
}
// Extract the seasons
let seasons = {};
for (let ep of episodes) {
if (!seasons[ep.season]) {
seasons[ep.season] = { episodes: [] };
}
seasons[ep.season].episodes.push(ep);
}
if (seasons.length === 0) {
return show;
}
// Put all the season in an array
let sortedSeasons = [];
for (let season of Object.keys(seasons)) {
let seasonEpisodes = seasons[season].episodes;
// Order the episodes in each season
seasonEpisodes.sort((a,b) => (a.episode - b.episode))
// Add the season in the list
sortedSeasons.push({
season: season,
episodes: seasonEpisodes,
})
}
// Order the seasons
for (let i=0; i<sortedSeasons.length; i++) {
sortedSeasons.sort((a,b) => (a.season - b.season))
}
show.seasons = sortedSeasons;
return show;
}

View File

@ -12,21 +12,33 @@ body {
background-color: @brand-primary; background-color: @brand-primary;
} }
.movie-plot { .clickable {
cursor: pointer;
}
.plot {
.text-justify; .text-justify;
margin-right: 5%; margin-right: 5%;
} }
.movie-details-buttons { .list-details-buttons {
position: fixed; position: fixed;
bottom: 1%; bottom: 1%;
right: 1%; right: 1%;
} }
.movie-list-filter { .list-filter {
padding-bottom: 10px; padding-bottom: 10px;
} }
.show-thumbnail {
max-height: 300px;
}
.episode-button {
padding-right: 5px;
}
.navbar { .navbar {
opacity: 0.95; opacity: 0.95;
} }