├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── app.yaml ├── bin └── server.dart ├── lib ├── isomorphic_dart.dart └── src │ ├── actions.dart │ ├── actions │ └── routing.dart │ ├── apis.dart │ ├── apis │ ├── movies.dart │ └── tmdb.dart │ ├── components.dart │ ├── components │ ├── application.dart │ ├── home.dart │ ├── movie_detail.dart │ ├── poster_image.dart │ ├── search.dart │ └── search_results.dart │ ├── models.dart │ ├── models │ ├── credits.dart │ ├── movie.dart │ └── state.dart │ └── util │ ├── async.dart │ └── http.dart ├── pubspec.yaml ├── screenshot.jpg └── web ├── index.html ├── main.dart └── styles ├── main.css └── normalize.css /.gitignore: -------------------------------------------------------------------------------- 1 | .buildlog 2 | .DS_Store 3 | .idea 4 | .pub/ 5 | build/ 6 | packages 7 | pubspec.lock 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.0.1 4 | 5 | - Initial version, created by Stagehand 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM google/dart-runtime -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, . 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # An isomorphic Dart app 2 | 3 | An isomorphic web app using Dart and React. Search for and list information about movies and TV shows. Checkout the working demo [here][demo]. 4 | 5 | [![Screenshot](screenshot.jpg)][demo] 6 | 7 | ## Features 8 | 9 | * [x] *Server side rendering.* All entry points are rendered using React components. These entry points include: 10 | * `/` 11 | * `/search` 12 | * `/search/?q=:movie_name` 13 | * `/movie/:imdb_id` 14 | * [x] *Progressive enhancement.* The app doesn't require client-side Dart or JS to function. All search fields and links will work in cases where the client-side code is still downloading, or the user has disabled JS. 15 | * [x] *History API.* Client updates the browser's history API on screen transitions, and responds to history changes. 16 | * [x] *Isomorphic routing.* The same routing code is used on the server and client. 17 | 18 | ## Why Isomorphic? 19 | 20 | Single page applications are great for developers and for users. For developers, it offers a clean separation of concerns. Backend logic is isolated to the server, and view logic is isolated to the client, with the two communicating through some API. For users, it means navigation can happen quickly between different pages without having to do a full refresh of the page. 21 | 22 | There's a hitch though. Since the server is delegating what needs to be rendered to the client, the browser has to wait for the necessary JavaScript to load before it can start rendering. The client may also need to make additional requests to the API to load additional data. 23 | 24 | There's quite a bit of data out there that correlates perceived page load times to convertion rates. Kiss Metrics [claims](https://blog.kissmetrics.com/loading-time/) that every second in loading your page results in a 7% decrease in conversion. And, [According to Amazon](http://www.radware.com/Products/FastView/), every 100ms decrease in page load time, increases conversion by 1%. 25 | 26 | This is where an isomorphic approach comes in. The server sends fully-formed HTML to the browser for fast perceived performance. Once the JavaScript loads, the client takes over so we get the benefits of a single page application. 27 | 28 | ## Architecture 29 | 30 | ### Shared view components on the server and client 31 | 32 | React's uses a virtual DOM for rendering, which allows views to be used on the client or server. The server renders the fully-formed HTML using `React.renderToString()`. Once the JavaScript is loaded, the client takes over and installs the necessary event handlers using `React.render()`. 33 | 34 | ### Initializing the client from server state 35 | 36 | When the client is initialized, React replaces the DOM provided by the server. In order for the client to render the same DOM as the server, it needs to have the same state the server used for rendering. To do this, the server writes its state as a JSON object in a script tag. The client then reads the JSON from the script tag and uses it to render the DOM. 37 | 38 | ### Rerendering state changes 39 | 40 | DOM rendering is treated as a stateless function, with the `State` object being used to represent the view's state. `State` objects are passed to an `ApplicationView` component which it uses for generating the DOM. 41 | 42 | To trigger state changes, the `ApplicationView` is also passed a `StreamController`. When a user performs some interaction, the view adds an `Action` onto this stream controller. `Action`s are just closures that are passed the current state and return a new state. The application listens to actions added to the controller's stream, invokes them, and rerenders the view. You can think of this as an endless cycle of `Render -> User Action -> State Modification -> Render`. 43 | 44 | ### Dependencies on `dart:html` and `dart:io` 45 | 46 | In order for React components to be reused on the client and server, special care needs to be given so they don't depend on `dart:html` or `dart:io`. This app requires network calls to an external movies API, which depending on the environment, will either need to use the network classes from `dart:io` or `dart:html`. To solve this, the `MoviesApi` uses factories for generating the appropriate request objects depending on if we're in a server or browser environment. 47 | 48 | ## Running 49 | 50 | Checkout the working demo [here][demo]. Otherwise, if you want to run it locally, there's a couple options listed below. 51 | 52 | ### App Engine (default) 53 | 54 | Take a look at Dart's AppEngine [guide](https://www.dartlang.org/server/google-cloud-platform/app-engine/) for setting up Docker and AppEngine for Dart. 55 | 56 | * Run `boot2docker up` 57 | * Run `$(boot2docker shellinit)` 58 | * Run `gcloud preview app run app.yaml` 59 | * Run `pub serve web --hostname 192.168.59.3 --port 7777` in another terminal window 60 | * Open `http://localhost:8080` in your browser 61 | 62 | ### Without App Engine (Non-Dartium) 63 | 64 | * Run `pub build` 65 | * Run `dart bin/server.dart --no-app-engine --serve-dir build/` 66 | * Open `http://localhost:8080` in your browser 67 | 68 | ### Without App Engine (Dartium) 69 | 70 | * Run `dart bin/server.dart --no-app-engine` 71 | * Open `http://localhost:8080` in your Dartium 72 | 73 | [demo]: http://isomorphic-dart-demo.appspot.com 74 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | version: helloworld 2 | runtime: custom 3 | vm: true 4 | api_version: 1 5 | 6 | env_variables: 7 | DART_PUB_SERVE: 'http://192.168.59.3:7777' -------------------------------------------------------------------------------- /bin/server.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:appengine/appengine.dart'; 4 | import 'package:args/args.dart'; 5 | import 'package:http/http.dart' as http; 6 | import 'package:redstone/server.dart' as app; 7 | import 'package:react/react.dart'; 8 | import 'package:react/react_server.dart' as react_server; 9 | import 'package:isomorphic_dart/isomorphic_dart.dart'; 10 | import 'package:shelf_static/shelf_static.dart'; 11 | import 'package:shelf_appengine/shelf_appengine.dart' as shelf_ae; 12 | import 'package:isomorphic_dart/src/apis.dart'; 13 | 14 | void main(List args) { 15 | var parser = new ArgParser(); 16 | parser 17 | ..addOption('serve-dir', defaultsTo: "web") 18 | ..addOption("host", defaultsTo: "localhost") 19 | ..addOption("port", defaultsTo: "8080") 20 | ..addFlag("app-engine", defaultsTo: true); 21 | 22 | var params = parser.parse(args); 23 | 24 | react_server.setServerConfiguration(); 25 | 26 | app.setupConsoleLog(); 27 | app.setUp(); 28 | 29 | if (params["app-engine"]) { 30 | app.setShelfHandler(shelf_ae.assetHandler( 31 | directoryIndexServeMode: shelf_ae.DirectoryIndexServeMode.SERVE)); 32 | runAppEngine((req) => app.handleRequest(req)); 33 | } else { 34 | app.setShelfHandler(createStaticHandler(params["serve-dir"], serveFilesOutsidePath: true)); 35 | app.start(address: params["host"], port: int.parse(params["port"]), autoCompress: true); 36 | } 37 | } 38 | 39 | @app.Route("/", responseType: "text/html") 40 | String root() => renderTemplate(new State("/", {})); 41 | 42 | @app.Route("/search", responseType: "text/html") 43 | searchMovieWithQuery(@app.QueryParam("q") String query) async { 44 | var movieApi = new TmdbMoviesApi(() => new http.IOClient()); 45 | var path = app.request.url.toString(); 46 | 47 | return renderTemplate(new State(path, { 48 | "term": query != null ? Uri.decodeQueryComponent(query) : "", 49 | "movies": await movieApi.search(query) 50 | })); 51 | } 52 | 53 | @app.Route("/movie/:id", responseType: "text/html") 54 | Future movie(String id) async { 55 | var path = app.request.url.path; 56 | var omdbApi = new TmdbMoviesApi(() => new http.IOClient()); 57 | return renderTemplate(new State(path, {"movie": await omdbApi.getMovie(id)})); 58 | } 59 | 60 | String renderTemplate(State state) { 61 | var serverData = JSON.encode(state); 62 | return """ 63 | 64 | 65 | 66 | 67 | IMDB Dart 68 | 69 | 70 | 71 | 72 | 73 | Fork me on GitHub 74 |
75 | ${renderToString(applicationView(state: state))} 76 |
77 | 78 | 79 | 80 | 81 | 82 | """; 83 | } 84 | 85 | -------------------------------------------------------------------------------- /lib/isomorphic_dart.dart: -------------------------------------------------------------------------------- 1 | library isomorphic_dart; 2 | 3 | export 'src/components.dart'; 4 | export 'src/models.dart'; 5 | export 'src/actions.dart'; 6 | -------------------------------------------------------------------------------- /lib/src/actions.dart: -------------------------------------------------------------------------------- 1 | library isomorphic_dart.actions; 2 | 3 | import 'models.dart'; 4 | 5 | part 'actions/routing.dart'; 6 | 7 | typedef T Action(T state); -------------------------------------------------------------------------------- /lib/src/actions/routing.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.actions; 2 | 3 | Action showSearchResults(String term, Iterable movies) { 4 | return (State state) { 5 | return new State("/search?q=${Uri.encodeQueryComponent(term)}", { 6 | "term": term, 7 | "movies": movies.map((movie) => movie.toJson()).toList() 8 | }); 9 | }; 10 | } 11 | 12 | Action showMovie(Movie movie) { 13 | return (State state) { 14 | return new State("/movie/${movie.id}", { 15 | "movie": movie.toJson() 16 | }); 17 | }; 18 | } -------------------------------------------------------------------------------- /lib/src/apis.dart: -------------------------------------------------------------------------------- 1 | library isomorphic_dart.apis; 2 | 3 | import 'dart:async'; 4 | import 'dart:convert'; 5 | import 'package:isomorphic_dart/src/util/http.dart'; 6 | 7 | part 'apis/movies.dart'; 8 | part 'apis/tmdb.dart'; -------------------------------------------------------------------------------- /lib/src/apis/movies.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.apis; 2 | 3 | abstract class MoviesApi { 4 | Future getMovie(id); 5 | 6 | Future> search(String term); 7 | } -------------------------------------------------------------------------------- /lib/src/apis/tmdb.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.apis; 2 | 3 | class TmdbMoviesApi implements MoviesApi { 4 | final ClientFactory _clientFactory; 5 | final Uri _baseUri = Uri.parse("https://api.themoviedb.org/3"); 6 | final String _apiKey; 7 | 8 | TmdbMoviesApi(this._clientFactory, {String apiKey: "f9dba24a3b8ed9425600eb5d5fbd9a93"}) : 9 | _apiKey = apiKey; 10 | 11 | Future getMovie(id) async { 12 | var response = _request("movie/$id", params: {"append_to_response": "credits,releases"}); 13 | return JSON.decode(await response); 14 | } 15 | 16 | Future> search(String term) async { 17 | if (term != null && term.isNotEmpty) { 18 | var response = _request("search/movie", params: {"query": term}); 19 | var results = JSON.decode(await response)["results"]; 20 | var ids = results.map((json) => json["id"]); 21 | return Future.wait(ids.map((id) => getMovie(id))); 22 | } else { 23 | return []; 24 | } 25 | } 26 | 27 | Future _request(String path, {Map params: const {}}) { 28 | var pathSegments = _baseUri.pathSegments.toList()..addAll(path.split("/")); 29 | var queryParams = new Map.from(params)..addAll({"api_key": _apiKey}); 30 | var uri = _baseUri.replace(pathSegments: pathSegments, queryParameters: queryParams); 31 | var client = _clientFactory(); 32 | return client.read(uri); 33 | } 34 | } -------------------------------------------------------------------------------- /lib/src/components.dart: -------------------------------------------------------------------------------- 1 | library isomorphic_dart.components; 2 | 3 | import 'dart:async'; 4 | import 'package:frappe/frappe.dart'; 5 | import 'package:react/react.dart'; 6 | import 'package:isomorphic_dart/isomorphic_dart.dart'; 7 | import 'package:isomorphic_dart/src/util/async.dart'; 8 | import 'package:isomorphic_dart/src/apis.dart'; 9 | 10 | part 'components/application.dart'; 11 | part 'components/home.dart'; 12 | part 'components/search_results.dart'; 13 | part 'components/movie_detail.dart'; 14 | part 'components/search.dart'; 15 | part 'components/poster_image.dart'; 16 | -------------------------------------------------------------------------------- /lib/src/components/application.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.components; 2 | 3 | typedef ApplicationView({State state, Subject updates, MoviesApi moviesApi}); 4 | 5 | var _applicationView = registerComponent(() => new _ApplicationView()); 6 | 7 | ApplicationView applicationView = ({State state, Subject updates, MoviesApi moviesApi}) { 8 | return _applicationView({"state": state, "updates": updates, "moviesApi": moviesApi}); 9 | }; 10 | 11 | class _ApplicationView extends Component { 12 | State get _state => props["state"]; 13 | Subject get _updates => props["updates"]; 14 | MoviesApi get _moviesApi => props["moviesApi"]; 15 | 16 | final _search = new Subject(); 17 | final _selectMovie = new Subject(); 18 | 19 | void componentDidMount(rootNode) { 20 | _search.stream 21 | .flatMapLatest((term) => new EventStream.fromFuture(_searchMovies(term).then((movies) => [term, movies]))) 22 | .listen((result) { 23 | var term = result.first; 24 | var movies = result.last; 25 | _updates.add(showSearchResults(term, movies)); 26 | }); 27 | 28 | _selectMovie.stream 29 | .listen((movie) => _updates.add(showMovie(movie))); 30 | } 31 | 32 | Future> _searchMovies(String term) async { 33 | var results = await _moviesApi.search(term); 34 | return results.map((json) => new Movie.fromJson(json)); 35 | } 36 | 37 | render() { 38 | return div({"className": "application"}, [_renderPath(_state.path, _state.data)]); 39 | } 40 | 41 | _renderPath(String path, Map data) { 42 | if (path == "/") { 43 | return homeView(_search); 44 | } else if (path.startsWith("/search")) { 45 | var movies = data["movies"].map((json) => new Movie.fromJson(json)); 46 | return searchResultsView(data["term"], movies, _search, _selectMovie); 47 | } else if (path.startsWith("/movie")) { 48 | var movie = new Movie.fromJson(data["movie"]); 49 | return movieDetailView(movie); 50 | } else { 51 | throw new ArgumentError("Undefined route `$path`"); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /lib/src/components/home.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.components; 2 | 3 | typedef HomeView(Subject search); 4 | 5 | var _homeView = registerComponent(() => new _HomeView()); 6 | 7 | HomeView homeView = (Subject search) => _homeView({"search": search}); 8 | 9 | class _HomeView extends Component { 10 | Subject get _search => props["search"]; 11 | 12 | render() { 13 | return div({"className": "home"}, [searchView(_search)]); 14 | } 15 | } -------------------------------------------------------------------------------- /lib/src/components/movie_detail.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.components; 2 | 3 | typedef MovieDetailView(Movie movie); 4 | 5 | var _movieDetailView = registerComponent(() => new _MovieDetailView()); 6 | 7 | MovieDetailView movieDetailView = (Movie movie) => _movieDetailView({"movie": movie}); 8 | 9 | class _MovieDetailView extends Component { 10 | Movie get _movie => props["movie"]; 11 | 12 | render() { 13 | return div({"className": "tile movie movie-detail"}, [ 14 | posterImageView(_movie.posterUri), 15 | div({}, [ 16 | div({}, [ 17 | h2({}, [_movie.title, span({"className": "title-year"}, " (${_movie.year})")]), 18 | ]), 19 | div({"className": "movie-meta-items"}, [ 20 | div({"className": "movie-meta"}, "Rated: ${_movie.rating}"), 21 | div({"className": "movie-meta"}, "Runtime: ${_movie.runtime} min"), 22 | div({"className": "movie-meta"}, "Released: ${_movie.releaseDate}"), 23 | ]), 24 | hr({"className": "separator"}), 25 | div({}, _movie.plot), 26 | div({"className": "movie-credits"}, [ 27 | div({"className": "movie-credit"}, [ 28 | strong({}, "Director: "), 29 | _movie.credits.director 30 | ]), 31 | div({"className": "movie-credit"}, [ 32 | strong({}, "Stars: "), 33 | _movie.credits.cast.take(3).join(", ") 34 | ]) 35 | ]) 36 | ]) 37 | ]); 38 | } 39 | 40 | renderMovie(Movie movie) { 41 | return li({}, [ 42 | img({"src": movie.posterUri.toString()}), 43 | span({}, movie.title) 44 | ]); 45 | } 46 | } -------------------------------------------------------------------------------- /lib/src/components/poster_image.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.components; 2 | 3 | typedef PosterImageView(Uri posterUri); 4 | 5 | var _posterImageView = registerComponent(() => new _PosterImageView()); 6 | 7 | PosterImageView posterImageView = (Uri posterUri) => _posterImageView({"posterUri": posterUri}); 8 | 9 | class _PosterImageView extends Component { 10 | Uri get _posterUri => props["posterUri"]; 11 | 12 | render() { 13 | var children = _posterUri != null ? [img({"src": _posterUri.toString()})] : []; 14 | return div({"className": "poster-image"}, children); 15 | } 16 | } -------------------------------------------------------------------------------- /lib/src/components/search.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.components; 2 | 3 | typedef SearchView(Subject submit, {String text}); 4 | 5 | var _searchView = registerComponent(() => new _SearchView()); 6 | 7 | SearchView searchView = (Subject submit, {String text: ""}) => _searchView({"submit": submit, "text": text}); 8 | 9 | class _SearchView extends Component { 10 | Subject get _submit => props["submit"]; 11 | String get _text => state["text"]; 12 | 13 | final _onChange = new Subject(sync: true); 14 | final _onSubmit = new Subject(sync: true); 15 | 16 | Map getInitialState() => {"text": props["text"]}; 17 | 18 | void componentDidMount(rootNode) { 19 | _onChange.stream 20 | .map((event) => event.target.value) 21 | .listen((text) => setState({"text": text})); 22 | 23 | _onSubmit.stream 24 | .doAction((event) => event.preventDefault()) 25 | .map((_) => _text) 26 | .listen((text) => _submit(text)); 27 | } 28 | 29 | render() { 30 | return form({"className": "search tile", "action": "/search", "method": "get", "onSubmit": _onSubmit}, [ 31 | div({}, "Search for a movie or TV show"), 32 | input({"className": "search-field", "type": "text", "name": "q", "onChange": _onChange, "value": _text}), 33 | div({}, [ 34 | button({"className": "search-button", "type": "submit"}, "Search") 35 | ]) 36 | ]); 37 | } 38 | } -------------------------------------------------------------------------------- /lib/src/components/search_results.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.components; 2 | 3 | typedef SearchResultsView(String term, Iterable movies, Subject search, Subject select); 4 | 5 | var _searchResultsView = registerComponent(() => new _SearchResultsView()); 6 | 7 | SearchResultsView searchResultsView = (String term, Iterable movies, Subject search, Subject select) { 8 | return _searchResultsView({"term": term, "movies": movies, "search": search, "select": select}); 9 | }; 10 | 11 | class _SearchResultsView extends Component { 12 | String get _term => props["term"]; 13 | Iterable get _movies => props["movies"]; 14 | Subject get _search => props["search"]; 15 | Subject get _select => props["select"]; 16 | 17 | render() { 18 | return _term.isEmpty ? renderWithoutTerm() : renderWithTerm(); 19 | } 20 | 21 | renderWithoutTerm() { 22 | return homeView(_search); 23 | } 24 | 25 | renderWithTerm() { 26 | return div({}, [ 27 | searchView(_search, text: _term), 28 | h2({"className": "tile results-count"}, "Results for \"$_term\""), 29 | div({}, _movies.map((movie) => renderMovie(movie)).toList()) 30 | ]); 31 | } 32 | 33 | renderMovie(Movie movie) { 34 | var onLinkClick = new Subject(sync: true); 35 | onLinkClick.stream 36 | .doAction((event) => event.preventDefault()) 37 | .listen((_) => _select.add(movie)); 38 | 39 | return div({"className": "tile movie movie-summary"}, [ 40 | a({"href": "/movie/${movie.id}", "onClick": onLinkClick}, [ 41 | posterImageView(movie.posterUri) 42 | ]), 43 | a({"href": "/movie/${movie.id}", "onClick": onLinkClick}, [ 44 | span({}, movie.title) 45 | ]) 46 | ]); 47 | } 48 | } -------------------------------------------------------------------------------- /lib/src/models.dart: -------------------------------------------------------------------------------- 1 | library isomorphic_dart.models; 2 | 3 | part 'models/state.dart'; 4 | part 'models/movie.dart'; 5 | part 'models/credits.dart'; -------------------------------------------------------------------------------- /lib/src/models/credits.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.models; 2 | 3 | class Credits { 4 | final Map _json; 5 | 6 | Iterable get cast => _json["cast"].map((member) => member["name"]); 7 | 8 | String get director { 9 | Iterable crew = _json["crew"]; 10 | var match = crew.firstWhere((member) => member["job"] == "Director", orElse: () => null); 11 | return match != null ? match["name"] : null; 12 | } 13 | 14 | Credits._(this._json); 15 | 16 | factory Credits.fromJson(Map json) => new Credits._(json); 17 | } -------------------------------------------------------------------------------- /lib/src/models/movie.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.models; 2 | 3 | class Movie { 4 | final Map _json; 5 | 6 | int get id => _json["id"]; 7 | String get posterPath => _json["poster_path"]; 8 | String get rating { 9 | var releases = _json["releases"]; 10 | var countries = releases != null ? releases["countries"] : null; 11 | var primary = countries.firstWhere((country) => country["primary"], orElse: () => null); 12 | var rating = primary != null ? primary["certification"] : null; 13 | return rating != null && rating.isNotEmpty ? rating : "Unrated"; 14 | } 15 | String get releaseDate => _json["release_date"]; 16 | String get year => releaseDate.split("-").first; 17 | String get title => _json["title"]; 18 | String get plot => _json["overview"]; 19 | int get runtime => _json["runtime"]; 20 | Credits get credits => new Credits.fromJson(_json["credits"]); 21 | 22 | Uri get posterUri { 23 | if (posterPath != null) { 24 | var baseUri = Uri.parse("https://image.tmdb.org/t/p/w185"); 25 | var path = baseUri.pathSegments.toList()..add(posterPath.split("/").last); 26 | return baseUri.replace(pathSegments: path); 27 | } else { 28 | return null; 29 | } 30 | } 31 | 32 | Movie._(this._json); 33 | 34 | factory Movie.fromJson(Map json) => new Movie._(json); 35 | 36 | Map toJson() => new Map.from(_json); 37 | } 38 | 39 | // Example JSON response 40 | 41 | //{ 42 | // "Actors": "Tom Cruise, Kelly McGillis, Val Kilmer, Anthony Edwards", 43 | // "Awards": "Won 1 Oscar. Another 9 wins & 5 nominations.", 44 | // "Country": "USA", 45 | // "Director": "Tony Scott", 46 | // "Genre": "Action, Drama, Romance", 47 | // "Language": "English", 48 | // "Metascore": "N/A", 49 | // "Plot": "As students at the Navy's elite fighter weapons school compete to be best in the class, one daring young flyer learns a few things from a civilian instructor that are not taught in the classroom.", 50 | // "Poster": "http://ia.media-imdb.com/images/M/MV5BMTY3ODg4OTU3Nl5BMl5BanBnXkFtZTYwMjI1Nzg4._V1_SX300.jpg", 51 | // "Rated": "PG", 52 | // "Released": "1986-05-16", 53 | // "Response": "True", 54 | // "Runtime": "110 min", 55 | // "Title": "Top Gun", 56 | // "Type": "movie", 57 | // "Writer": "Jim Cash, Jack Epps Jr., Ehud Yonay (magazine article \"Top Guns\")", 58 | // "Year": "1986", 59 | // "imdbID": "tt0092099", 60 | // "imdbRating": "6.8", 61 | // "imdbVotes": "188,869" 62 | //} -------------------------------------------------------------------------------- /lib/src/models/state.dart: -------------------------------------------------------------------------------- 1 | part of isomorphic_dart.models; 2 | 3 | class State { 4 | final String path; 5 | final Map data; 6 | 7 | State(this.path, this.data); 8 | 9 | factory State.fromJson(Map json) => new State(json["path"], json["data"]); 10 | 11 | Map toJson() => { 12 | "path": path, 13 | "data": data, 14 | }; 15 | } -------------------------------------------------------------------------------- /lib/src/util/async.dart: -------------------------------------------------------------------------------- 1 | library isomorphic_dart.async; 2 | 3 | import 'dart:async'; 4 | import 'package:frappe/frappe.dart'; 5 | 6 | class Subject implements StreamController { 7 | final StreamController _controller; 8 | 9 | Subject._(this._controller); 10 | 11 | factory Subject({bool sync: false}) => new Subject._(new StreamController(sync: sync)); 12 | 13 | factory Subject.broadcast({bool sync: false}) => new Subject._(new StreamController.broadcast(sync: sync)); 14 | 15 | EventStream get stream => new EventStream(_controller.stream); 16 | StreamSink get sink => _controller.sink; 17 | bool get isClosed => _controller.isClosed; 18 | bool get isPaused => _controller.isPaused; 19 | bool get hasListener => _controller.hasListener; 20 | Future get done => _controller.done; 21 | 22 | Future close() => _controller.close(); 23 | 24 | void add(T event) => _controller.add(event); 25 | 26 | Future addStream(Stream source, {bool cancelOnError: true}) => _controller.addStream(source, cancelOnError: cancelOnError); 27 | 28 | void addError(Object error, [StackTrace stackTrace]) => _controller.addError(error, stackTrace); 29 | 30 | call(T event) => add(event); 31 | 32 | } -------------------------------------------------------------------------------- /lib/src/util/http.dart: -------------------------------------------------------------------------------- 1 | library isomorphic_dart.http; 2 | 3 | import 'package:http/http.dart'; 4 | 5 | typedef Client ClientFactory(); -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: 'isomorphic_dart' 2 | version: 0.0.1 3 | description: > 4 | An absolute bare-bones web app. 5 | #author: 6 | #homepage: https://www.example.com 7 | environment: 8 | sdk: '>=1.0.0 <2.0.0' 9 | dependencies: 10 | autoprefixer_transformer: any 11 | appengine: any 12 | args: any 13 | browser: any 14 | frappe: any 15 | http: any 16 | redstone: any 17 | react: any 18 | shelf_appengine: any 19 | shelf_static: any 20 | transformers: 21 | - autoprefixer_transformer: 22 | browsers: 23 | - "last 2 versions" -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danschultz/isomorphic_dart/91d74a08403dfd35e990eb8b6d69fb21f55650a1/screenshot.jpg -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | isomorphic_dart 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /web/main.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, . All rights reserved. Use of this source code 2 | // is governed by a BSD-style license that can be found in the LICENSE file. 3 | 4 | import 'dart:convert'; 5 | import 'dart:html'; 6 | import 'package:http/browser_client.dart'; 7 | import 'package:react/react.dart'; 8 | import 'package:react/react_client.dart' as react_client; 9 | import 'package:isomorphic_dart/isomorphic_dart.dart'; 10 | import 'package:isomorphic_dart/src/util/async.dart'; 11 | import 'package:isomorphic_dart/src/apis.dart'; 12 | 13 | void main() { 14 | react_client.setClientConfiguration(); 15 | 16 | var moviesApi = new TmdbMoviesApi(() => new BrowserClient()); 17 | 18 | var serverData = JSON.decode(document.querySelector("#server-data").text); 19 | var initialState = new State.fromJson(serverData); 20 | 21 | var updates = new Subject>.broadcast(); 22 | var appState = updates.stream.scan(initialState, (state, action) => action(state)); 23 | var historyState = window.onPopState 24 | .map((event) => JSON.decode(event.state)) 25 | .map((json) => new State.fromJson(json)); 26 | 27 | // Render the application with the updated state. 28 | appState.merge(historyState).listen((state) { 29 | var view = applicationView(state: state, updates: updates, moviesApi: moviesApi); 30 | render(view, document.querySelector("#application")); 31 | }); 32 | 33 | // Replace the current history with the state given by the server. 34 | appState.take(1).listen((state) { 35 | window.history.replaceState(JSON.encode(state), window.name, state.path); 36 | }); 37 | 38 | // Append additional route changes to the history. 39 | appState.skip(1) 40 | .distinct((a, b) => a.path == b.path) 41 | .listen((state) { 42 | window.history.pushState(JSON.encode(state), window.name, state.path); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /web/styles/main.css: -------------------------------------------------------------------------------- 1 | html, body, .viewport { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | background-color: #f0f0f0; 7 | } 8 | 9 | .tile { 10 | background-color: #fff; 11 | box-sizing: border-box; 12 | margin-bottom: 1em; 13 | width: 600px; 14 | } 15 | 16 | .application { 17 | display: flex; 18 | justify-content: center; 19 | height: 100%; 20 | } 21 | 22 | .home { 23 | align-self: center; 24 | } 25 | 26 | .search { 27 | padding: 1em; 28 | text-align: center; 29 | } 30 | 31 | .search-field { 32 | margin: 0.5em 0; 33 | } 34 | 35 | .search-button { 36 | 37 | } 38 | 39 | .results-count { 40 | padding: 1em; 41 | } 42 | 43 | .movie { 44 | display: flex; 45 | align-items: center; 46 | } 47 | 48 | .movie:last-child { 49 | padding-right: 1em; 50 | } 51 | 52 | .movie-detail { 53 | align-items: flex-start; 54 | align-self: center; 55 | } 56 | 57 | .movie-meta-items { 58 | font-size: 0.8em; 59 | } 60 | 61 | .movie-meta { 62 | display: inline; 63 | } 64 | 65 | .movie-meta:not(:last-child) { 66 | border-right: 1px solid grey; 67 | padding-right: 0.5em; 68 | } 69 | 70 | .movie-meta:not(:first-child) { 71 | padding-left: 0.5em; 72 | } 73 | 74 | .movie-credit { 75 | margin: 0.75em 0; 76 | } 77 | 78 | .poster-image { 79 | background-color: lightgrey; 80 | display: inline-block; 81 | margin-right: 1em; 82 | } 83 | 84 | .poster-image img { 85 | vertical-align: bottom; 86 | width: 100%; 87 | } 88 | 89 | .movie-summary .poster-image { 90 | min-width: 75px; 91 | width: 75px; 92 | } 93 | 94 | .movie-summary .poster-image:empty { 95 | height: 108px; 96 | } 97 | 98 | .movie-detail .poster-image { 99 | min-width: 200px; 100 | width: 200px; 101 | } 102 | 103 | .movie-detail .poster-image:empty { 104 | height: 300px; 105 | } 106 | 107 | .separator { 108 | background-color: rgba(0, 0, 0, 0.2); 109 | border: none; 110 | height: 1px; 111 | margin: 1em 0; 112 | } -------------------------------------------------------------------------------- /web/styles/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } --------------------------------------------------------------------------------