<div id=Root />
$wiki-grey: #eaecf0;
$border: 1px solid darken($wiki-grey, 15%);
$wiki-logo: url("https://upload.wikimedia.org/wikipedia/commons/thumb/8/80/Wikipedia-logo-v2.svg/300px-Wikipedia-logo-v2.svg.png");

div,
section {
  box-sizing: border-box;
}

#App {
  font-family: 'Helvetica Neue','Helvetica','Nimbus Sans L','Arial','Liberation Sans',sans-serif;
  height: 100vh;
  display: flex;
  flex-direction: column;
}

.SearchPanel {
  padding: .6rem;
  z-index: 1;
  box-shadow: 0 0 4px 2px rgba(0,0,0,.3);
  font-size: 1.2rem;
  display: flex;
  input {
    flex: 1;
  }
  select,
  button {
    margin-left: .5em;
    &:enabled {
      cursor: pointer;
      &:hover {
        background-color: #eee;
      }
    }
  }
  input,
  select,
  button {
    border: $border;
    padding: .2rem .5rem;
    &:focus {
      border-color: #555;
      outline: none;
    }
  }
}

#mainPanel {
  background-repeat: no-repeat;
  background-position: center;
  background-color: $wiki-grey;
  background-image: $wiki-logo;
  display: flex;
  flex: 1;
  flex-direction: row;
  .ResultList {
    background: $wiki-grey;
    flex: 1;
    width: 100%;
    height: 100%;
    overflow-y: scroll;
    
    .Result {
      padding: 0.5rem;
      border-bottom: $border;  
      cursor: pointer;
      &:hover,
      &:focus {
        background: lighten($wiki-grey, 5%);
        outline: none;
      }
      .title {
        font-weight: bold;
        font-size: .9em;
      }
      .text {
        font-size: .8em;
      }
    }
  }
  .Preview {
    border: 0;
    border-left: $border;
    flex: 2;
    width: 100%;
    background: $wiki-grey;
  }
}
const { connect, Provider } = ReactRedux
const { debounce } = _ // Lodash
const DEBOUNCE_TIMEOUT = 250 // wait n ms before calling api

// Actions

const SEARCH_CHANGED = "SEARCH_CHANGED"
const searchChanged = text => ({ type: SEARCH_CHANGED, payload: text })

const LANGUAGE_CHANGED = "LANGUAGE_CHANGED"
const languageChanged = language => ({ type: LANGUAGE_CHANGED, payload: language })

const RESULTS_FETCHED = "RESULTS_FETCHED"
const resultsFetched = data => ({ type: RESULTS_FETCHED, payload: data })

const SELECT_ARTICLE = "SELECT_ARTICLE"
const selectArticle = url => ({ type: SELECT_ARTICLE, payload: url })

// Thunk Actions

const searchAction = text => (dispatch, getState) => {
  // update search in store and queue api call to wikipedia
  dispatch(searchChanged(text))
  const language = getState().search.language
  // debounced api call
  debouncedWikiSearch(text, language, data => dispatch(resultsFetched(data)))
}

// Utility functions for wikipedia api

const performWikiSearch = (text, language, callback) => {
  // perform http call to wikipedia opensearch api
  if (!text) return // abort if search is empty
  const url = buildUrl(text, language, "json")
  console.log(url)
  fetch(url, { mode: "cors" }) // use cors mode for cross origin sharing
    .then(response => response.json())
    .then(normalizeJsonData)
    .then(callback)
    .catch(console.error)
}
const debouncedWikiSearch = debounce(
  performWikiSearch, DEBOUNCE_TIMEOUT, {maxWait: DEBOUNCE_TIMEOUT * 2}
)
// https://lodash.com/docs/4.17.4#debounce

const normalizeJsonData = ([q, titles, texts, urls]) =>
  // convert results from wikipedia opensearch api into array of objects
  titles.map((title, index) => 
    ({ index, title, text: texts[index], url: urls[index] }))

const escapeQuery = query => encodeURIComponent(query).replace(/(%20)+/g, "+")

const buildUrl = (search, language = "en", format = "json", limit = 20) => {
  // builds wikipedia api url to fetch search results
  const attrs = {
    search: escapeQuery(search), // search query
    limit, // max number of results
    format, // json or xml
    action: "opensearch", // api action
    origin: "*" // needed for Cross-Origin resource sharing
  }
  const queryString = Object.entries(attrs)
    .map(([key, value]) => `${key}=${value}`)
    .join("&")
  return `https://${language}.wikipedia.org/w/api.php?${queryString}`
}

// Components

let SearchPanel = ({ text, language, searchChanged, languageChanged }) => {
  // search input, language select and clear button
  const clearButtonOnClick = e => {
    searchChanged("")
    searchInput.focus()
  }
  const searchInputOnChange = e => {
    searchChanged(e.target.value)
  }
  const languageSelectOnChange = e => {
    languageChanged(e.target.value)
    searchChanged(text) // force new fetch from api
  }
  const languages = [
    ["en", "English"],
    ["no", "Norwegian"],
    ["es", "Spanish"],
    ["de", "German"],
    ["fr", "French"]
  ]
  let searchInput = null // ref placeholder
  return (
    <section className="SearchPanel">
      <input
        value={text}
        onChange={searchInputOnChange}
        ref={input => (searchInput = input)}
        type="search"
        placeholder="Search Wikipedia"
        spellCheck={false}
        tabIndex={1}
      />
      <select onChange={languageSelectOnChange}>
        {languages.map(([val, text]) => (
          <option value={val} selected={val === language}>{text}</option>
        ))}
      </select>
      <button disabled={!text} onClick={clearButtonOnClick}>
        Clear
      </button>
    </section>
  )
}
SearchPanel = connect(
  store => store.search,
  dispatch => ({
    searchChanged: text => dispatch(searchAction(text)),
    languageChanged: value => dispatch(languageChanged(value))
  })
)(SearchPanel)

let ResultList = ({ results }) =>
  (results.length == 0
    ? null
    : // List of search results
      <div
        className="ResultList"
        ref={div => div && (div.scrollTop = 0)} // scroll to top on render
      >
        {results.map(props => <Result {...props} />)}
      </div>)
ResultList = connect(({ results }) => ({ results }))(ResultList)

let Result = ({
  index,
  title,
  text,
  onClick,
  onKeyDown
}) => // Single search result
(
  <div
    className="Result"
    tabIndex={index + 2}
    onKeyDown={onKeyDown}
    onClick={onClick}
  >
    <div className="title">{title}</div>
    <div className="text">{text}</div>
  </div>
)
Result = connect(null, (dispatch, { url }) => ({
  onKeyDown: e => e.keyCode == 13 && dispatch(selectArticle(url)),
  onClick: e => dispatch(selectArticle(url))
}))(Result)

let Preview = ({ url }) => {
  // Iframe containing wiki article served from wikipedia
  if (!url) return null
  // use mobile url if window is narrow
  const src = window.innerWidth > 1200
    ? url
    : url.replace(/wikipedia/, "m.wikipedia")
  return <iframe className="Preview" src={src} />
}
Preview = connect(({ url }) => ({ url }))(Preview)

const App = () => // Main container
(
  <section id="App">
    <SearchPanel />
    <section id="mainPanel">
      <ResultList />
      <Preview />
    </section>
  </section>
)

// Reducers

const searchReducer = (state = { text: "", language: "en" }, action) => {
  switch (action.type) {
    case SEARCH_CHANGED:
      return { ...state, text: action.payload }
    case LANGUAGE_CHANGED:
      return { ...state, language: action.payload }
    default:
      return state
  }
}
const resultsReducer = (state = [], action) => {
  switch (action.type) {
    case RESULTS_FETCHED:
      return action.payload
    case SEARCH_CHANGED:
      // clear results if search is empty
      return action.payload ? state : []
    default:
      return state
  }
}
const urlReducer = (state = "", action) => {
  switch (action.type) {
    case SELECT_ARTICLE:
      return action.payload
    case SEARCH_CHANGED:
      // clear iframe if search is empty
      return action.payload ? state : ""
    default:
      return state
  }
}
const rootReducer = Redux.combineReducers({
  search: searchReducer,
  results: resultsReducer,
  url: urlReducer
})

// use redux-thunk middleware for async api calls
let middleware = Redux.applyMiddleware(ReduxThunk.default)
// use redux devtools if available
middleware = (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || Redux.compose)(
  middleware
)
// create Redux root store
const rootStore = Redux.createStore(rootReducer, middleware)

// render app
ReactDOM.render(
  <Provider store={rootStore}><App /></Provider>,
  document.getElementById("Root")
)
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/15.5.4/react.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react/15.5.4/react-dom.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/redux/3.6.0/redux.min.js
  4. https://cdnjs.cloudflare.com/ajax/libs/react-redux/5.0.4/react-redux.min.js
  5. https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js
  6. https://cdnjs.cloudflare.com/ajax/libs/fetch/2.0.3/fetch.min.js
  7. https://cdnjs.cloudflare.com/ajax/libs/redux-thunk/2.2.0/redux-thunk.min.js