From 10de58594717dfd6f14d102336ee07d7568187ef Mon Sep 17 00:00:00 2001 From: Martina Date: Mon, 24 Aug 2020 16:24:29 -0700 Subject: [PATCH] spotlight-search --- common/search.js | 45 ++ common/svg.js | 88 ++++ components/core/Application.js | 2 + components/core/ApplicationHeader.js | 11 +- components/system/components/GlobalModal.js | 21 +- components/system/components/InputMenu.js | 201 ++++++++ components/system/index.js | 4 + components/system/modules/SpotlightSearch.js | 456 +++++++++++++++++++ package.json | 1 + 9 files changed, 811 insertions(+), 18 deletions(-) create mode 100644 common/search.js create mode 100644 components/system/components/InputMenu.js create mode 100644 components/system/modules/SpotlightSearch.js diff --git a/common/search.js b/common/search.js new file mode 100644 index 00000000..03fb8aac --- /dev/null +++ b/common/search.js @@ -0,0 +1,45 @@ +createUserSearchResult = (user) => { + return { + id: user.id, + type: "user", + name: user.data.name, + username: user.username, + url: user.data.photo, + }; +}; + +createSlateSearchResult = (slate) => { + let files; + if (slate.data.objects.length > 3) { + files = slate.data.objects.slice(0, 3); + } else { + files = slate.data.objects; + } + return { + id: slate.id, + type: "slate", + name: slate.slatename, + username: slate.user.username, + url: files.map((file) => { + return { + type: file.type + ? file.type.includes("image") + ? "image" + : "file" + : "file", + name: file.name, + url: file.url, + }; + }), + }; +}; + +createFileSearchResult = (file) => { + return { + id: file.id, + type: file.type ? (file.type.includes("image") ? "image" : "file") : "file", + name: file.name, + username: file.user.username, + url: file.url, + }; +}; diff --git a/common/svg.js b/common/svg.js index 7caacfbb..f578ccc5 100644 --- a/common/svg.js +++ b/common/svg.js @@ -537,3 +537,91 @@ export const Logo = (props) => ( /> ); + +export const Slate2 = (props) => ( + + + + + +); + +export const Folder2 = (props) => ( + + + +); + +export const Tool2 = (props) => ( + + + +); + +export const Wallet2 = (props) => ( + + + + + + + + + + + + + +); diff --git a/components/core/Application.js b/components/core/Application.js index 3e361194..804396f0 100644 --- a/components/core/Application.js +++ b/components/core/Application.js @@ -484,6 +484,7 @@ export default class ApplicationPage extends React.Component { pageTitle={current.target.pageTitle} currentIndex={this.state.currentIndex} history={this.state.history} + onAction={this._handleAction} onBack={this._handleBack} onForward={this._handleForward} /> @@ -543,6 +544,7 @@ export default class ApplicationPage extends React.Component { {scene} + ); diff --git a/components/core/ApplicationHeader.js b/components/core/ApplicationHeader.js index c88976e8..f2c8c423 100644 --- a/components/core/ApplicationHeader.js +++ b/components/core/ApplicationHeader.js @@ -3,6 +3,8 @@ import * as Constants from "~/common/constants"; import * as SVG from "~/common/svg"; import { css } from "@emotion/react"; +import { SpotlightSearch } from "~/components/system/modules/SpotlightSearch"; +import { dispatchCustomEvent } from "~/common/custom-events"; const STYLES_ICON_ELEMENT = css` height: 40px; @@ -67,6 +69,13 @@ const STYLES_RIGHT = css` `; export default class ApplicationHeader extends React.Component { + _handleCreateSearch = (e) => { + dispatchCustomEvent({ + name: "create-modal", + detail: { modal: }, + }); + }; + render() { const isBackDisabled = this.props.currentIndex === 0 || this.props.history.length < 2; @@ -107,7 +116,7 @@ export default class ApplicationHeader extends React.Component { window.alert("TODO: SPOTLIGHT SEARCH")} + onClick={this._handleCreateSearch} > diff --git a/components/system/components/GlobalModal.js b/components/system/components/GlobalModal.js index 50ed594f..a585de07 100644 --- a/components/system/components/GlobalModal.js +++ b/components/system/components/GlobalModal.js @@ -17,27 +17,19 @@ const STYLES_BACKGROUND = css` display: flex; align-items: center; justify-content: center; - background-color: rgba(45, 41, 38, 0.6); + background-color: rgba(0, 0, 0, 0.2); z-index: ${Constants.zindex.modal}; `; const STYLES_MODAL = css` + box-sizing: border-box; position: relative; - padding: 8px; - max-width: 568px; + max-width: 680px; width: 100%; - border-radius: 4px; + border-radius: 16px; background-color: ${Constants.system.white}; `; -const STYLES_CLOSE_ICON = css` - height: 24px; - position: absolute; - top: 8px; - right: 8px; - cursor: pointer; -`; - export class GlobalModal extends React.Component { state = { modal: null, @@ -92,11 +84,6 @@ export class GlobalModal extends React.Component { isDataMenuCaptured={true} >
- {this.state.modal}
diff --git a/components/system/components/InputMenu.js b/components/system/components/InputMenu.js new file mode 100644 index 00000000..aa9c5940 --- /dev/null +++ b/components/system/components/InputMenu.js @@ -0,0 +1,201 @@ +import * as React from "react"; +import * as Constants from "~/common/constants"; +import * as SVG from "~/common/svg"; + +import { css } from "@emotion/react"; + +const STYLES_DROPDOWN_CONTAINER = css` + box-sizing: border-box; + z-index: ${Constants.zindex.modal}; +`; + +const STYLES_DROPDOWN = css` + box-sizing: border-box; + position: absolute; + display: flex; + flex-direction: column; + background-color: ${Constants.system.white}; + overflow: hidden; + width: 100%; +`; + +const STYLES_DROPDOWN_ITEM = css` + box-sizing: border-box; + padding: 8px; + font-size: 0.8em; + border-radius: 16px; + border: 1px solid ${Constants.system.white}; + :hover { + border-color: ${Constants.system.border} !important; + } +`; + +const STYLES_INPUT = css` + font-family: ${Constants.font.text}; + -webkit-appearance: none; + width: 100%; + height: 40px; + background: ${Constants.system.foreground}; + color: ${Constants.system.black}; + display: flex; + font-size: 14px; + align-items: center; + justify-content: flex-start; + outline: 0; + border: 0; + box-sizing: border-box; + transition: 200ms ease all; + padding: 0 24px 0 48px; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 12px; + margin-bottom: 16px; + ::placeholder { + /* Chrome, Firefox, Opera, Safari 10.1+ */ + color: ${Constants.system.black}; + opacity: 1; /* Firefox */ + } + :-ms-input-placeholder { + /* Internet Explorer 10-11 */ + color: ${Constants.system.black}; + } + ::-ms-input-placeholder { + /* Microsoft Edge */ + color: ${Constants.system.black}; + } +`; + +export class InputMenu extends React.Component { + _input; + _optionRoot; + + state = { + selectedIndex: -1, + }; + + componentDidMount = () => { + window.addEventListener("keydown", this._handleDocumentKeydown); + this._input.focus(); + }; + + componentWillUnmount = () => { + window.removeEventListener("keydown", this._handleDocumentKeydown); + }; + + _handleInputChange = (e) => { + if (this.state.selectedIndex !== -1) { + this.setState({ selectedIndex: -1 }); + } + this.props.onChange({ + target: { + value: null, + name: this.props.name, + }, + }); + this.props.onInputChange(e); + }; + + _handleSelect = (index) => { + let e = { + target: { + value: this.props.options[index].value, + name: this.props.name, + }, + }; + this.props.onChange(e); + }; + + _handleDocumentKeydown = (e) => { + if (e.keyCode === 27) { + this._handleDelete(); + e.preventDefault(); + } else if (e.keyCode === 9) { + this._handleDelete(); + } else if (e.keyCode === 40) { + if (this.state.selectedIndex < this.props.options.length - 1) { + let listElem = this._optionRoot.children[this.state.selectedIndex + 1]; + let elemRect = listElem.getBoundingClientRect(); + let rootRect = this._optionRoot.getBoundingClientRect(); + if (elemRect.bottom > rootRect.bottom) { + this._optionRoot.scrollTop = + listElem.offsetTop + + listElem.offsetHeight - + this._optionRoot.offsetHeight; + } + this.setState({ selectedIndex: this.state.selectedIndex + 1 }); + } + e.preventDefault(); + } else if (e.keyCode === 38) { + if (this.state.selectedIndex > 0) { + let listElem = this._optionRoot.children[this.state.selectedIndex - 1]; + let elemRect = listElem.getBoundingClientRect(); + let rootRect = this._optionRoot.getBoundingClientRect(); + if (elemRect.top < rootRect.top) { + this._optionRoot.scrollTop = listElem.offsetTop; + } + this.setState({ selectedIndex: this.state.selectedIndex - 1 }); + } + e.preventDefault(); + } else if (e.keyCode === 13) { + if ( + this.props.options.length > this.state.selectedIndex && + this.state.selectedIndex !== -1 + ) { + this._handleSelect(this.state.selectedIndex); + } + e.preventDefault(); + } + }; + + render() { + return ( +
+
+ { + this._input = c; + }} + /> + +
+ { +
{ + this._optionRoot = c; + }} + css={STYLES_DROPDOWN} + style={this.props.style} + > + {(this.props.options && this.props.options.length + ? this.props.options + : this.props.defaultOptions + ).map((each, i) => ( +
+ {each.name} +
+ ))} +
+ } +
+ ); + } +} diff --git a/components/system/index.js b/components/system/index.js index a53b9bad..ef1ec289 100644 --- a/components/system/index.js +++ b/components/system/index.js @@ -16,6 +16,7 @@ import { FilecoinRetrievalDealsList, } from "~/components/system/modules/FilecoinDealsList"; import { FilecoinSettings } from "~/components/system/modules/FilecoinSettings"; +import { SpotlightSearch } from "~/components/system/modules/SpotlightSearch"; // NOTE(jim): Global components import { GlobalModal } from "~/components/system/components/GlobalModal"; @@ -36,6 +37,7 @@ import { CheckBox } from "~/components/system/components/CheckBox"; import { CodeTextarea } from "~/components/system/components/CodeTextarea"; import { DatePicker } from "~/components/system/components/DatePicker"; import { Input } from "~/components/system/components/Input"; +import { InputMenu } from "~/components/system/components/InputMenu"; import { ListEditor } from "~/components/system/components/ListEditor"; import { HoverTile } from "~/components/system/components/HoverTile"; import { PopoverNavigation } from "~/components/system/components/PopoverNavigation"; @@ -119,6 +121,7 @@ export { GlobalCarousel, GlobalNotification, Input, + InputMenu, HoverTile, ListEditor, PopoverNavigation, @@ -126,6 +129,7 @@ export { SelectCountryMenu, SelectMenu, Slider, + SpotlightSearch, StatUpload, StatDownload, TabGroup, diff --git a/components/system/modules/SpotlightSearch.js b/components/system/modules/SpotlightSearch.js new file mode 100644 index 00000000..4bb962d6 --- /dev/null +++ b/components/system/modules/SpotlightSearch.js @@ -0,0 +1,456 @@ +import * as React from "react"; +import * as Constants from "~/common/constants"; +import * as SVG from "~/common/svg"; +import * as Strings from "~/common/strings"; + +import MiniSearch from "minisearch"; +import Slate from "~/components/core/Slate"; + +import { css } from "@emotion/react"; +import { InputMenu } from "~/components/system/components/InputMenu"; +import { dispatchCustomEvent } from "~/common/custom-events"; + +const fileImg = + "https://hub.textile.io/ipfs/bafkreihoi5c3tt4h3qx3gorbi7rrtekgactkpc2tfewwkahxqrxj2elvse"; + +let items = [ + { + id: "0cc3732d-d572-4ddd-900e-483dd1f4cbfb", + type: "user", + name: "Haris Butt", + username: "haris", + url: + "https://hub.textile.io/ipfs/bafybeiguo2uhd63reslbqkkgsqedgeikhtuwn5lzqpnqzluoaa3rnkfcvi", + }, + { + id: "c32b95ed-9472-4b01-acc2-0fb8303dc140", + type: "slate", + name: "Doggos", + username: "martinalong", + url: [ + { + type: "image", + name: "tuna.png", + url: + "https://hub.textile.io/ipfs/bafybeicuz5wrxonu7ud6eskrnshxb66ksg3ncu3ie776xuiydlxrkfuvmu", + }, + { + type: "image", + name: "khaleesi.jpg", + url: + "https://hub.textile.io/ipfs/bafkreicb2lookm56omsfjwuwuziwftizmdsj4oneveuqiqlu6k5hc7j5nq", + }, + { + type: "file", + name: + "Seneca - On the Shortness of Life and other things relating to philosophy and culture of the greeks", + url: + "https://hub.textile.io/ipfs/bafkreic3w24qwy6nxvwzidwvdvmyfeyha5w2uyk6rycli5utdquvafgosq", + }, + ], + }, + { + id: "data-75384245-0a6e-4e53-938e-781895556265", + type: "image", + name: "butter.jpg", + username: "jim", + url: + "https://hub.textile.io/ipfs/bafybeidcn5ucp3mt5bl7vllkeo7uai24ja4ra5i7wctl22ffq2rev7z7au", + }, + { + id: "data-bc1bd1c8-5db4-448d-ab35-f4d4866b9fa2", + type: "file", + name: "seneca-on-the-shortness-of-life.pdf", + username: "colin", + url: + "https://hub.textile.io/ipfs/bafkreic3w24qwy6nxvwzidwvdvmyfeyha5w2uyk6rycli5utdquvafgosq", + }, + { + id: "0ba6d7ab-7b1c-4420-bb42-4e66b82df099", + type: "slate", + name: "Meta", + username: "haris", + url: [ + { + type: "image", + name: "landscape1", + url: + "https://hub.textile.io/ipfs/bafybeihxn5non5wtt63e2vhk7am4xpmdh3fnmya2vx4jfk52t2jdqudztq", + }, + { + type: "image", + name: "landscape2", + url: + "https://hub.textile.io/ipfs/bafybeiddiv44vobree4in7n6gawqzlelpyqwoji6appb6dzpgxzrdonepq", + }, + { + type: "image", + name: "landscape3", + url: + "https://hub.textile.io/ipfs/bafkreih2mw66pmi4mvcxb32rhiyas7tohafaiez54lxvy652pdcfmgxrba", + }, + { + type: "image", + name: "landscape4", + url: + "https://hub.textile.io/ipfs/bafybeihxn5non5wtt63e2vhk7am4xpmdh3fnmya2vx4jfk52t2jdqudztq", + }, + ], + }, +]; + +const STYLES_ICON_CIRCLE = css` + height: 24px; + width: 24px; + border-radius: 50%; + background-color: ${Constants.system.foreground}; + display: flex; + align-items: center; + justify-content: center; +`; + +const STYLES_MODAL = css` + width: 95vw; + max-width: 600px; + height: 60vh; + padding: 24px; +`; + +const STYLES_INPUT_MENU = { + height: "calc(100% - 80px)", + width: "calc(100% - 48px)", + overflowY: "scroll", +}; + +const STYLES_USER_ENTRY_CONTAINER = css` + display: grid; + grid-template-columns: repeat(3, auto) 1fr; + grid-column-gap: 16px; + align-items: center; +`; + +const STYLES_PROFILE_IMAGE = css` + background-size: cover; + background-position: 50% 50%; + height: 24px; + width: 24px; + border-radius: 50%; +`; + +const UserEntry = ({ item }) => { + //TODO: change from link to onAction once profiles are supported in-client + return ( + +
+ + + ); +}; + +const STYLES_ENTRY = css` + padding: 8px 0px; +`; + +const STYLES_SLATE_ENTRY_CONTAINER = css` + display: grid; + grid-template-columns: repeat(3, auto) 1fr; + grid-column-gap: 16px; + align-items: center; +`; + +const STYLES_SLATE_IMAGES_CONTAINER = css` + display: grid; + grid-template-columns: repeat(3, auto) 1fr; + grid-column-gap: 16px; + margin: 8px 0px; + margin-left: 40px; +`; + +const STYLES_SLATE_IMAGE = css` + background-size: cover; + background-position: 50% 50%; + height: 72px; + width: 72px; +`; + +const STYLES_LINK = css` + color: ${Constants.system.black}; + text-decoration: none; +`; + +const STYLES_LINK_HOVER = css` + color: ${Constants.system.black}; + text-decoration: none; + :hover { + color: ${Constants.system.brand}; + } +`; + +const STYLES_FILE_ALTERNATE = css` + display: flex; + justify-content: center; + background-color: ${Constants.system.foreground}; + height: 72px; + width: 72px; + padding: 4px; + text-overflow: ellipsis; + word-break: break-word; + font-size: 0.7em; + overflow: hidden; + line-height: 17px; +`; + +const SlateEntry = ({ item, onAction }) => { + return ( +
{ + onAction({ type: "NAVIGATE", value: 17, data: item }); + }} + > +
+
+
+ +
+ {item.name} + +
+
+ {item.url.map((each) => + each.type === "image" ? ( +
+ ) : ( +
{each.name}
+ ) + )} +
+
+
+ ); +}; + +const FileEntry = ({ item, onAction }) => { + return ( +
{ + onAction({ type: "NAVIGATE", value: 15, data: { url: item.url } }); + }} + > +
+
+
+ +
+ {item.name} + + @{item.username} + +
+
+
+
+ ); +}; + +const STYLES_DROPDOWN_ITEM = css` + display: grid; + grid-template-columns: 56px 1fr; + align-items: center; + cursor: pointer; +`; + +const options = [ + { + name: "Send money", + link: null, + icon: , + action: { type: "NAVIGATE", value: 2 }, + }, + { + name: "New slate", + link: null, + icon: , + action: { type: "NAVIGATE", value: 3 }, + }, + { + name: "Upload file", + link: null, + icon: , + action: { type: "NAVIGATE", value: "data" }, + }, + { + name: "Account settings", + link: null, + icon: , + action: { type: "NAVIGATE", value: 13 }, + }, + { + name: "Filecoin settings", + link: null, + icon: , + action: { type: "NAVIGATE", value: 14 }, + }, +]; + +export class SpotlightSearch extends React.Component { + state = { + options: [], + value: null, + inputValue: "", + }; + + componentDidMount = async () => { + //let documents = await getDocuments(); + this.miniSearch = new MiniSearch({ + fields: ["name", "username"], // fields to index for full-text search + storeFields: ["type", "name", "username", "url"], // fields to return with search results + searchOptions: { + boost: { name: 2 }, + fuzzy: 0.2, + }, + }); + //this.miniSearch.addAll(documents); + this.miniSearch.addAll(items); + }; + + _handleChange = (e) => { + if (e.target.value !== null) { + if (e.target.value.substring(0, 1) === "/") { + window.location.pathname = e.target.value; + } else { + window.location.href = e.target.value; + } + } + }; + + _handleInputChange = (e) => { + this.setState({ inputValue: e.target.value }, () => { + let results = this.miniSearch.search(this.state.inputValue); + let options = []; + for (let item of results) { + if (item.type === "user") { + options.push({ + value: `/${item.username}`, + name: , + }); + } else if (item.type === "slate") { + let slug = item.name.toLowerCase().split(" ").join("-"); + options.push({ + value: `/${item.username}/${slug}`, + name: , + }); + } else if (item.type === "image" || item.type == "file") { + options.push({ + value: `${item.url}`, + name: , + }); + } + } + this.setState({ options }); + }); + }; + + _handleAction = (action) => { + this.props.onAction(action); + dispatchCustomEvent({ + name: "delete-modal", + detail: {}, + }); + }; + + render() { + return ( +
+ { + return { + name: ( +
this._handleAction(option.action)} + > +
+ {option.icon} +
+
{option.name}
+
+ ), + value: option.name, + }; + })} + /> +
+ ); + } +} + +const STYLES_ANCHOR_ICON = css` + height: 16px; + color: ${Constants.system.black}; +`; + +const STYLES_ANCHOR_BOX = css` + height: 20px; + width: 20px; + cursor: pointer; +`; + +export class SpotlightSearchAnchor extends React.Component { + _handleCreate = (e) => { + dispatchCustomEvent({ + name: "create-modal", + detail: { modal: }, + }); + }; + + render() { + return ( +
+ +
+ ); + } +} diff --git a/package.json b/package.json index 0587d74a..1467cc14 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "isomorphic-fetch": "^2.2.1", "jsonwebtoken": "^8.5.1", "knex": "^0.20.10", + "minisearch": "^2.5.1", "moment": "^2.27.0", "next": "^9.4.4", "pg": "^8.3.0",