Compare commits

...

19 Commits

Author SHA1 Message Date
Robbie Antenesse 3f9ca67918 Fix Open Library search results de-duplication 2019-09-26 13:59:40 -06:00
Robbie Antenesse bf33ff8e3e Change wording for See Book Details 2019-09-26 13:58:21 -06:00
Robbie Antenesse c5e73242d0 Add localiation to search options 2019-09-26 13:25:25 -06:00
Robbie Antenesse ebc5c9b06d Remove BookBrainz option because there's no public API 2019-09-26 13:00:13 -06:00
Robbie Antenesse 1062b9b2fd Fix search not working on fresh load 2019-09-26 12:56:28 -06:00
Robbie Antenesse 40084c4072 Don't collapse search options 2019-09-26 12:25:36 -06:00
Robbie Antenesse e39fe52e65 Add search by Open Library 2019-09-26 12:22:58 -06:00
Robbie Antenesse abc1e10b3f Simplify search results to only use Works from inventaire 2019-09-26 10:51:52 -06:00
Robbie Antenesse 691f0c350b Make search options interactive 2019-09-26 10:42:49 -06:00
Robbie Antenesse 69563eb74e Add buttonClasses option to modals 2019-09-26 10:38:55 -06:00
Robbie Antenesse 9823ae8290 Make `hidden` attribute force display:none 2019-09-26 10:38:40 -06:00
Robbie Antenesse fe7305164b Add html for example of search options 2019-09-25 17:19:35 -06:00
Robbie Antenesse 63a25283af Change search page load and button behavior 2019-09-25 17:02:14 -06:00
Robbie Antenesse c22f632b53 Stop saving session state and move i18n to appState 2019-09-25 16:48:35 -06:00
Robbie Antenesse eab60fe159 Add search icon; move search bar to /search 2019-09-25 16:41:45 -06:00
Robbie Antenesse 8e9f74a901 Add a reload button to search 2019-09-25 14:41:56 -06:00
Robbie Antenesse dd131550dc Add reload icon 2019-09-25 14:25:36 -06:00
Robbie Antenesse 192a78188c Remove unused currentView state variable 2019-09-25 14:07:41 -06:00
Robbie Antenesse b093595f1d Restructure app initiation
- Split initialization steps into their own files.
- Use Choo routes instead of viewManager to properly set up 404
- Add styling for different colors of Picnic cards
2019-09-25 12:32:52 -06:00
31 changed files with 526 additions and 354 deletions

22
app/appListeners.js Normal file
View File

@ -0,0 +1,22 @@
export const appListeners = (app, state, emitter) => {
emitter.on('DOMContentLoaded', () => {
document.title = app.siteConfig.siteName;
// Emitter listeners
emitter.on('render', callback => {
// This is a dirty hack to get the callback to call *after* re-rendering.
if (callback && typeof callback === "function") {
setTimeout(() => {
callback();
}, 50);
}
});
emitter.on('set-language', newLanguage => {
app.setSettingsItem('lang', newLanguage);
state.language = newLanguage;
emitter.emit('render', () => { });
});
emitter.emit('render'); // This should hopefully only run once after the DOM is loaded. It prevents routing issues where 'render' hasn't been defined yet
});
}

15
app/appRoutes.js Normal file
View File

@ -0,0 +1,15 @@
import { globalView } from './views/global';
import { homeView } from './views/home';
import { loginView } from './views/login';
import { searchView } from './views/search';
import { errorView } from './views/404';
export const appRoutes = (app) => {
app.route('/', (state, emit) => globalView(state, emit, homeView));
app.route('/login', (state, emit) => globalView(state, emit, loginView));
app.route('/search', (state, emit) => globalView(state, emit, searchView));
app.route('/404', (state, emit) => globalView(state, emit, errorView));
}

8
app/appState.js Normal file
View File

@ -0,0 +1,8 @@
import { I18n } from "./i18n";
export const appState = (app, state, emitter) => {
state.language = app.getSettingsItem('lang') ? app.getSettingsItem('lang') : (navigator.language || navigator.userLanguage).split('-')[0];
state.viewStates = {};
state.isLoggedIn = false;
state.i18n = new I18n(state); // Global I18n class passed to all views
}

22
app/appUtilities.js Normal file
View File

@ -0,0 +1,22 @@
export const appUtilities = (app) => {
app.getSettingsItem = settingsKey => {
let savedSettings = window.localStorage.getItem('settings');
if (savedSettings) {
savedSettings = JSON.parse(savedSettings);
if (typeof savedSettings[settingsKey] !== 'undefined') {
return savedSettings[settingsKey];
}
}
return null;
}
app.setSettingsItem = (settingsKey, value) => {
let savedSettings = window.localStorage.getItem('settings');
if (savedSettings) {
savedSettings = JSON.parse(savedSettings);
} else {
savedSettings = {};
}
savedSettings[settingsKey] = value;
return window.localStorage.setItem('settings', JSON.stringify(savedSettings));
}
}

View File

@ -19,6 +19,15 @@ Font license info
Homepage: http://typicons.com/
## Iconic
Copyright (C) 2012 by P.J. Onori
Author: P.J. Onori
License: SIL (http://scripts.sil.org/OFL)
Homepage: http://somerandomdude.com/work/iconic/
## Fontelico
Copyright (C) 2012 by Fontello project

View File

@ -5,8 +5,10 @@
.icon-heart-outline:before { content: '\e803'; } /* '' */
.icon-heart-filled:before { content: '\e804'; } /* '' */
.icon-comment:before { content: '\e805'; } /* '' */
.icon-reload:before { content: '\e806'; } /* '' */
.icon-check:before { content: '\e807'; } /* '' */
.icon-plus:before { content: '\e808'; } /* '' */
.icon-search:before { content: '\e809'; } /* '' */
.icon-loading:before { content: '\e839'; } /* '' */
.icon-external:before { content: '\f08e'; } /* '' */
.icon-star-half:before { content: '\f123'; } /* '' */

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,10 @@
.icon-heart-outline { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-heart-filled { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-comment { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-reload { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-check { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-search { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-loading { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-external { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-star-half { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }

View File

@ -16,8 +16,10 @@
.icon-heart-outline { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-heart-filled { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-comment { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-reload { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-check { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-search { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-loading { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-external { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
.icon-star-half { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }

View File

@ -1,11 +1,11 @@
@font-face {
font-family: 'icons';
src: url('../font/icons.eot?59778868');
src: url('../font/icons.eot?59778868#iefix') format('embedded-opentype'),
url('../font/icons.woff2?59778868') format('woff2'),
url('../font/icons.woff?59778868') format('woff'),
url('../font/icons.ttf?59778868') format('truetype'),
url('../font/icons.svg?59778868#icons') format('svg');
src: url('../font/icons.eot?3055787');
src: url('../font/icons.eot?3055787#iefix') format('embedded-opentype'),
url('../font/icons.woff2?3055787') format('woff2'),
url('../font/icons.woff?3055787') format('woff'),
url('../font/icons.ttf?3055787') format('truetype'),
url('../font/icons.svg?3055787#icons') format('svg');
font-weight: normal;
font-style: normal;
}
@ -15,7 +15,7 @@
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: 'icons';
src: url('../font/icons.svg?59778868#icons') format('svg');
src: url('../font/icons.svg?3055787#icons') format('svg');
}
}
*/
@ -61,8 +61,10 @@
.icon-heart-outline:before { content: '\e803'; } /* '' */
.icon-heart-filled:before { content: '\e804'; } /* '' */
.icon-comment:before { content: '\e805'; } /* '' */
.icon-reload:before { content: '\e806'; } /* '' */
.icon-check:before { content: '\e807'; } /* '' */
.icon-plus:before { content: '\e808'; } /* '' */
.icon-search:before { content: '\e809'; } /* '' */
.icon-loading:before { content: '\e839'; } /* '' */
.icon-external:before { content: '\f08e'; } /* '' */
.icon-star-half:before { content: '\f123'; } /* '' */

View File

@ -229,11 +229,11 @@ body {
}
@font-face {
font-family: 'icons';
src: url('./font/icons.eot?54567039');
src: url('./font/icons.eot?54567039#iefix') format('embedded-opentype'),
url('./font/icons.woff?54567039') format('woff'),
url('./font/icons.ttf?54567039') format('truetype'),
url('./font/icons.svg?54567039#icons') format('svg');
src: url('./font/icons.eot?21491342');
src: url('./font/icons.eot?21491342#iefix') format('embedded-opentype'),
url('./font/icons.woff?21491342') format('woff'),
url('./font/icons.ttf?21491342') format('truetype'),
url('./font/icons.svg?21491342#icons') format('svg');
font-weight: normal;
font-style: normal;
}
@ -306,12 +306,16 @@ body {
<div class="row">
<div class="the-icons span3" title="Code: 0xe804"><i class="demo-icon icon-heart-filled">&#xe804;</i> <span class="i-name">icon-heart-filled</span><span class="i-code">0xe804</span></div>
<div class="the-icons span3" title="Code: 0xe805"><i class="demo-icon icon-comment">&#xe805;</i> <span class="i-name">icon-comment</span><span class="i-code">0xe805</span></div>
<div class="the-icons span3" title="Code: 0xe806"><i class="demo-icon icon-reload">&#xe806;</i> <span class="i-name">icon-reload</span><span class="i-code">0xe806</span></div>
<div class="the-icons span3" title="Code: 0xe807"><i class="demo-icon icon-check">&#xe807;</i> <span class="i-name">icon-check</span><span class="i-code">0xe807</span></div>
<div class="the-icons span3" title="Code: 0xe808"><i class="demo-icon icon-plus">&#xe808;</i> <span class="i-name">icon-plus</span><span class="i-code">0xe808</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xe808"><i class="demo-icon icon-plus">&#xe808;</i> <span class="i-name">icon-plus</span><span class="i-code">0xe808</span></div>
<div class="the-icons span3" title="Code: 0xe809"><i class="demo-icon icon-search">&#xe809;</i> <span class="i-name">icon-search</span><span class="i-code">0xe809</span></div>
<div class="the-icons span3" title="Code: 0xe839"><i class="demo-icon icon-loading animate-spin">&#xe839;</i> <span class="i-name">icon-loading</span><span class="i-code">0xe839</span></div>
<div class="the-icons span3" title="Code: 0xf08e"><i class="demo-icon icon-external">&#xf08e;</i> <span class="i-name">icon-external</span><span class="i-code">0xf08e</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xf123"><i class="demo-icon icon-star-half">&#xf123;</i> <span class="i-name">icon-star-half</span><span class="i-code">0xf123</span></div>
</div>
</div>

Binary file not shown.

View File

@ -18,10 +18,14 @@
<glyph glyph-name="comment" unicode="&#xe805;" d="M781 662l-625 0q-21 0-37-15t-15-36l0-365q0-21 15-37t37-16l157 0 0-8 8 8 460 0q22 0 37 16t16 37l0 365q0 21-16 36t-37 15z m0 105q65 0 111-46t46-110l0-365q0-65-46-111t-111-45l-416 0-156-157 0 157-53 0q-65 0-110 45t-46 111l0 365q0 65 46 110t110 46l625 0z" horiz-adv-x="938" />
<glyph glyph-name="reload" unicode="&#xe806;" d="M843 261q0-3 0-4-36-150-150-243t-267-93q-81 0-157 31t-136 88l-72-72q-11-11-25-11t-25 11-11 25v250q0 14 11 25t25 11h250q14 0 25-11t10-25-10-25l-77-77q40-36 90-57t105-20q74 0 139 37t104 99q6 10 30 66 4 13 16 13h107q8 0 13-6t5-12z m14 446v-250q0-14-10-25t-26-11h-250q-14 0-25 11t-10 25 10 25l77 77q-82 77-194 77-75 0-140-37t-104-99q-6-10-29-66-5-13-17-13h-111q-7 0-13 6t-5 12v4q36 150 151 243t268 93q81 0 158-31t137-88l72 72q11 11 25 11t26-11 10-25z" horiz-adv-x="857.1" />
<glyph glyph-name="check" unicode="&#xe807;" d="M625 676q38-20 49-63t-9-80l-260-469q-30-53-91-53-43 0-74 31l-209 207q-31 32-31 74t31 74 74 31 74-31l111-110 193 347q20 38 62 50t80-8z" horiz-adv-x="679" />
<glyph glyph-name="plus" unicode="&#xe808;" d="M729 454q44 0 74-31t31-73-31-73-74-30l-208 3 0-212q0-43-30-73t-75-31-73 31-30 73l3 212-212-3q-44 0-74 30t-30 73 30 73 74 31l212 0-3 209q0 42 30 73t73 31 75-31 30-73l0-209 208 0z" horiz-adv-x="834" />
<glyph glyph-name="search" unicode="&#xe809;" d="M335 246l25-25q-28-28-85-86t-73-74l-27 27q16 15 74 73t86 85z m245 551q136 0 234-97t97-234-97-234-234-96q-64 0-123 24l-255-257-184 185 256 255q-26 63-26 123 0 137 98 234t234 97z m0-551q91 0 155 64t64 156-64 155-155 64-156-64-64-155 64-156 156-64z" horiz-adv-x="928" />
<glyph glyph-name="loading" unicode="&#xe839;" d="M855 9c-189-190-520-172-705 13-190 190-200 494-28 695 11 13 21 26 35 34 36 23 85 18 117-13 30-31 35-76 16-112-5-9-9-15-16-22-140-151-145-379-8-516 153-153 407-121 542 34 106 122 142 297 77 451-83 198-305 291-510 222l0 1c236 82 492-24 588-252 71-167 37-355-72-493-11-15-23-29-36-42z" horiz-adv-x="1000" />
<glyph glyph-name="external" unicode="&#xf08e;" d="M786 332v-178q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h393q7 0 12-5t5-13v-36q0-8-5-13t-12-5h-393q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v178q0 8 5 13t13 5h36q8 0 13-5t5-13z m214 482v-285q0-15-11-25t-25-11-25 11l-98 98-364-364q-5-6-13-6t-12 6l-64 64q-6 5-6 12t6 13l364 364-98 98q-11 11-11 25t11 25 25 11h285q15 0 25-11t11-25z" horiz-adv-x="1000" />

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -71,6 +71,18 @@
"css": "comment",
"code": 59397,
"src": "typicons"
},
{
"uid": "a73c5deb486c8d66249811642e5d719a",
"css": "reload",
"code": 59398,
"src": "fontawesome"
},
{
"uid": "c6344a6ed148da12354cc90705287696",
"css": "search",
"code": 59401,
"src": "iconic"
}
]
}

View File

@ -2,7 +2,7 @@
"name": "English",
"locale": "en",
"global": {
"searchbar_placeholder": "Search for Books",
"menu_search": "Search for Books",
"menu_login": "Log In",
"menu_logout": "Log Out",
"footer_repo": "Repo",
@ -18,6 +18,10 @@
"logged_out_recent_updates": "Recent Updates",
"logged_out_join_now": "Join Now!"
},
"404": {
"header": "Oops!",
"subheader": "It looks like the page you requested doesn't exist. Please try a different one!"
},
"login": {
"log_in": "Log In",
"email": "Email",
@ -31,17 +35,25 @@
},
"search": {
"header": "Search",
"placeholder": "Search for Books",
"button_text": "Search",
"search_source_label": "Search Source",
"search_source_help_button": "What's This?",
"search_source_help_header": "What does \"Search Source\" mean?",
"search_source_help_text": "This refers to where the search tries to look for data. Each source can be easily contributed to if you want to add missing books or correct errors.",
"search_source_help_inventaire": "Sources and extends data from WikiData, the service structure that powers Wikipedia and the like. Offers as many language options as possible.",
"search_source_help_openLibrary": "Sources data from Internet Archive and may provide a digital copy to read. Only offers English unless a work is in another language.",
"search_by_label": "Search By",
"search_by_title": "Title",
"search_by_author": "Author",
"loading": "Loading...",
"results_header": "Results for:",
"no_results": "None Found",
"no_results_suggestion": "If you're expecting book data, go and help fill out the Inventaire database!",
"people_header": "People",
"series_header": "Series",
"books_header": "Books",
"see_interaction_details": "See All Interactions",
"see_book_details": "See Book Details",
"see_inventaire_details": "See Details",
"see_details_tooltip": "Opens Inventaire in a new tab/window"
"see_book_details": "See Book Details"
},
"interaction": {
"reload": "Reload",

View File

@ -3,7 +3,10 @@ import 'babel-polyfill';
import choo from 'choo';
import config from './config.json';
import { viewManager } from './views/manager';
import { appRoutes } from './appRoutes';
import { appListeners } from './appListeners';
import { appState } from './appState.js';
import { appUtilities } from './appUtilities.js';
const app = choo();
@ -13,88 +16,18 @@ if (process.env.NODE_ENV !== 'production') {
}
app.use((state, emitter) => {
app.getSettingsItem = settingsKey => {
let savedSettings = window.localStorage.getItem('settings');
if (savedSettings) {
savedSettings = JSON.parse(savedSettings);
if (typeof savedSettings[settingsKey] !== 'undefined') {
return savedSettings[settingsKey];
}
}
return null;
}
app.setSettingsItem = (settingsKey, value) => {
let savedSettings = window.localStorage.getItem('settings');
if (savedSettings) {
savedSettings = JSON.parse(savedSettings);
} else {
savedSettings = {};
}
savedSettings[settingsKey] = value;
return window.localStorage.setItem('settings', JSON.stringify(savedSettings));
}
app.getSessionState = () => {
let sessionState = window.sessionStorage.getItem('sessionState');
if (sessionState) {
return JSON.parse(sessionState);
}
return null;
}
app.setSessionState = () => {
return window.sessionStorage.setItem('sessionState', JSON.stringify(app.state));
}
app.siteConfig = config;
appUtilities(app);
});
// App state and emitters
app.use((state, emitter) => {
const sessionState = app.getSessionState();
if (sessionState) {
Object.keys(sessionState).forEach(key => {
if (typeof state[key] === 'undefined') {
state[key] = sessionState[key];
}
});
} else {
// Default state variables
state.currentView = 'home';
state.language = app.getSettingsItem('lang') ? app.getSettingsItem('lang') : (navigator.language || navigator.userLanguage).split('-')[0];
state.viewStates = {};
state.isLoggedIn = false;
}
appState(app, state);
// Listeners
emitter.on('DOMContentLoaded', () => {
document.title = config.siteName;
// Emitter listeners
emitter.on('render', callback => {
app.setSessionState();
// This is a dirty hack to get the callback to call *after* re-rendering.
if (callback && typeof callback === "function") {
setTimeout(() => {
callback();
}, 50);
}
});
emitter.on('change-view', newView => {
// Change the view and call render. Makes it easier to call within views.
state.currentView = newView;
emitter.emit('render', () => {});
});
emitter.on('set-language', newLanguage => {
app.setSettingsItem('lang', newLanguage);
state.language = newLanguage;
emitter.emit('render', () => {});
});
});
appListeners(app, state, emitter);
});
// For the main screen, pass the viewManager function in viewManager.js,
// which is given the app's state from above and the emitter.emit method that
// triggers the app's emitter listeners.
app.route('/', viewManager);
app.route('/:page', viewManager);
app.route('/404', viewManager);
// Routes
appRoutes(app);
app.mount('body'); // Overwrite the `<body>` tag with the content of the Choo app

View File

@ -19,6 +19,11 @@ nav {
position: relative;
}
// For some reason, hidden is not important by default!
[hidden] {
display: none !important;
}
.pseudo[data-tooltip]::after {
background-color: $picnic-black;
color: $picnic-white;
@ -107,12 +112,38 @@ footer nav {
margin: 0 0 0 auto;
}
.card.info {
background: $picnic-info;
color: $picnic-white;
* {
.card {
&.info {
background: $picnic-info;
color: $picnic-white;
* {
color: $picnic-white;
}
}
&.success {
background: $picnic-success;
color: $picnic-white;
* {
color: $picnic-white;
}
}
&.warning {
background: $picnic-warning;
color: $picnic-white;
* {
color: $picnic-white;
}
}
&.error {
background: $picnic-error;
color: $picnic-white;
* {
color: $picnic-white;
}
}
}
@ -139,6 +170,9 @@ th {
}
// Handy Utilities
.marginless {
margin: 0 !important;
}
.paddingless {
padding: 0 !important;
}

12
app/views/404.js Normal file
View File

@ -0,0 +1,12 @@
import html from 'choo/html';
export const errorView = (state, emit, i18n) => {
return html`<section class="error card">
<header>
<h1>${i18n.__('404.header')}</h1>
</header>
<footer>
<h2>${i18n.__('404.subheader')}</h2>
</footer>
</section>`;
}

46
app/views/global.js Normal file
View File

@ -0,0 +1,46 @@
import html from 'choo/html';
import headerImage from '../../dev/images/header.png';
export const globalView = (state, emit, view) => {
const { i18n } = state;
// Create a wrapper for view content that includes global header/footer
return html`<body>
<header>
<nav>
<div class="brand">
<a href="/">
<span><img src=${headerImage} alt="Readlebee"></span>
</a>
</div>
<!-- responsive-->
<input id="navMenu" type="checkbox" class="show">
<label for="navMenu" class="burger pseudo button">${'\u2261'}</label>
<div class="menu">
<a href="/search" class="pseudo button"><i class="icon-search" aria-label=${i18n.__('global.menu_search')}></i></a>
<a href="/login" class="pseudo button">${i18n.__('global.menu_login')}</a>
<a href="/logout" class="pseudo button">${i18n.__('global.menu_logout')}</a>
</div>
</nav>
</header>
<main class="container">
${view(state, emit, i18n)}
</main>
<footer>
<nav>
<div class="links">
<a href="https://gitlab.com/Alamantus/Readlebee" class="pseudo button">
${i18n.__('global.footer_repo')}
</a>
<a href="https://gitter.im/Readlebee/community" class="pseudo button">
${i18n.__('global.footer_chat')}
</a>
</div>
</nav>
</footer>
</body>`;
}

View File

@ -53,7 +53,7 @@ export const loggedOutView = (homeController, emit) => {
<header>
<h3>${__('home.logged_out_recent_reviews')}</h3>
<button class="small pseudo pull-right tooltip-left" data-tooltip=${__('interaction.reload')}>
<i class="icon-loading"></i><!--/* This needs to get updated to a reload icon */-->
<i class="icon-reload"></i>
</button>
</header>
<footer>
@ -66,7 +66,7 @@ export const loggedOutView = (homeController, emit) => {
<header>
<h3>${__('home.logged_out_recent_updates')}</h3>
<button class="small pseudo pull-right tooltip-left" data-tooltip=${__('interaction.reload')}>
<i class="icon-loading"></i><!--/* This needs to get updated to a reload icon */-->
<i class="icon-reload"></i>
</button>
</header>
<footer>

View File

@ -1,79 +0,0 @@
import html from 'choo/html';
import headerImage from '../../dev/images/header.png';
import { I18n } from '../i18n';
import { homeView } from './home';
import { loginView } from './login';
import { searchView } from './search';
export const viewManager = (state, emit) => {
const i18n = new I18n(state); // Global I18n class passed to all views
// In viewManager all we are doing is checking the app's state
// and passing the state and emit to the relevant view.
let htmlContent = html`<div>loading</div>`;
switch (state.params.page) {
case 'home':
default: {
htmlContent = homeView(state, emit, i18n);
break;
}
case 'login': {
htmlContent = loginView(state, emit, i18n);
break;
}
case 'search': {
htmlContent = searchView(state, emit, i18n);
break;
}
}
// Create a wrapper for view content that includes global header/footer
let view = html`<body>
<header>
<nav>
<div class="brand">
<a href="/">
<span><img src=${headerImage} alt="Readlebee"></span>
</a>
</div>
<!-- responsive-->
<input id="navMenu" type="checkbox" class="show">
<label for="navMenu" class="burger pseudo button">${'\u2261'}</label>
<div class="menu">
<label style="display: inline-block;">
<input type="text" name="search"
placeholder=${i18n.__('global.searchbar_placeholder')}
onchange=${e => {
emit('pushState', '/search?for=' + encodeURIComponent(e.target.value.trim()));
}}
>
</label>
<a href="/login" class="pseudo button">${i18n.__('global.menu_login')}</a>
<a href="/logout" class="pseudo button">${i18n.__('global.menu_logout')}</a>
</div>
</nav>
</header>
<main class="container">
${htmlContent}
</main>
<footer>
<nav>
<div class="links">
<a href="https://gitlab.com/Alamantus/Readlebee" class="pseudo button">
${i18n.__('global.footer_repo')}
</a>
<a href="https://gitter.im/Readlebee/community" class="pseudo button">
${i18n.__('global.footer_chat')}
</a>
</div>
</nav>
</footer>
</body>`;
return view;
}

View File

@ -5,6 +5,7 @@ export const modal = (modalId, controller, contentHTML, options = {}) => {
* controller <class>: Pass the controller class with state; Requires get/set for openModal in state.
* buttonHTML <choo/html>: Displayed in place of the default button to open the modal
* buttonText <string>: Displayed if no buttonHTML is specified
* buttonClasses <string>: Used with buttonText. If excluded, 'button' is used.
* noHeader <bool>: Set to `true` and exclude headerHTML to not include a modal header
* headerHTML <choo/html>: Displayed in place of the default header; Recommended to use `<header>` tag
* headerText <string>: Displayed in an `<h3>` if no header is specified
@ -19,7 +20,9 @@ export const modal = (modalId, controller, contentHTML, options = {}) => {
return [
(
typeof options.buttonHTML === 'undefined'
? html`<label for=${modalId} class="button">${options.buttonText}</label>`
? html`<label for=${modalId} class=${typeof options.buttonClasses === 'undefined' ? 'button' : options.buttonClasses}>
${options.buttonText}
</label>`
: options.buttonHTML
),

View File

@ -1,20 +1,22 @@
import { ViewController } from '../controller';
export class SearchController extends ViewController {
constructor(state, i18n) {
constructor(state, emit, i18n) {
// Super passes state, view name, and default state to ViewController,
// which stores state in this.appState and the view controller's state to this.state
super(state, i18n, 'search', {
lastSearch: undefined,
done: false,
results: {
humans: [],
series: [],
works: [],
},
lastSearch: '',
lastSource: 'inventaire',
lastBy: 'title',
searchSource: 'inventaire',
searchBy: 'title',
done: true,
results: [],
openModal: null,
});
this.emit = emit;
// If using controller methods in an input's onchange or onclick instance,
// either bind the class's 'this' instance to the method first...
// or use `onclick=${() => controller.submit()}` to maintain the 'this' of the class instead.
@ -32,6 +34,12 @@ export class SearchController extends ViewController {
return this.appState.query.hasOwnProperty('for') && this.appState.query.for.trim() !== '';
}
get queryIsNew() {
return this.state.lastSearch !== this.appState.query.for.trim()
|| this.state.lastSource !== this.state.searchSource
|| this.state.lastBy !== this.state.searchBy;
}
get openModal() {
return this.state.openModal;
}
@ -43,18 +51,22 @@ export class SearchController extends ViewController {
search() {
if (this.hasQuery) {
this.state.done = false;
this.state.lastSearch = this.appState.query.for;
const searchTerm = this.appState.query.for.trim();
return fetch(`/api/search?for=${searchTerm}&lang=${this.appState.language}`)
.then(response => response.json())
.then(responseJSON => {
this.state.results = responseJSON;
this.state.done = true;
});
this.emit('render', () => {
this.state.lastSearch = this.appState.query.for;
this.state.lastSource = this.state.searchSource;
this.state.lastBy = this.state.searchBy;
const searchTerm = this.appState.query.for.trim();
return fetch(`/api/search?for=${searchTerm}&by=${this.state.searchBy}&lang=${this.appState.language}&source=${this.state.searchSource}`)
.then(response => response.json())
.then(responseJSON => {
this.state.results = responseJSON;
this.state.done = true;
})
.then(() => this.emit('render'));
});
}
return Promise.resolve();
}
getCovers(inventaireURI) {

View File

@ -2,52 +2,156 @@ import html from 'choo/html';
import { SearchController } from './controller'; // The controller for this view, where processing should happen.
import { resultDetails } from './resultDetails';
import { modal } from '../partials/modal';
// This is the view function that is exported and used in the view manager.
export const searchView = (state, emit, i18n) => {
const controller = new SearchController(state, i18n);
const controller = new SearchController(state, emit, i18n);
const { __ } = controller.i18n;
if (controller.state.lastSearch !== state.query.for) {
controller.search().then(() => {
emit('render');
});
if (controller.hasQuery && controller.queryIsNew) {
controller.search();
} else if (controller.state.lastSearch !== '') {
controller.appState.query.for = controller.state.lastSearch;
}
// Returning an array in a view allows non-shared parent HTML elements.
// This one doesn't have the problem right now, but it's good to remember.
return [
html`<section>
<h1 class="title">${__('search.header')}</h1>
<article>
<h2>
${controller.doneSearching
? html`<span>${__('search.results_header')}</span> <code>${controller.state.lastSearch}</code>`
: html`<i class="icon-loading animate-spin"></i> <span>${__('search.loading')}</span>`
}
</h2>
html`<h1 class="title">${__('search.header')}</h1>`,
${!controller.doneSearching || controller.results.works < 1
? [
html`<h3>${__('search.no_results')}</h3>`,
html`<a class="button" href="https://wiki.inventaire.io/wiki/How-to-contribute" target="_blank">
${__('search.no_results_suggestion')}
</a>`
]
: controller.results.works.map(result => {
return html`<div class="flex search-result">
<div class="two-third-800 half-500">
<h3 class="title">${result.name}</h3>
${result.description ? html`<h4 class="subtitle">${result.description}</h4>` : null}
</div>
<div class="third-800 half-500">
${resultDetails(controller, result, emit)}
</div>
</div>`;
})
html`<section class="flex">
<label class="three-fourth">
<input type="text" name="search"
aria-label=${i18n.__('search.placeholder')}
placeholder=${i18n.__('search.placeholder')}
value=${controller.state.lastSearch}
onchange=${e => {
emit('pushState', '/search?for=' + encodeURIComponent(e.target.value.trim()));
}}
${!controller.doneSearching ? 'disabled' : null}
>
</label>
<button class="fourth" style="margin-top:0;height:2.1em;"
onclick=${() => emit('pushState', '/search?for=' + encodeURIComponent(controller.appState.query.for))}
${!controller.doneSearching ? 'disabled' : null}
>
${!controller.doneSearching
? html`<i class="icon-loading animate-spin"></i>`
: __('search.button_text')
}
</button>
</section>`,
// Search Options Section
html`<section class="flex one two-700">
<div>
${modal('searchSourceInfo', controller, [
html`<p>
${__('search.search_source_help_text')}
</p>`,
html`<ul>
<li>
<a href="https://inventaire.io" target="_blank">
Inventaire
</a>
<ul>
<li>
${__('search.search_source_help_inventaire')}
</li>
</ul>
</li>
<li>
<a href="https://openlibrary.org" target="_blank">
Open Library
</a>
<ul>
<li>
${__('search.search_source_help_openLibrary')}
</li>
</ul>
</li>
</ul>`,
], {
buttonText: __('search.search_source_help_button'),
buttonClasses: 'small marginless pseudo button pull-right',
headerText: __('search.search_source_help_header'),
})}
<label>
${__('search.search_source_label')}
<select onchange=${event => {
controller.state.searchSource = event.target.value;
}}>
<option value="inventaire" ${controller.state.searchSource === 'inventaire' ? 'selected' : null}>
Inventaire
</option>
<option value="openLibrary" ${controller.state.searchSource === 'openLibrary' ? 'selected' : null}>
Open Library
</option>
</select>
</label>
</div>
<div>
${__('search.search_by_label')}<br>
<label>
<input type="radio" name="searchBy" value="title"
${controller.state.searchBy === 'title' ? 'checked' : null}
onchange=${(event) => {
if (event.target.checked) {
controller.state.searchBy = event.target.value;
}
}}
>
<span class="checkable">
${__('search.search_by_title')}
</span>
</label>
<label>
<input type="radio" name="searchBy" value="author"
${controller.state.searchBy === 'author' ? 'checked' : null}
onchange=${(event) => {
if (event.target.checked) {
controller.state.searchBy = event.target.value;
}
}}
>
<span class="checkable">
${__('search.search_by_author')}
</span>
</label>
</div>
</section>`,
// Search Results section
html`<section>
<h2>
${controller.hasQuery && !controller.doneSearching
? html`<span>${__('search.loading')}</span> <i class="icon-loading animate-spin"></i>`
: null
}
</article>
</h2>
${controller.hasQuery && controller.doneSearching && controller.results < 1
? [
html`<h3>${__('search.no_results')}</h3>`,
html`<a class="button" href="https://wiki.inventaire.io/wiki/How-to-contribute" target="_blank">
${__('search.no_results_suggestion')}
</a>`
]
: controller.results.map(result => {
return html`<article class="flex search-result">
<header class="two-third-800 half-500">
<h3 class="title">${result.name}</h3>
${result.description ? html`<h4 class="subtitle">${result.description}</h4>` : null}
</header>
<footer class="third-800 half-500">
${resultDetails(controller, result, emit)}
</footer>
</article>`;
})
}
</section>`,
];
}

View File

@ -83,11 +83,9 @@ export const resultDetails = (searchController, result, emit = () => {}) => {
</span>
</p>
<p>
<span data-tooltip=${__('search.see_details_tooltip')}>
<a class="small pseudo button" href=${result.link} target="_blank">
${__('search.see_book_details')}
</a>
</span>
<a class="small button" href=${result.link} target="_blank">
${__('search.see_book_details')}
</a>
</p>
</div>
</article>`;

View File

@ -1,8 +1,11 @@
const fetch = require('node-fetch');
class BooksController {
constructor(inventaireDomain, bookURI, language) {
this.inventaire = inventaireDomain;
constructor(bookSource, bookURI, language) {
this.source = bookSource;
this.inventaire = 'https://inventaire.io';
this.openLibrary = 'https://openlibrary.org';
this.bookBrainz = 'https://bookbrainz.org';
this.uri = bookURI;
this.lang = language;
}
@ -87,7 +90,7 @@ class BooksController {
typeof entityObject.image !== 'undefined'
? entityObject.image.map(imageId => {
return {
uri: imageId,
uri: imageId.toString(),
url: `${this.inventaire}/img/entities/${imageId}`,
}
})
@ -132,6 +135,36 @@ class BooksController {
};
}
handleOpenLibraryEntity(entityObject) {
return {
name: (
typeof entityObject.title_suggest !== 'undefined'
? entityObject.title_suggest
: null
),
description: (
typeof entityObject.author_name !== 'undefined'
? `${entityObject.type} by ${entityObject.author_name.map(name => name.trim()).join(', ')}`
: null
),
link: (
typeof entityObject.key !== 'undefined'
? `${this.openLibrary}${entityObject.key}`
: null
),
uri: (
typeof entityObject.key !== 'undefined'
? entityObject.key.substr(entityObject.key.lastIndexOf('/') + 1)
: null
),
coverId: (
typeof entityObject.cover_i !== 'undefined'
? entityObject.cover_i.toString()
: false
),
};
}
async getBookDataFromInventaire() {
if (this.uri) {
const request = fetch(`${this.inventaire}/api/entities?action=by-uris&uris=${encodeURIComponent(this.uri)}`)

View File

@ -3,8 +3,7 @@ const fetch = require('node-fetch');
const BooksController = require('./books');
class SearchController {
constructor(inventaireDomain, searchTerm, language = 'en') {
this.inventaire = inventaireDomain;
constructor(searchTerm, language = 'en') {
this.term = searchTerm;
this.lang = language;
}
@ -15,7 +14,7 @@ class SearchController {
quickSearchInventaire() {
if (this.hasQuery) {
const request = fetch(`${this.inventaire}/api/search?types=works&search=${encodeURIComponent(this.term)}&lang=${encodeURIComponent(this.lang)}&limit=10`)
const request = fetch(`https://inventaire.io/api/search?types=works&search=${encodeURIComponent(this.term)}&lang=${encodeURIComponent(this.lang)}&limit=10`)
request.catch(exception => {
console.error(exception);
return {
@ -32,9 +31,11 @@ class SearchController {
}
});
return json.then(responseJSON => {
const works = responseJSON.results.map(work => {
const booksController = new BooksController(this.inventaire, work.uri, this.lang);
const booksController = new BooksController('inventaire', undefined, this.lang);
return responseJSON.results.map(work => {
const bookData = booksController.handleQuickInventaireEntity(work);
booksController.uri = bookData.uri; // Update booksController.uri for each book when fetching community data.
const communityData = booksController.getCommunityData(5);
return {
@ -42,19 +43,13 @@ class SearchController {
...communityData,
}
});
return {
humans: [],
series: [],
works,
}
});
}
}
searchInventaire() {
searchInventaire(searchBy = 'title') {
if (this.hasQuery) {
const request = fetch(`${this.inventaire}/api/entities?action=search&search=${encodeURIComponent(this.term)}&lang=${encodeURIComponent(this.lang)}`)
const request = fetch(`https://inventaire.io/api/entities?action=search&search=${encodeURIComponent(this.term)}&lang=${encodeURIComponent(this.lang)}`)
request.catch(exception => {
console.error(exception);
return {
@ -71,74 +66,8 @@ class SearchController {
}
});
return json.then(responseJSON => {
const humans = responseJSON.humans.map(human => {
const hasLabels = typeof human.labels !== 'undefined';
const hasDescriptions = typeof human.descriptions !== 'undefined';
const hasImage = typeof human.image !== 'undefined';
return {
name: (
hasLabels && typeof human.labels[this.lang] !== 'undefined'
? human.labels[this.lang]
: (
hasLabels && Object.keys(human.labels).length > 0
? human.labels[Object.keys(human.labels)[0]]
: null
)
),
description: (
hasDescriptions && typeof human.descriptions[this.lang] !== 'undefined'
? human.descriptions[this.lang]
: (
hasDescriptions && Object.keys(human.descriptions).length > 0
? human.descriptions[Object.keys(human.descriptions)[0]]
: null
)
),
link: (
typeof human.uri !== 'undefined'
? `${this.inventaire}/entity/${human.uri}`
: null
),
image: (
hasImage && typeof human.image.url !== 'undefined'
? human.image
: null
),
};
});
const series = responseJSON.series.map(serie => {
const hasLabels = typeof serie.labels !== 'undefined';
const hasDescriptions = typeof serie.descriptions !== 'undefined';
return {
name: (
hasLabels && typeof serie.labels[this.lang] !== 'undefined'
? serie.labels[this.lang]
: (
hasLabels && Object.keys(serie.labels).length > 0
? serie.labels[Object.keys(serie.labels)[0]]
: null
)
),
description: (
hasDescriptions && typeof serie.descriptions[this.lang] !== 'undefined'
? serie.descriptions[this.lang]
: (
hasDescriptions && Object.keys(serie.descriptions).length > 0
? serie.descriptions[Object.keys(serie.descriptions)[0]]
: null
)
),
link: (
typeof serie.uri !== 'undefined'
? `${this.inventaire}/entity/${serie.uri}`
: null
),
};
});
const works = responseJSON.works.map(work => {
const booksController = new BooksController(this.inventaire, work.uri, this.lang);
const booksController = new BooksController('inventaire', undefined, this.lang);
return responseJSON.works.map(work => {
const bookData = booksController.handleInventaireEntity(work);
const communityData = booksController.getCommunityData(5);
@ -147,12 +76,6 @@ class SearchController {
...communityData,
}
});
return {
humans,
series,
works,
}
});
}
}
@ -245,44 +168,62 @@ class SearchController {
});
}
async searchOpenLibrary() {
async searchOpenLibrary(searchBy = 'title') {
if (!this.hasQuery) {
return [];
}
return fetch('http://openlibrary.org/search.json?q=' + encodeURIComponent(this.term))
return fetch(`https://openlibrary.org/search.json?${searchBy}=${encodeURIComponent(this.term)}`)
.then(res => res.json())
.then(response => {
if (!response.hasOwnProperty('docs')) {
return [];
}
const booksController = new BooksController('openLibrary', undefined, this.lang);
// Format the response into usable objects
const docs = response.docs.map(doc => {
return {
title: doc.title_suggest.trim(),
authors: doc.hasOwnProperty('author_name') ? doc.author_name.map(name => name.trim()) : [],
cover: doc.hasOwnProperty('cover_i') ? `//covers.openlibrary.org/b/id/${doc.cover_i}-S.jpg` : false,
};
return booksController.handleOpenLibraryEntity(doc);
});
let results = [];
// Filter out duplicate items with the same title and author
const results = docs.filter((doc, index, allDocs) => {
return typeof allDocs.find((filterResult, filterIndex) => {
return index !== filterIndex && filterResult.title === doc.title
&& JSON.stringify(filterResult.authors) === JSON.stringify(doc.authors);
}) === 'undefined';
}).map(result => {
docs.forEach(doc => {
const existingDoc = results.find((filterResult) => {
return filterResult.title === doc.title && filterResult.description === doc.description;
});
if (!existingDoc) {
results.push(doc);
}
});
results = results.map(result => {
// Find any duplicates in case they have different cover data
const duplicates = docs.filter(doc => {
return doc.title.toLowerCase() === result.title.toLowerCase() && JSON.stringify(doc.authors) === JSON.stringify(result.authors);
return doc.name.toLowerCase() === result.name.toLowerCase() && doc.description === result.description;
});
result.covers = [];
duplicates.forEach(duplicate => {
if (duplicate.cover !== false) {
result.covers.push(duplicate.cover);
if (duplicate.coverId !== false) {
result.covers.push({
uri: duplicate.coverId,
url: `//covers.openlibrary.org/b/id/${duplicate.coverId}-M.jpg`,
});
}
});
delete result.coverId;
return result;
}).map(bookData => {
// Use bookController to get community data
booksController.uri = bookData.uri; // Update booksController.uri for each book when fetching community data.
const communityData = booksController.getCommunityData(5);
return {
...bookData,
...communityData,
};
});
return results;

View File

@ -3,10 +3,27 @@ const SearchController = require('../controllers/search');
async function routes(fastify, options) {
fastify.get('/api/search', async (request, reply) => {
const searchTerm = typeof request.query.for !== 'undefined' ? request.query.for.trim() : '';
const searchBy = typeof request.query.by !== 'undefined' ? request.query.by.trim() : 'title';
const language = typeof request.query.lang !== 'undefined' ? request.query.lang.trim().split('-')[0] : undefined; // Get base language in cases like 'en-US'
const search = new SearchController(fastify.siteConfig.inventaireDomain, searchTerm, language);
const searchSource = typeof request.query.source !== 'undefined' ? request.query.source.trim() : undefined; // Get base language in cases like 'en-US'
const search = new SearchController(searchTerm, language);
return await search.quickSearchInventaire();
switch (searchSource) {
case 'openLibrary': {
return await search.searchOpenLibrary(searchBy);
}
case 'bookBrainz': {
return await search.searchOpenLibrary(searchBy);
}
case 'inventaire':
default: {
if (searchBy === 'title') {
return await search.quickSearchInventaire();
} else {
return await search.searchInventaire(searchBy);
}
}
}
});
fastify.get('/api/search/cover', async (request, reply) => {