├── src ├── common │ └── components │ │ ├── pagination │ │ ├── index.ts │ │ ├── pagination.scss │ │ ├── page.tsx │ │ └── pagination.tsx │ │ ├── menu-button │ │ ├── index.ts │ │ ├── menu-button.style.scss │ │ └── menu-button.component.tsx │ │ ├── chevron │ │ ├── index.ts │ │ ├── chevron.style.scss │ │ └── chevron.component.tsx │ │ ├── logo │ │ ├── index.ts │ │ └── logo.component.tsx │ │ ├── hocr │ │ ├── hocr-proofreader │ │ │ ├── index.ts │ │ │ ├── hocr-proofreader.style.scss │ │ │ └── hocr-proofreader.component.tsx │ │ ├── hocr-document │ │ │ ├── index.ts │ │ │ ├── hocr-document.style.ts │ │ │ ├── hocr-document.style.scss │ │ │ ├── hocr-docnode.component.tsx │ │ │ └── hocr-document.component.tsx │ │ ├── index.ts │ │ ├── hocr-preview │ │ │ ├── index.ts │ │ │ ├── hocr-page.style.scss │ │ │ ├── hocr-preview.style.scss │ │ │ ├── hocr-preview.style.ts │ │ │ ├── hocr-page.style.ts │ │ │ ├── hocr-node.style.ts │ │ │ ├── hocr-node.style.scss │ │ │ ├── hocr-svg.component.tsx │ │ │ ├── hocr-page.component.tsx │ │ │ ├── hocr-node.component.tsx │ │ │ └── hocr-preview.component.tsx │ │ └── util │ │ │ └── common-util.ts │ │ ├── vertical-separator │ │ ├── index.ts │ │ ├── vertical-separator.component.tsx │ │ └── vertical-separator.style.scss │ │ └── horizontal-separator │ │ ├── index.ts │ │ ├── horizontal-separator.style.scss │ │ └── horizontal-separator.component.tsx ├── theme │ ├── index.ts │ ├── _mixins.scss │ ├── main.scss │ ├── _breakpoints.scss │ ├── _typography.scss │ ├── theme.ts │ ├── _base.scss │ └── _palette.scss ├── pages │ ├── search-page │ │ ├── service │ │ │ ├── jfk │ │ │ │ ├── index.ts │ │ │ │ ├── mapper.suggestion.ts │ │ │ │ ├── config.ts │ │ │ │ └── mapper.search.ts │ │ │ ├── index.ts │ │ │ ├── service.registry.ts │ │ │ ├── service.model.ts │ │ │ └── service.ts │ │ ├── components │ │ │ ├── spacer │ │ │ │ ├── index.ts │ │ │ │ ├── spacer.component.tsx │ │ │ │ └── spacer.style.scss │ │ │ ├── graph │ │ │ │ ├── index.ts │ │ │ │ ├── graph-view.style.scss │ │ │ │ ├── graph-view.handlers.ts │ │ │ │ ├── graph-view.component.tsx │ │ │ │ └── graph-view.business.ts │ │ │ ├── drawer │ │ │ │ ├── index.ts │ │ │ │ ├── drawer-bar.style.scss │ │ │ │ ├── drawer.style.scss │ │ │ │ ├── drawer-bar.component.tsx │ │ │ │ └── drawer.component.tsx │ │ │ ├── search │ │ │ │ ├── index.ts │ │ │ │ ├── autocomplete.style.scss │ │ │ │ ├── search.style.scss │ │ │ │ ├── search.component.tsx │ │ │ │ └── autocomplete.component.tsx │ │ │ ├── facets │ │ │ │ ├── index.ts │ │ │ │ ├── facet-item.style.scss │ │ │ │ ├── facet-view.style.scss │ │ │ │ ├── facet-body.style.scss │ │ │ │ ├── facet-header.style.scss │ │ │ │ ├── facet-body.component.tsx │ │ │ │ ├── facet-view.component.tsx │ │ │ │ ├── facet-header.component.tsx │ │ │ │ └── facet-item.component.tsx │ │ │ ├── page-bar │ │ │ │ ├── index.ts │ │ │ │ ├── view-mode-toggler.style.scss │ │ │ │ ├── page-bar.style.scss │ │ │ │ ├── page-bar.component.tsx │ │ │ │ └── view-mode-toggler.component.tsx │ │ │ ├── placeholder │ │ │ │ ├── link.styles.scss │ │ │ │ ├── index.ts │ │ │ │ ├── azure-button.style.scss │ │ │ │ ├── link.component.tsx │ │ │ │ ├── dialog.styles.scss │ │ │ │ ├── placeholder.component.tsx │ │ │ │ ├── azure-button.component.tsx │ │ │ │ └── dialog.component.tsx │ │ │ ├── selection-controls │ │ │ │ ├── index.ts │ │ │ │ ├── year-picker.style.scss │ │ │ │ ├── checkbox-list.style.scss │ │ │ │ ├── selection-control.tsx │ │ │ │ ├── year-picker.component.tsx │ │ │ │ └── checkbox-list.component.tsx │ │ │ └── item │ │ │ │ ├── index.ts │ │ │ │ ├── item-collection-view.style.scss │ │ │ │ ├── item-collection-view.component.tsx │ │ │ │ ├── item.style.scss │ │ │ │ └── item.component.tsx │ │ ├── index.ts │ │ ├── view-model │ │ │ ├── suggestion.model.ts │ │ │ ├── filter.model.ts │ │ │ ├── index.ts │ │ │ ├── item.model.ts │ │ │ ├── facet.model.ts │ │ │ ├── state.memento.ts │ │ │ └── state.model.ts │ │ ├── search-page.route.tsx │ │ ├── search-page.style.scss │ │ ├── search-page.container.state.tsx │ │ ├── search-page.component.tsx │ │ └── search-page.container.tsx │ ├── detail-page │ │ ├── components │ │ │ └── toolbar │ │ │ │ ├── index.ts │ │ │ │ ├── toolbar.style.scss │ │ │ │ └── toolbar.component.tsx │ │ ├── index.ts │ │ ├── detail-page.memento.ts │ │ ├── detail-page.route.tsx │ │ ├── detail-page.style.scss │ │ ├── detail-page.component.tsx │ │ └── detail-page.container.tsx │ └── home-page │ │ ├── components │ │ ├── caption │ │ │ ├── index.ts │ │ │ ├── caption.component.tsx │ │ │ └── caption.style.scss │ │ └── search │ │ │ ├── index.ts │ │ │ ├── search-button.component.tsx │ │ │ ├── search-button.style.scss │ │ │ ├── search-input.component.tsx │ │ │ └── search-input.style.scss │ │ ├── index.ts │ │ ├── home-page.route.tsx │ │ ├── home-page.component.tsx │ │ ├── home-page.container.tsx │ │ └── home-page.style.scss ├── az-api │ ├── doc │ │ ├── az-api.md │ │ └── az-api.png │ ├── index.ts │ ├── payload │ │ ├── index.ts │ │ ├── facet.model.ts │ │ ├── filter.model.ts │ │ ├── payload.model.ts │ │ ├── payload.parser.ts │ │ ├── facet.parser.ts │ │ └── filter.parser.ts │ ├── config.parser.ts │ ├── config.model.ts │ ├── request.ts │ ├── response.model.ts │ ├── response.parser.ts │ └── api.ts ├── assets │ ├── img │ │ ├── bg.jpg │ │ └── jfk-files-scenario.png │ ├── fonts │ │ ├── jfk-icons.eot │ │ ├── jfk-icons.ttf │ │ └── jfk-icons.woff │ └── svg │ │ └── azure-search.logo.svg ├── graph-api │ ├── index.ts │ ├── payload.model.ts │ ├── config.parser.ts │ ├── payload.parser.ts │ ├── config.model.ts │ ├── response.model.ts │ ├── request.ts │ ├── response.parser.ts │ └── api.ts ├── util │ ├── type.helper.ts │ ├── index.ts │ ├── log-debug.helper.ts │ ├── classname.helper.ts │ └── array-immutable.helper.ts ├── app.tsx └── app.router.tsx ├── .gitignore ├── server └── index.js ├── tsconfig.json ├── .babelrc ├── .env ├── README.md ├── webpack.dev.config.js ├── webpack.prod.config.js ├── package.json └── webpack.base.config.js /src/common/components/pagination/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/theme/index.ts: -------------------------------------------------------------------------------- 1 | export { theme } from "./theme"; 2 | -------------------------------------------------------------------------------- /src/pages/search-page/service/jfk/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./config"; -------------------------------------------------------------------------------- /src/az-api/doc/az-api.md: -------------------------------------------------------------------------------- 1 | # AzApi 2 | 3 | ![AzApi Diagram](az-api.png) -------------------------------------------------------------------------------- /src/common/components/menu-button/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./menu-button.component"; 2 | -------------------------------------------------------------------------------- /src/pages/search-page/components/spacer/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./spacer.component"; 2 | -------------------------------------------------------------------------------- /src/common/components/chevron/index.ts: -------------------------------------------------------------------------------- 1 | export { Chevron } from "./chevron.component"; 2 | -------------------------------------------------------------------------------- /src/common/components/logo/index.ts: -------------------------------------------------------------------------------- 1 | export { LogoComponent } from "./logo.component"; 2 | -------------------------------------------------------------------------------- /src/pages/detail-page/components/toolbar/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./toolbar.component"; 2 | -------------------------------------------------------------------------------- /src/pages/search-page/components/graph/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./graph-view.component"; 2 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-proofreader/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hocr-proofreader.component"; 2 | -------------------------------------------------------------------------------- /src/assets/img/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/az-search-jfk-demo/master/src/assets/img/bg.jpg -------------------------------------------------------------------------------- /src/pages/home-page/components/caption/index.ts: -------------------------------------------------------------------------------- 1 | export { CaptionComponent } from "./caption.component"; 2 | -------------------------------------------------------------------------------- /src/pages/search-page/components/drawer/index.ts: -------------------------------------------------------------------------------- 1 | export { DrawerComponent } from "./drawer.component"; 2 | -------------------------------------------------------------------------------- /src/pages/search-page/components/search/index.ts: -------------------------------------------------------------------------------- 1 | export { SearchComponent } from "./search.component"; 2 | -------------------------------------------------------------------------------- /src/theme/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin jfk-icon { 2 | font-family: "jfk-icons"; 3 | font-size: 2.5rem; 4 | } 5 | -------------------------------------------------------------------------------- /src/pages/search-page/components/facets/index.ts: -------------------------------------------------------------------------------- 1 | export { FacetViewComponent } from "./facet-view.component"; 2 | -------------------------------------------------------------------------------- /src/pages/search-page/components/page-bar/index.ts: -------------------------------------------------------------------------------- 1 | export { PageBarComponent } from "./page-bar.component"; 2 | -------------------------------------------------------------------------------- /src/az-api/doc/az-api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/az-search-jfk-demo/master/src/az-api/doc/az-api.png -------------------------------------------------------------------------------- /src/pages/search-page/components/placeholder/link.styles.scss: -------------------------------------------------------------------------------- 1 | .link { 2 | font-weight: 600 !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/common/components/vertical-separator/index.ts: -------------------------------------------------------------------------------- 1 | export { VerticalSeparator } from "./vertical-separator.component"; 2 | -------------------------------------------------------------------------------- /src/pages/search-page/components/placeholder/index.ts: -------------------------------------------------------------------------------- 1 | export { PlaceholderComponent } from "./placeholder.component"; 2 | -------------------------------------------------------------------------------- /src/assets/fonts/jfk-icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/az-search-jfk-demo/master/src/assets/fonts/jfk-icons.eot -------------------------------------------------------------------------------- /src/assets/fonts/jfk-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/az-search-jfk-demo/master/src/assets/fonts/jfk-icons.ttf -------------------------------------------------------------------------------- /src/assets/fonts/jfk-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/az-search-jfk-demo/master/src/assets/fonts/jfk-icons.woff -------------------------------------------------------------------------------- /src/common/components/horizontal-separator/index.ts: -------------------------------------------------------------------------------- 1 | export { HorizontalSeparator } from "./horizontal-separator.component"; 2 | -------------------------------------------------------------------------------- /src/pages/search-page/components/selection-controls/index.ts: -------------------------------------------------------------------------------- 1 | export { CreateSelectionControl } from "./selection-control"; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/ 3 | .DS_Store 4 | *.orig 5 | .idea/ 6 | .vscode/ 7 | .dist/ 8 | desktop.ini 9 | package-lock.json -------------------------------------------------------------------------------- /src/pages/search-page/components/item/index.ts: -------------------------------------------------------------------------------- 1 | export { ItemCollectionViewComponent } from "./item-collection-view.component"; 2 | -------------------------------------------------------------------------------- /src/assets/img/jfk-files-scenario.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/az-search-jfk-demo/master/src/assets/img/jfk-files-scenario.png -------------------------------------------------------------------------------- /src/common/components/menu-button/menu-button.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../theme/_mixins.scss"; 2 | 3 | .icon { 4 | @include jfk-icon; 5 | } 6 | -------------------------------------------------------------------------------- /src/theme/main.scss: -------------------------------------------------------------------------------- 1 | @import "./_palette.scss"; 2 | @import "./_breakpoints.scss"; 3 | @import "./_base.scss"; 4 | @import "./_typography.scss"; 5 | -------------------------------------------------------------------------------- /src/az-api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api"; 2 | export * from "./config.model"; 3 | export * from "./response.model"; 4 | export * from "./payload"; 5 | -------------------------------------------------------------------------------- /src/pages/home-page/index.ts: -------------------------------------------------------------------------------- 1 | export { HomePageContainer } from "./home-page.container"; 2 | export { HomeRoute, homePath } from "./home-page.route"; 3 | -------------------------------------------------------------------------------- /src/pages/search-page/service/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./service"; 2 | export * from "./service.model"; 3 | export * from "./service.registry"; 4 | 5 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-document/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hocr-document.component"; 2 | export { HocrDocumentStyleMap } from "./hocr-document.style"; 3 | -------------------------------------------------------------------------------- /src/graph-api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api"; 2 | export * from "./config.model"; 3 | export * from "./response.model"; 4 | export * from "./payload.model"; 5 | -------------------------------------------------------------------------------- /src/pages/search-page/index.ts: -------------------------------------------------------------------------------- 1 | export { SearchPageContainer } from "./search-page.container"; 2 | export { SearchRoute, searchPath } from "./search-page.route"; -------------------------------------------------------------------------------- /src/pages/home-page/components/search/index.ts: -------------------------------------------------------------------------------- 1 | export { SearchButton } from "./search-button.component"; 2 | export { SearchInput } from "./search-input.component"; 3 | -------------------------------------------------------------------------------- /src/pages/search-page/components/facets/facet-item.style.scss: -------------------------------------------------------------------------------- 1 | .card { 2 | flex: 0 0 auto; 3 | margin-bottom: 2.5rem; 4 | background-color: transparent; 5 | } 6 | -------------------------------------------------------------------------------- /src/util/type.helper.ts: -------------------------------------------------------------------------------- 1 | export const checkDuckType = function(obj: T, property: keyof T) { 2 | return obj ? obj.hasOwnProperty(property) : false; 3 | } 4 | 5 | -------------------------------------------------------------------------------- /src/pages/search-page/view-model/suggestion.model.ts: -------------------------------------------------------------------------------- 1 | export interface Suggestion { 2 | text: string; 3 | } 4 | 5 | export type SuggestionCollection = Suggestion[]; 6 | -------------------------------------------------------------------------------- /src/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./classname.helper"; 2 | export * from "./array-immutable.helper"; 3 | export * from "./type.helper"; 4 | export * from "./log-debug.helper"; -------------------------------------------------------------------------------- /src/pages/detail-page/index.ts: -------------------------------------------------------------------------------- 1 | export { DetailPageContainer } from "./detail-page.container"; 2 | export { DetailRoute, DetailRouteState, detailPath } from "./detail-page.route"; -------------------------------------------------------------------------------- /src/pages/search-page/view-model/filter.model.ts: -------------------------------------------------------------------------------- 1 | export interface Filter { 2 | fieldId: string; 3 | store: any; 4 | } 5 | 6 | export type FilterCollection = Filter[]; 7 | -------------------------------------------------------------------------------- /src/util/log-debug.helper.ts: -------------------------------------------------------------------------------- 1 | export const consoleDebug = (function () { 2 | console.debug = process.env.DEBUG_TRACES ? 3 | console.log.bind(console, "[Debug]") : () => {}; 4 | })(); 5 | -------------------------------------------------------------------------------- /src/common/components/hocr/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hocr-preview"; 2 | export * from "./hocr-document"; 3 | export * from "./hocr-proofreader"; 4 | export { PageIndex } from "./util/common-util"; -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-preview/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hocr-preview.component"; 2 | export { HocrPreviewStyleMap } from "./hocr-preview.style"; 3 | export { ZoomMode } from "./hocr-page.component"; 4 | -------------------------------------------------------------------------------- /src/pages/search-page/components/graph/graph-view.style.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | flex: 1 1 auto; 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: center; 6 | align-items: stretch; 7 | } -------------------------------------------------------------------------------- /src/pages/search-page/components/page-bar/view-mode-toggler.style.scss: -------------------------------------------------------------------------------- 1 | @import "./../../../../theme/_mixins.scss"; 2 | 3 | .icon { 4 | @include jfk-icon; 5 | 6 | transition: color 0.25s ease-out; 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/search-page/service/service.registry.ts: -------------------------------------------------------------------------------- 1 | import { Service, CreateService } from "../service"; 2 | import { jfkServiceConfig } from "./jfk"; 3 | 4 | export const jfkService = CreateService(jfkServiceConfig); -------------------------------------------------------------------------------- /src/pages/search-page/components/facets/facet-view.style.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | flex: 1 1 auto; 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: flex-start; 6 | align-content: stretch; 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/search-page/view-model/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./item.model"; 2 | export * from "./facet.model"; 3 | export * from "./filter.model"; 4 | export * from "./suggestion.model"; 5 | export * from "./state.model"; 6 | -------------------------------------------------------------------------------- /src/pages/search-page/components/item/item-collection-view.style.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | flex: 1 0 auto; 3 | display: flex; 4 | flex-flow: row wrap; 5 | justify-content: center; 6 | align-items: flex-start; 7 | } 8 | -------------------------------------------------------------------------------- /src/az-api/payload/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./facet.model"; 2 | export * from "./facet.parser"; 3 | export * from "./filter.model"; 4 | export * from "./filter.parser"; 5 | export * from "./payload.model"; 6 | export * from "./payload.parser"; 7 | -------------------------------------------------------------------------------- /src/common/components/vertical-separator/vertical-separator.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | const style = require("./vertical-separator.style.scss"); 3 | 4 | export const VerticalSeparator = () =>
-------------------------------------------------------------------------------- /src/common/components/hocr/hocr-preview/hocr-page.style.scss: -------------------------------------------------------------------------------- 1 | .page { 2 | // TODO: Entry Point 3 | } 4 | 5 | .background { 6 | fill: none; 7 | } 8 | 9 | .image { 10 | display: block; 11 | } 12 | 13 | .placeholders { 14 | display: block; 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/search-page/components/selection-controls/year-picker.style.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: flex-start; 5 | align-items: flex-start; 6 | padding-left: 1rem; 7 | margin-bottom: 1rem; 8 | z-index: 3000; 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/search-page/components/facets/facet-body.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../theme/_breakpoints.scss"; 2 | 3 | .body { 4 | margin-left: 0.75rem; 5 | font-size: 0.85rem; 6 | 7 | @include respond-to-desktop { 8 | margin-left: 1.75rem; 9 | font-size: 1rem; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/pages/search-page/view-model/item.model.ts: -------------------------------------------------------------------------------- 1 | export interface Item { 2 | title: string; 3 | subtitle?: string; 4 | thumbnail?: string; 5 | excerpt?: string; 6 | rating?: number; 7 | extraFields?: any[]; 8 | metadata?: any; 9 | } 10 | 11 | export type ItemCollection = Item[]; 12 | -------------------------------------------------------------------------------- /src/graph-api/payload.model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Object that represents API payload parameters. 3 | * So far, it only contains the search term. 4 | */ 5 | 6 | export interface GraphPayload { 7 | search: string; 8 | } 9 | 10 | export const defaultGraphPayload: GraphPayload = { 11 | search: "", 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/search-page/components/spacer/spacer.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const style = require("./spacer.style.scss"); 4 | 5 | export const SpacerComponent = (props) => { 6 | return ( 7 |
8 | {props.children} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/pages/home-page/home-page.route.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Route } from 'react-router'; 3 | import { HomePageContainer } from './home-page.container'; 4 | 5 | export const homePath = "/"; 6 | 7 | export const HomeRoute = ( 8 | 9 | ); -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var path = require('path'); 3 | 4 | var app = express(); 5 | var distPath = path.resolve(__dirname, '../dist'); 6 | 7 | app.use(express.static(distPath)); 8 | app.listen(process.env.PORT, function() { 9 | console.log('Server running on port ' + process.env.PORT); 10 | }); -------------------------------------------------------------------------------- /src/common/components/chevron/chevron.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../theme/_mixins.scss"; 2 | 3 | .chevron { 4 | @include jfk-icon; 5 | 6 | transform: rotate(180deg); 7 | transition: transform 0.25s ease-out; 8 | } 9 | 10 | .chevron-up { 11 | @extend .chevron; 12 | 13 | transform: rotate(0deg); 14 | } 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/pages/search-page/search-page.route.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Route } from 'react-router'; 3 | import { SearchPageContainer } from './search-page.container'; 4 | 5 | export const searchPath = "/search"; 6 | 7 | export const SearchRoute = ( 8 | 9 | ); -------------------------------------------------------------------------------- /src/util/classname.helper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CNC = Class Name Componser. 3 | * So you pas an array of names (whether they are null, undefined, 4 | * or have a valid value) and it filter invalid ones and join them 5 | * together to compose a full style class name. 6 | */ 7 | 8 | export const cnc = (...names) => names.filter(n => n).join(" ") -------------------------------------------------------------------------------- /src/common/components/horizontal-separator/horizontal-separator.style.scss: -------------------------------------------------------------------------------- 1 | @import "./../../../theme/_breakpoints.scss"; 2 | @import "./../../../theme/_palette.scss"; 3 | 4 | hr.separator { 5 | background-color: $colorPrimary; 6 | opacity: 0.35; 7 | 8 | @include respond-to-desktop { 9 | margin: 0 2.5rem; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/common/components/vertical-separator/vertical-separator.style.scss: -------------------------------------------------------------------------------- 1 | @import "./../../../theme/_palette.scss"; 2 | 3 | .vertical-separator { 4 | flex: 1 1 auto; 5 | display: flex; 6 | flex-direction: column; 7 | align-self: stretch; 8 | width: 1px; 9 | margin: 0 0.75rem; 10 | background-color: darken($colorGrey, 50%); 11 | opacity: 0.5; 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/detail-page/detail-page.memento.ts: -------------------------------------------------------------------------------- 1 | import { DetailRouteState } from "."; 2 | 3 | 4 | let detailState : DetailRouteState = {hocr: "", targetWords: []}; 5 | 6 | export const setDetailState = (state : DetailRouteState) => { 7 | detailState = state; 8 | } 9 | 10 | export const getDetailState = () : DetailRouteState => { 11 | return detailState; 12 | } 13 | -------------------------------------------------------------------------------- /src/common/components/logo/logo.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const logoSvg = require("../../../assets/svg/logoJFK.svg"); 4 | 5 | export const LogoComponent = ({classes}) => ( 6 |
7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/pages/search-page/view-model/facet.model.ts: -------------------------------------------------------------------------------- 1 | export interface FacetValue { 2 | value: string; 3 | count: number; 4 | } 5 | 6 | export interface Facet { 7 | fieldId: string; 8 | displayName: string; 9 | iconName?: string; 10 | selectionControl: string; 11 | values: FacetValue[]; 12 | maxCount: number; 13 | } 14 | 15 | export type FacetCollection = Facet[]; 16 | -------------------------------------------------------------------------------- /src/pages/search-page/view-model/state.memento.ts: -------------------------------------------------------------------------------- 1 | import {State} from './state.model'; 2 | 3 | let mementoState : State = null; 4 | 5 | export const storeState = (state : State) => { 6 | mementoState = state; 7 | } 8 | 9 | export const isLastStateAvailable = () : boolean => (mementoState !== null); 10 | 11 | export const restoreLastState = () : State => { 12 | return mementoState; 13 | } -------------------------------------------------------------------------------- /src/pages/home-page/components/caption/caption.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const style = require("./caption.style.scss"); 4 | 5 | 6 | export const CaptionComponent = () => ( 7 |
8 |

Documents revealed.

9 |

Let's find out what happened that day.

10 |
11 | ); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "es6", 5 | "moduleResolution": "node", 6 | "declaration": false, 7 | "noImplicitAny": false, 8 | "jsx": "react", 9 | "sourceMap": true, 10 | "noLib": false, 11 | "suppressImplicitAnyIndexErrors": true 12 | }, 13 | "compileOnSave": false, 14 | "exclude": [ 15 | "node_modules" 16 | ] 17 | } -------------------------------------------------------------------------------- /src/pages/search-page/components/spacer/spacer.style.scss: -------------------------------------------------------------------------------- 1 | @import "./../../../../theme/_breakpoints.scss"; 2 | 3 | .spacer { 4 | flex: 1 1 auto; 5 | min-height: 0; 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: flex-start; 9 | align-items: stretch; 10 | margin: 1.25rem; 11 | overflow-y: auto; 12 | 13 | @include respond-to-desktop { 14 | margin: 2.5rem; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/detail-page/detail-page.route.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Route } from 'react-router'; 3 | import { DetailPageContainer } from './detail-page.container'; 4 | 5 | export interface DetailRouteState { 6 | hocr: string; 7 | targetWords: string[]; 8 | } 9 | 10 | export const detailPath = "/detail"; 11 | 12 | export const DetailRoute = ( 13 | 14 | ); -------------------------------------------------------------------------------- /src/pages/search-page/components/placeholder/azure-button.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../theme/breakpoints"; 2 | 3 | $logo-size: 60%; 4 | 5 | .button { 6 | flex-shrink: 0; 7 | position: absolute !important; 8 | top: 6rem; 9 | right: 1.7rem; 10 | 11 | @include respond-to-desktop { 12 | top: 9.5rem; 13 | right: 3.5rem; 14 | } 15 | 16 | .logo { 17 | width: $logo-size; 18 | height: $logo-size; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/graph-api/config.parser.ts: -------------------------------------------------------------------------------- 1 | import { GraphConfig } from "./config.model"; 2 | 3 | /** 4 | * Parsers for Config. 5 | * A parser will do a transformation from Config object to a connection URL. 6 | */ 7 | 8 | export const parseConfig = (config: GraphConfig): string => { 9 | const root = `${config.protocol}://${config.serviceName}.${config.serviceDomain}/`; 10 | const path = `${config.servicePath}?`; 11 | 12 | return (root + path); 13 | } -------------------------------------------------------------------------------- /src/pages/home-page/components/search/search-button.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Button from "material-ui/Button"; 3 | 4 | const style = require("./search-button.style.scss"); 5 | 6 | export const SearchButton = ({ onClick }) => ( 7 | 15 | ); -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { Reboot } from 'material-ui'; 4 | import { AppRouter } from './app.router'; 5 | import { MuiThemeProvider } from 'material-ui/styles'; 6 | import { theme } from "./theme"; 7 | 8 | 9 | ReactDOM.render( 10 | 11 | 12 | 13 | 14 | , document.getElementById('app') 15 | ); 16 | -------------------------------------------------------------------------------- /src/az-api/config.parser.ts: -------------------------------------------------------------------------------- 1 | import { AzConfig } from "./config.model"; 2 | 3 | /** 4 | * Parsers for Config. 5 | * A parser will do a transformaton from Config object to a connection URL. 6 | */ 7 | 8 | export const parseConfig = (config: AzConfig): string => { 9 | const root = `${config.protocol}://${config.serviceName}.${config.serviceDomain}/`; 10 | const path = `${config.servicePath}?api-version=${config.apiVer}`; 11 | 12 | return (root + path); 13 | } -------------------------------------------------------------------------------- /src/theme/_breakpoints.scss: -------------------------------------------------------------------------------- 1 | $breakpointXs: 0; 2 | $breakpointSm: 960; 3 | $breakpointMd: 1024; 4 | $breakpointLg: 1280; 5 | $breakpointXl: 1920; 6 | 7 | :export { 8 | breakpointXs: $breakpointXs; 9 | breakpointSm: $breakpointSm; 10 | breakpointMd: $breakpointMd; 11 | breakpointLg: $breakpointLg; 12 | breakpointXl: $breakpointXl; 13 | } 14 | 15 | 16 | @mixin respond-to-desktop { 17 | @media screen and (min-width: $breakpointSm#{px}) { 18 | @content; 19 | } 20 | } -------------------------------------------------------------------------------- /src/graph-api/payload.parser.ts: -------------------------------------------------------------------------------- 1 | import { GraphPayload } from "./payload.model"; 2 | 3 | /** 4 | * Parsers for Payload. 5 | * Very simple so far, segregated just in case query got more 6 | * complex in the future. 7 | * Example: 8 | * Input: {search: "oswald"} 9 | * Output: "q=oswald" 10 | */ 11 | 12 | export const parsePayload = (p: GraphPayload): string => { 13 | return [ 14 | p.search ? `q=${p.search}` : "", 15 | ] 16 | .filter(i => i) 17 | .join("&"); 18 | }; 19 | -------------------------------------------------------------------------------- /src/pages/home-page/components/search/search-button.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../theme/_breakpoints.scss"; 2 | @import "../../../../theme/_palette.scss"; 3 | 4 | .button { 5 | @include red-gradient; 6 | 7 | flex: 0 0 auto; 8 | border-radius: 6px !important; 9 | padding: 0.75rem; 10 | font-size: 1rem; 11 | width: 8rem; 12 | height: 2.4rem; 13 | 14 | @include respond-to-desktop { 15 | font-size: 1.25rem; 16 | width: 11rem; 17 | height: 3.25rem; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "production": { 4 | "presets": [ 5 | [ 6 | "env", 7 | { 8 | "modules": false 9 | } 10 | ] 11 | ] 12 | }, 13 | "development": { 14 | "presets": [ 15 | [ 16 | "env", 17 | { 18 | "modules": false, 19 | "targets": { 20 | "node": "current" 21 | } 22 | } 23 | ] 24 | ] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-preview/hocr-preview.style.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | flex: 1 1 auto; 3 | display: flex; 4 | flex-direction: column; 5 | max-width: 100%; 6 | max-height: 100%; 7 | } 8 | 9 | .viewport { 10 | display: block; 11 | width: 100%; 12 | flex-grow: 1; 13 | margin: 0; 14 | overflow: scroll; 15 | scroll-behavior: smooth; 16 | background-color: lightgrey; 17 | 18 | &.no-scrollable { 19 | overflow: hidden; 20 | scroll-behavior: unset; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/common/components/horizontal-separator/horizontal-separator.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Divider from "material-ui/Divider"; 3 | import { cnc } from "../../../util"; 4 | 5 | const style = require("./horizontal-separator.style.scss"); 6 | 7 | interface HorizontalSeparatorProps { 8 | className?: string; 9 | } 10 | 11 | export const HorizontalSeparator = (props: HorizontalSeparatorProps) => ( 12 | 13 | ); 14 | 15 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-preview/hocr-preview.style.ts: -------------------------------------------------------------------------------- 1 | import { HocrPageStyleMap, injectDefaultPageStyle } from "./hocr-page.style"; 2 | import { HocrNodeStyleMap, injectDefaultNodeStyle } from "./hocr-node.style"; 3 | 4 | export interface HocrPreviewStyleMap extends HocrPageStyleMap, HocrNodeStyleMap {}; 5 | 6 | export const injectDefaultPreviewStyle = (userStyle: HocrPreviewStyleMap): HocrPreviewStyleMap => { 7 | return { 8 | ...injectDefaultPageStyle(userStyle), 9 | ...injectDefaultNodeStyle(userStyle), 10 | }; 11 | } -------------------------------------------------------------------------------- /src/pages/search-page/search-page.style.scss: -------------------------------------------------------------------------------- 1 | .page-container { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: flex-start; 5 | align-items: stretch; 6 | width: 100vw; 7 | height: 100vh; 8 | margin: 0; 9 | position: relative; 10 | } 11 | 12 | .drawer-container { 13 | // Entry point. 14 | } 15 | 16 | .main-container { 17 | flex: 1 1 auto; 18 | display: flex; 19 | flex-direction: column; 20 | justify-content: flex-start; 21 | max-width: 100%; 22 | max-height: 100%; 23 | overflow: hidden; 24 | } 25 | -------------------------------------------------------------------------------- /src/common/components/menu-button/menu-button.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import IconButton from "material-ui/IconButton"; 3 | import { cnc } from "../../../util"; 4 | 5 | const style = require("./menu-button.style.scss"); 6 | 7 | export const MenuButton = ({ onClick, className = "" }) => ( 8 | {}} 14 | > 15 |  16 | 17 | ); -------------------------------------------------------------------------------- /src/pages/search-page/components/selection-controls/checkbox-list.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../theme/_palette.scss"; 2 | 3 | .container { 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: flex-start; 7 | align-items: flex-start; 8 | padding-left: 1rem; 9 | 10 | .checkbox { 11 | color: $colorWhite; 12 | font-size: inherit; 13 | 14 | &-checked { 15 | color: $colorPrimary; 16 | } 17 | } 18 | 19 | .label { 20 | color: $colorWhite; 21 | font-size: inherit; 22 | } 23 | } 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/pages/search-page/components/facets/facet-header.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../theme/_breakpoints.scss"; 2 | 3 | .actions { 4 | display: flex; 5 | flex-direction: row; 6 | align-items: center; 7 | } 8 | 9 | .title { 10 | display: flex; 11 | flex-direction: row; 12 | justify-content: flex-start; 13 | align-items: center; 14 | color: inherit; 15 | font-weight: 700; 16 | font-size: 1rem; 17 | 18 | @include respond-to-desktop { 19 | font-size: 1.2rem; 20 | } 21 | } 22 | 23 | .icon { 24 | margin-right: 0.5rem; 25 | } 26 | -------------------------------------------------------------------------------- /src/theme/_typography.scss: -------------------------------------------------------------------------------- 1 | // Main Font. 2 | @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,600,700"); 3 | 4 | // Icon Font. 5 | $font-path: "../assets/fonts"; 6 | 7 | @font-face { 8 | font-family: "jfk-icons"; 9 | src: 10 | url("#{$font-path}/jfk-icons.eot") format("embedded-opentype"), 11 | url("#{$font-path}/jfk-icons.ttf") format("truetype"), 12 | url("#{$font-path}/jfk-icons.woff") format("woff"), 13 | url("#{$font-path}/jfk-icons.svg") format("svg"); 14 | font-weight: normal; 15 | font-style: normal; 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/home-page/components/caption/caption.style.scss: -------------------------------------------------------------------------------- 1 | @import "./../../../../theme/_breakpoints.scss"; 2 | 3 | .caption { 4 | flex: 1 1 auto; 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: center; 8 | align-items: center; 9 | 10 | .title { 11 | margin: 0; 12 | font-size: 1rem; 13 | 14 | @include respond-to-desktop { 15 | font-size: 1.25rem; 16 | } 17 | } 18 | 19 | .subtitle { 20 | margin: 0; 21 | font-size: 0.75rem; 22 | 23 | @include respond-to-desktop { 24 | font-size: 1rem; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/graph-api/config.model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Object that represents API conection parameters. 3 | */ 4 | 5 | export type GraphMethodType = "GET"; 6 | 7 | export interface GraphConfig { 8 | protocol: string; 9 | serviceName: string; 10 | serviceDomain: string; 11 | servicePath: string; 12 | method: GraphMethodType; 13 | } 14 | 15 | // TODO: Migrate to environment variables. 16 | export const defaultGraphConfig: GraphConfig = { 17 | protocol: "https", 18 | serviceName: "jfkfiles2", 19 | serviceDomain: "azurewebsites.net", 20 | servicePath: "api/data/GetFDNodes", 21 | method: "GET", 22 | } 23 | -------------------------------------------------------------------------------- /src/util/array-immutable.helper.ts: -------------------------------------------------------------------------------- 1 | export const isArrayEmpty = (array: any[]) => !array || !array.length; 2 | 3 | export const isValueInArray = (array: any[], value) => isArrayEmpty(array) ? false : (array.indexOf(value) >= 0); 4 | 5 | export const addValueToArray = (array: any[], value) => { 6 | if (array) { 7 | return isValueInArray(array, value) ? array : [...array, value]; 8 | } else { 9 | return [value]; 10 | } 11 | }; 12 | 13 | export const removeValueFromArray = (array: any[], value) => { 14 | return isValueInArray(array, value) ? array.filter(v => v !== value) : array; 15 | }; 16 | -------------------------------------------------------------------------------- /src/pages/search-page/components/placeholder/link.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Button from "material-ui/Button"; 3 | const styles = require('./link.styles.scss'); 4 | 5 | interface Props { 6 | to: string; 7 | target?: string; 8 | } 9 | 10 | export const LinkComponent: React.StatelessComponent = ({ to, target, children }) => { 11 | return ( 12 | 15 | ); 16 | }; 17 | 18 | LinkComponent.defaultProps = { 19 | target: '_blank', 20 | }; 21 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=8083 3 | SEARCH_CONFIG_PROTOCOL=https 4 | SEARCH_CONFIG_SERVICE_NAME=jfk-files 5 | SEARCH_CONFIG_SERVICE_DOMAIN=search.windows.net 6 | SEARCH_CONFIG_SERVICE_PATH=indexes/jfkdocs/docs 7 | SEARCH_CONFIG_API_VER=2017-11-11 8 | SEARCH_CONFIG_API_KEY=263B733973D2050A214AF930AFD36D60 9 | SUGGESTION_CONFIG_PROTOCOL=https 10 | SUGGESTION_CONFIG_SERVICE_NAME=jfk-files 11 | SUGGESTION_CONFIG_SERVICE_DOMAIN=search.windows.net 12 | SUGGESTION_CONFIG_SERVICE_PATH=indexes/jfk/docs/autocomplete 13 | SUGGESTION_CONFIG_API_VER=2017-11-11-Preview 14 | SUGGESTION_CONFIG_API_KEY=263B733973D2050A214AF930AFD36D60 15 | -------------------------------------------------------------------------------- /src/pages/search-page/components/placeholder/dialog.styles.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../theme/palette"; 2 | 3 | .dialog { 4 | .title-container { 5 | display: flex; 6 | flex-direction: row; 7 | justify-content: space-between; 8 | align-items: center; 9 | 10 | .title { 11 | font-weight: 600 !important; 12 | } 13 | } 14 | 15 | .content { 16 | background-color: $colorWhite; 17 | 18 | .img { 19 | display: block; 20 | max-width: 100%; 21 | } 22 | 23 | .block { 24 | color: $colorBlack; 25 | display: block; 26 | margin: 1rem 0; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-preview/hocr-page.style.ts: -------------------------------------------------------------------------------- 1 | const style = require("./hocr-page.style.scss"); 2 | 3 | 4 | export interface HocrPageStyleMap { 5 | page?: string; 6 | background?: string; 7 | image?: string; 8 | placeholders?: string; 9 | } 10 | 11 | export const defaultPageStyle: HocrPageStyleMap = { 12 | page: style.page, 13 | background: style.background, 14 | image: style.image, 15 | placeholders: style.placeholders, 16 | } 17 | 18 | export const injectDefaultPageStyle = (userStyle: HocrPageStyleMap): HocrPageStyleMap => { 19 | return { 20 | ...defaultPageStyle, 21 | ...userStyle, 22 | }; 23 | } -------------------------------------------------------------------------------- /src/az-api/config.model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Object that represents API conection parameters, set and forget parameters 3 | * or those parameters that do not change frequently. 4 | */ 5 | 6 | export interface AzConfig { 7 | protocol: string; 8 | serviceName: string; 9 | serviceDomain: string; 10 | servicePath: string; 11 | apiVer: string; 12 | apiKey: string; 13 | method: "GET" | "POST"; 14 | } 15 | 16 | export const defaultAzConfig: AzConfig = { 17 | protocol: "https", 18 | serviceName: "", 19 | serviceDomain: "search.windows.net", 20 | servicePath: "", 21 | apiVer: "2017-11-11", 22 | apiKey: "", 23 | method: "GET", 24 | } 25 | -------------------------------------------------------------------------------- /src/az-api/payload/facet.model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Types for Facets. 3 | */ 4 | 5 | export interface AzPayloadFacetConfigCountSort { 6 | count?: number; 7 | sort?: "count" | "-count" | "value" | "-value"; 8 | } 9 | 10 | export interface AzPayloadFacetConfigValues { 11 | values: number[]; 12 | } 13 | 14 | export interface AzPayloadFacetConfigInterval { 15 | interval: number; 16 | } 17 | 18 | export type AzPayloadFacetConfig = 19 | | AzPayloadFacetConfigCountSort 20 | | AzPayloadFacetConfigValues 21 | | AzPayloadFacetConfigInterval; 22 | 23 | export interface AzPayloadFacet { 24 | fieldName: string; 25 | config?: AzPayloadFacetConfig; 26 | } 27 | -------------------------------------------------------------------------------- /src/common/components/pagination/pagination.scss: -------------------------------------------------------------------------------- 1 | @import "../../../theme/_palette.scss"; 2 | 3 | .pagination { 4 | display: flex; 5 | justify-content: space-around; 6 | max-width: 30rem; 7 | margin: auto; 8 | 9 | .button { 10 | font-size: inherit; 11 | margin: 0.1rem; 12 | width: 2.2rem; 13 | height: 2.2rem; 14 | box-shadow: none; 15 | color: inherit; 16 | background-color: transparent; 17 | 18 | &:hover { 19 | background-color: transparentize($colorGrey, 0.85); 20 | } 21 | } 22 | 23 | .primary { 24 | color: $colorPrimary; 25 | font-weight: 600; 26 | background-color: transparentize($colorPrimary, 0.85); 27 | } 28 | } 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-preview/hocr-node.style.ts: -------------------------------------------------------------------------------- 1 | const style = require("./hocr-node.style.scss"); 2 | 3 | export interface HocrNodeStyleMap { 4 | area?: string; 5 | paragraph?: string; 6 | line?: string; 7 | word?: string; 8 | target?: string; 9 | highlight?: string; 10 | }; 11 | 12 | export const defaultNodeStyle: HocrNodeStyleMap = { 13 | area: style.area, 14 | paragraph: style.par, 15 | line: style.line, 16 | word: style.word, 17 | target: style.target, 18 | highlight: style.highlight, 19 | } 20 | 21 | export const injectDefaultNodeStyle = (userStyle: HocrNodeStyleMap): HocrNodeStyleMap => { 22 | return { 23 | ...defaultNodeStyle, 24 | ...userStyle, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JFK Files 2 | 3 | Azure Search demo based on declassified JFK Files. 4 | 5 | ## To get started 6 | 7 | 1. Install Nodejs. 8 | 2. Download this repo. 9 | 3. Open the command line of your choice, cd to the root directory of this repo on your machine. 10 | 4. Download and install third partie packages, Execute from the command prompt: 11 | > Note: if you are not starting from a clean cut, check if there is an existing `node_modules` folder and remove it's content. 12 | ```cmd 13 | npm install 14 | ``` 15 | 5. Start the application: 16 | ``` 17 | npm start 18 | ``` 19 | 20 | You can open a browser and navigate to the following route: 21 | 22 | ```cmd 23 | http://localhost:8082 24 | ``` 25 | 26 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-preview/hocr-node.style.scss: -------------------------------------------------------------------------------- 1 | @mixin default-fill { 2 | fill: none; 3 | pointer-events: fill; 4 | } 5 | 6 | .area { 7 | @include default-fill(); 8 | } 9 | 10 | .par { 11 | @include default-fill(); 12 | } 13 | 14 | .line { 15 | @include default-fill(); 16 | 17 | &:hover { 18 | stroke: #ffac30; 19 | stroke-width: 1.5; 20 | stroke-dasharray: 0; 21 | } 22 | } 23 | 24 | .word { 25 | @include default-fill(); 26 | 27 | &.target { 28 | fill: yellow; 29 | opacity: 0.5; 30 | } 31 | 32 | &:hover, 33 | &.highlight { 34 | fill: #99cfff; 35 | opacity: 0.5; 36 | stroke: #007eff; 37 | stroke-width: 3; 38 | stroke-dasharray: 0; 39 | } 40 | } 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/pages/detail-page/detail-page.style.scss: -------------------------------------------------------------------------------- 1 | @import "./../../theme/_palette.scss"; 2 | @import "./../../theme/_breakpoints.scss"; 3 | 4 | .container { 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: flex-start; 8 | align-items: stretch; 9 | width: 100vw; 10 | height: 100vh; 11 | color: $colorPrimaryDark; 12 | background-color: $colorGrey; 13 | 14 | .separator { 15 | background-color: darken($colorGrey, 50%); 16 | opacity: 0.5; 17 | margin: 0; 18 | 19 | @include respond-to-desktop { 20 | margin: 0 2rem; 21 | } 22 | } 23 | 24 | .hocr { 25 | flex: 1 1 auto; 26 | margin: 0; 27 | 28 | @include respond-to-desktop { 29 | margin: 1rem; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/search-page/components/search/autocomplete.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../theme/_palette.scss"; 2 | 3 | .container { 4 | position: relative; 5 | 6 | .input { 7 | font-size: 1.35rem; 8 | font-weight: 400; 9 | color: inherit; 10 | } 11 | 12 | .underline::before, 13 | .underline:hover::before { 14 | background-color: transparentize($colorGrey, 0.5) !important; 15 | } 16 | 17 | .underline::after { 18 | background-color: $colorPrimary; 19 | } 20 | 21 | .dropdown-area { 22 | position: absolute; 23 | width: 100%; 24 | z-index: 1500; 25 | background-color: transparentize(darken($colorGrey, 85%), 0.2); 26 | 27 | .suggestion-item { 28 | color: inherit; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/az-api/payload/filter.model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Types for Filters. 3 | */ 4 | 5 | 6 | export interface AzFilterSingle { 7 | fieldName: string; 8 | operator: "eq" | "ne" | "gt" | "lt" | "ge" | "le"; 9 | value: string | string[]; 10 | logic?: "and" | "or"; // Only applicable to multiple values. 11 | } 12 | 13 | export interface AzFilterCollection { 14 | fieldName: string; 15 | mode: "any" | "all"; 16 | operator: "eq" | "ne"; 17 | value: string | string[]; 18 | logic?: "and" | "or"; 19 | } 20 | 21 | export type AzFilter = AzFilterSingle | AzFilterCollection; 22 | 23 | export type AzFilterGroupItem = (AzFilter | AzFilterGroup); 24 | 25 | export interface AzFilterGroup { 26 | items: AzFilterGroupItem[]; 27 | logic: "and" | "or"; 28 | } -------------------------------------------------------------------------------- /src/pages/search-page/components/graph/graph-view.handlers.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import { GraphNode } from "../../../../graph-api"; 3 | 4 | 5 | export const createDragBehaviour = (simulation) => { 6 | return d3.drag() 7 | .on("start", dragstarted(simulation)) 8 | .on("drag", dragged) 9 | .on("end", dragended(simulation)); 10 | } 11 | 12 | const dragstarted = (simulation) => (d) => { 13 | if (!d3.event.active) simulation.alphaTarget(1).restart(); 14 | d.fx = d.x; 15 | d.fy = d.y; 16 | } 17 | 18 | const dragged = (d) => { 19 | d.fx = d3.event.x; 20 | d.fy = d3.event.y; 21 | } 22 | 23 | const dragended = (simulation) => (d) => { 24 | if (!d3.event.active) simulation.alphaTarget(0); 25 | d.fx = null; 26 | d.fy = null; 27 | } 28 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-document/hocr-document.style.ts: -------------------------------------------------------------------------------- 1 | const style = require("./hocr-document.style.scss"); 2 | 3 | export interface HocrDocumentStyleMap { 4 | page?: string; 5 | area?: string; 6 | paragraph?: string; 7 | line?: string; 8 | word?: string; 9 | target?: string; 10 | highlight?: string; 11 | }; 12 | 13 | export const defaultDocumentStyle: HocrDocumentStyleMap = { 14 | page: style.page, 15 | area: style.area, 16 | paragraph: style.par, 17 | line: style.line, 18 | word: style.word, 19 | target: style.target, 20 | highlight: style.highlight, 21 | } 22 | 23 | export const injectDefaultDocumentStyle = (userStyle: HocrDocumentStyleMap): HocrDocumentStyleMap => { 24 | return { 25 | ...defaultDocumentStyle, 26 | ...userStyle, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/search-page/view-model/state.model.ts: -------------------------------------------------------------------------------- 1 | import { Service } from "../service"; 2 | import { ItemCollection } from "./item.model"; 3 | import { FacetCollection } from "./facet.model"; 4 | import { FilterCollection } from "./filter.model"; 5 | import { SuggestionCollection } from "./suggestion.model"; 6 | 7 | export type ResultViewMode = "grid" | "graph"; 8 | 9 | export interface State { 10 | searchValue: string; 11 | itemCollection: ItemCollection; 12 | activeSearch: string; 13 | facetCollection: FacetCollection; 14 | filterCollection: FilterCollection; 15 | suggestionCollection: SuggestionCollection; 16 | resultCount: number; 17 | showDrawer: boolean; 18 | resultViewMode: ResultViewMode; 19 | loading: boolean; 20 | pageSize: number; 21 | pageIndex: number; 22 | lastPageIndexReached: boolean; 23 | } -------------------------------------------------------------------------------- /src/app.router.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { HashRouter, Switch, Route } from 'react-router-dom'; 3 | import { HomeRoute } from './pages/home-page'; 4 | import { SearchRoute } from './pages/search-page'; 5 | import { DetailRoute } from './pages/detail-page'; 6 | 7 | export class AppRouter extends React.Component { 8 | 9 | public componentDidMount() { 10 | // We just want to display the background image once all the app is ready 11 | // if not it just doesn't display. 12 | document.body.style.backgroundImage = 'url("../assets/img/bg.jpg")'; 13 | } 14 | 15 | public render() { 16 | return ( 17 | 18 | 19 | {HomeRoute} 20 | {SearchRoute} 21 | {DetailRoute} 22 | 23 | 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/pages/search-page/components/facets/facet-body.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Facet, Filter } from "../../view-model"; 3 | import Collapse from "material-ui/transitions/Collapse"; 4 | import { CreateSelectionControl } from "../selection-controls"; 5 | 6 | const style = require("./facet-body.style.scss"); 7 | 8 | 9 | interface FacetBodyProps { 10 | facet: Facet; 11 | expanded: boolean; 12 | filter: Filter; 13 | onFilterUpdate: (newFilter: Filter) => void; 14 | } 15 | 16 | export const FacetBodyComponent: React.StatelessComponent = (props) => { 17 | return ( 18 | 19 |
20 | {CreateSelectionControl(props.facet, props.filter, props.onFilterUpdate)} 21 |
22 |
23 | ); 24 | }; -------------------------------------------------------------------------------- /src/common/components/chevron/chevron.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import IconButton from "material-ui/IconButton"; 3 | import { cnc } from "../../../util"; 4 | 5 | const style = require("./chevron.style.scss"); 6 | 7 | 8 | interface ChevronProps { 9 | expanded: boolean; 10 | onClick: () => void; 11 | className?: string; 12 | } 13 | 14 | export const Chevron: React.StatelessComponent = (props) => { 15 | return ( 16 | 26 |  27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-proofreader/hocr-proofreader.style.scss: -------------------------------------------------------------------------------- 1 | @import "./../../../../theme/_breakpoints.scss"; 2 | 3 | .container { 4 | flex: 1 1 auto; 5 | min-height: 0; 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | align-items: stretch; 10 | 11 | @include respond-to-desktop { 12 | flex-direction: row; 13 | } 14 | } 15 | 16 | .hocr-preview { 17 | box-sizing: border-box; 18 | padding: 1rem; 19 | height: 50%; 20 | width: unset; 21 | 22 | @include respond-to-desktop { 23 | width: 50%; 24 | height: unset; 25 | } 26 | } 27 | 28 | .hocr-document { 29 | box-sizing: border-box; 30 | padding: 1rem; 31 | height: 50%; 32 | width: unset; 33 | 34 | @include respond-to-desktop { 35 | width: 50%; 36 | height: unset; 37 | } 38 | 39 | &-hidden { 40 | display: none; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/search-page/components/drawer/drawer-bar.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../theme/_breakpoints.scss"; 2 | @import "../../../../theme/_mixins.scss"; 3 | 4 | .container { 5 | flex: 0 0 auto; 6 | justify-content: space-between; 7 | 8 | &-closed { 9 | @extend .container; 10 | 11 | justify-content: center; 12 | } 13 | } 14 | 15 | .caption { 16 | flex: 1 1 auto; 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: center; 20 | align-items: flex-start; 21 | 22 | .title { 23 | margin: 0; 24 | font-size: 0.9rem; 25 | 26 | @include respond-to-desktop { 27 | font-size: 1.05rem; 28 | } 29 | } 30 | 31 | .subtitle { 32 | margin: 0; 33 | font-size: 0.7rem; 34 | 35 | @include respond-to-desktop { 36 | font-size: 0.85rem; 37 | } 38 | } 39 | } 40 | 41 | .close-icon { 42 | @include jfk-icon; 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/search-page/components/selection-controls/selection-control.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Facet, Filter } from "../../view-model"; 3 | import { CheckboxListComponent } from "./checkbox-list.component"; 4 | import { YearPickerComponent } from "./year-picker.component"; 5 | 6 | export interface SelectionProps { 7 | facet: Facet; 8 | filter: Filter; 9 | onFilterUpdate: (newFilter: Filter) => void; 10 | } 11 | 12 | export const CreateSelectionControl = (facet: Facet, filter: Filter, onFilterUpdate) => { 13 | switch (facet.selectionControl) { 14 | case "checkboxList": 15 | return 16 | case "yearPicker": 17 | return 18 | default: 19 | return JSON.stringify(facet.values); 20 | } 21 | } -------------------------------------------------------------------------------- /src/graph-api/response.model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Object that represents the RESPONSE and RESPONSE configuration parameters. 3 | * These config parameters will help in parsing the raw API response (JSON) 4 | * to a RESPONSE object. 5 | */ 6 | 7 | 8 | export interface GraphEdge { 9 | source: number; 10 | target: number; 11 | } 12 | 13 | export interface GraphNode { 14 | name: string; 15 | } 16 | 17 | export interface GraphResponse { 18 | edges: GraphEdge[]; 19 | nodes: GraphNode[]; 20 | } 21 | 22 | export interface GraphResponseConfig { 23 | edgesAccessor: string; 24 | edgeSourceAccessor: string; 25 | edgeTargetAccessor: string; 26 | nodesAccessor: string; 27 | nodeNameAccessor: string; 28 | } 29 | 30 | export const defaultGraphResponseConfig: GraphResponseConfig = { 31 | edgesAccessor: "edges", 32 | edgeSourceAccessor: "source", 33 | edgeTargetAccessor: "target", 34 | nodesAccessor: "nodes", 35 | nodeNameAccessor: "name", 36 | } 37 | -------------------------------------------------------------------------------- /src/pages/search-page/components/drawer/drawer.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../theme/_palette.scss"; 2 | 3 | $drawer-max-width: 31rem; 4 | $dock-visible-width: 5rem; 5 | 6 | @mixin border-separator { 7 | border-right-width: 0.65rem; 8 | border-right-color: transparentize($colorGrey, 0.65); 9 | border-right-style: solid; 10 | } 11 | 12 | .drawer-dock { 13 | @include border-separator; 14 | 15 | margin-left: 0; 16 | color: inherit; 17 | 18 | .drawer-paper-desktop { 19 | position: static; 20 | width: $drawer-max-width; 21 | padding: 2.5rem; 22 | transition: width 0.15s ease-out; 23 | 24 | &-hidden { 25 | @extend .drawer-paper-desktop; 26 | 27 | width: $dock-visible-width; 28 | overflow-x: hidden; 29 | } 30 | } 31 | } 32 | 33 | .drawer-paper-mobile { 34 | @include border-separator; 35 | 36 | width: 85vw; 37 | max-width: $drawer-max-width; 38 | padding: 1.25rem; 39 | background-color: $colorPrimaryDarker !important; 40 | } 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/common/components/pagination/page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Button from 'material-ui/Button'; 3 | 4 | 5 | interface Props { 6 | pageText: (string | React.ReactNode); // Review this Element not sure if make sense 7 | pageNumber: number; 8 | onClick: (pageNumber : number) => void; 9 | isActive?: boolean; 10 | isDisabled?: boolean, 11 | classes?: any; 12 | } 13 | 14 | 15 | const handleClick = ({onClick, pageNumber} : Props) => (e) => { 16 | e.preventDefault(); 17 | onClick(pageNumber); 18 | } 19 | 20 | 21 | export const Page : React.StatelessComponent = (props) => { 22 | return ( 23 | !props.isDisabled && 24 | 33 | ); 34 | } 35 | 36 | Page.defaultProps = { 37 | isActive: false, 38 | isDisabled: false, 39 | }; 40 | -------------------------------------------------------------------------------- /src/pages/search-page/components/placeholder/placeholder.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { DialogComponent } from "./dialog.component"; 3 | import { AzureButtonComponent } from './azure-button.component'; 4 | 5 | interface State { 6 | isDialogOpen: boolean; 7 | } 8 | 9 | export class PlaceholderComponent extends React.PureComponent<{}, State> { 10 | constructor(props) { 11 | super(props); 12 | 13 | this.state = { 14 | isDialogOpen: false, 15 | }; 16 | } 17 | 18 | render() { 19 | return ( 20 | <> 21 | 25 | 28 | 29 | ); 30 | } 31 | 32 | private handleClose = () => { 33 | this.setState({ isDialogOpen: false }); 34 | } 35 | 36 | private handleClickOpen = () => { 37 | this.setState({ isDialogOpen: true }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/search-page/service/service.model.ts: -------------------------------------------------------------------------------- 1 | import { FacetCollection } from "../view-model"; 2 | import { AzConfig, AzPayload, AzResponse, AzResponseConfig } from "../../../az-api"; 3 | 4 | export type MapperToPayload = (state: any, config: ServiceConfig) => AzPayload; 5 | export type MapperToState = (state: any, response: AzResponse, config: ServiceConfig) => any; 6 | 7 | export interface ActionConfig { 8 | apiConfig: AzConfig; 9 | responseConfig?: AzResponseConfig; 10 | defaultPayload?: AzPayload; 11 | mapStateToPayload: MapperToPayload; 12 | mapResponseToState: MapperToState; 13 | } 14 | 15 | export interface ServiceConfig { 16 | serviceId: string; 17 | serviceName: string; 18 | serviceIcon: string; 19 | searchConfig: ActionConfig; 20 | suggestionConfig: ActionConfig; 21 | initialState?: any; 22 | } 23 | 24 | export type StateReducer = (state: S) => S; 25 | 26 | export interface Service { 27 | config: ServiceConfig; 28 | search: (state: S) => Promise; 29 | suggest: (state: S) => Promise; 30 | } 31 | -------------------------------------------------------------------------------- /src/pages/detail-page/components/toolbar/toolbar.style.scss: -------------------------------------------------------------------------------- 1 | @import "./../../../../theme/_mixins.scss"; 2 | @import "./../../../../theme/_breakpoints.scss"; 3 | 4 | .toolbar { 5 | display: flex; 6 | flex-direction: row; 7 | justify-content: space-between; 8 | align-items: center; 9 | margin: 0.5rem 1rem; 10 | 11 | @include respond-to-desktop { 12 | margin: 1rem 2rem; 13 | } 14 | 15 | .group { 16 | display: flex; 17 | flex-direction: row; 18 | justify-content: flex-start; 19 | align-items: center; 20 | } 21 | 22 | .toggle-view { 23 | flex: 0 0 auto; 24 | padding: 0; 25 | margin-right: 0.5rem; 26 | } 27 | 28 | .icon { 29 | @include jfk-icon; 30 | } 31 | 32 | .toggle-icon { 33 | @extend .icon; 34 | 35 | transition: color 0.25s ease-out; 36 | } 37 | 38 | .close-button { 39 | display: flex; 40 | flex-direction: column; 41 | justify-content: center; 42 | align-items: center; 43 | } 44 | 45 | .close-icon { 46 | @extend .icon; 47 | 48 | font-size: 3.6rem; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/pages/home-page/components/search/search-input.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Icon from "material-ui/Icon"; 3 | 4 | const style = require("./search-input.style.scss"); 5 | 6 | interface SearchInputProps { 7 | searchValue: string; 8 | onSearchUpdate: (newValue: string) => void; 9 | onSearchSubmit: () => void; 10 | } 11 | 12 | const handleOnChange = (onSearchUpdate) => (e) => { 13 | onSearchUpdate(e.target.value); 14 | } 15 | 16 | const captureEnter = (onSearchSubmit) => (e => { 17 | if (e.key === "Enter") { 18 | onSearchSubmit(); 19 | } 20 | }); 21 | 22 | export const SearchInput = (props: SearchInputProps) => ( 23 |
24 | 25 | 34 |
35 | ); -------------------------------------------------------------------------------- /src/graph-api/request.ts: -------------------------------------------------------------------------------- 1 | import { GraphConfig } from "./config.model"; 2 | import { GraphPayload } from "./payload.model"; 3 | import { parsePayload } from "./payload.parser"; 4 | import { parseConfig } from "./config.parser"; 5 | 6 | /** 7 | * Given an API configuration and payload, it creates the Request object for the fetch method. 8 | */ 9 | 10 | 11 | export interface GraphRequest { 12 | url: string; 13 | options: RequestInit; 14 | } 15 | 16 | const buildURL = (config: GraphConfig, payload: GraphPayload): string => { 17 | return [ 18 | parseConfig(config), 19 | parsePayload(payload), 20 | ].filter(i => i).join(""); 21 | }; 22 | 23 | const defaultOptions: RequestInit = { 24 | method: "GET", 25 | headers: { 26 | "Accept": "application/json", 27 | }, 28 | mode: "cors" 29 | }; 30 | 31 | export const CreateRequest = (config: GraphConfig, payload: GraphPayload): GraphRequest => { 32 | return { 33 | url: buildURL(config, payload), 34 | options: { 35 | ...defaultOptions, 36 | method: config.method, 37 | } 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/search-page/components/item/item-collection-view.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ItemComponent } from "./item.component"; 3 | import { ItemCollection, Item } from "../../view-model"; 4 | 5 | const style = require("./item-collection-view.style.scss"); 6 | 7 | 8 | interface ItemViewProps { 9 | items?: ItemCollection; 10 | activeSearch?: string; 11 | onClick?: (item: Item) => void; 12 | } 13 | 14 | export class ItemCollectionViewComponent extends React.Component { 15 | public constructor(props) { 16 | super(props); 17 | } 18 | 19 | public render() { 20 | return ( 21 |
22 | { this.props.items ? 23 | this.props.items.map((child, index) => ( 24 | 30 | )) 31 | : null } 32 |
33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/search-page/service/jfk/mapper.suggestion.ts: -------------------------------------------------------------------------------- 1 | import { isArrayEmpty } from "../../../../util"; 2 | import { ServiceConfig, MapperToPayload } from "../../service"; 3 | import { AzResponse, AzPayload } from "../../../../az-api"; 4 | import { Suggestion, State } from "../../view-model"; 5 | 6 | 7 | // [Suggestion] FROM AzApi TO view model. 8 | 9 | const mapSuggestionResponse = (suggestion: any): Suggestion => { 10 | return suggestion ? { 11 | text: suggestion.text, 12 | } : null; 13 | }; 14 | 15 | export const mapSuggestionResponseToState = (state: State, response: AzResponse, config: ServiceConfig): State => { 16 | return { 17 | ...state, 18 | suggestionCollection: isArrayEmpty(response.value) ? null : 19 | response.value.map(s => mapSuggestionResponse(s)), 20 | } 21 | }; 22 | 23 | 24 | // [Suggestion] FROM view model TO AzApi. 25 | 26 | export const mapStateToSuggestionPayload = (state: State, config: ServiceConfig): AzPayload => { 27 | return state.searchValue ? { 28 | ...config.suggestionConfig.defaultPayload, 29 | search: state.searchValue, 30 | } : null; 31 | }; 32 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-document/hocr-document.style.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | flex: 1 1 auto; 3 | display: flex; 4 | flex-direction: column; 5 | max-width: 100%; 6 | max-height: 100%; 7 | } 8 | 9 | .viewport { 10 | display: block; 11 | width: 100%; 12 | height: 100%; 13 | margin: 0; 14 | overflow: auto; 15 | scroll-behavior: smooth; 16 | } 17 | 18 | .page { 19 | margin: 0 0.25rem 1rem; 20 | padding: 1rem; 21 | border-radius: 0.5rem; 22 | background-color: white; 23 | } 24 | 25 | .area { 26 | padding-left: 10px; 27 | border-left: 3px solid transparent; 28 | 29 | &:hover { 30 | border-left: 3px solid #39f; 31 | } 32 | } 33 | 34 | .paragraph { 35 | border: 1px solid transparent; 36 | 37 | &:hover { 38 | border: 1px dashed #39f; 39 | } 40 | } 41 | 42 | .line { 43 | word-wrap: normal; 44 | 45 | &:hover { 46 | background: hsla(36, 100%, 59%, 0.1); 47 | outline: solid 1px #ffac30; 48 | } 49 | } 50 | 51 | .word { 52 | &.target { 53 | background: yellow; 54 | } 55 | 56 | &:hover, 57 | &.highlight { 58 | background: #99cfff; 59 | outline: solid 3px #007eff; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/pages/search-page/components/page-bar/page-bar.style.scss: -------------------------------------------------------------------------------- 1 | @import "./../../../../theme/_breakpoints.scss"; 2 | 3 | .appbar { 4 | box-shadow: none !important; 5 | 6 | .toolbar { 7 | display: flex; 8 | flex-direction: row; 9 | justify-content: flex-start; 10 | align-items: center; 11 | height: 3rem; 12 | margin: 0.75rem; 13 | 14 | @include respond-to-desktop { 15 | height: 7.8rem; 16 | margin: 0 1.75rem; 17 | } 18 | 19 | .menu-button { 20 | @include respond-to-desktop { 21 | display: none; 22 | } 23 | } 24 | 25 | .logo-container { 26 | flex: 1 1 auto; 27 | display: flex; 28 | flex-direction: row; 29 | justify-content: center; 30 | align-items: center; 31 | margin-left: 3rem; 32 | 33 | @include respond-to-desktop { 34 | margin-left: 6rem; 35 | } 36 | 37 | .logo-object { 38 | flex: 1 1 auto; 39 | margin: 0 0.5rem; 40 | max-height: 2.5rem; 41 | max-width: 100%; 42 | 43 | @include respond-to-desktop { 44 | max-height: 4.5rem; 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/theme/theme.ts: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from 'material-ui/styles'; 2 | 3 | const defs = require("./main.scss"); 4 | 5 | 6 | export const theme = createMuiTheme({ 7 | breakpoints: { 8 | values: { 9 | xs: parseInt(defs.breakpointXs), 10 | sm: parseInt(defs.breakpointSm), 11 | md: parseInt(defs.breakpointMd), 12 | lg: parseInt(defs.breakpointLg), 13 | xl: parseInt(defs.breakpointXl), 14 | }, 15 | }, 16 | palette: { 17 | common: { 18 | black: defs.colorBlack, 19 | white: defs.colorWhite, 20 | }, 21 | primary: { 22 | main: defs.colorPrimary, 23 | dark: defs.colorPrimaryDark, 24 | }, 25 | secondary: { 26 | main: defs.colorSecondary, 27 | }, 28 | background: { 29 | default: defs.colorBackground, 30 | paper: defs.colorPaper, 31 | }, 32 | }, 33 | transitions: { 34 | duration: { 35 | shortest: 100, 36 | shorter: 125, 37 | short: 150, 38 | standard:200, 39 | complex: 225, 40 | } 41 | }, 42 | typography: { 43 | fontFamily: '"Open Sans", sans-serif', 44 | fontSize: "1rem", 45 | fontWeightRegular: 300, 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /src/az-api/request.ts: -------------------------------------------------------------------------------- 1 | import { AzConfig } from "./config.model"; 2 | import { AzPayload, parsePayloadGET } from "./payload"; 3 | import { parseConfig } from "./config.parser"; 4 | 5 | /** 6 | * Given an API configuration and payload, it creates the Request object for the fetch method. 7 | * TODO: Implement POST request. 8 | */ 9 | 10 | 11 | export interface AzRequest { 12 | url: string; 13 | options: RequestInit; 14 | } 15 | 16 | const buildURL = (config: AzConfig, payload: AzPayload): string => { 17 | return [ 18 | parseConfig(config), 19 | config.method === "GET" ? parsePayloadGET(payload) : "", 20 | ].filter(i => i).join("&"); 21 | }; 22 | 23 | const buildBody = (config: AzConfig, payload: AzPayload): any => { 24 | config.method === "GET" ? null : {}; // TODO: Implement POST body. 25 | } 26 | 27 | export const CreateRequest = (config: AzConfig, payload: AzPayload): AzRequest => ({ 28 | url: buildURL(config, payload), 29 | options: { 30 | method: config.method, 31 | headers: { 32 | "Content-Type": "application/json", 33 | "api-key": config.apiKey, 34 | }, 35 | mode: "cors", 36 | body: buildBody(config, payload), 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /src/pages/search-page/components/facets/facet-view.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { FacetCollection, FilterCollection, Filter } from "../../view-model"; 3 | import { FacetItemComponent } from "./facet-item.component"; 4 | 5 | const style = require("./facet-view.style.scss"); 6 | 7 | 8 | interface FacetViewProps { 9 | facets: FacetCollection; 10 | filters: FilterCollection; 11 | onFilterUpdate: (newFilter: Filter) => void; 12 | } 13 | 14 | class FacetViewComponent extends React.PureComponent { 15 | render() { 16 | return this.props.facets ? ( 17 |
18 | {this.props.facets.map((facet, index) => { 19 | const filter = this.props.filters ? 20 | this.props.filters.find(f => f.fieldId === facet.fieldId) : null; 21 | return ( 22 | 28 | ) 29 | })} 30 |
31 | ) : null; 32 | } 33 | } 34 | 35 | export { FacetViewComponent }; 36 | -------------------------------------------------------------------------------- /src/az-api/payload/payload.model.ts: -------------------------------------------------------------------------------- 1 | import { AzPayloadFacet } from "./facet.model"; 2 | import { AzFilterGroup } from "./filter.model"; 3 | 4 | /** 5 | * Object that represents API payload parameters. These params are used to fetch 6 | * certain data from the API and they will usually differ from query to query. 7 | * TODO: Some pending payload features: scoring, highlight, etc. 8 | */ 9 | 10 | 11 | export interface AzOrderBy { 12 | fieldName: string; 13 | order: "asc" | "desc"; 14 | } 15 | 16 | export type AzSearchMode = "any" | "all"; 17 | 18 | export interface AzPayload { 19 | search: string; 20 | 21 | // Search payload 22 | count?: boolean; 23 | facets?: AzPayloadFacet[]; 24 | filters?: AzFilterGroup; 25 | minimumCoverage?: number; 26 | orderBy?: AzOrderBy[]; 27 | searchFields?: string[]; 28 | searchMode?: AzSearchMode; 29 | select?: string[]; 30 | skip?: number; 31 | top?: number; 32 | 33 | // Suggestions payload 34 | fuzzy?: boolean; 35 | suggesterName?: string; 36 | autocompleteMode?: string; 37 | } 38 | 39 | export const defaultAzPayload: AzPayload = { 40 | search: "*", 41 | count: true, 42 | searchMode: "any", 43 | top: 10, 44 | } 45 | -------------------------------------------------------------------------------- /src/pages/search-page/components/facets/facet-header.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Facet } from "../../view-model"; 3 | import { Chevron } from "../../../../common/components/chevron"; 4 | import { CardActions } from "material-ui/Card"; 5 | import { Icon } from "material-ui"; 6 | import Typography from "material-ui/Typography"; 7 | 8 | const style = require("./facet-header.style.scss"); 9 | 10 | 11 | interface FacetHeaderProps { 12 | facet: Facet; 13 | expanded: boolean; 14 | onToggleExpanded: () => void; 15 | } 16 | 17 | export const FacetHeaderComponent: React.StatelessComponent = (props) => { 18 | return ( 19 | 20 |
21 | { props.facet.iconName ? 22 | 23 | {props.facet.iconName} 24 | 25 | : null 26 | } 27 | {props.facet.displayName.toUpperCase()} 28 |
29 | 30 |
31 | ); 32 | }; -------------------------------------------------------------------------------- /src/pages/home-page/home-page.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { searchPath } from "../search-page"; 3 | import { LogoComponent } from "../../common/components/logo"; 4 | import { SearchButton } from "./components/search"; 5 | import { CaptionComponent } from "./components/caption"; 6 | import { SearchInput } from "./components/search"; 7 | 8 | const style = require("./home-page.style.scss"); 9 | 10 | 11 | interface HomePageProps { 12 | searchValue: string; 13 | onSearchSubmit: () => void; 14 | onSearchUpdate: (newValue: string) => void; 15 | } 16 | 17 | export const HomePageComponent: React.StatelessComponent = (props) => { 18 | return ( 19 |
20 | 21 |
22 | 23 | 28 | 29 |
30 |
31 | ) 32 | }; 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/pages/home-page/home-page.container.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RouteComponentProps } from "react-router"; 3 | import { HomePageComponent } from "./home-page.component"; 4 | import { searchPath } from "../search-page"; 5 | var qs= require('qs'); 6 | 7 | interface HomePageState { 8 | searchValue: string; 9 | } 10 | 11 | export class HomePageContainer extends React.Component, HomePageState> { 12 | constructor(props) { 13 | super(props); 14 | 15 | this.state = { 16 | searchValue: "", 17 | } 18 | } 19 | 20 | private handleSearchSubmit = () => { 21 | const params = qs.stringify({term: this.state.searchValue}); 22 | 23 | this.props.history.push({ 24 | pathname: searchPath, 25 | search: `?${params}` 26 | }); 27 | 28 | }; 29 | 30 | private handleSearchUpdate = (newSearch: string) => { 31 | this.setState({...this.state, searchValue: newSearch}); 32 | }; 33 | 34 | public render() { 35 | return ( 36 | 41 | ) 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/pages/home-page/home-page.style.scss: -------------------------------------------------------------------------------- 1 | @import "./../../theme/_breakpoints.scss"; 2 | 3 | .container { 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | flex: 0 0 auto; 8 | align-items: center; 9 | position: relative; 10 | height: 100vh; 11 | overflow: auto; 12 | min-height: 21.6rem; 13 | 14 | @include respond-to-desktop { 15 | min-height: 30rem; 16 | } 17 | 18 | .logo-container { 19 | display: flex; 20 | flex-direction: column; 21 | justify-content: center; 22 | flex: 0 0 auto; 23 | align-items: center; 24 | position: absolute; 25 | top: 0; 26 | height: 50%; 27 | width: 50%; 28 | color: inherit; 29 | 30 | .logo-object { 31 | width: 100%; 32 | height: 100%; 33 | min-width: 7.5rem; 34 | min-height: 7.5rem; 35 | max-width: 22.5rem; 36 | max-height: 22.5rem; 37 | margin: 2.5rem 0 6.25rem; 38 | } 39 | } 40 | 41 | .main { 42 | display: flex; 43 | flex-direction: column; 44 | justify-content: center; 45 | flex: 0 0 auto; 46 | align-items: center; 47 | width: 80vw; 48 | max-width: 42rem; 49 | margin-top: 3.6rem; 50 | 51 | @include respond-to-desktop { 52 | margin-top: 5rem; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/pages/search-page/components/page-bar/page-bar.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ResultViewMode } from "../../view-model"; 3 | import { MenuButton } from "../../../../common/components/menu-button"; 4 | import { LogoComponent } from "./../../../../common/components/logo"; 5 | import { ResultViewModeToggler } from "./view-mode-toggler.component"; 6 | import AppBar from "material-ui/AppBar"; 7 | import Toolbar from "material-ui/Toolbar"; 8 | 9 | const style = require("./page-bar.style.scss"); 10 | 11 | 12 | interface BarProps{ 13 | resultViewMode: ResultViewMode; 14 | onChangeResultViewMode: (newMode: ResultViewMode) => void; 15 | onMenuClick: () => void; 16 | } 17 | 18 | export const PageBarComponent = (props) => { 19 | return ( 20 | 25 | 26 | 30 | 31 | 32 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/assets/svg/azure-search.logo.svg: -------------------------------------------------------------------------------- 1 | Recurso 3 -------------------------------------------------------------------------------- /src/graph-api/response.parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphResponseConfig, 3 | GraphResponse, 4 | GraphNode, 5 | GraphEdge, 6 | } from "./response.model"; 7 | import { isArrayEmpty } from "../util"; 8 | 9 | /** 10 | * Parser for Response. 11 | * It will transform a raw JSON response from server to a Response object. 12 | */ 13 | 14 | const parseEdge = (rawEdge: any, config: GraphResponseConfig): GraphEdge => { 15 | return { 16 | source: parseInt(rawEdge[config.edgeSourceAccessor]), 17 | target: parseInt(rawEdge[config.edgeTargetAccessor]), 18 | } 19 | } 20 | 21 | const parseNode = (rawNode: any, config: GraphResponseConfig): GraphNode => { 22 | return { 23 | name: rawNode[config.nodeNameAccessor], 24 | } 25 | } 26 | 27 | export const parseResponse = async (response: Response, config: GraphResponseConfig 28 | ): Promise => { 29 | const jsonObject = await response.json(); 30 | 31 | if (!response.ok) { 32 | console.debug(jsonObject); 33 | throw new Error(`${response.status} - ${response.statusText} 34 | Message: ${jsonObject.error.message}`); 35 | } 36 | 37 | return { 38 | edges: jsonObject[config.edgesAccessor].map(e => parseEdge(e, config)), 39 | nodes: jsonObject[config.nodesAccessor].map(n => parseNode(n, config)), 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/pages/search-page/components/placeholder/azure-button.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Button, { ButtonProps } from "material-ui/Button"; 3 | import { withTheme, WithTheme } from "material-ui/styles"; 4 | import AddIcon from "material-ui-icons/Add"; 5 | import Hidden, { HiddenProps } from "material-ui/Hidden"; 6 | const styles = require('./azure-button.style.scss'); 7 | const azureLogo = require('../../../../assets/svg/azure-search.logo.svg'); 8 | 9 | const azureButtonFor = (hiddenProps: HiddenProps): React.StatelessComponent => (props) => { 10 | return ( 11 | 12 | 21 | 22 | ); 23 | }; 24 | 25 | const AzureButtonForDesktop = azureButtonFor({ xsDown: true }); 26 | const AzureButtonForMobile = azureButtonFor({ smUp: true }); 27 | 28 | export const AzureButtonComponent: React.StatelessComponent = (props) => { 29 | return ( 30 | <> 31 | 32 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/theme/_base.scss: -------------------------------------------------------------------------------- 1 | // Setup body. 2 | 3 | // To get the bundler adding this image 4 | .body-bkg { 5 | background: url("../assets/img/bg.jpg"); 6 | } 7 | 8 | body { 9 | margin: 0; 10 | 11 | // Default font style. 12 | font-family: "Open Sans", sans-serif; 13 | 14 | // Default background style. 15 | color: $colorWhite; 16 | background-color: $colorPrimaryDarker; 17 | // This will loaded once the react app is ready 18 | // background: url("../assets/img/bg.jpg"); 19 | background-position: center; 20 | background-size: cover; 21 | background-repeat: no-repeat; 22 | -webkit-background-size: cover; 23 | -moz-background-size: cover; 24 | -o-background-size: cover; 25 | 26 | // Disable highlight on touch. 27 | -webkit-tap-highlight-color: transparent; 28 | 29 | // Default scrollbars style. Chrome. 30 | ::-webkit-scrollbar { 31 | width: 0.3rem; 32 | height: 0.3rem; 33 | } 34 | 35 | ::-webkit-scrollbar-thumb { 36 | background: $colorPrimary; 37 | } 38 | 39 | ::-webkit-scrollbar-track { 40 | background: $colorGrey; 41 | border: 0.2rem solid transparent; 42 | background-clip: content-box; 43 | } 44 | 45 | // Default scrollbars style. Internet Explorer 46 | body { 47 | scrollbar-face-color: $colorPrimary; 48 | scrollbar-track-color: $colorGrey; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/theme/_palette.scss: -------------------------------------------------------------------------------- 1 | $colorWhite: white; 2 | $colorBlack: black; 3 | $colorPrimary: hsl(193, 99%, 57%); 4 | $colorPrimaryDark: hsl(210, 100%, 20%); 5 | $colorPrimaryDarker: hsl(202, 38%, 14%); 6 | $colorSecondary: hsl(347, 88%, 34%); 7 | $colorBackground: white; 8 | $colorPaper: transparent; 9 | $colorGrey: rgb(236, 236, 236); 10 | 11 | // Red gradient generator cross-browser. 12 | // Colors for gradients 13 | $redStart: hsl(349, 81%, 37%); 14 | $redEnd: hsl(346, 88%, 20%); 15 | 16 | @mixin red-gradient { 17 | // Old browsers 18 | background: $redStart; 19 | // FF3.6-15 20 | background: -moz-linear-gradient(top, $redStart 0%, $redEnd 100%); 21 | // Chrome10-25,Safari5.1-6 22 | background: -webkit-linear-gradient(top, $redStart 0%, $redEnd 100%); 23 | // W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ 24 | background: linear-gradient(to bottom, $redStart 0%, $redEnd 100%); 25 | // IE6-9 26 | filter: progid:DXImageTransform.Microsoft.gradient( 27 | startColorstr="#a9122d", 28 | endColorstr="#60061b", 29 | GradientType=0 30 | ); 31 | } 32 | 33 | :export { 34 | colorWhite: $colorWhite; 35 | colorBlack: $colorBlack; 36 | colorPrimary: $colorPrimary; 37 | colorPrimaryDark: $colorPrimaryDark; 38 | colorSecondary: $colorSecondary; 39 | colorBackground: $colorBackground; 40 | colorPaper: $colorPaper; 41 | colorGrey: $colorGrey; 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/home-page/components/search/search-input.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../theme/_breakpoints.scss"; 2 | @import "../../../../theme/_palette.scss"; 3 | @import "../../../../theme/_mixins.scss"; 4 | 5 | .container { 6 | position: relative; 7 | width: 100%; 8 | margin-top: 0.9rem; 9 | margin-bottom: 2.07rem; 10 | 11 | @include respond-to-desktop { 12 | margin-top: 1.25rem; 13 | margin-bottom: 2.875rem; 14 | } 15 | 16 | .input { 17 | font-family: inherit; 18 | font-weight: 600; 19 | position: relative; 20 | width: 100%; 21 | box-sizing: border-box; 22 | color: $colorPrimaryDark; 23 | border-width: 0; 24 | border-radius: 6px; 25 | outline: 0; 26 | background-color: $colorWhite; 27 | font-size: 1.3rem; 28 | height: 3.06rem; 29 | padding-left: 3rem; 30 | 31 | @include respond-to-desktop { 32 | height: 4.25rem; 33 | font-size: 1.8rem; 34 | padding-left: 4.25rem; 35 | } 36 | } 37 | 38 | .icon { 39 | @include jfk-icon; 40 | 41 | margin: 0; 42 | position: absolute; 43 | color: $colorPrimaryDark; 44 | z-index: 100; 45 | font-size: 1.8rem; 46 | left: 0.7038rem; 47 | top: calc(50% - 3.06rem / 3.5); 48 | 49 | @include respond-to-desktop { 50 | font-size: 2.5rem; 51 | left: 1rem; 52 | top: calc(50% - 4.25rem / 3.5); 53 | } 54 | } 55 | } 56 | 57 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-preview/hocr-svg.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { getNodeOptions, bboxToPosSize, getNodeId, composeId } from "../util/common-util"; 3 | 4 | /** 5 | * HOCR Node SVG 6 | */ 7 | 8 | interface SvgRectProps { 9 | node: Element; 10 | className: string; 11 | idSuffix: string; 12 | onHover?: (id: string) => void; 13 | } 14 | 15 | export const SvgRectComponent: React.StatelessComponent = (props) => { 16 | const nodeOptions = getNodeOptions(props.node); 17 | if (!nodeOptions || !nodeOptions.bbox) return null; 18 | 19 | const nodePosSize = bboxToPosSize(nodeOptions.bbox); 20 | const id = getNodeId(props.node); 21 | const suffixedId = composeId(id, props.idSuffix); 22 | 23 | return ( 24 | props.onHover(id))} 32 | onMouseLeave={props.onHover && (() => props.onHover(null))} 33 | /> 34 | ); 35 | } 36 | 37 | interface SvgGroupProps { 38 | className: string; 39 | } 40 | 41 | export const SvgGroupComponent: React.StatelessComponent = (props) => { 42 | return ( 43 | 44 | {props.children} 45 | 46 | ); 47 | }; -------------------------------------------------------------------------------- /src/pages/search-page/components/page-bar/view-mode-toggler.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ResultViewMode } from "../../view-model"; 3 | import IconButton from "material-ui/IconButton"; 4 | 5 | const style = require("./view-mode-toggler.style.scss"); 6 | 7 | 8 | interface ViewModeTogglerProps { 9 | resultViewMode: ResultViewMode; 10 | onChangeResultViewMode: (newMode: ResultViewMode) => void; 11 | } 12 | 13 | const toggleColor = (props: ViewModeTogglerProps) => (viewMode: ResultViewMode) => { 14 | return props.resultViewMode === viewMode ? "primary" : "inherit"; 15 | } 16 | 17 | const notifyModeChanged = (props: ViewModeTogglerProps) => (newMode: ResultViewMode) => () =>{ 18 | return props.onChangeResultViewMode(newMode); 19 | } 20 | 21 | export const ResultViewModeToggler = (props: ViewModeTogglerProps) => { 22 | const toggleColorFunc = toggleColor(props); 23 | const notifyModeChangedFunc = notifyModeChanged(props); 24 | return ( 25 | <> 26 | 31 |  32 | 33 | 38 |  39 | 40 | 41 | ); 42 | } -------------------------------------------------------------------------------- /src/az-api/response.model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Object that represents the RESPONSE and RESPONSE configuration parameters. 3 | * These config parameters will help in parsing the raw API response (JSON) to a RESPONSE object. 4 | * We should not be modifying this, server API should not publish break changes. 5 | * TODO: Only needed logic for JFK demo has been implemented so far. 6 | */ 7 | 8 | 9 | export interface AzResponseFacetValue { 10 | value: string; 11 | count: number; 12 | from?: string; 13 | to?: string; 14 | } 15 | 16 | export interface AzResponseFacet { 17 | fieldName: string; 18 | type?: string; 19 | values: AzResponseFacetValue[]; 20 | } 21 | 22 | export interface AzResponse { 23 | value: any[]; 24 | count?: number; 25 | coverage?: number; 26 | facets?: AzResponseFacet[]; 27 | } 28 | 29 | export interface AzResponseConfig { 30 | countAccessor: string; 31 | coverageAccessor: string; 32 | facetsAccessor: string; 33 | facetTypeSuffix: string; 34 | facetValueAccessor: string; 35 | facetCountAccessor: string; 36 | facetFromAccessor: string; 37 | facetToAccessor: string; 38 | valueAccessor: string; 39 | } 40 | 41 | export const defaultAzResponseConfig: AzResponseConfig = { 42 | countAccessor: "@odata.count", 43 | coverageAccessor: "@search.coverage", 44 | facetsAccessor: "@search.facets", 45 | facetTypeSuffix: "@odata.type", 46 | facetValueAccessor: "value", 47 | facetCountAccessor: "count", 48 | facetFromAccessor: "from", 49 | facetToAccessor: "to", 50 | valueAccessor: "value", 51 | } -------------------------------------------------------------------------------- /src/pages/search-page/components/item/item.style.scss: -------------------------------------------------------------------------------- 1 | @import "./../../../../theme/_breakpoints.scss"; 2 | @import "./../../../../theme/_palette.scss"; 3 | 4 | $colorTranslucent: transparentize($colorGrey, 0.85); 5 | 6 | .card { 7 | width: 16rem; 8 | margin: 0 0.75rem 1.5rem; 9 | 10 | @include respond-to-desktop { 11 | width: 19rem; 12 | margin: 0 1rem 2.5rem; 13 | } 14 | 15 | .media { 16 | object-fit: cover; 17 | object-position: center; 18 | cursor: pointer; 19 | height: 22rem; 20 | 21 | @include respond-to-desktop { 22 | height: 26rem; 23 | } 24 | } 25 | 26 | .caption { 27 | cursor: pointer; 28 | background-color: $colorTranslucent; 29 | } 30 | 31 | .subtitle { 32 | font-size: 0.8em; 33 | margin-left: 0.3em; 34 | color: silver; 35 | } 36 | 37 | .actions { 38 | display: flex; 39 | flex-direction: row; 40 | justify-content: space-between; 41 | align-items: center; 42 | background-color: $colorTranslucent; 43 | } 44 | 45 | .rating { 46 | display: flex; 47 | flex-direction: row; 48 | justify-content: flex-start; 49 | align-items: center; 50 | } 51 | 52 | .star { 53 | width: 16px; 54 | } 55 | 56 | .chevron { 57 | margin: 0 0 0 auto; 58 | } 59 | 60 | .collapse { 61 | background-color: $colorTranslucent; 62 | } 63 | } 64 | 65 | .tag { 66 | margin: 0.15rem; 67 | 68 | &-container { 69 | display: flex; 70 | flex-flow: row wrap; 71 | justify-content: center; 72 | width: 100%; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/pages/search-page/components/search/search.style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../theme/_breakpoints.scss"; 2 | @import "../../../../theme/_palette.scss"; 3 | @import "../../../../theme/_mixins.scss"; 4 | 5 | .container { 6 | flex: 0 0 auto; 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: flex-start; 10 | align-items: stretch; 11 | margin: 1.25rem 0; 12 | 13 | @include respond-to-desktop { 14 | margin: 2.5rem 0; 15 | } 16 | 17 | .control-container { 18 | flex: 0 0 auto; 19 | display: flex; 20 | flex-direction: row; 21 | justify-content: flex-start; 22 | align-items: center; 23 | 24 | .icon { 25 | @include jfk-icon; 26 | 27 | font-size: 1.4rem; 28 | margin-right: 1rem; 29 | 30 | @include respond-to-desktop { 31 | font-size: 1.8rem; 32 | } 33 | } 34 | 35 | .input { 36 | flex: 1 1 auto; 37 | font-size: 0.8rem; 38 | 39 | @include respond-to-desktop { 40 | font-size: 1rem; 41 | } 42 | } 43 | 44 | .button { 45 | @include red-gradient; 46 | 47 | flex: 0 0 auto; 48 | margin-left: 1rem; 49 | border-radius: 6px; 50 | padding: 0.75rem; 51 | font-size: 0.8rem; 52 | 53 | @include respond-to-desktop { 54 | font-size: 1.2rem; 55 | } 56 | } 57 | } 58 | 59 | .info-container { 60 | flex: 0 0 auto; 61 | display: flex; 62 | flex-direction: row; 63 | justify-content: center; 64 | align-items: center; 65 | margin-top: 1rem; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/pages/detail-page/detail-page.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { HocrProofreaderComponent } from "../../common/components/hocr"; 3 | import { ZoomMode } from "../../common/components/hocr"; 4 | import { ToolbarComponent } from "./components/toolbar"; 5 | import { HorizontalSeparator } from "../../common/components/horizontal-separator"; 6 | 7 | const style = require("./detail-page.style.scss"); 8 | 9 | 10 | interface DetailPageProps { 11 | hocr: string; 12 | targetWords: string[]; 13 | zoomMode?: ZoomMode; 14 | showText?: boolean; 15 | onToggleTextClick: () => void; 16 | onZoomChange: (zoomMode: ZoomMode) => void; 17 | onCloseClick: () => void; 18 | } 19 | 20 | export class DetailPageComponent extends React.Component { 21 | constructor(props) { 22 | super(props); 23 | } 24 | 25 | public render() { 26 | return ( 27 |
28 | 34 | 35 | 42 |
43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/az-api/payload/payload.parser.ts: -------------------------------------------------------------------------------- 1 | import { AzPayload, AzOrderBy } from "./payload.model"; 2 | import { parseFacetGET } from "./facet.parser"; 3 | import { parseFilterGroup } from "./filter.parser"; 4 | import { checkDuckType, isArrayEmpty } from "../../util"; 5 | 6 | /** 7 | * Parsers for Payload. 8 | * A parser will do a transformation from Payload object to GET or POST query params. 9 | * TODO: Implement POST parser. 10 | */ 11 | 12 | const parseOrderByGET = (ob: AzOrderBy): string => { 13 | return `"${ob.fieldName} ${ob.order}"`; 14 | }; 15 | 16 | export const parsePayloadGET = (p: AzPayload): string => { 17 | return [ 18 | p.search ? `search=${p.search}` : "", 19 | p.searchMode === "all" ? "searchMode=all" : "", 20 | isArrayEmpty(p.searchFields) ? "" : `searchFields=${p.searchFields.join(",")}`, 21 | isArrayEmpty(p.orderBy) ? "" : `$orderby=${p.orderBy.map(ob => parseOrderByGET(ob)).join(",")}`, 22 | isArrayEmpty(p.facets) ? "" : p.facets.map(f => parseFacetGET(f)).join("&"), 23 | isArrayEmpty(p.select) ? "" : `$select=${p.select.join(",")}`, 24 | p.filters ? `$filter=${parseFilterGroup(p.filters)}` : "", 25 | p.minimumCoverage ? `minimumCoverage=${p.minimumCoverage}` : "", 26 | p.count ? `$count=true` : "", 27 | p.top ? `$top=${p.top}` : "", 28 | p.skip ? `$skip=${p.skip}` : "", 29 | p.fuzzy ? `fuzzy=true` : "", 30 | p.suggesterName ? `suggesterName=${p.suggesterName}` : "", 31 | p.autocompleteMode ? `autocompleteMode=${p.autocompleteMode}` : "", 32 | ] 33 | .filter(i => i) 34 | .join("&"); 35 | }; 36 | -------------------------------------------------------------------------------- /src/az-api/response.parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AzResponseConfig, 3 | AzResponse, 4 | AzResponseFacet, 5 | AzResponseFacetValue, 6 | } from "./response.model"; 7 | import { isArrayEmpty } from "../util"; 8 | 9 | /** 10 | * Parser for Response. 11 | * It will transform a raw JSON response from server to a Response object. 12 | */ 13 | 14 | const parseResponseFacetObject = (facetObj: any, config: AzResponseConfig): AzResponseFacet[] => { 15 | if (!facetObj) return null; 16 | 17 | return Object.keys(facetObj).filter(k => !k.includes(config.facetTypeSuffix)).map(k => ({ 18 | fieldName: k, 19 | type: facetObj[k + config.facetTypeSuffix], 20 | values: facetObj[k].map(fv => ({ 21 | value: fv[config.facetValueAccessor], 22 | count: fv[config.facetCountAccessor], 23 | from: fv[config.facetFromAccessor], 24 | to: fv[config.facetToAccessor], 25 | } as AzResponseFacetValue)), 26 | } as AzResponseFacet)); 27 | }; 28 | 29 | export const parseResponse = async (response: Response, config: AzResponseConfig 30 | ): Promise => { 31 | const jsonObject = await response.json(); 32 | 33 | if (!response.ok) { 34 | console.debug(jsonObject); 35 | throw new Error(`${response.status} - ${response.statusText} 36 | Message: ${jsonObject.error.message}`); 37 | } 38 | 39 | return { 40 | value: jsonObject[config.valueAccessor], 41 | count: jsonObject[config.countAccessor], 42 | coverage: jsonObject[config.coverageAccessor], 43 | facets: parseResponseFacetObject(jsonObject[config.facetsAccessor], config), 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/az-api/api.ts: -------------------------------------------------------------------------------- 1 | import { AzConfig, defaultAzConfig } from "./config.model"; 2 | import { AzResponseConfig, defaultAzResponseConfig, AzResponse } from "./response.model"; 3 | import { parseResponse } from "./response.parser"; 4 | import { CreateRequest } from "./request"; 5 | import { AzPayload } from "./payload"; 6 | 7 | /** 8 | * Main entry point. An API object represents an user interface to run queries. An API object 9 | * is created by passing a config object (and optionally a response config, rarely used). 10 | * Once created, we can run queries by calling 'runQuery' with the desired payload. 11 | */ 12 | 13 | export interface AzApi { 14 | setConfig: (config: AzConfig) => AzApi; 15 | setResponseConfig: (responseConfig: AzResponseConfig) => AzApi; 16 | runQuery: (payload: AzPayload) => Promise; 17 | } 18 | 19 | export const CreateAzApi = (config: AzConfig, responseConfig: AzResponseConfig = defaultAzResponseConfig): AzApi => { 20 | return { 21 | setConfig(newConfig) { 22 | return CreateAzApi(newConfig, responseConfig); 23 | }, 24 | setResponseConfig(newResponseConfig) { 25 | return CreateAzApi(config, newResponseConfig); 26 | }, 27 | 28 | async runQuery(payload) { 29 | try { 30 | const request = CreateRequest(config, payload); 31 | console.debug("Running Query:", request.url); // Debug only. 32 | const response = await fetch(request.url, request.options); 33 | return await parseResponse(response, responseConfig); 34 | } catch (e) { 35 | throw new Error(e); 36 | } 37 | }, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/graph-api/api.ts: -------------------------------------------------------------------------------- 1 | import { GraphConfig } from "./config.model"; 2 | import { GraphResponseConfig, defaultGraphResponseConfig, GraphResponse } from "./response.model"; 3 | import { parseResponse } from "./response.parser"; 4 | import { CreateRequest } from "./request"; 5 | import { GraphPayload } from "./payload.model"; 6 | 7 | /** 8 | * Main entry point. An API object represents an interface to run queries. An API object 9 | * is created by passing a config object. Once created, we can run queries by calling 10 | * 'runQuery' with the desired payload. 11 | */ 12 | 13 | export interface GraphApi { 14 | setConfig: (config: GraphConfig) => GraphApi; 15 | setResponseConfig: (responseConfig: GraphResponseConfig) => GraphApi; 16 | runQuery: (payload: GraphPayload) => Promise; 17 | } 18 | 19 | export const CreateGraphApi = (config: GraphConfig, 20 | responseConfig: GraphResponseConfig = defaultGraphResponseConfig): GraphApi => { 21 | return { 22 | setConfig(newConfig) { 23 | return CreateGraphApi(newConfig, responseConfig); 24 | }, 25 | setResponseConfig(newResponseConfig) { 26 | return CreateGraphApi(config, newResponseConfig); 27 | }, 28 | async runQuery(payload) { 29 | try { 30 | const request = CreateRequest(config, payload); 31 | console.debug("Running Query:", request.url); // Debug only. 32 | const response = await fetch(request.url, request.options); 33 | return await parseResponse(response, responseConfig); 34 | } catch (e) { 35 | throw new Error(e); 36 | } 37 | }, 38 | }; 39 | }; 40 | 41 | -------------------------------------------------------------------------------- /src/pages/search-page/components/facets/facet-item.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Card from "material-ui/Card"; 3 | import { FacetHeaderComponent } from "./facet-header.component"; 4 | import { FacetBodyComponent } from "./facet-body.component"; 5 | import { Facet, Filter } from "../../view-model"; 6 | 7 | const style = require("./facet-item.style.scss"); 8 | 9 | 10 | interface FacetItemProps { 11 | facet: Facet; 12 | filter: Filter; 13 | onFilterUpdate: (newFilter: Filter) => void; 14 | } 15 | 16 | interface State { 17 | expanded: boolean; 18 | } 19 | 20 | export class FacetItemComponent extends React.Component { 21 | constructor(props) { 22 | super(props); 23 | 24 | this.state = { 25 | expanded: true, 26 | } 27 | } 28 | 29 | private toggleExpand = () => { 30 | this.setState({ 31 | ...this.state, 32 | expanded: !this.state.expanded, 33 | }); 34 | } 35 | 36 | public render() { 37 | const { facet, filter, onFilterUpdate } = this.props; 38 | const { expanded } = this.state; 39 | 40 | if (!facet.values) { return null } 41 | 42 | return ( 43 | 48 | 53 | 59 | 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/pages/search-page/components/selection-controls/year-picker.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { SelectionProps } from "./selection-control"; 3 | import { Facet, FacetValue, Filter } from "../../view-model"; 4 | import { DatePicker } from 'material-ui-pickers'; 5 | import { Moment } from "moment"; 6 | 7 | const style = require("./year-picker.style.scss"); 8 | 9 | 10 | class YearPickerComponent extends React.Component { 11 | constructor(props) { 12 | super(props); 13 | } 14 | 15 | private getFilter = (): Filter => { 16 | if (this.props.filter) { 17 | return this.props.filter; 18 | } else { 19 | const newFilter = { 20 | fieldId: this.props.facet.fieldId, 21 | store: null, 22 | }; 23 | return newFilter; 24 | } 25 | } 26 | 27 | private handleChange = (newDate: Moment) => { 28 | const currentFilter = this.getFilter(); 29 | this.props.onFilterUpdate({ 30 | ...currentFilter, 31 | store: newDate, 32 | }); 33 | } 34 | 35 | public render() { 36 | return ( 37 |
38 | 50 |
51 | ); 52 | } 53 | }; 54 | 55 | export { YearPickerComponent }; 56 | -------------------------------------------------------------------------------- /src/pages/detail-page/detail-page.container.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { withRouter, RouteComponentProps } from 'react-router'; 3 | import { DetailPageComponent } from "./detail-page.component"; 4 | import { DetailRouteState } from "./detail-page.route"; 5 | import { searchPath } from "../search-page"; 6 | import { ZoomMode } from "../../common/components/hocr"; 7 | import { getDetailState } from "./detail-page.memento"; 8 | 9 | 10 | interface DetailPageState { 11 | showText: boolean; 12 | zoomMode: ZoomMode; 13 | } 14 | 15 | class DetailPageInnerContainer extends React.Component, DetailPageState> { 16 | constructor(props) { 17 | super(props); 18 | 19 | this.state = { 20 | zoomMode: "original", 21 | showText: true, 22 | }; 23 | } 24 | 25 | private handleClose = () => { 26 | this.props.history.push(searchPath); 27 | } 28 | 29 | private handleToggleText = () => { 30 | this.setState({ 31 | ...this.state, 32 | showText: !this.state.showText, 33 | }); 34 | } 35 | 36 | private handleZoomChange = (zoomMode: ZoomMode) => { 37 | this.setState({...this.state, zoomMode,}); 38 | } 39 | 40 | public render() { 41 | const detailState = getDetailState(); 42 | 43 | return ( 44 | 53 | ); 54 | } 55 | } 56 | 57 | export const DetailPageContainer = withRouter(DetailPageInnerContainer); -------------------------------------------------------------------------------- /src/pages/search-page/service/service.ts: -------------------------------------------------------------------------------- 1 | import { CreateAzApi, AzResponse } from "../../../az-api"; 2 | import { Service, ServiceConfig, StateReducer, MapperToState,} from "./service.model"; 3 | 4 | 5 | export const CreateService = (config: ServiceConfig): Service => { 6 | const {searchConfig, suggestionConfig} = config; 7 | const searchApi = CreateAzApi(searchConfig.apiConfig, searchConfig.responseConfig); 8 | const suggestionApi = CreateAzApi(suggestionConfig.apiConfig, suggestionConfig.responseConfig); 9 | const throwInvalidPayload = () => {throw "Invalid Payload";} 10 | 11 | return { 12 | config, 13 | 14 | search: async (state: S): Promise => { 15 | try { 16 | const payload = config.searchConfig.mapStateToPayload(state, config); 17 | if (!payload) throwInvalidPayload(); 18 | const response = await searchApi.runQuery(payload); 19 | 20 | // Return a state reducer. 21 | return (updatedState: S): S => { 22 | return config.searchConfig.mapResponseToState(updatedState, response, config); 23 | }; 24 | } catch (e) { 25 | throw e; 26 | } 27 | }, 28 | 29 | suggest: async (state: S): Promise => { 30 | try { 31 | const payload = config.suggestionConfig.mapStateToPayload(state, config); 32 | if (!payload) throwInvalidPayload(); 33 | const response = await suggestionApi.runQuery(payload); 34 | 35 | // Return a state reducer. 36 | return (updatedState: S): S => { 37 | return config.suggestionConfig.mapResponseToState(updatedState, response, config); 38 | }; 39 | } catch (e) { 40 | throw e; 41 | } 42 | }, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const path = require("path"); 3 | const webpackMerge = require("webpack-merge"); 4 | const commonConfig = require("./webpack.base.config.js"); 5 | 6 | const basePath = __dirname; 7 | 8 | module.exports = function () { 9 | return webpackMerge(commonConfig, { 10 | devtool: 'source-map', 11 | 12 | devServer: { 13 | contentBase: './dist', // Content base 14 | inline: true, // Enable watch and live reload 15 | host: 'localhost', 16 | port: 8082, 17 | stats: 'errors-only' 18 | }, 19 | 20 | output: { 21 | path: path.join(basePath, "dist"), 22 | filename: "[name].js" 23 | }, 24 | 25 | module: { 26 | rules: [ 27 | // *** Loading pipe for vendor CSS. No CSS Modules *** 28 | { 29 | test: /\.css$/, 30 | include: [/node_modules/], 31 | use: [ 32 | "style-loader", 33 | { 34 | loader: "css-loader", 35 | }, 36 | ] 37 | }, 38 | // *** Loading pipe for SASS stylesheets *** 39 | { 40 | test: /\.scss$/, 41 | exclude: [/node_modules/], 42 | use: [ 43 | "style-loader", 44 | { 45 | loader: "css-loader", 46 | options: { 47 | modules: true, 48 | camelCase: true, 49 | sourceMap: true, 50 | importLoaders: 1, 51 | localIdentName: "[local]__[name]___[hash:base64:5]" 52 | } 53 | }, 54 | { loader: 'resolve-url-loader' }, 55 | "sass-loader" 56 | ] 57 | } 58 | ] 59 | }, 60 | plugins: [ 61 | new webpack.DefinePlugin({ 62 | "process.env": { 63 | DEBUG_TRACES: true 64 | } 65 | }) 66 | ], 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-preview/hocr-page.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { getNodeId, getNodeOptions, WordComparator } from "../util/common-util"; 3 | import { HocrNodeProps, getNodeChildrenComponents } from "./hocr-node.component"; 4 | import { HocrPageStyleMap } from "./hocr-page.style"; 5 | 6 | 7 | /** 8 | * HOCR Page 9 | */ 10 | 11 | export type ZoomMode = "page-full" | "page-width" | "original"; 12 | 13 | export interface HocrPageProps extends HocrNodeProps { 14 | zoomMode?: ZoomMode; 15 | } 16 | 17 | export class HocrPageComponent extends React.PureComponent { 18 | constructor(props) { 19 | super(props); 20 | } 21 | 22 | public render() { 23 | if (!this.props.node) return null; 24 | const pageOptions = getNodeOptions(this.props.node); 25 | 26 | return ( 27 | 34 | 36 | 39 | 40 | {getNodeChildrenComponents(this.props)} 41 | 42 | 43 | ); 44 | } 45 | } 46 | 47 | const getZoomStyle = (zoomMode: ZoomMode, bbox: any) => { 48 | return { 49 | width: (zoomMode === "original") ? `${(bbox[2]-bbox[0])}px` 50 | : (zoomMode === "page-width") ? "100%" : "", 51 | height: (zoomMode === "original") ? `${(bbox[3]-bbox[1])}px` 52 | : (zoomMode === "page-full") ? "100%" : "", 53 | display: "block", 54 | margin: "auto", 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/pages/search-page/components/drawer/drawer-bar.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Toolbar from "material-ui/Toolbar"; 3 | import Icon from "material-ui/Icon"; 4 | import IconButton from "material-ui/IconButton"; 5 | import Typography from "material-ui/Typography"; 6 | import { cnc } from "../../../../util"; 7 | import { Service } from "../../service"; 8 | import { MenuButton } from "../../../../common/components/menu-button"; 9 | 10 | const style = require("./drawer-bar.style.scss"); 11 | 12 | 13 | interface DrawerBarProps { 14 | viewMode: "open" | "closed"; 15 | activeService: Service; 16 | onClose: () => void; 17 | onMenuClick: () => void; 18 | className?: string; 19 | } 20 | 21 | const DrawerBarCaption = () => ( 22 |
23 |

24 | Documents revealed. 25 |

26 |

27 | Let's find out what happened that day. 28 |

29 |
30 | ); 31 | 32 | const DrawerBarOpenContent = ({activeService, onClose}) => ( 33 | <> 34 | 35 | 41 |  42 | 43 | 44 | ); 45 | 46 | const DrawerBarClosedContent = ({onMenuClick}) => ( 47 | 48 | ); 49 | 50 | export const DrawerBarComponent: React.StatelessComponent = (props) => { 51 | const containerStyle = props.viewMode === "open" ? style.container : style.containerClosed; 52 | return ( 53 | 57 | { 58 | props.viewMode === "open" ? 59 | 63 | : 64 | } 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const path = require("path"); 3 | const webpackMerge = require("webpack-merge"); 4 | const commonConfig = require("./webpack.base.config.js"); 5 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 6 | 7 | const basePath = __dirname; 8 | 9 | module.exports = function () { 10 | return webpackMerge(commonConfig, { 11 | devtool: "none", 12 | 13 | output: { 14 | path: path.join(basePath, "dist"), 15 | filename: "[chunkhash].[name].js" 16 | }, 17 | 18 | module: { 19 | rules: [ 20 | // *** Loading pipe for vendor CSS. No CSS Modules here *** 21 | { 22 | test: /\.css$/, 23 | include: [/node_modules/], 24 | loader: ExtractTextPlugin.extract({ 25 | fallback: "style-loader", 26 | use: [ 27 | { 28 | loader: "css-loader", 29 | }, 30 | ] 31 | }) 32 | }, 33 | // *** Loading pipe for SASS stylesheets *** 34 | { 35 | test: /\.scss$/, 36 | exclude: [/node_modules/], 37 | loader: ExtractTextPlugin.extract({ 38 | fallback: "style-loader", 39 | use: [ 40 | { 41 | loader: "css-loader", 42 | options: { 43 | modules: true, 44 | camelCase: true, 45 | importLoaders: 1, 46 | localIdentName: "[local]__[name]___[hash:base64:5]" 47 | } 48 | }, 49 | { loader: 'resolve-url-loader' }, 50 | { loader: "sass-loader" } 51 | ] 52 | }) 53 | } 54 | ] 55 | }, 56 | 57 | plugins: [ 58 | new ExtractTextPlugin({ 59 | filename: "[chunkhash].[name].css", 60 | disable: false, 61 | allChunks: true 62 | }), 63 | new webpack.DefinePlugin({ 64 | "process.env": { 65 | DEBUG_TRACES: false 66 | } 67 | }) 68 | ], 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /src/pages/search-page/service/jfk/config.ts: -------------------------------------------------------------------------------- 1 | import { defaultAzPayload } from "../../../../az-api"; 2 | import { ServiceConfig } from "../../service"; 3 | import { mapStateToSuggestionPayload, mapSuggestionResponseToState } from "./mapper.suggestion"; 4 | import { mapStateToSearchPayload, mapSearchResponseToState } from "./mapper.search"; 5 | 6 | export const jfkServiceConfig: ServiceConfig = { 7 | serviceId: "jfk-docs", 8 | serviceName: "JFK Documents", 9 | serviceIcon: "fingerprint", 10 | 11 | searchConfig: { 12 | apiConfig: { 13 | protocol: process.env.SEARCH_CONFIG_PROTOCOL, 14 | serviceName: process.env.SEARCH_CONFIG_SERVICE_NAME, 15 | serviceDomain: process.env.SEARCH_CONFIG_SERVICE_DOMAIN, 16 | servicePath: process.env.SEARCH_CONFIG_SERVICE_PATH, 17 | apiVer: process.env.SEARCH_CONFIG_API_VER, 18 | apiKey: process.env.SEARCH_CONFIG_API_KEY, 19 | method: "GET", 20 | }, 21 | defaultPayload: defaultAzPayload, 22 | mapStateToPayload: mapStateToSearchPayload, 23 | mapResponseToState: mapSearchResponseToState, 24 | }, 25 | 26 | suggestionConfig: { 27 | apiConfig: { 28 | protocol: process.env.SUGGESTION_CONFIG_PROTOCOL, 29 | serviceName: process.env.SUGGESTION_CONFIG_SERVICE_NAME, 30 | serviceDomain: process.env.SUGGESTION_CONFIG_SERVICE_DOMAIN, 31 | servicePath: process.env.SUGGESTION_CONFIG_SERVICE_PATH, 32 | apiVer: process.env.SUGGESTION_CONFIG_API_VER, 33 | apiKey: process.env.SUGGESTION_CONFIG_API_KEY, 34 | method: "GET", 35 | }, 36 | defaultPayload: { 37 | ...defaultAzPayload, 38 | count: false, 39 | top: 15, 40 | suggesterName: "sg-jfk", 41 | //autocompleteMode: "twoTerms", 42 | }, 43 | mapStateToPayload: mapStateToSuggestionPayload, 44 | mapResponseToState: mapSuggestionResponseToState, 45 | }, 46 | 47 | initialState: { 48 | facetCollection: [ 49 | { 50 | fieldId: "tags", 51 | displayName: "Tags", 52 | iconName: null, 53 | selectionControl: "checkboxList", 54 | maxCount: 10, 55 | values: null, 56 | }, 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/az-api/payload/facet.parser.ts: -------------------------------------------------------------------------------- 1 | import { isArrayEmpty, checkDuckType } from "../../util"; 2 | import { 3 | AzPayloadFacet, 4 | AzPayloadFacetConfigCountSort, 5 | AzPayloadFacetConfigValues, 6 | AzPayloadFacetConfigInterval, 7 | AzPayloadFacetConfig, 8 | } from "./facet.model"; 9 | 10 | /** 11 | * Parsers for Facets. 12 | * A parser will do a transformaton from Facet object to GET or POST query params. 13 | * TODO: Advanced faceting with timeOffset. 14 | * TODO: Implement POST parser. 15 | */ 16 | 17 | const invalidFacet = (facet: AzPayloadFacet) => { 18 | return !facet.fieldName || facet.fieldName.length === 0; 19 | }; 20 | 21 | const parseConfigCountSortGET = (config: AzPayloadFacetConfigCountSort): string => { 22 | return [config.count ? `count:${config.count}` : "", config.sort ? `sort:${config.sort}` : ""] 23 | .filter(i => i) 24 | .join(","); 25 | }; 26 | 27 | const parseConfigValuesGET = (config: AzPayloadFacetConfigValues): string => { 28 | return isArrayEmpty(config.values) ? "" : `values:${config.values.join("|")}`; 29 | }; 30 | 31 | const parseConfigIntervalGET = (config: AzPayloadFacetConfigInterval): string => { 32 | return config.interval ? `interval:${config.interval}` : ""; 33 | }; 34 | 35 | const parseConfigGET = (config: AzPayloadFacetConfig): string => { 36 | checkDuckType(config as AzPayloadFacetConfigCountSort, "count" ); 37 | if (checkDuckType(config as AzPayloadFacetConfigCountSort, "count" ) || 38 | checkDuckType(config as AzPayloadFacetConfigCountSort, "sort" )) { 39 | return parseConfigCountSortGET(config as AzPayloadFacetConfigCountSort); 40 | } else if (checkDuckType(config as AzPayloadFacetConfigValues, "values")) { 41 | return parseConfigValuesGET(config as AzPayloadFacetConfigValues); 42 | } else if (checkDuckType(config as AzPayloadFacetConfigInterval, "interval")) { 43 | return parseConfigIntervalGET(config as AzPayloadFacetConfigInterval); 44 | } else { 45 | return ""; 46 | } 47 | }; 48 | 49 | export const parseFacetGET = (facet: AzPayloadFacet): string => { 50 | if (invalidFacet(facet)) return ""; 51 | 52 | let config = ""; 53 | if (facet.config) { 54 | config = parseConfigGET(facet.config); 55 | } 56 | 57 | return `facet=${facet.fieldName}${config ? `,${config}` : ""}`; 58 | }; 59 | -------------------------------------------------------------------------------- /src/az-api/payload/filter.parser.ts: -------------------------------------------------------------------------------- 1 | import { AzFilterGroup, AzFilter, AzFilterCollection, AzFilterSingle } from "./filter.model"; 2 | import { isArrayEmpty, checkDuckType } from "../../util"; 3 | import { AzFilterGroupItem } from "."; 4 | 5 | /** 6 | * Parsers for Filters. 7 | * A parser will do a transformation from Filter object to GET or POST query params. 8 | * TODO: Only core filtering has been implemented. The standard is wider. Check: 9 | * https://docs.microsoft.com/en-us/rest/api/searchservice/odata-expression-syntax-for-azure-search 10 | */ 11 | 12 | 13 | const isComparingEquality = (operator, logic) => { 14 | // Is comparing equality + or / not equality + and ? 15 | return (operator === "eq" && (!logic || logic === "or")) || 16 | (operator === "ne" && (!logic || logic === "and")); 17 | } 18 | 19 | const parseFilterSingle = (f: AzFilterSingle): string => { 20 | if (f.value.length) { // Compare against multiple values. 21 | const values = f.value as string[]; 22 | if (isComparingEquality(f.operator, f.logic)) { 23 | return `${f.operator==="ne" ? "not " : ""}search.in(${f.fieldName},'${values.join("|")}', '|')`; 24 | } else { 25 | return values.map(v => `${f.fieldName} ${f.operator} ${v}`).join(` ${f.logic} `); 26 | } 27 | } else { // Compare against single value. 28 | return `${f.fieldName} ${f.operator} ${f.value}`; 29 | } 30 | } 31 | 32 | const parseFilterCollection = (f: AzFilterCollection): string => { 33 | return `${f.fieldName}/${f.mode}(x: ${parseFilterSingle({ 34 | fieldName: "x", 35 | operator: f.operator, 36 | value: f.value, 37 | logic: f.logic, 38 | })})`; 39 | } 40 | 41 | const reduceItemToExpression = (item: AzFilterGroupItem) => { 42 | if (checkDuckType(item as AzFilterGroup, "items" )) { 43 | return parseFilterGroup(item as AzFilterGroup); 44 | } else if (checkDuckType(item as AzFilterCollection, "mode" )){ 45 | return parseFilterCollection(item as AzFilterCollection); 46 | } else { 47 | return parseFilterSingle(item as AzFilterSingle); 48 | } 49 | } 50 | 51 | export const parseFilterGroup = (fg: AzFilterGroup): string => { 52 | if (isArrayEmpty(fg.items)) return ""; 53 | 54 | return `(${ 55 | fg.items 56 | .map(reduceItemToExpression) 57 | .filter(expression => expression) 58 | .join(` ${fg.logic} `) 59 | })`; 60 | }; 61 | -------------------------------------------------------------------------------- /src/pages/search-page/components/placeholder/dialog.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import IconButton from "material-ui/IconButton"; 3 | import MuiDialog, { DialogTitle, DialogContent, DialogContentText, DialogProps } from "material-ui/Dialog"; 4 | import CloseIcon from "material-ui-icons/Close"; 5 | import Typography from "material-ui/Typography"; 6 | import { withTheme } from "material-ui/styles"; 7 | import { cnc } from "../../../../util"; 8 | import { LinkComponent } from './link.component'; 9 | const styles = require('./dialog.styles.scss'); 10 | const jfkFilesScenario = require('../../../../assets/img/jfk-files-scenario.png'); 11 | 12 | const Dialog: React.StatelessComponent = ({ ...props }) => { 13 | return ( 14 | 15 | 16 |
17 | JFKFiles Cognitive Search Pattern 18 | 19 | 20 | 21 |
22 |
23 | 24 | 25 | 26 | In this JFK Files scenario demo, you will explore how you can leverage Azure Cognitive Services and Search to 27 | implement the Cognitive Search pattern in an application, using the released documents from 28 | The President John F. Kennedy Assassination Records Collection. 29 | 30 | 31 | You can find more information 32 | here 33 | 34 | Here's the architecture used for JFK files scenario: 35 | JFK scenario 36 | 37 | You can find the source code 38 | here 39 | 40 | 41 | 42 |
43 | ); 44 | } 45 | 46 | export const DialogComponent = withTheme()(Dialog); 47 | -------------------------------------------------------------------------------- /src/pages/search-page/components/selection-controls/checkbox-list.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { SelectionProps } from "./selection-control"; 3 | import { FormControlLabel } from "material-ui/Form"; 4 | import { Facet, FacetValue, Filter } from "../../view-model"; 5 | import Checkbox from "material-ui/Checkbox"; 6 | import { isValueInArray, addValueToArray, removeValueFromArray } from "../../../../util"; 7 | 8 | const style = require("./checkbox-list.style.scss"); 9 | 10 | 11 | class CheckboxListComponent extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | } 15 | 16 | private getFilter = (): Filter => { 17 | if (this.props.filter) { 18 | return this.props.filter; 19 | } else { 20 | const newFilter = { 21 | fieldId: this.props.facet.fieldId, 22 | store: null, 23 | }; 24 | return newFilter; 25 | } 26 | } 27 | 28 | private handleChange = (facetValue) => (event) => { 29 | const currentFilter = this.getFilter(); 30 | const newCheckedList = (event.target.checked) ? 31 | addValueToArray(currentFilter.store, facetValue) : 32 | removeValueFromArray(currentFilter.store, facetValue); 33 | this.props.onFilterUpdate({ 34 | ...currentFilter, 35 | store: newCheckedList.length ? newCheckedList : null, 36 | }); 37 | } 38 | 39 | private isValueInFilterList = (facetValue): boolean => { 40 | if (this.props.filter && this.props.filter.store){ 41 | return isValueInArray(this.props.filter.store, facetValue); 42 | } else { 43 | return false; 44 | } 45 | } 46 | 47 | private getCheckbox = (facetValue) => ( 48 | 58 | ); 59 | 60 | private getCheckboxList = () => ( 61 | this.props.facet.values.map((facetValue, index) => 62 | 68 | )); 69 | 70 | public render() { 71 | return ( 72 |
73 | {this.getCheckboxList()} 74 |
75 | ); 76 | } 77 | }; 78 | 79 | export { CheckboxListComponent }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jfk-files", 3 | "version": "1.0.0", 4 | "description": "Azure Search Demo based on declassified JFK Files", 5 | "main": "app.js", 6 | "config": { 7 | "build": "dist", 8 | "remote": "GH-Lemoncode", 9 | "msg": "Github Deploy in gh-pages" 10 | }, 11 | "scripts": { 12 | "start": "env-cmd .env if-env NODE_ENV=production && npm run start:prod || npm run start:dev", 13 | "start:dev": "env-cmd .env cross-env NODE_ENV=development webpack-dev-server --config=webpack.dev.config.js", 14 | "start:prod": "env-cmd .env cross-env NODE_ENV=production node server", 15 | "clean": "rimraf dist", 16 | "build": "env-cmd .env if-env NODE_ENV=production && npm run build:prod || npm run build:dev", 17 | "build:dev": "npm run clean && env-cmd .env cross-env NODE_ENV=development webpack --config=webpack.dev.config.js", 18 | "build:prod": "npm run clean && env-cmd .env cross-env NODE_ENV=production webpack -p --config=webpack.prod.config.js" 19 | }, 20 | "author": "Javier Calzado", 21 | "license": "ISC", 22 | "dependencies": { 23 | "babel-polyfill": "^6.26.0", 24 | "d3": "4.13.0", 25 | "downshift": "^1.28.0", 26 | "express": "^4.16.3", 27 | "lodash.throttle": "^4.1.1", 28 | "material-ui": "1.0.0-beta.33", 29 | "material-ui-icons": "1.0.0-beta.17", 30 | "material-ui-pickers": "1.0.0-beta.15.1", 31 | "moment": "^2.20.1", 32 | "paginator": "^1.0.0", 33 | "react": "^16.2.0", 34 | "react-dom": "^16.2.0", 35 | "react-router-dom": "^4.2.2" 36 | }, 37 | "devDependencies": { 38 | "@types/d3": "4.13.0", 39 | "@types/history": "^4.6.1", 40 | "@types/node": "^9.6.2", 41 | "@types/qs": "^6.5.1", 42 | "@types/react": "^16.0.21", 43 | "@types/react-dom": "^16.0.2", 44 | "@types/react-router-dom": "^4.2.0", 45 | "awesome-typescript-loader": "^3.3.0", 46 | "babel-core": "^6.26.0", 47 | "babel-minify-webpack-plugin": "^0.3.1", 48 | "babel-preset-env": "^1.6.1", 49 | "cross-env": "^5.1.4", 50 | "css-loader": "~0.28.7", 51 | "env-cmd": "^7.0.0", 52 | "extract-text-webpack-plugin": "^3.0.2", 53 | "file-loader": "~1.1.5", 54 | "html-webpack-plugin": "~2.30.1", 55 | "if-env": "^1.0.4", 56 | "node-sass": "^4.7.2", 57 | "qs": "^6.5.1", 58 | "resolve-url-loader": "^2.3.0", 59 | "rimraf": "^2.6.2", 60 | "sass-loader": "^6.0.6", 61 | "style-loader": "~0.19.0", 62 | "typescript": "~2.6.1", 63 | "url-loader": "~0.6.2", 64 | "webpack": "~3.8.1", 65 | "webpack-dev-server": "^2.9.4", 66 | "webpack-merge": "^4.1.1" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/pages/search-page/components/drawer/drawer.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Hidden from "material-ui/Hidden"; 3 | import Drawer from "material-ui/Drawer"; 4 | import { DrawerBarComponent } from "./drawer-bar.component"; 5 | import { MenuButton } from "../../../../common/components/menu-button"; 6 | import { cnc } from "../../../../util"; 7 | import { Service } from "../../service"; 8 | 9 | const style = require("./drawer.style.scss"); 10 | 11 | 12 | interface DrawerProps { 13 | activeService: Service; 14 | show: boolean; 15 | onClose: () => void; 16 | onMenuClick: () => void; 17 | className?: string; 18 | } 19 | 20 | const DrawerContent: React.StatelessComponent = (props) => ( 21 | <> 22 | 28 | { props.show ? props.children : null } 29 | 30 | ); 31 | 32 | const DrawerForMobileComponent: React.StatelessComponent = (props) => { 33 | return ( 34 | 35 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | const DrawerForDesktopComponent: React.StatelessComponent = (props) => { 54 | return ( 55 | 56 | 67 | 68 | 69 | 70 | ); 71 | }; 72 | 73 | const DrawerComponent: React.StatelessComponent = (props) => { 74 | return ( 75 |
76 | 77 | {props.children} 78 | 79 | 80 | {props.children} 81 | 82 |
83 | ); 84 | }; 85 | 86 | export { DrawerComponent }; 87 | -------------------------------------------------------------------------------- /src/pages/search-page/components/search/search.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Button from "material-ui/Button"; 3 | import TextField from "material-ui/TextField"; 4 | import Icon from "material-ui/Icon"; 5 | import Typography from "material-ui/Typography"; 6 | import { AutocompleteInputComponent } from "./autocomplete.component"; 7 | import { SuggestionCollection } from "../../view-model"; 8 | import { cnc } from "../../../../util"; 9 | 10 | const style = require("./search.style.scss"); 11 | 12 | 13 | interface SearchProps { 14 | value: string; 15 | onSearchSubmit: () => void; 16 | onSearchUpdate: (value: string) => void; 17 | resultCount?: number; 18 | suggestionCollection?: SuggestionCollection; 19 | className?: string; 20 | } 21 | 22 | const captureEnter = (props) => (event => { 23 | if (event.key === "Enter") { 24 | props.onSearchSubmit(); 25 | } 26 | }); 27 | 28 | const SearchAutocompleteInput = ({searchValue, suggestionCollection, onSearchUpdate, onKeyPress}) => ( 29 | 40 | ); 41 | 42 | const SearchButton = ({ onClick }) => ( 43 | 51 | ); 52 | 53 | const ResultCounter = ({ count }) => ( 54 | 57 | {`${count} results found`} 58 | 59 | ); 60 | 61 | const SearchComponent: React.StatelessComponent = (props) => { 62 | return ( 63 |
64 |
65 | 66 | 72 | 73 |
74 | { 75 | props.resultCount !== null ? 76 |
77 | 78 |
79 | : null 80 | } 81 |
82 | ); 83 | } 84 | 85 | export { SearchComponent }; -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-document/hocr-docnode.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { WordComparator, resolveNodeEntity } from "../util/common-util"; 3 | import { HocrDocumentStyleMap } from "./hocr-document.style"; 4 | import { cnc } from "../../../../util"; 5 | 6 | 7 | /** 8 | * HOCR Document Nodes 9 | * A document node is a generic component that represents an entity or node from the original 10 | * HOCR input. This entity could be a page, an area, a paragraph, line or word. Each of them 11 | * would render differently. E.g: a page is represented as a card, an area provides a visual 12 | * indication, lines and words are hoverable, etc. 13 | */ 14 | 15 | interface HocrDocNodeProps { 16 | node: Element; 17 | index: number; 18 | wordCompare: WordComparator; 19 | userStyle: HocrDocumentStyleMap; 20 | onWordHover?: (wordId: string) => void; 21 | onPageHover?: (pageIndex: number) => void; 22 | } 23 | 24 | const HocrDocNodeComponent: React.StatelessComponent = (props) => { 25 | const nodeChildren = getDocNodeChildrenComponents(props); 26 | const entity = resolveNodeEntity(props.node); 27 | const isTarget = (entity === "word") && props.wordCompare && props.wordCompare(props.node.textContent); 28 | const className = cnc(props.userStyle[entity], isTarget && props.userStyle["target"]); 29 | const NodeType = resolveTypeFromEntity(entity); 30 | const nodeProps = { 31 | className, 32 | id: props.node.id, 33 | index: props.index, 34 | ...resolveEventHandlersFromEntity(entity, props), 35 | } 36 | 37 | const reactElement = {nodeChildren} 38 | 39 | return (NodeType === "span") ? 40 | <>{reactElement}{" "} // Add literal whitespace to span ending. 41 | : reactElement; 42 | } 43 | 44 | const resolveTypeFromEntity = (entity: string): string => { 45 | switch (entity) { 46 | case "word": 47 | case "line": 48 | return "span"; 49 | case "paragraph": 50 | return "p"; 51 | default: 52 | return "div"; 53 | } 54 | } 55 | 56 | const resolveEventHandlersFromEntity = (entity: string, props: HocrDocNodeProps) => { 57 | if (entity === "word") { 58 | return { 59 | onMouseEnter: () => props.onWordHover && props.onWordHover(props.node.id), 60 | onMouseLeave: () => props.onWordHover && props.onWordHover(null), 61 | } 62 | } else if (entity === "page") { 63 | return { 64 | onMouseEnter: () => props.onPageHover && props.onPageHover(props.index), 65 | } 66 | } 67 | return null; 68 | } 69 | 70 | export const getDocNodeChildrenComponents = (props: HocrDocNodeProps) => { 71 | return (props.node.children && props.node.children.length) ? 72 | Array.from(props.node.children).map((child, index) => 73 | 79 | ) : props.node.textContent; 80 | } 81 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-preview/hocr-node.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { HocrPreviewStyleMap } from "./hocr-preview.style"; 3 | import { SvgRectComponent, SvgGroupComponent } from "./hocr-svg.component"; 4 | import { 5 | WordComparator, 6 | getNodeId, 7 | getNodeOptions, 8 | resolveNodeEntity, 9 | composeId, 10 | bboxToPosSize, 11 | } from "../util/common-util"; 12 | import { cnc } from "../../../../util"; 13 | 14 | 15 | /** 16 | * HOCR Node 17 | * Generic component to graphically represent placeholders for the different entities parsed 18 | * from the original HOCR input. Given an HOCR input, we can have pages, areas, paragraphs, 19 | * lines or words, in that order, creating a tree of nodes. Branch nodes in the tree (area, 20 | * paragraph and lines) will be rendered as SVG groups (), the leafs of the tree (words) 21 | * will be represented with a rectangle placeholder (). 22 | */ 23 | 24 | export interface HocrNodeProps { 25 | node: Element; 26 | key?: number; 27 | wordCompare: WordComparator; 28 | idSuffix: string; 29 | renderOnlyTargetWords?: boolean; 30 | userStyle?: HocrPreviewStyleMap; 31 | onWordHover?: (wordId: string) => void; 32 | } 33 | 34 | interface HocrGroupProps extends HocrNodeProps { 35 | entity: string; 36 | } 37 | 38 | export const HocrNodeComponent: React.StatelessComponent = (props) => { 39 | const entity = resolveNodeEntity(props.node); 40 | if (!entity) return null; 41 | 42 | return (entity === "word") ? 43 | : 44 | 45 | } 46 | 47 | const HocrWordComponent: React.StatelessComponent = (props) => { 48 | const isTarget = props.wordCompare && props.wordCompare(props.node.textContent); 49 | const shouldRenderSvg = (!props.renderOnlyTargetWords || (props.renderOnlyTargetWords && isTarget)); 50 | 51 | return shouldRenderSvg ? 52 | 58 | : null; 59 | } 60 | 61 | const HocrGroupComponent: React.StatelessComponent = (props) => { 62 | const shouldRenderSvg = !props.renderOnlyTargetWords; 63 | const childrenComponents = getNodeChildrenComponents(props); 64 | 65 | return shouldRenderSvg ? 66 | 67 | 73 | {childrenComponents} 74 | 75 | : <>{childrenComponents}; 76 | } 77 | 78 | export const getNodeChildrenComponents = (props: HocrNodeProps) => { 79 | return (props.node.children && props.node.children.length) ? 80 | Array.from(props.node.children).map((child, index) => 81 | 87 | ) : null; 88 | } -------------------------------------------------------------------------------- /src/pages/search-page/search-page.container.state.tsx: -------------------------------------------------------------------------------- 1 | import { jfkService, StateReducer } from "./service"; 2 | import { State, SuggestionCollection, FilterCollection, ResultViewMode } from "./view-model"; 3 | 4 | export const CreateInitialState = (): State => ({ 5 | searchValue: null, 6 | resultViewMode: "grid", 7 | itemCollection: null, 8 | activeSearch: null, 9 | facetCollection: null, 10 | filterCollection: null, 11 | suggestionCollection: null, 12 | resultCount: null, 13 | showDrawer: true, // TODO: Hide it by default. 14 | pageSize: 10, 15 | pageIndex: 0, 16 | // Override with user config initial state (if exists). 17 | ...jfkService.config.initialState 18 | }); 19 | 20 | export const searchValueUpdate = (searchValue: string) => (prevState: State): State => { 21 | return { 22 | ...prevState, 23 | searchValue, 24 | } 25 | }; 26 | 27 | export const showDrawerUpdate = (showDrawer: boolean) => (prevState: State): State => { 28 | return { 29 | ...prevState, 30 | showDrawer, 31 | } 32 | }; 33 | 34 | export const resultViewModeUpdate = (resultViewMode: ResultViewMode) => (prevState: State): State => { 35 | return { 36 | ...prevState, 37 | resultViewMode, 38 | } 39 | }; 40 | 41 | export const receivedSearchValueUpdate = (searchValue: string, showDrawer: boolean, resultViewMode: ResultViewMode) => 42 | (prevState: State): State => { 43 | return { 44 | ...prevState, 45 | searchValue, 46 | showDrawer, 47 | resultViewMode, 48 | } 49 | }; 50 | 51 | export const suggestionsUpdate = (suggestionCollection: SuggestionCollection) => (prevState: State): State => { 52 | return { 53 | ...prevState, 54 | suggestionCollection, 55 | } 56 | }; 57 | 58 | export const preSearchUpdate = (filters: FilterCollection, pageIndex?: number) => (prevState: State) => { 59 | return { 60 | ...prevState, 61 | suggestionCollection: null, 62 | filterCollection: filters, 63 | pageIndex: pageIndex || 0, 64 | } 65 | }; 66 | 67 | export const postSearchSuccessUpdate = (stateReducer: StateReducer) => (prevState: State): State => { 68 | return { 69 | ...stateReducer(prevState), 70 | suggestionCollection: null, 71 | activeSearch: prevState.searchValue ? prevState.searchValue : null, 72 | } 73 | }; 74 | 75 | export const postSearchMoreSuccessUpdate = (stateReducer: StateReducer) => (prevState: State): State => { 76 | const reducedState = stateReducer(prevState); 77 | return { 78 | ...reducedState, 79 | itemCollection: reducedState.itemCollection, 80 | } 81 | }; 82 | 83 | export const postSearchErrorReset = (rejectValue) => (prevState: State): State => { 84 | console.debug(`Search Failed: ${rejectValue}`); 85 | return { 86 | ...prevState, 87 | resultCount: null, 88 | itemCollection: null, 89 | facetCollection: null, 90 | filterCollection: null, 91 | suggestionCollection: null, 92 | pageIndex: 0, 93 | activeSearch: null, 94 | } 95 | }; 96 | 97 | export const postSearchErrorKeep = (rejectValue) => (prevState: State): State => { 98 | console.debug(`Search Failed: ${rejectValue}`); 99 | return { 100 | ...prevState, 101 | suggestionCollection: null, 102 | pageIndex: prevState.pageIndex, 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/pages/detail-page/components/toolbar/toolbar.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { VerticalSeparator } from "./../../../../common/components/vertical-separator"; 4 | import { ZoomMode } from "../../../../common/components/hocr"; 5 | import AppBar from "material-ui/AppBar"; 6 | import Toolbar from "material-ui/Toolbar"; 7 | import Button from "material-ui/Button"; 8 | import IconButton from "material-ui/IconButton"; 9 | 10 | const style = require("./toolbar.style.scss"); 11 | 12 | 13 | /** 14 | * Main toolbar for Detail page. 15 | */ 16 | 17 | interface ToolbarProps { 18 | zoomMode: ZoomMode; 19 | onToggleTextClick: () => void; 20 | onZoomChange: (zoomMode: ZoomMode) => void; 21 | onCloseClick: () => void; 22 | } 23 | 24 | export class ToolbarComponent extends React.Component { 25 | constructor(props) { 26 | super(props); 27 | } 28 | 29 | private handleZoomClick = (zoomMode: ZoomMode) => () => { 30 | this.props.onZoomChange(zoomMode); 31 | } 32 | 33 | public render() { 34 | return ( 35 | 36 |
37 | 38 | 39 | 43 | 47 | 51 |
52 |
53 | 54 |
55 |
56 | ); 57 | } 58 | } 59 | 60 | const ToggleViewButton = ({ onClick }) => ( 61 | 68 | ); 69 | 70 | const toggleColor = (targetMode: ZoomMode, zoomMode: ZoomMode) => { 71 | return targetMode === zoomMode ? "primary" : "inherit"; 72 | } 73 | 74 | const OriginalSizeButton = ({ zoomMode, onClick }) => ( 75 | 80 |  81 | 82 | ); 83 | 84 | const PageWidthButton = ({ zoomMode, onClick }) => ( 85 | 90 |  91 | 92 | ); 93 | 94 | const PageFullButton = ({ zoomMode, onClick }) => ( 95 | 100 |  101 | 102 | ); 103 | 104 | const CloseButton = ({ onClick }) => ( 105 | 110 |  111 | 112 | ); 113 | -------------------------------------------------------------------------------- /src/pages/search-page/components/graph/graph-view.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { loadGraph, resetGraph } from "./graph-view.business"; 3 | import { withTheme, WithTheme } from "material-ui/styles"; 4 | import { 5 | GraphApi, 6 | CreateGraphApi, 7 | GraphConfig, 8 | defaultGraphConfig, 9 | GraphResponse 10 | } from "../../../../graph-api"; 11 | import { cnc } from "../../../../util"; 12 | 13 | 14 | const style = require("./graph-view.style.scss"); 15 | 16 | interface GraphViewProps extends WithTheme { 17 | searchValue: string; 18 | graphConfig?: GraphConfig; 19 | onGraphNodeDblClick : (searchValue : string) => string; 20 | className?: string; 21 | } 22 | 23 | interface GraphViewState { 24 | graphApi: GraphApi; 25 | graphDescriptor: GraphResponse; 26 | } 27 | 28 | const containerId = "fdGraphId"; 29 | 30 | class GraphView extends React.Component { 31 | constructor(props) { 32 | super(props); 33 | 34 | this.state = { 35 | graphApi: CreateGraphApi(defaultGraphConfig), 36 | graphDescriptor: null, 37 | } 38 | } 39 | 40 | private fetchGraphDescriptor = async (searchValue: string) => { 41 | if (!this.state.graphApi || !searchValue) return Promise.resolve(null); 42 | 43 | try { 44 | const payload = {search: searchValue}; 45 | return await this.state.graphApi.runQuery(payload); 46 | } catch (e) { 47 | throw e; 48 | } 49 | }; 50 | 51 | private updateGraphDescriptor = (searchValue: string) => { 52 | this.fetchGraphDescriptor(searchValue) 53 | .then(graphDescriptor => this.setState({ 54 | ...this.state, 55 | graphDescriptor, 56 | })) 57 | .catch(e => console.log(e)); 58 | } 59 | 60 | private updateGraphApiAndDescriptor = (graphConfig: GraphConfig, searchValue: string) => { 61 | this.setState({ 62 | ...this.state, 63 | graphApi: CreateGraphApi(graphConfig || defaultGraphConfig), 64 | }, () => this.updateGraphDescriptor(searchValue)); 65 | } 66 | 67 | public componentDidMount() { 68 | this.updateGraphApiAndDescriptor(this.props.graphConfig, this.props.searchValue); 69 | }; 70 | 71 | public componentWillReceiveProps(nextProps: GraphViewProps) { 72 | if (this.props.searchValue != nextProps.searchValue) { 73 | this.updateGraphDescriptor(nextProps.searchValue); 74 | } else if (this.props.graphConfig != nextProps.graphConfig) { 75 | this.updateGraphApiAndDescriptor(nextProps.graphConfig, nextProps.searchValue); 76 | } 77 | } 78 | 79 | public shouldComponentUpdate(nextProps: GraphViewProps, nextState: GraphViewState) { 80 | return this.state.graphDescriptor != nextState.graphDescriptor 81 | } 82 | 83 | public componentDidUpdate(prevProps: GraphViewProps, prevState: GraphViewState) { 84 | if (this.state.graphDescriptor != prevState.graphDescriptor) { 85 | loadGraph(containerId, this.state.graphDescriptor, this.props.onGraphNodeDblClick, this.props.theme); 86 | } 87 | } 88 | 89 | public componentWillUnmount() { 90 | resetGraph(containerId); 91 | } 92 | 93 | public render() { 94 | return ( 95 |
99 |
100 | ); 101 | } 102 | } 103 | 104 | export const GraphViewComponent = withTheme()(GraphView); -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-proofreader/hocr-proofreader.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Link } from 'react-router-dom'; 3 | import { HocrPreviewComponent, HocrPreviewStyleMap } from "../hocr-preview"; 4 | import { HocrDocumentComponent, HocrDocumentStyleMap } from "../hocr-document"; 5 | import { PageIndex } from "../util/common-util"; 6 | import { ZoomMode } from "../hocr-preview"; 7 | import { cnc } from "../../../../util"; 8 | 9 | const style = require("./hocr-proofreader.style.scss"); 10 | 11 | 12 | /** 13 | * HOCR Proofreader 14 | */ 15 | 16 | interface HocrProofreaderProps { 17 | hocr: string; 18 | targetWords: string[]; 19 | zoomMode?: ZoomMode; 20 | showText?: boolean; 21 | previewStyle?: HocrPreviewStyleMap; 22 | documentStyle?: HocrDocumentStyleMap; 23 | className?: string; 24 | } 25 | 26 | interface HocrProofreaderState { 27 | docIdHighlighted: string; 28 | previewIdHightlighted: string; 29 | previewPageIndex: PageIndex; 30 | } 31 | 32 | export class HocrProofreaderComponent extends React.PureComponent { 33 | constructor(props) { 34 | super(props); 35 | 36 | this.state = { 37 | docIdHighlighted: null, 38 | previewIdHightlighted: null, 39 | previewPageIndex: "auto", 40 | } 41 | } 42 | 43 | private fixIndex: PageIndex = "auto"; // **FIX. Read below. 44 | 45 | private handleDocumentWordHover = (id: string) => { 46 | this.setState({ 47 | ...this.state, 48 | previewIdHightlighted: id, 49 | }); 50 | } 51 | 52 | private handleDocumentPageHover = (index: number) => { 53 | this.fixIndex = index; // **FIX. Read below. 54 | this.setState({ 55 | ...this.state, 56 | previewPageIndex: index, 57 | }); 58 | } 59 | 60 | private handlePreviewWordHover = (id: string) => { 61 | this.setState({ 62 | ...this.state, 63 | docIdHighlighted: id, 64 | }); 65 | } 66 | 67 | public render() { 68 | return ( 69 |
70 | 80 | 89 |
90 | ); 91 | } 92 | } 93 | 94 | 95 | // **FIX. Apparently, there should be a bug in React that makes setState not finish 96 | // when scrolling fast through document pages. PageIndex does not get updated 97 | // in the state eventhough a setState is called with the new index. If we store the 98 | // index in a private variable, it works. For some reason, it seems that setState 99 | // got interrupted. 100 | // Although not exactly the same, some related issues: 101 | // https://github.com/facebook/react/issues/10906 102 | // https://github.com/facebook/react/issues/11164 103 | // https://github.com/facebook/react/issues/11152 104 | // setState inside an event handler may lead to some issues. I read somewhere that 105 | // React has to wait for the event to finish, so maybe the setState is skipped. -------------------------------------------------------------------------------- /src/pages/search-page/components/search/autocomplete.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Downshift from "downshift"; 3 | import { MenuItem } from "material-ui/Menu"; 4 | import Paper from "material-ui/Paper"; 5 | import TextField, { TextFieldProps } from "material-ui/TextField"; 6 | import { SuggestionCollection, Suggestion } from "../../view-model"; 7 | import { cnc } from "../../../../util"; 8 | 9 | const style = require("./autocomplete.style.scss"); 10 | 11 | 12 | interface AutocompleteInputProps { 13 | type: string; 14 | name: string; 15 | id: string; 16 | searchValue: string; 17 | onSearchUpdate: (newValue: string) => void; 18 | onKeyPress?: (event) => void; 19 | suggestionCollection?: SuggestionCollection; 20 | placeholder?: string; 21 | autoFocus?: boolean; 22 | className?: string; 23 | } 24 | 25 | const renderInput = (params) => { 26 | const { innerInputProps, ...other } = params; 27 | return ( 28 | 38 | ); 39 | }; 40 | 41 | const renderSuggestionItem = (params) => { 42 | const { suggestion, index, composedProps, highlightedIndex, selectedItem } = params; 43 | const isHighlighted = highlightedIndex === index; 44 | const isSelected = selectedItem === suggestion.text; 45 | 46 | return ( 47 | 54 | {suggestion.text} 55 | 56 | ); 57 | }; 58 | 59 | const renderSuggestionCollection = (params) => { 60 | const { suggestionCollection, getItemProps, isOpen, selectedItem, highlightedIndex } = params; 61 | if (isOpen && suggestionCollection && suggestionCollection.length) { 62 | return ( 63 | 64 | {suggestionCollection.map((suggestion, index) => 65 | renderSuggestionItem({ 66 | suggestion, 67 | index, 68 | composedProps: getItemProps({ 69 | item: suggestion.text, 70 | index: index, 71 | }), 72 | highlightedIndex, 73 | selectedItem, 74 | }) 75 | )} 76 | 77 | ); 78 | } else { 79 | return null; 80 | } 81 | }; 82 | 83 | const handleItemToString = item => (item ? item.toString() : ""); 84 | 85 | const handleInputValueChange = props => newValue => { 86 | if (newValue !== props.searchValue) { 87 | props.onSearchUpdate(newValue); 88 | } 89 | } 90 | 91 | export const AutocompleteInputComponent: React.StatelessComponent = props => { 92 | return ( 93 | 98 | {({ getInputProps, getItemProps, isOpen, inputValue, selectedItem, highlightedIndex }) => { 99 | 100 | return ( 101 |
102 | {renderInput({ 103 | autoFocus: props.autoFocus, 104 | fullWidth: true, 105 | placeholder: props.placeholder, 106 | innerInputProps: getInputProps({ 107 | type: props.type, 108 | name: props.name, 109 | id: props.id, 110 | onKeyDown: props.onKeyPress, 111 | }), 112 | })} 113 | {renderSuggestionCollection({ 114 | suggestionCollection: props.suggestionCollection, 115 | getItemProps, 116 | isOpen, 117 | selectedItem, 118 | highlightedIndex, 119 | })} 120 |
121 | ); 122 | }} 123 |
124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /src/pages/search-page/service/jfk/mapper.search.ts: -------------------------------------------------------------------------------- 1 | import { isArrayEmpty } from "../../../../util"; 2 | import { ServiceConfig } from "../../service"; 3 | import { 4 | AzResponse, 5 | AzResponseFacet, 6 | AzPayload, 7 | AzPayloadFacet, 8 | AzFilterGroup, 9 | AzFilterCollection 10 | } from "../../../../az-api"; 11 | import { 12 | Item, 13 | ItemCollection, 14 | FacetCollection, 15 | FacetValue, 16 | Facet, 17 | State, 18 | FilterCollection, 19 | Filter, 20 | } from "../../view-model"; 21 | 22 | // [Search] FROM AzApi response TO view model. 23 | 24 | const mapImgUrlInMetadata = (metadata: string) => { 25 | const captures = /title=(?:'|")image\s?"(.+)"/g.exec(metadata); 26 | return captures && captures.length ? captures[1] : ""; 27 | }; 28 | 29 | const mapResultToItem = (result: any): Item => { 30 | return result ? { 31 | title: result.id, 32 | subtitle: "", 33 | thumbnail: mapImgUrlInMetadata(result.metadata), 34 | excerpt: "", 35 | rating: 0, 36 | extraFields: [result.tags], 37 | metadata: result.metadata, 38 | } : null; 39 | }; 40 | 41 | const mapSearchResponseForResults = (response: AzResponse): ItemCollection => { 42 | return isArrayEmpty(response.value) ? null : response.value.map(r => mapResultToItem(r)); 43 | }; 44 | 45 | const mapResponseFacetToViewFacet = (responseFacet: AzResponseFacet, baseFacet: Facet): Facet => { 46 | return responseFacet ? ({ 47 | ...baseFacet, 48 | values: responseFacet.values.map(responseFacetValue => ({ 49 | value: responseFacetValue.value, 50 | count: responseFacetValue.count, 51 | } as FacetValue)), 52 | }) : null; 53 | }; 54 | 55 | const mapSearchResponseForFacets = (response: AzResponse, baseFacets: FacetCollection): FacetCollection => { 56 | return isArrayEmpty(response.facets) ? null : 57 | baseFacets.map(bf => 58 | mapResponseFacetToViewFacet(response.facets.find(rf => rf.fieldName === bf.fieldId), bf) 59 | ).filter(f => f && !isArrayEmpty(f.values)); 60 | }; 61 | 62 | export const mapSearchResponseToState = (state: State, response: AzResponse, config: ServiceConfig): State => { 63 | const viewFacets = isArrayEmpty(state.facetCollection) ? config.initialState.facetCollection : 64 | state.facetCollection; 65 | return { 66 | ...state, 67 | resultCount: response.count, 68 | itemCollection: mapSearchResponseForResults(response), 69 | facetCollection: mapSearchResponseForFacets(response, viewFacets), 70 | } 71 | }; 72 | 73 | 74 | // [Search] FROM view model TO AzApi. 75 | 76 | const mapViewFilterToPayloadCollectionFilter = (filter: Filter): AzFilterCollection => { 77 | return filter ? { 78 | fieldName: filter.fieldId, 79 | mode: "any", 80 | operator: "eq", 81 | value: filter.store, 82 | } : null; 83 | } 84 | 85 | // TODO: WARNING, this is just tailor made for JFK single tag facet. 86 | const mapViewFiltersToPayloadFilters = (filters: FilterCollection): AzFilterGroup => { 87 | if (isArrayEmpty(filters)) return null; 88 | // TODO: Only collection filter implemented. 89 | const filterGroup: AzFilterGroup = { 90 | logic: "and", 91 | items: filters.map(f => mapViewFilterToPayloadCollectionFilter(f)).filter(f => f), 92 | }; 93 | return filterGroup; 94 | }; 95 | 96 | const mapViewFacetToPayloadFacet = (viewFacet: Facet): AzPayloadFacet => { 97 | return { 98 | fieldName: viewFacet.fieldId, 99 | config: { 100 | count: viewFacet.maxCount, 101 | }, 102 | }; 103 | }; 104 | 105 | export const mapStateToSearchPayload = (state: State, config: ServiceConfig): AzPayload => { 106 | const viewFacets = isArrayEmpty(state.facetCollection) ? config.initialState.facetCollection : 107 | state.facetCollection; 108 | return { 109 | ...config.searchConfig.defaultPayload, 110 | search: state.searchValue, 111 | top: state.pageSize, 112 | skip: state.pageIndex * state.pageSize, 113 | facets: viewFacets.map(f => mapViewFacetToPayloadFacet(f)), 114 | filters: mapViewFiltersToPayloadFilters(state.filterCollection), 115 | }; 116 | } 117 | 118 | 119 | -------------------------------------------------------------------------------- /src/common/components/pagination/pagination.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import paginator from 'paginator'; 3 | import { Page } from './page'; 4 | 5 | // based on: https://github.com/vayser/react-js-pagination 6 | const style = require('./pagination.scss'); 7 | 8 | interface Props { 9 | totalItemsCount: number; 10 | onChange: (pageNumber: number) => void; 11 | activePage?: number; 12 | itemsCountPerPage?: number; 13 | pageRangeDisplayed?: number; 14 | prevPageText?: (string | React.ReactNode); // Not sure if element makes sense here 15 | nextPageText?: (string | React.ReactNode); 16 | lastPageText?: (string | React.ReactNode); 17 | firstPageText?: (string | React.ReactNode); 18 | hideDisabled?: boolean; 19 | hideNavigation?: boolean, 20 | 21 | hideFirstLastPages?: boolean; 22 | } 23 | 24 | export class Pagination extends React.Component { 25 | static defaultProps = { 26 | itemsCountPerPage: 10, 27 | pageRangeDisplayed: 5, 28 | activePage: 1, 29 | prevPageText: "⟨", 30 | firstPageText: "⟨⟨", 31 | nextPageText: "⟩", 32 | lastPageText: "⟩⟩", 33 | hideFirstLastPages: false, 34 | }; 35 | 36 | isFirstPageVisible(has_previous_page) { 37 | const { hideDisabled, hideNavigation, hideFirstLastPages } = this.props; 38 | return !hideNavigation && !hideFirstLastPages && !(hideDisabled && !has_previous_page); 39 | } 40 | 41 | isPrevPageVisible(has_previous_page) { 42 | const { hideDisabled, hideNavigation } = this.props; 43 | return !hideNavigation && !(hideDisabled && !has_previous_page); 44 | } 45 | 46 | isNextPageVisible(has_next_page) { 47 | const { hideDisabled, hideNavigation } = this.props; 48 | return !hideNavigation && !(hideDisabled && !has_next_page); 49 | } 50 | 51 | isLastPageVisible(has_next_page) { 52 | const { hideDisabled, hideNavigation, hideFirstLastPages } = this.props; 53 | return !hideNavigation && !hideFirstLastPages && !(hideDisabled && !has_next_page); 54 | } 55 | 56 | buildPages() { 57 | const pages = []; 58 | const { 59 | itemsCountPerPage, 60 | pageRangeDisplayed, 61 | activePage, 62 | prevPageText, 63 | nextPageText, 64 | firstPageText, 65 | lastPageText, 66 | totalItemsCount, 67 | onChange, 68 | hideFirstLastPages, 69 | } = this.props; 70 | 71 | const paginationInfo : any = new paginator( 72 | itemsCountPerPage, 73 | pageRangeDisplayed 74 | ).build(totalItemsCount, activePage); 75 | 76 | const buttonStyles = { 77 | root: style.button, 78 | raisedPrimary: style.primary, 79 | }; 80 | 81 | for ( 82 | let i = paginationInfo.first_page; 83 | i <= paginationInfo.last_page; 84 | i++ 85 | ) { 86 | pages.push( 87 | 95 | ); 96 | } 97 | 98 | this.isPrevPageVisible(paginationInfo.has_previous_page) && 99 | pages.unshift( 100 | 108 | ); 109 | 110 | this.isFirstPageVisible(paginationInfo.has_previous_page) && 111 | pages.unshift( 112 | 120 | ); 121 | 122 | this.isNextPageVisible(paginationInfo.has_next_page) && 123 | pages.push( 124 | 132 | ); 133 | 134 | this.isLastPageVisible(paginationInfo.has_next_page) && 135 | pages.push( 136 | 146 | ); 147 | 148 | return pages; 149 | } 150 | 151 | render() { 152 | const pages = this.buildPages(); 153 | return ( 154 |
155 | {pages} 156 |
157 | ); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 5 | 6 | const basePath = __dirname; 7 | 8 | module.exports = { 9 | context: path.join(basePath, "src"), 10 | 11 | resolve: { 12 | extensions: ['.js', '.jsx', '.ts', '.tsx'] 13 | }, 14 | 15 | entry: { 16 | app: [ 17 | './app.tsx', 18 | ], 19 | appStyles: [ 20 | './theme/main.scss', 21 | ], 22 | vendor: [ 23 | 'babel-polyfill', 24 | 'material-ui', 25 | 'material-ui-icons', 26 | 'material-ui-pickers', 27 | 'moment', 28 | 'paginator', 29 | 'qs', 30 | 'react', 31 | 'react-dom', 32 | 'react-router-dom', 33 | 'd3' 34 | ], 35 | }, 36 | 37 | module: { 38 | rules: [ 39 | // *** Loading pipe for Typescript *** 40 | { 41 | test: /\.(ts|tsx)$/, 42 | exclude: [/node_modules/], 43 | loader: 'awesome-typescript-loader', 44 | options: { 45 | useBabel: true, 46 | }, 47 | }, 48 | // *** Loading pipe for Raster Images *** 49 | { 50 | test: /\.(png|jpg|gif|bmp)?$/, 51 | exclude: [/node_modules/], 52 | use: [ 53 | { 54 | loader: "url-loader", 55 | options: { 56 | limit: 16000, 57 | name: "assets/img/[name].[ext]", 58 | }, 59 | }, 60 | ], 61 | }, 62 | // *** Loading pipe for Vector Images (exclude svg from fonts) *** 63 | { 64 | test: /\.svg$/, 65 | exclude: [/node_modules/, /fonts/], 66 | use: [ 67 | { 68 | loader: "url-loader", 69 | options: { 70 | limit: 1000, 71 | name: "assets/svg/[name].[ext]", 72 | }, 73 | }, 74 | ], 75 | }, 76 | // *** Loading pipe for Fonts. Primary EOT (welcome to be embedded). 77 | // The rest are fallbacks just in case, dont' embedd them *** 78 | { 79 | test: /\.eot$/, 80 | // exclude: [/node_modules/], 81 | use: [ 82 | { 83 | loader: "url-loader", 84 | options: { 85 | limit: 5000, 86 | mimetype: "application/vnd.ms-fontobject", 87 | name: "assets/fonts/[name].[ext]", 88 | }, 89 | }, 90 | ], 91 | }, 92 | { 93 | test: /\.([ot]tf)$/, 94 | // exclude: [/node_modules/], 95 | use: [ 96 | { 97 | loader: "url-loader", 98 | options: { 99 | limit: 1000, 100 | mimetype: "application/octet-stream", 101 | name: "assets/fonts/[name].[ext]", 102 | }, 103 | }, 104 | ], 105 | }, 106 | { 107 | test: /\.(woff|woff2)?$/, 108 | // exclude: [/node_modules/], 109 | use: [ 110 | { 111 | loader: "url-loader", 112 | options: { 113 | limit: 1000, 114 | mimetype: "mimetype=application/font-woff", 115 | name: "assets/fonts/[name].[ext]", 116 | }, 117 | }, 118 | ], 119 | }, 120 | { 121 | test: /\.svg$/, 122 | // exclude: [/node_modules/], 123 | include: [/fonts/], 124 | use: [ 125 | { 126 | loader: "url-loader", 127 | options: { 128 | limit: 1000, 129 | mimetype: "image/svg+xml", 130 | name: "assets/fonts/[name].[ext]", 131 | }, 132 | }, 133 | ], 134 | }, 135 | ] 136 | }, 137 | plugins: [ 138 | // *** Generate index.html in /dist *** 139 | new HtmlWebpackPlugin({ 140 | filename: 'index.html', // Name of file in ./dist/ 141 | template: 'index.html', // Name of template in ./src 142 | hash: true, 143 | chunksSortMode: 'manual', 144 | chunks: ['manifest', 'vendor', 'appStyles', 'app'], 145 | }), 146 | new webpack.optimize.CommonsChunkPlugin({ 147 | names: ['appStyles', 'vendor', 'manifest'], 148 | }), 149 | new webpack.DefinePlugin({ 150 | 'process.env': { 151 | 'SEARCH_CONFIG_PROTOCOL': JSON.stringify(process.env.SEARCH_CONFIG_PROTOCOL), 152 | 'SEARCH_CONFIG_SERVICE_NAME': JSON.stringify(process.env.SEARCH_CONFIG_SERVICE_NAME), 153 | 'SEARCH_CONFIG_SERVICE_DOMAIN': JSON.stringify(process.env.SEARCH_CONFIG_SERVICE_DOMAIN), 154 | 'SEARCH_CONFIG_SERVICE_PATH': JSON.stringify(process.env.SEARCH_CONFIG_SERVICE_PATH), 155 | 'SEARCH_CONFIG_API_VER': JSON.stringify(process.env.SEARCH_CONFIG_API_VER), 156 | 'SEARCH_CONFIG_API_KEY': JSON.stringify(process.env.SEARCH_CONFIG_API_KEY), 157 | 'SUGGESTION_CONFIG_PROTOCOL': JSON.stringify(process.env.SUGGESTION_CONFIG_PROTOCOL), 158 | 'SUGGESTION_CONFIG_SERVICE_NAME': JSON.stringify(process.env.SUGGESTION_CONFIG_SERVICE_NAME), 159 | 'SUGGESTION_CONFIG_SERVICE_DOMAIN': JSON.stringify(process.env.SUGGESTION_CONFIG_SERVICE_DOMAIN), 160 | 'SUGGESTION_CONFIG_SERVICE_PATH': JSON.stringify(process.env.SUGGESTION_CONFIG_SERVICE_PATH), 161 | 'SUGGESTION_CONFIG_API_VER': JSON.stringify(process.env.SUGGESTION_CONFIG_API_VER), 162 | 'SUGGESTION_CONFIG_API_KEY': JSON.stringify(process.env.SUGGESTION_CONFIG_API_KEY), 163 | } 164 | }) 165 | ] 166 | } 167 | -------------------------------------------------------------------------------- /src/common/components/hocr/util/common-util.ts: -------------------------------------------------------------------------------- 1 | export type PageIndex = number | "auto"; 2 | export type WordComparator = (word: string) => boolean; 3 | 4 | export interface WordPosition { 5 | pageIndex: number; 6 | firstOcurrenceNode: Element; 7 | } 8 | 9 | 10 | const hocrEntityMap = { 11 | page: "ocr_page", 12 | area: "ocr_carea", 13 | paragraph: "ocr_par", 14 | line: "ocr_line", 15 | word: "ocrx_word", 16 | } 17 | 18 | export const resolveNodeEntity = (node: Element): string => { 19 | let entity = null; 20 | Object.keys(hocrEntityMap).some(key => { 21 | const match = node.classList.contains(hocrEntityMap[key]); 22 | if (match) entity = key; 23 | return match; 24 | }); 25 | 26 | return entity; 27 | }; 28 | 29 | export const CreateWordComparator = (targetWords: string[], caseSensitive: boolean = false) => { 30 | if (!targetWords || targetWords.length <= 0) return null; 31 | 32 | const parsedTargetWords = caseSensitive ? targetWords : targetWords.map(w => w.toLowerCase()); 33 | return (word: string): boolean => { 34 | return parsedTargetWords.indexOf(caseSensitive ? word : word.toLowerCase()) >= 0; 35 | } 36 | }; 37 | 38 | export const parseHocr = (hocr: string): Document => { 39 | const domParser = new DOMParser(); 40 | return domParser.parseFromString(hocr, "text/html"); 41 | } 42 | 43 | export const parseWordPosition = (doc: Document, pageIndex: PageIndex, 44 | wordComparator: WordComparator): WordPosition => { 45 | if (typeof pageIndex === "number") { 46 | const validatedPageIndex = (pageIndex < 0 || !checkPageIndexInRange(doc, pageIndex)) ? 0 : pageIndex; 47 | const firstOcurrenceNode = wordIdInPage(doc.body.children[validatedPageIndex], wordComparator); 48 | return { 49 | pageIndex: validatedPageIndex, 50 | firstOcurrenceNode, 51 | }; 52 | } else { // Auto page index based on the first ocurrence of a target word. 53 | return findFirstOcurrencePosition(doc, wordComparator); 54 | } 55 | }; 56 | 57 | const checkPageIndexInRange = (doc: Document, pageIndex: number) => { 58 | return doc.body && doc.body.children && (pageIndex < doc.body.children.length); 59 | }; 60 | 61 | const findFirstOcurrencePosition = (doc: Document, wordComparator: WordComparator): WordPosition => { 62 | let pos: WordPosition = {pageIndex: 0, firstOcurrenceNode: null}; 63 | if (wordComparator) { 64 | Array.from(doc.body.children).some((page, index) => { 65 | const foundNode = wordIdInPage(page, wordComparator); 66 | if (foundNode) { 67 | pos.pageIndex = index; 68 | pos.firstOcurrenceNode = foundNode; 69 | } 70 | return Boolean(foundNode); 71 | }); 72 | } 73 | return pos; 74 | }; 75 | 76 | const wordIdInPage = (page: Element, wordComparator: WordComparator): Element => { 77 | const pageWords = page.getElementsByClassName(hocrEntityMap.word); 78 | let wordNode = null; 79 | Array.from(pageWords).some((word, index) => { 80 | const comparison = wordComparator(word.textContent); 81 | if (comparison) wordNode = word; 82 | return comparison; 83 | }) 84 | return wordNode; 85 | }; 86 | 87 | export const getNodeId = (node: Element, suffix: string = ""): string => { 88 | return composeId(node.getAttribute("id"), suffix); 89 | }; 90 | 91 | export const composeId = (id: string, suffix: string = ""): string => { 92 | return [id, suffix].filter(s => s).join("-"); 93 | }; 94 | 95 | export const getNodeById = (parentNode: Element, id: string) => { 96 | return parentNode.querySelector(`#${id}`); 97 | } 98 | 99 | const optionArrayFields = ['bbox', 'baseline', 'scan_res']; 100 | 101 | export const getNodeOptions = (node: Element): any => { 102 | const optionsStr = node["title"] ? node["title"] : ""; 103 | const regex = /(?:^|;)\s*(\w+)\s+(?:([^;"']+?)|"((?:\\"|[^"])+?)"|'((?:\\'|[^'])+?)')\s*(?=;|$)/g; 104 | let match; 105 | 106 | let options = {}; 107 | while (match = regex.exec(optionsStr)) { 108 | const name = match[1]; 109 | let value = match[4] || match[3] || match[2]; 110 | if (optionArrayFields.indexOf(name) !== -1) { 111 | value = value.split(/\s+/); 112 | } 113 | options[name] = value; 114 | } 115 | return options; 116 | }; 117 | 118 | 119 | export interface PosSize { 120 | x: number, 121 | y: number, 122 | width: number, 123 | height: number, 124 | } 125 | 126 | export const bboxToPosSize = (bbox: number[]): PosSize => ({ 127 | x: bbox[0], 128 | y: bbox[1], 129 | width: bbox[2] - bbox[0], 130 | height: bbox[3] - bbox[1] 131 | }) 132 | 133 | export const getPosSizeFromDOMNode = (node: Element): PosSize => { 134 | if (node) { 135 | return { 136 | x: parseInt(node.getAttribute("x")), 137 | y: parseInt(node.getAttribute("y")), 138 | width: parseInt(node.getAttribute("width")), 139 | height: parseInt(node.getAttribute("height")), 140 | }; 141 | } 142 | return null; 143 | } 144 | 145 | export const getPosSizeFromBBoxNode = (node: Element): PosSize => { 146 | if (!node) return null; 147 | const nodeOptions = getNodeOptions(node); 148 | if (!nodeOptions || !nodeOptions.bbox) return null; 149 | return bboxToPosSize(nodeOptions.bbox); 150 | } 151 | 152 | export const calculateNodeShiftInContainer = (target: PosSize, container: PosSize) => { 153 | const shiftX = target.x - container.x; 154 | const shiftY = target.y - container.y; 155 | const shiftCentroidX = shiftX + (target.width / 2); 156 | const shiftCentroidY = shiftY + (target.height / 2); 157 | const shiftCentroidXPercentage = shiftCentroidX / container.width; 158 | const shiftCentroidYPercentage = shiftCentroidY / container.height; 159 | 160 | return {x: shiftCentroidXPercentage, y: shiftCentroidYPercentage}; 161 | } 162 | -------------------------------------------------------------------------------- /src/pages/search-page/components/item/item.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Item } from "../../view-model"; 3 | import { Chevron } from "../../../../common/components/chevron"; 4 | import { HocrPreviewComponent } from "../../../../common/components/hocr"; 5 | import Card, { CardActions, CardContent, CardMedia } from "material-ui/Card"; 6 | import List, { ListItem, ListItemIcon, ListItemText} from 'material-ui/List'; 7 | import Collapse from "material-ui/transitions/Collapse"; 8 | import Typography from "material-ui/Typography"; 9 | import Chip from 'material-ui/Chip'; 10 | import StarIcon from "material-ui-icons/Star"; 11 | 12 | const style = require("./item.style.scss"); 13 | 14 | 15 | interface ItemProps { 16 | item: Item; 17 | activeSearch?: string; 18 | onClick?: (item: Item) => void; 19 | simplePreview?: boolean; 20 | } 21 | 22 | interface State { 23 | expanded: boolean; 24 | } 25 | 26 | const handleOnClick = ({item, onClick}) => () => onClick? onClick(item) : null; 27 | 28 | const ratingStars = (item: Item) => ((item.rating >= 1.0) ? 29 | Array(Math.floor(item.rating)).fill(0).map((item, index) => ( 30 | 31 | )) : null 32 | ); 33 | 34 | const ItemMediaThumbnail: React.StatelessComponent = ({ item, onClick }) => { 35 | return ( 36 | item.thumbnail ? 37 | : null 43 | ); 44 | } 45 | 46 | const ItemMediaHocrPreview: React.StatelessComponent = ({ item, activeSearch, onClick }) => { 47 | return ( 48 |
51 | 59 |
60 | ); 61 | } 62 | 63 | const ItemMedia: React.StatelessComponent = ( 64 | { item, activeSearch, onClick, simplePreview }) => { 65 | return ( 66 | simplePreview ? 67 | : 71 | 76 | ); 77 | } 78 | 79 | const ItemCaption: React.StatelessComponent = ({ item, onClick }) => { 80 | return ( 81 | 85 | 86 | {item.title} 87 | 88 | {item.subtitle} 89 | 90 | 91 | 92 | {item.excerpt} 93 | 94 | 95 | ); 96 | } 97 | 98 | const generateExtraFieldContent = (field: any) => { 99 | if (typeof field == "string") { 100 | return 101 | } else if (field instanceof Array) { 102 | return ( 103 |
104 | {field.map((tag, tagIndex) => 105 | 106 | )} 107 |
); 108 | } else { 109 | return null; 110 | } 111 | } 112 | 113 | const generateExtraField = (field: any, index: number) => ( 114 | field ? ( 115 | 116 | { generateExtraFieldContent(field) } 117 | 118 | ) : null 119 | ); 120 | 121 | const ItemExtraFieldList: React.StatelessComponent = ({ item }) => { 122 | if (item.extraFields) { 123 | return ( 124 | 125 | 126 | { 127 | item.extraFields.map((field, fieldIndex) => 128 | generateExtraField(field, fieldIndex)) 129 | } 130 | 131 | 132 | ); 133 | } else { 134 | return null; 135 | } 136 | } 137 | 138 | export class ItemComponent extends React.Component { 139 | constructor(props) { 140 | super(props); 141 | 142 | this.state = { 143 | expanded: false, 144 | } 145 | } 146 | 147 | private toggleExpand = () => { 148 | this.setState({ 149 | ...this.state, 150 | expanded: !this.state.expanded, 151 | }); 152 | } 153 | 154 | public render() { 155 | const {item, activeSearch, onClick } = this.props; 156 | 157 | return ( 158 | 160 | 165 | 166 | 167 |
168 | {ratingStars(item)} 169 |
170 | 172 |
173 | 179 | 180 | 181 |
182 | ); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-document/hocr-document.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { getDocNodeChildrenComponents } from "./hocr-docnode.component"; 3 | import { injectDefaultDocumentStyle, HocrDocumentStyleMap } from "./hocr-document.style"; 4 | import { 5 | parseHocr, 6 | CreateWordComparator, 7 | WordComparator, 8 | resolveNodeEntity, 9 | getNodeById, 10 | } from "../util/common-util"; 11 | import { cnc } from "../../../../util"; 12 | 13 | const style = require("./hocr-document.style.scss"); 14 | 15 | 16 | /** 17 | * HOCR Document. 18 | * Given an HOCR input string, it parses it and represents the document in text format. 19 | * It allows user to navigate through document pages, highlighting the hovered items, and 20 | * also provides the necessary wiring and events to be connected to a HocrPreviewComponent 21 | * to create a whole proofreader. 22 | */ 23 | 24 | export interface HocrDocumentProps { 25 | hocr: string; 26 | targetWords?: string[]; 27 | caseSensitiveComparison?: boolean; 28 | autoFocusId?: string; 29 | hightlightId?: string; 30 | userStyle?: HocrDocumentStyleMap; 31 | onWordHover?: (wordId: string) => void; 32 | onPageHover?: (pageIndex: number) => void; 33 | className?: string; 34 | }; 35 | 36 | interface HocrDocumentState { 37 | docBody: Element; 38 | wordCompare: WordComparator; 39 | autoFocusNode: Element; 40 | safeStyle: HocrDocumentStyleMap; 41 | } 42 | 43 | export class HocrDocumentComponent extends React.Component { 44 | constructor(props) { 45 | super(props); 46 | 47 | this.state = { 48 | docBody: getDocumentBody(props.hocr), 49 | wordCompare: CreateWordComparator(props.targetWords, props.caseSensitiveComparison), 50 | safeStyle: injectDefaultDocumentStyle(props.userStyle), 51 | autoFocusNode: null, 52 | } 53 | } 54 | 55 | private viewportRef = null; 56 | 57 | private saveViewportRef = (node) => { 58 | this.viewportRef = node; 59 | } 60 | 61 | private scrollTo = (node: Element) => { 62 | if (node) { 63 | const verticalShift = node.getBoundingClientRect().top - this.viewportRef.getBoundingClientRect().top; 64 | const scrollTop = this.viewportRef.scrollTop + verticalShift - (this.viewportRef.clientHeight / 2); 65 | this.viewportRef.scrollTop = scrollTop; 66 | } 67 | } 68 | 69 | private resetHighlight = (node: Element) => { 70 | if (node) node.classList.remove(this.state.safeStyle["highlight"]); 71 | } 72 | 73 | private setHighlight = (node: Element) => { 74 | if (node) node.classList.add(this.state.safeStyle["highlight"]); 75 | } 76 | 77 | private autoFocusToNode = (nodeId: string) => { 78 | this.resetHighlight(this.state.autoFocusNode); 79 | if (nodeId) { 80 | const focusNode = getNodeById(this.viewportRef, nodeId); 81 | this.setHighlight(focusNode); 82 | this.scrollTo(focusNode); 83 | this.setState({ 84 | ...this.state, 85 | autoFocusNode: focusNode, 86 | }); 87 | } 88 | } 89 | 90 | // *** Lifecycle *** 91 | 92 | public componentDidMount() { 93 | if (this.props.autoFocusId) { 94 | this.scrollTo(getNodeById(this.viewportRef, this.props.autoFocusId)); 95 | } 96 | } 97 | 98 | public componentWillReceiveProps(nextProps: HocrDocumentProps) { 99 | if( this.props.hocr !== nextProps.hocr) { 100 | this.setState({ 101 | ...this.state, 102 | docBody: getDocumentBody(nextProps.hocr) 103 | }) 104 | } else if ( 105 | this.props.targetWords !== nextProps.targetWords || 106 | this.props.caseSensitiveComparison !== nextProps.caseSensitiveComparison 107 | ) { 108 | this.setState({ 109 | ...this.state, 110 | wordCompare: CreateWordComparator(nextProps.targetWords, nextProps.caseSensitiveComparison), 111 | }); 112 | } else if ( this.props.userStyle != nextProps.userStyle ) { 113 | this.setState({ 114 | ...this.state, 115 | safeStyle: injectDefaultDocumentStyle(nextProps.userStyle), 116 | }); 117 | } else if (this.props.autoFocusId !== nextProps.autoFocusId) { 118 | this.autoFocusToNode(nextProps.autoFocusId); 119 | } 120 | } 121 | 122 | public shouldComponentUpdate(nextProps: HocrDocumentProps, nextState: HocrDocumentState) { 123 | return ( 124 | this.props.hocr !== nextProps.hocr || 125 | this.props.targetWords !== nextProps.targetWords || 126 | this.props.caseSensitiveComparison !== nextProps.caseSensitiveComparison || 127 | this.props.userStyle !== nextProps.userStyle || 128 | this.props.onWordHover !== nextProps.onWordHover || 129 | this.props.onPageHover !== nextProps.onPageHover || 130 | this.props.className !== nextProps.className || 131 | this.state.docBody !== nextState.docBody || 132 | this.state.wordCompare !== nextState.wordCompare 133 | ); 134 | } 135 | 136 | public render() { 137 | if (!this.state.docBody || !this.state.docBody.children) return null; 138 | return ( 139 |
140 |
141 | { getDocNodeChildrenComponents({ 142 | node: this.state.docBody, 143 | index: 0, 144 | wordCompare: this.state.wordCompare, 145 | userStyle: this.state.safeStyle, 146 | onWordHover: this.props.onWordHover, 147 | onPageHover: this.props.onPageHover, 148 | })} 149 |
150 |
151 | ); 152 | } 153 | }; 154 | 155 | const getDocumentBody = (hocr: string) => { 156 | if (!hocr) return null; 157 | const doc = parseHocr(hocr); 158 | return doc.body 159 | } 160 | -------------------------------------------------------------------------------- /src/pages/search-page/search-page.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Divider from "material-ui/Divider"; 3 | import Hidden from "material-ui/Hidden"; 4 | import { PageBarComponent } from "./components/page-bar"; 5 | import { DrawerComponent } from "./components/drawer"; 6 | import { SearchComponent } from "./components/search"; 7 | import { ItemCollectionViewComponent } from "./components/item"; 8 | import { FacetViewComponent } from "./components/facets"; 9 | import { HorizontalSeparator } from "./../../common/components/horizontal-separator"; 10 | import { GraphViewComponent } from "./components/graph"; 11 | import { SpacerComponent } from "./components/spacer"; 12 | import { 13 | ItemCollection, 14 | FacetCollection, 15 | FilterCollection, 16 | Filter, 17 | SuggestionCollection, 18 | Item, 19 | ResultViewMode, 20 | } from "./view-model"; 21 | import { Service } from "./service"; 22 | import { Pagination } from "../../common/components/pagination/pagination"; 23 | import { PlaceholderComponent } from "./components/placeholder"; 24 | 25 | const style = require("./search-page.style.scss"); 26 | 27 | 28 | interface SearchPageProps { 29 | activeService: Service; 30 | showDrawer: boolean; 31 | resultViewMode: ResultViewMode; 32 | searchValue: string; 33 | itemCollection: ItemCollection; 34 | activeSearch?: string 35 | facetCollection: FacetCollection; 36 | filterCollection: FilterCollection; 37 | suggestionCollection?: SuggestionCollection; 38 | resultCount: number; 39 | resultsPerPage: number; 40 | pageIndex: number; 41 | onSearchSubmit: () => void; 42 | onSearchUpdate: (value: string) => void; 43 | onFilterUpdate: (newFilter: Filter) => void; 44 | onItemClick: (item: Item) => void; 45 | onDrawerClose: () => void; 46 | onMenuClick: () => void; 47 | onLoadMore: (pageIndex: number) => void; 48 | onChangeResultViewMode: (newMode: ResultViewMode) => void; 49 | onGraphNodeDblClick: (searchValue: string) => void; 50 | } 51 | 52 | const DrawerAreaComponent = (props: SearchPageProps) => ( 53 | 60 | 67 | 72 | 73 | ); 74 | 75 | const handlePageChange = callback => pageNum => callback(pageNum - 1); 76 | 77 | const Paginator = (props: Partial) => ( 78 | <> 79 | 80 | {/* Mobile */} 81 | 88 | 89 | 90 | {/* Desktop */} 91 | 98 | 99 | 100 | ); 101 | 102 | class ResultAreaComponent extends React.PureComponent> { 103 | 104 | 105 | render() { 106 | return ( 107 | <> 108 | 109 | 110 | { 111 | this.props.resultViewMode === "grid" ? 112 |
113 | 118 | 124 |
: 125 | 129 | } 130 |
131 | 132 | ); 133 | } 134 | } 135 | 136 | const SearchPageComponent = (props: SearchPageProps) => ( 137 |
138 | 139 |
140 | 145 | 146 | 157 |
158 |
159 | ) 160 | 161 | 162 | export { SearchPageComponent }; 163 | -------------------------------------------------------------------------------- /src/pages/search-page/search-page.container.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { withRouter, RouteComponentProps } from "react-router-dom"; 3 | import * as throttle from 'lodash.throttle'; 4 | import { parse } from "qs"; 5 | import { SearchPageComponent } from "./search-page.component"; 6 | import { State, FilterCollection, Filter, Item, ResultViewMode } from "./view-model"; 7 | import { Service, StateReducer } from "./service"; 8 | import { jfkService } from "./service"; 9 | import { isArrayEmpty } from "../../util"; 10 | import { 11 | CreateInitialState, 12 | searchValueUpdate, 13 | showDrawerUpdate, 14 | suggestionsUpdate, 15 | preSearchUpdate, 16 | postSearchSuccessUpdate, 17 | postSearchMoreSuccessUpdate, 18 | postSearchErrorReset, 19 | postSearchErrorKeep, 20 | resultViewModeUpdate, 21 | receivedSearchValueUpdate, 22 | } from "./search-page.container.state"; 23 | import { detailPath, DetailRouteState } from "../detail-page"; 24 | import { storeState, restoreLastState, isLastStateAvailable} from './view-model/state.memento'; 25 | import { setDetailState } from "../detail-page/detail-page.memento"; 26 | 27 | class SearchPageInnerContainer extends React.Component, State> { 28 | constructor(props) { 29 | super(props); 30 | 31 | this.state = CreateInitialState(); 32 | } 33 | 34 | componentDidMount() { 35 | if(isLastStateAvailable()) { 36 | this.setState(restoreLastState()); 37 | } else if (this.props.location.search) { 38 | const receivedSearchValue = parse(this.props.location.search.substring(1)); 39 | this.handleReceivedSearchValue(receivedSearchValue.term); 40 | } 41 | } 42 | 43 | // *** Search Value received through query string *** 44 | 45 | handleReceivedSearchValue = (searchValue : string) => { 46 | this.setState( 47 | receivedSearchValueUpdate(searchValue, true, "grid"), 48 | this.handleSearchSubmit 49 | ); 50 | } 51 | 52 | // *** DRAWER LOGIC *** 53 | 54 | private handleDrawerClose = () => { 55 | this.setState(showDrawerUpdate(false)); 56 | }; 57 | 58 | private handleDrawerToggle = () => { 59 | this.setState(showDrawerUpdate(!this.state.showDrawer)); 60 | }; 61 | 62 | private handleMenuClick = () => { 63 | this.handleDrawerToggle(); 64 | }; 65 | 66 | // *** VIEW MODE LOGIC *** 67 | 68 | private handleResultViewMode = (resultViewMode: ResultViewMode) => { 69 | this.setState(resultViewModeUpdate(resultViewMode)); 70 | } 71 | 72 | 73 | // *** SEARCH LOGIC *** 74 | 75 | private handleSearchSubmit = () => { 76 | this.setState( 77 | preSearchUpdate(null), 78 | this.runSearch(postSearchSuccessUpdate, postSearchErrorReset) 79 | ); 80 | }; 81 | 82 | private runSearch = ( 83 | successCallback: (stateReducer: StateReducer) => (prevState: State) => State, 84 | errorCallback: (rejectValue) => (prevState: State) => State 85 | ) => () => { 86 | jfkService 87 | .search(this.state) 88 | .then(stateReducer => this.setState(successCallback(stateReducer))) 89 | .catch(rejectValue => this.setState(errorCallback(rejectValue))); 90 | } 91 | 92 | 93 | // *** FILTER LOGIC *** 94 | 95 | private updateFilterCollection = (newFilter: Filter) => { 96 | return ( 97 | this.state.filterCollection ? 98 | [...this.state.filterCollection.filter(f => f.fieldId !== newFilter.fieldId), newFilter] 99 | : [newFilter]) 100 | .filter(f => f.store); 101 | } 102 | 103 | private handleFilterUpdate = (newFilter: Filter) => { 104 | const newFilterCollection = this.updateFilterCollection(newFilter); 105 | this.setState( 106 | preSearchUpdate(newFilterCollection), 107 | this.runSearch(postSearchSuccessUpdate, postSearchErrorReset) 108 | ); 109 | }; 110 | 111 | 112 | // *** PAGINATION LOGIC *** 113 | 114 | private handleLoadMore = (pageIndex: number) => { 115 | this.setState( 116 | preSearchUpdate(this.state.filterCollection, pageIndex), 117 | this.runSearch(postSearchMoreSuccessUpdate, postSearchErrorKeep) 118 | ); 119 | } 120 | 121 | 122 | // *** SUGGESTIONS LOGIC *** 123 | 124 | private handleSearchUpdate = (newValue: string) => { 125 | this.setState(searchValueUpdate(newValue), this.runSuggestions); 126 | }; 127 | 128 | private runSuggestions = throttle(() => { 129 | jfkService 130 | .suggest(this.state) 131 | .then(stateReducer => this.setState(stateReducer(this.state))) 132 | .catch(rejectValue => { 133 | console.debug(`Suggestions halted: ${rejectValue}`); 134 | this.setState(suggestionsUpdate(null)); 135 | }); 136 | }, 500, {leading: true, trailing: true}); 137 | 138 | 139 | // *** MISC *** 140 | 141 | private handleOnItemClick = (item: Item) => { 142 | storeState(this.state); 143 | 144 | setDetailState( { 145 | hocr: item.metadata, 146 | targetWords: this.state.activeSearch && this.state.activeSearch.split(" "), 147 | } as DetailRouteState); 148 | 149 | this.props.history.push(detailPath); 150 | } 151 | 152 | // TODO: Snackbar implementation. 153 | private informMessage = (message: string) => { 154 | console.log(message); 155 | } 156 | 157 | 158 | // *** REACT LIFECYCLE *** 159 | 160 | public render() { 161 | return ( 162 |
163 | 186 |
187 | ); 188 | } 189 | } 190 | 191 | export const SearchPageContainer = withRouter(SearchPageInnerContainer); 192 | -------------------------------------------------------------------------------- /src/pages/search-page/components/graph/graph-view.business.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import { GraphResponse, GraphEdge, GraphNode } from "../../../../graph-api"; 3 | import { Theme } from "material-ui/styles"; 4 | import { createDragBehaviour } from "./graph-view.handlers"; 5 | 6 | 7 | /** 8 | * Graph configuration parameters. 9 | */ 10 | const nodeRadius = 15; 11 | const nodeSeparationFactor = 1; 12 | const nodeChargeStrength = -250; // Being negative Charge = Repulsion. 13 | const nodeChargeAccuracy = 0.4; 14 | 15 | const colorizeNode = (theme: Theme) => (d, i) => 16 | (i == 0) ? theme.palette.secondary.main : theme.palette.primary.main; 17 | 18 | /** 19 | * Graph Utils. 20 | */ 21 | 22 | const getSvgBbox = (svg) => (svg.node() as Element).getBoundingClientRect(); 23 | 24 | const createKeepCoordInCanvas = (svgRect) => (n: number, dim: "X" | "Y", margin: number): number => { 25 | return (dim === "X") ? Math.max(margin, Math.min(svgRect.width - margin, n)) 26 | : Math.max(margin, Math.min(svgRect.height - margin, n)); 27 | } 28 | 29 | const navigateToSelectedTerm = (onGraphNodeDblClick: (string) => void) => (d) => { 30 | onGraphNodeDblClick(d.name); 31 | } 32 | 33 | 34 | /** 35 | * Graph definitions. 36 | */ 37 | 38 | const createSvg = (containerNodeId: string, theme: Theme) => { 39 | return d3.select(`#${containerNodeId}`) 40 | .append("svg") 41 | .style("flex", "1 1 auto") 42 | .style("font-family", theme.typography.fontFamily); 43 | } 44 | 45 | const createArrowDef = (svg) => { 46 | return svg 47 | .append("defs").append("marker") 48 | .attr("id", "arrowhead") 49 | .attr("viewBox", "-0 -5 10 10") 50 | .attr("refX", 25) 51 | .attr("refY", 0) 52 | .attr("orient", "auto") 53 | .attr("markerWidth", 10) 54 | .attr("markerHeight", 10) 55 | .attr("xoverflow", "visible") 56 | .append("svg:path") 57 | .attr("d", "M 0,-5 L 10 ,0 L 0,5") 58 | .attr("fill", "#ccc") 59 | .attr("stroke", "#ccc"); 60 | } 61 | 62 | const createEdges = (svg, graphDescriptor: GraphResponse) => { 63 | return svg 64 | .append("g") 65 | .attr("class", "edges") 66 | .selectAll("line") 67 | .data(graphDescriptor.edges) 68 | .enter().append("line") 69 | .attr("id", function (d, i) { return "edge" + i }) 70 | .attr("marker-end", "url(#arrowhead)") 71 | .style("stroke", "#ccc") 72 | .style("pointer-events", "none"); 73 | } 74 | 75 | const createNodes = (svg, graphDescriptor: GraphResponse, onGraphNodeDblClick: (string) => void, theme: Theme) => { 76 | const nodes = svg 77 | .append("g") 78 | .attr("class", "nodes") 79 | .selectAll("circle") 80 | .data(graphDescriptor.nodes) 81 | .enter().append("circle") 82 | .attr("r", nodeRadius) 83 | .style("fill", colorizeNode(theme)) 84 | .style("cursor", "pointer") 85 | .on("dblclick", navigateToSelectedTerm(onGraphNodeDblClick)); 86 | 87 | const nodetitles = nodes 88 | .append("title") 89 | .text(d => d.name); 90 | 91 | return nodes; 92 | } 93 | 94 | const createNodeLabels = (svg, graphDescriptor: GraphResponse, onGraphNodeDblClick: (string) => void, theme: Theme) => { 95 | const ellipticalArc = `M${-5*nodeRadius},${0} A${5*nodeRadius},${2*nodeRadius} 0, 0,0 ${5*nodeRadius},${0}`; 96 | 97 | const nodeLabelArcs = svg 98 | .append("g") 99 | .attr("class", "nodelabelarcs") 100 | .selectAll(".nodelabelarc") 101 | .data(graphDescriptor.nodes) 102 | .enter().append("path") 103 | .attr("id", (d, i) => `nodelabelarc${i}`) 104 | .attr("d", ellipticalArc) 105 | .attr("fill", "none"); 106 | 107 | const nodeLabels = svg 108 | .append("g") 109 | .attr("class", "nodelabel") 110 | .selectAll(".nodelabel") 111 | .data(graphDescriptor.nodes) 112 | .enter() 113 | .append("text") 114 | .attr("class", "nodelabel") 115 | .style("cursor", "pointer") 116 | .on("dblclick", navigateToSelectedTerm(onGraphNodeDblClick)) 117 | .append("textPath") 118 | .attr("xlink:href", (d, i) => `#nodelabelarc${i}`) 119 | .attr("startOffset", "50%") 120 | .text(d => d.name) 121 | .style("text-anchor", "middle") 122 | .style("alignment-baseline", "hanging") 123 | .style("fill", theme.palette.common.white); 124 | 125 | return {nodeLabels, nodeLabelArcs}; 126 | } 127 | 128 | 129 | /** 130 | * Graph implementation. 131 | */ 132 | 133 | export const resetGraph = (containerNodeId: string) => { 134 | d3.select(`#${containerNodeId} > *`).remove(); 135 | }; 136 | 137 | export const loadGraph = (containerNodeId: string, graphDescriptor: GraphResponse, onGraphNodeDblClick: (string) => void, theme: Theme) => { 138 | resetGraph(containerNodeId); 139 | 140 | const svg = createSvg(containerNodeId, theme); 141 | const arrowDef = createArrowDef(svg); 142 | const edges = createEdges(svg, graphDescriptor); 143 | const nodes = createNodes(svg, graphDescriptor, onGraphNodeDblClick, theme); 144 | const {nodeLabels, nodeLabelArcs} = createNodeLabels(svg, graphDescriptor, onGraphNodeDblClick, theme); 145 | 146 | const svgRect = getSvgBbox(svg); 147 | const nodeDistance = nodeSeparationFactor * Math.min(svgRect.width, svgRect.height) / 5; 148 | const keepCoordInCanvas = createKeepCoordInCanvas(svgRect); 149 | 150 | const ticked = () => { 151 | nodes 152 | .attr("cx", (d: any) => keepCoordInCanvas(d.x, "X", nodeRadius)) 153 | .attr("cy", (d: any) => keepCoordInCanvas(d.y, "Y", nodeRadius)); 154 | edges 155 | .attr("x1", (d: any) => keepCoordInCanvas(d.source.x, "X", nodeRadius)) 156 | .attr("y1", (d: any) => keepCoordInCanvas(d.source.y, "Y", nodeRadius)) 157 | .attr("x2", (d: any) => keepCoordInCanvas(d.target.x, "X", nodeRadius)) 158 | .attr("y2", (d: any) => keepCoordInCanvas(d.target.y, "Y", nodeRadius)); 159 | nodeLabelArcs 160 | .attr("transform", (d: any) => `translate( 161 | ${keepCoordInCanvas(d.x, "X", nodeRadius)}, 162 | ${keepCoordInCanvas(d.y, "Y", nodeRadius)})`); 163 | nodeLabels 164 | .attr("x", (d: any) => keepCoordInCanvas(d.x, "X", nodeRadius)) 165 | .attr("y", (d: any) => keepCoordInCanvas(d.y, "Y", nodeRadius)); 166 | } 167 | 168 | 169 | const simulation = d3.forceSimulation() 170 | .nodes(graphDescriptor.nodes) 171 | .force("link", d3.forceLink(graphDescriptor.edges) 172 | .id((d: any) => d.index) 173 | .distance(nodeDistance)) 174 | .force("charge", d3.forceManyBody() 175 | .strength(nodeChargeStrength) 176 | .theta(nodeChargeAccuracy) 177 | .distanceMax(2 * nodeDistance)) 178 | .force("center", d3.forceCenter(svgRect.width / 2, svgRect.height / 2)) 179 | .force("collide", d3.forceCollide(nodeRadius)) 180 | .on("tick", ticked) 181 | ; 182 | 183 | const dragBehaviour = createDragBehaviour(simulation); 184 | 185 | nodes 186 | .call(dragBehaviour); 187 | nodeLabels 188 | .call(dragBehaviour); 189 | }; 190 | -------------------------------------------------------------------------------- /src/common/components/hocr/hocr-preview/hocr-preview.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ZoomMode, HocrPageComponent } from "./hocr-page.component"; 3 | import { HocrPreviewStyleMap, injectDefaultPreviewStyle } from "./hocr-preview.style"; 4 | import { 5 | calculateNodeShiftInContainer, 6 | PageIndex, 7 | parseHocr, 8 | CreateWordComparator, 9 | WordComparator, 10 | parseWordPosition, 11 | getNodeById, 12 | PosSize, 13 | getPosSizeFromBBoxNode, 14 | composeId, 15 | getPosSizeFromDOMNode, 16 | } from "../util/common-util"; 17 | import { cnc } from "../../../../util"; 18 | 19 | const style = require("./hocr-preview.style.scss"); 20 | 21 | const idSuffix = "preview"; 22 | 23 | 24 | /** 25 | * HOCR-Preview 26 | * Given an HOCR input string, it parses it and represents the document in graphic format, 27 | * showing the source image the text was extracted from. It shows a placeholder for each 28 | * recognised word and provides the necessary wiring and events to be connected to a 29 | * HocrDocumentComponent to create a whole proofreader. 30 | */ 31 | 32 | export interface HocrPreviewProps { 33 | hocr: string; 34 | pageIndex: PageIndex; 35 | zoomMode: ZoomMode; 36 | targetWords?: string[]; 37 | caseSensitiveComparison?: boolean; 38 | renderOnlyTargetWords?: boolean; 39 | autoFocusId?: string; 40 | highlightId?: string; 41 | disabelScroll?: boolean; 42 | userStyle?: HocrPreviewStyleMap; 43 | onWordHover?: (wordId: string) => void; 44 | className?: string; 45 | }; 46 | 47 | interface HocrPreviewState { 48 | pageNode: Element; 49 | pagePosSize: PosSize; 50 | wordCompare: WordComparator; 51 | autoFocusNode: Element; 52 | autoFocusPosSize: PosSize; 53 | safeStyle?: HocrPreviewStyleMap; 54 | } 55 | 56 | export class HocrPreviewComponent extends React.Component { 57 | constructor(props) { 58 | super(props); 59 | 60 | this.state = { 61 | ...this.calculateStateFromProps(props), 62 | safeStyle: injectDefaultPreviewStyle(props.userStyle), 63 | } 64 | } 65 | 66 | private viewportRef = null; 67 | 68 | private saveViewportRef = (node) => { 69 | this.viewportRef = node; 70 | } 71 | 72 | private scrollTo = (targetPosSize: PosSize) => { 73 | if (!this.viewportRef || !targetPosSize || !this.state.pagePosSize) return; 74 | 75 | const shift = calculateNodeShiftInContainer(targetPosSize, this.state.pagePosSize); 76 | if (!shift) return; 77 | 78 | const {x, y} = shift; 79 | const scrollLeft = this.viewportRef.scrollWidth * x - (this.viewportRef.clientWidth / 2); 80 | const scrollTop = this.viewportRef.scrollHeight * y - (this.viewportRef.clientHeight / 2); 81 | 82 | // Workd around Edge Bug 83 | // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/15534521/ 84 | if(this.viewportRef.scrollTo) { 85 | this.viewportRef.scrollTo({left: scrollLeft, top: scrollTop}); 86 | } else { 87 | this.viewportRef.scrollTop = scrollTop; 88 | this.viewportRef.scrollLeft = scrollLeft; 89 | } 90 | 91 | } 92 | 93 | private resetHighlight = (node: Element) => { 94 | if (node) node.classList.remove(this.state.safeStyle["highlight"]); 95 | } 96 | 97 | private setHighlight = (node: Element) => { 98 | if (node) node.classList.add(this.state.safeStyle["highlight"]); 99 | } 100 | 101 | private autoFocusToNode = (nodeId: string) => { 102 | this.resetHighlight(this.state.autoFocusNode); 103 | if (nodeId) { 104 | const focusNode = getNodeById(this.viewportRef, composeId(nodeId, idSuffix)); 105 | this.setHighlight(focusNode); 106 | this.scrollTo(getPosSizeFromDOMNode(focusNode)); 107 | this.setState({ 108 | ...this.state, 109 | autoFocusNode: focusNode, 110 | }); 111 | } 112 | } 113 | 114 | private calculateStateFromProps = (newProps: HocrPreviewProps): HocrPreviewState => { 115 | if (newProps.hocr) { 116 | const doc = parseHocr(newProps.hocr); 117 | const wordCompare = CreateWordComparator(newProps.targetWords, newProps.caseSensitiveComparison); 118 | let pageIndex = newProps.pageIndex; 119 | let autoFocusNode = null; 120 | if (pageIndex === "auto") { 121 | const wordPosition = parseWordPosition(doc, newProps.pageIndex, wordCompare); 122 | pageIndex = wordPosition.pageIndex; 123 | autoFocusNode = wordPosition.firstOcurrenceNode; 124 | } 125 | 126 | if (pageIndex !== null) { 127 | const pageNode = doc.body.children[pageIndex]; 128 | const pagePosSize = getPosSizeFromBBoxNode(pageNode); 129 | const autoFocusPosSize = getPosSizeFromBBoxNode(autoFocusNode); 130 | return {pageNode, pagePosSize, wordCompare, autoFocusNode, autoFocusPosSize}; 131 | } 132 | } 133 | }; 134 | 135 | private onStateUpdated = () => { 136 | this.scrollTo(this.state.autoFocusPosSize); 137 | } 138 | 139 | // *** Lifecycle *** 140 | 141 | public componentDidMount() { 142 | this.onStateUpdated(); // Initial scroll on mount. 143 | } 144 | 145 | public componentWillReceiveProps(nextProps: HocrPreviewProps) { 146 | if( this.props.hocr !== nextProps.hocr || 147 | this.props.pageIndex !== nextProps.pageIndex || 148 | this.props.targetWords !== nextProps.targetWords || 149 | this.props.caseSensitiveComparison !== nextProps.caseSensitiveComparison 150 | ) { 151 | this.setState({ 152 | ...this.state, 153 | ...this.calculateStateFromProps(nextProps), 154 | }, this.onStateUpdated); 155 | } else if ( this.props.userStyle != nextProps.userStyle ) { 156 | this.setState({ 157 | ...this.state, 158 | safeStyle: injectDefaultPreviewStyle(nextProps.userStyle), 159 | }); 160 | } else if ( this.props.autoFocusId != nextProps.autoFocusId ) { 161 | this.autoFocusToNode(nextProps.autoFocusId); 162 | }; 163 | } 164 | 165 | public shouldComponentUpdate(nextProps: HocrPreviewProps, nextState: HocrPreviewState) { 166 | const shouldUpdate = ( 167 | this.props.hocr !== nextProps.hocr || 168 | this.props.pageIndex !== nextProps.pageIndex || 169 | this.props.zoomMode !== nextProps.zoomMode || 170 | this.props.targetWords !== nextProps.targetWords || 171 | this.props.caseSensitiveComparison !== nextProps.caseSensitiveComparison || 172 | this.props.renderOnlyTargetWords !== nextProps.renderOnlyTargetWords || 173 | this.props.disabelScroll !== nextProps.disabelScroll || 174 | this.props.userStyle !== nextProps.userStyle || 175 | this.props.onWordHover !== nextProps.onWordHover || 176 | this.props.className !== nextProps.className || 177 | this.state.pageNode !== nextState.pageNode || 178 | this.state.wordCompare !== nextState.wordCompare 179 | ); 180 | return shouldUpdate; 181 | } 182 | 183 | public render() { 184 | return ( 185 |
186 |
189 | 198 |
199 |
200 | ); 201 | } 202 | }; 203 | --------------------------------------------------------------------------------