From fea57147ca199033251c8cb696a6afda0334d089 Mon Sep 17 00:00:00 2001 From: Carl Gieringer <78054+carlgieringer@users.noreply.github.com> Date: Mon, 28 Aug 2023 17:39:54 -0700 Subject: [PATCH] Improve extension to search MediaExcerpts for current page and annotate UrlLocators (#541) Signed-off-by: Carl Gieringer <78054+carlgieringer@users.noreply.github.com> --- howdju-client-common/lib/actions.ts | 8 ++ howdju-client-common/lib/extensionMessages.ts | 5 +- howdju-client-common/lib/target.ts | 7 +- howdju-common/lib/commonPaths.js | 6 +- howdju-common/lib/serialization.ts | 15 ++- howdju-text-fragments/dist/rangeToFragment.js | 69 ++++++----- premiser-ext/src/annotate.ts | 9 +- premiser-ext/src/content.ts | 28 ++++- premiser-ext/src/sidebar.tsx | 9 +- premiser-ui/src/apiActions.ts | 28 ++++- .../mediaExcerpts/MediaExcerptViewer.tsx | 20 ++- premiser-ui/src/extensionCallbacks.ts | 31 ++++- .../MediaExcerptsSearchPage.tsx | 117 ++++++++++++++++++ .../mediaExcerptsSearchPageSlice.ts | 40 ++++++ premiser-ui/src/reducers/entities.js | 1 + premiser-ui/src/reducers/index.ts | 2 + premiser-ui/src/routes.tsx | 8 ++ premiser-ui/src/sagas/extensionSagas.js | 19 ++- premiser-ui/src/setupStore.ts | 5 + premiser-ui/src/types.ts | 14 ++- 20 files changed, 389 insertions(+), 52 deletions(-) create mode 100644 premiser-ui/src/pages/mediaExcerptsSearch/MediaExcerptsSearchPage.tsx create mode 100644 premiser-ui/src/pages/mediaExcerptsSearch/mediaExcerptsSearchPageSlice.ts diff --git a/howdju-client-common/lib/actions.ts b/howdju-client-common/lib/actions.ts index 2bbe03d5..f90e4a68 100644 --- a/howdju-client-common/lib/actions.ts +++ b/howdju-client-common/lib/actions.ts @@ -7,6 +7,8 @@ import { PersistedJustificationWithRootRef, UrlOut, JustificationView, + UrlLocator, + MediaExcerptView, } from "howdju-common"; /** @@ -35,6 +37,12 @@ export const extension = { }, }) ), + highlightUrlLocator: createAction( + "EXTENSION/HIGHLIGHT_URL_LOCATOR", + (mediaExcerpt: MediaExcerptView, urlLocator: UrlLocator) => ({ + payload: { mediaExcerpt, urlLocator }, + }) + ), messageHandlerReady: createAction("EXTENSION/MESSAGE_HANDLER_READY"), } as const; diff --git a/howdju-client-common/lib/extensionMessages.ts b/howdju-client-common/lib/extensionMessages.ts index f3d9cedd..2514631d 100644 --- a/howdju-client-common/lib/extensionMessages.ts +++ b/howdju-client-common/lib/extensionMessages.ts @@ -1,4 +1,4 @@ -import { UrlTarget } from "howdju-common"; +import { DomAnchor, UrlTarget } from "howdju-common"; import { RequireExactlyOne } from "type-fest"; import { actions } from "."; import { ExtensionFrameActionName } from "./actions"; @@ -30,6 +30,9 @@ export type ContentScriptCommand = } | { annotateTarget: [UrlTarget]; + } + | { + annotateUrlLocatorAnchors: DomAnchor[]; }; export const toggleSidebar = () => ({ type: "TOGGLE_SIDEBAR" } as const); diff --git a/howdju-client-common/lib/target.ts b/howdju-client-common/lib/target.ts index 4bfd4a3a..014812fe 100644 --- a/howdju-client-common/lib/target.ts +++ b/howdju-client-common/lib/target.ts @@ -9,6 +9,7 @@ import { UrlTarget, makeDomAnchor, nodeIsBefore, + DomAnchor, } from "howdju-common"; import { getPreviousLeafNode } from "./dom"; @@ -48,8 +49,12 @@ function rangeToAnchor(range: Range): CreateDomAnchor { } export function targetToRanges(target: UrlTarget) { + return anchorsToRanges(target.anchors as [DomAnchor]); +} + +export function anchorsToRanges(anchors: DomAnchor[]) { const ranges = []; - for (const anchor of target.anchors) { + for (const anchor of anchors) { let options = {}; if (anchor.startOffset) { // The average of the start and end seems like a good idea diff --git a/howdju-common/lib/commonPaths.js b/howdju-common/lib/commonPaths.js index 55d6448f..4f8bf28a 100644 --- a/howdju-common/lib/commonPaths.js +++ b/howdju-common/lib/commonPaths.js @@ -1,4 +1,5 @@ -// TODO(196): ensure that these paths follow a pattern compatible with the web app paths. +// TODO(#196): ensure that these paths follow a pattern compatible with the web app paths. Maybe +// move all paths here so that the extension and mobile app can access them. module.exports.CommonPaths = class CommonPaths { confirmRegistration() { return "/complete-registration"; @@ -12,6 +13,9 @@ module.exports.CommonPaths = class CommonPaths { requestPasswordReset() { return "/request-password-reset"; } + searchMediaExcerpts() { + return "/search-media-excerpts"; + } }; module.exports.commonPaths = new module.exports.CommonPaths(); diff --git a/howdju-common/lib/serialization.ts b/howdju-common/lib/serialization.ts index c97fb1c7..84f18c3a 100644 --- a/howdju-common/lib/serialization.ts +++ b/howdju-common/lib/serialization.ts @@ -1,7 +1,9 @@ -import cloneDeep from "lodash/cloneDeep"; +import { cloneDeep } from "lodash"; +import { isMoment } from "moment"; + import { JustificationOut, JustificationWithRootOut } from "./apiModels"; +import { mapValuesDeep } from "./general"; import { JustificationView } from "./viewModels"; - import { Entity, Proposition, SourceExcerpt } from "./zodSchemas"; // Recursively replace all Entity subtypes with Entity so that they can be @@ -12,6 +14,15 @@ export type Decircularized = { : Decircularized; }; +export function domSerializationSafe(obj: any) { + return mapValuesDeep(obj, (value) => { + if (isMoment(value)) { + return value.toISOString(); + } + return value; + }); +} + export function decircularizeJustification( justification: JustificationOut | JustificationWithRootOut | JustificationView ): Decircularized { diff --git a/howdju-text-fragments/dist/rangeToFragment.js b/howdju-text-fragments/dist/rangeToFragment.js index 0799ce78..50033f14 100644 --- a/howdju-text-fragments/dist/rangeToFragment.js +++ b/howdju-text-fragments/dist/rangeToFragment.js @@ -8906,7 +8906,7 @@ updateInProgress = false; } } - function isMoment(obj) { + function isMoment2(obj) { return obj instanceof Moment3 || obj != null && obj._isAMomentObject != null; } function warn(msg) { @@ -10747,7 +10747,7 @@ if (typeof input === "string") { config._i = input = config._locale.preparse(input); } - if (isMoment(input)) { + if (isMoment2(input)) { return new Moment3(checkOverflow(input)); } else if (isDate(input)) { config._d = input; @@ -10956,7 +10956,7 @@ var res, diff2; if (model._isUTC) { res = model.clone(); - diff2 = (isMoment(input) || isDate(input) ? input.valueOf() : createLocal(input).valueOf()) - res.valueOf(); + diff2 = (isMoment2(input) || isDate(input) ? input.valueOf() : createLocal(input).valueOf()) - res.valueOf(); res._d.setTime(res._d.valueOf() + diff2); hooks.updateOffset(res, false); return res; @@ -11210,7 +11210,7 @@ return typeof input === "string" || input instanceof String; } function isMomentInput(input) { - return isMoment(input) || isDate(input) || isString3(input) || isNumber2(input) || isNumberOrStringArray(input) || isMomentInputObject(input) || input === null || input === void 0; + return isMoment2(input) || isDate(input) || isString3(input) || isNumber2(input) || isNumberOrStringArray(input) || isMomentInputObject(input) || input === null || input === void 0; } function isMomentInputObject(input) { var objectTest = isObject3(input) && !isObjectEmpty(input), propertyTest = false, properties = [ @@ -11295,7 +11295,7 @@ return new Moment3(this); } function isAfter(input, units) { - var localInput = isMoment(input) ? input : createLocal(input); + var localInput = isMoment2(input) ? input : createLocal(input); if (!(this.isValid() && localInput.isValid())) { return false; } @@ -11307,7 +11307,7 @@ } } function isBefore(input, units) { - var localInput = isMoment(input) ? input : createLocal(input); + var localInput = isMoment2(input) ? input : createLocal(input); if (!(this.isValid() && localInput.isValid())) { return false; } @@ -11319,7 +11319,7 @@ } } function isBetween(from2, to2, units, inclusivity) { - var localFrom = isMoment(from2) ? from2 : createLocal(from2), localTo = isMoment(to2) ? to2 : createLocal(to2); + var localFrom = isMoment2(from2) ? from2 : createLocal(from2), localTo = isMoment2(to2) ? to2 : createLocal(to2); if (!(this.isValid() && localFrom.isValid() && localTo.isValid())) { return false; } @@ -11327,7 +11327,7 @@ return (inclusivity[0] === "(" ? this.isAfter(localFrom, units) : !this.isBefore(localFrom, units)) && (inclusivity[1] === ")" ? this.isBefore(localTo, units) : !this.isAfter(localTo, units)); } function isSame(input, units) { - var localInput = isMoment(input) ? input : createLocal(input), inputMs; + var localInput = isMoment2(input) ? input : createLocal(input), inputMs; if (!(this.isValid() && localInput.isValid())) { return false; } @@ -11451,7 +11451,7 @@ return this.localeData().postformat(output); } function from(time, withoutSuffix) { - if (this.isValid() && (isMoment(time) && time.isValid() || createLocal(time).isValid())) { + if (this.isValid() && (isMoment2(time) && time.isValid() || createLocal(time).isValid())) { return createDuration({ to: this, from: time }).locale(this.locale()).humanize(!withoutSuffix); } else { return this.localeData().invalidDate(); @@ -11461,7 +11461,7 @@ return this.from(createLocal(), withoutSuffix); } function to(time, withoutSuffix) { - if (this.isValid() && (isMoment(time) && time.isValid() || createLocal(time).isValid())) { + if (this.isValid() && (isMoment2(time) && time.isValid() || createLocal(time).isValid())) { return createDuration({ from: this, to: time }).locale(this.locale()).humanize(!withoutSuffix); } else { return this.localeData().invalidDate(); @@ -12604,7 +12604,7 @@ hooks.locale = getSetGlobalLocale; hooks.invalid = createInvalid; hooks.duration = createDuration; - hooks.isMoment = isMoment; + hooks.isMoment = isMoment2; hooks.weekdays = listWeekdays; hooks.parseZone = createInZone; hooks.localeData = getLocale; @@ -12980,6 +12980,9 @@ requestPasswordReset() { return "/request-password-reset"; } + searchMediaExcerpts() { + return "/search-media-excerpts"; + } }; module.exports.commonPaths = new module.exports.CommonPaths(); } @@ -30882,19 +30885,6 @@ } }); - // ../node_modules/lodash/cloneDeep.js - var require_cloneDeep = __commonJS({ - "../node_modules/lodash/cloneDeep.js"(exports, module) { - var baseClone = require_baseClone(); - var CLONE_DEEP_FLAG = 1; - var CLONE_SYMBOLS_FLAG = 4; - function cloneDeep5(value) { - return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG); - } - module.exports = cloneDeep5; - } - }); - // ../node_modules/lodash/_assignMergeValue.js var require_assignMergeValue = __commonJS({ "../node_modules/lodash/_assignMergeValue.js"(exports, module) { @@ -33044,6 +33034,7 @@ demuxJustificationBasisSourceExcerptInput: () => demuxJustificationBasisSourceExcerptInput, differenceDuration: () => differenceDuration, doTargetSameRoot: () => doTargetSameRoot, + domSerializationSafe: () => domSerializationSafe, emptyValidationResult: () => emptyValidationResult, encodeQueryStringObject: () => encodeQueryStringObject, encodeSorts: () => encodeSorts, @@ -34928,9 +34919,19 @@ __reExport(lib_exports, __toESM(require_standaloneValidation())); // ../howdju-common/lib/serialization.ts - var import_cloneDeep = __toESM(require_cloneDeep()); + var import_lodash11 = __toESM(require_lodash()); + var import_moment5 = __toESM(require_moment()); + init_general(); + function domSerializationSafe(obj) { + return mapValuesDeep(obj, (value) => { + if ((0, import_moment5.isMoment)(value)) { + return value.toISOString(); + } + return value; + }); + } function decircularizeJustification(justification) { - const decircularized = (0, import_cloneDeep.default)(justification); + const decircularized = (0, import_lodash11.cloneDeep)(justification); if (decircularized.rootTarget.id) { decircularized.rootTarget = { id: decircularized.rootTarget.id }; } @@ -35113,7 +35114,7 @@ } // ../howdju-common/lib/validation.ts - var import_lodash11 = __toESM(require_lodash()); + var import_lodash12 = __toESM(require_lodash()); init_logger(); var EmptyBespokeValidationErrors = { hasErrors: false, @@ -35121,16 +35122,16 @@ fieldErrors: {} }; function newBespokeValidationErrors() { - return (0, import_lodash11.cloneDeep)( + return (0, import_lodash12.cloneDeep)( EmptyBespokeValidationErrors ); } var onlyFieldError = (fieldError, code) => { - const errors = (0, import_lodash11.filter)(fieldError, (fe) => (0, import_lodash11.isObject)(fe) && fe.code === code); + const errors = (0, import_lodash12.filter)(fieldError, (fe) => (0, import_lodash12.isObject)(fe) && fe.code === code); if (errors.length > 1) { logger.error(`Multiple field errors have the code ${code}.`); } - return (0, import_lodash11.head)(errors); + return (0, import_lodash12.head)(errors); }; // ../howdju-common/lib/index.ts @@ -35152,6 +35153,12 @@ } }) ), + highlightUrlLocator: createAction( + "EXTENSION/HIGHLIGHT_URL_LOCATOR", + (mediaExcerpt, urlLocator) => ({ + payload: { mediaExcerpt, urlLocator } + }) + ), messageHandlerReady: createAction("EXTENSION/MESSAGE_HANDLER_READY") }; var extensionFrame = { @@ -35361,7 +35368,7 @@ // ../howdju-client-common/lib/models.ts var import_merge = __toESM(require_merge2()); - var import_lodash12 = __toESM(require_lodash()); + var import_lodash13 = __toESM(require_lodash()); // ../howdju-client-common/lib/target.ts var textPosition2 = __toESM(require_dom_anchor_text_position2()); diff --git a/premiser-ext/src/annotate.ts b/premiser-ext/src/annotate.ts index 3d9161b8..3ccf2545 100644 --- a/premiser-ext/src/annotate.ts +++ b/premiser-ext/src/annotate.ts @@ -1,7 +1,7 @@ import concat from "lodash/concat"; import { isUndefined } from "lodash"; -import { logger, UrlTarget, nodeIsBefore } from "howdju-common"; +import { logger, UrlTarget, nodeIsBefore, DomAnchor } from "howdju-common"; import { getSelection, clearSelection, @@ -13,6 +13,7 @@ import { isCoextensive, insertNodeAfter, insertNodeBefore, + anchorsToRanges, } from "howdju-client-common"; import { getNodeData } from "./node-data"; @@ -58,6 +59,12 @@ export function annotateTarget(target: UrlTarget) { return getOrCreateAnnotation(nodes); } +export function annotateAnchors(anchors: DomAnchor[]) { + const ranges = anchorsToRanges(anchors); + const nodes = rangesToNodes(ranges); + return getOrCreateAnnotation(nodes); +} + /** Returns an existing annotation if it's equivalent; only uses target if it returns a new annotation. */ function getOrCreateAnnotation(nodes: Node[]) { const equivalentAnnotation = getEquivalentAnnotation(nodes); diff --git a/premiser-ext/src/content.ts b/premiser-ext/src/content.ts index 7a2e302e..fa16d577 100644 --- a/premiser-ext/src/content.ts +++ b/premiser-ext/src/content.ts @@ -12,6 +12,7 @@ import { Exact } from "type-fest"; import { logger, PersistedJustificationWithRootRef, + UrlLocator, UrlOut, } from "howdju-common"; import { @@ -27,7 +28,7 @@ import { WindowMessageSource, } from "howdju-client-common"; -import { annotateSelection, annotateTarget } from "./annotate"; +import { annotateAnchors, annotateSelection, annotateTarget } from "./annotate"; import { getFrameApi, showSidebar, toggleSidebar } from "./sidebar"; import { getOption } from "./options"; import { FramePanelApi } from "./framePanel"; @@ -90,6 +91,11 @@ function routeWindowMessage(action: actions.ExtensionAction) { payload as PayloadOf ); break; + case `${actions.extension.highlightUrlLocator}`: + highlightUrlLocator( + payload as PayloadOf + ); + break; case `${actions.extension.messageHandlerReady}`: setMessageHandlerReady(true); break; @@ -104,6 +110,13 @@ function routeWindowMessage(action: actions.ExtensionAction) { } } +function highlightUrlLocator({ urlLocator }: { urlLocator: UrlLocator }) { + const { anchors } = urlLocator; + if (anchors?.length) { + runCommands([{ annotateUrlLocatorAnchors: anchors }]); + } +} + function highlightTarget({ justification, url, @@ -179,6 +192,19 @@ function runCommand>(command: T) { }); return; } + if ("annotateUrlLocatorAnchors" in command) { + // TODO(38) remove any typecast + const annotation = annotateAnchors( + command.annotateUrlLocatorAnchors as any + ); + annotation.nodes[0].scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + return; + } + logger.error(`Unrecognized command ${command}`); } diff --git a/premiser-ext/src/sidebar.tsx b/premiser-ext/src/sidebar.tsx index 5560fc6c..cda7c8a0 100644 --- a/premiser-ext/src/sidebar.tsx +++ b/premiser-ext/src/sidebar.tsx @@ -4,6 +4,7 @@ import ReactDOM from "react-dom"; import { getOption } from "./options"; import { FramePanel, FramePanelApi } from "./framePanel"; import { getCanonicalOrCurrentUrl } from "howdju-client-common"; +import { commonPaths } from "howdju-common"; let frameApi: FramePanelApi; @@ -33,9 +34,11 @@ function boot() { getOption("howdjuBaseUrl", (baseUrl) => { // TODO move paths to howdju-client-common: paths.searchJustificaitons({url, includeUrls: true}) - const url = encodeURIComponent(getCanonicalOrCurrentUrl()); - const frameUrl = - baseUrl + `/search-justifications?url=${url}&includeUrls=true`; + const url = getCanonicalOrCurrentUrl(); + const frameUrlObj = new URL(baseUrl); + frameUrlObj.pathname = commonPaths.searchMediaExcerpts(); + frameUrlObj.searchParams.set("url", url); + const frameUrl = frameUrlObj.toString(); const App = ; ReactDOM.render(App, root); }); diff --git a/premiser-ui/src/apiActions.ts b/premiser-ui/src/apiActions.ts index 0c4e9d03..231e850a 100644 --- a/premiser-ui/src/apiActions.ts +++ b/premiser-ui/src/apiActions.ts @@ -47,6 +47,7 @@ import { CreateAppearance, JustificationView, AppearanceSearchFilter, + MediaExcerptSearchFilter, } from "howdju-common"; import { InferPathParams, @@ -489,6 +490,31 @@ export const api = { pathParams: { mediaExcerptId }, }) ), + fetchMediaExcerpts: apiActionCreator( + "FETCH_MEDIA_EXCERPTS", + serviceRoutes.readMediaExcerpts, + ( + filters: MediaExcerptSearchFilter, + count: number, + continuationToken?: ContinuationToken + ) => { + const queryStringParams: SearchQueryStringParams = { + filters: encodeQueryStringObject(filters), + continuationToken, + count: toString(count), + }; + if (!queryStringParams.continuationToken) { + queryStringParams.sorts = defaultSorts; + } + return { + config: { + queryStringParams, + normalizationSchema: { mediaExcerpts: mediaExcerptsSchema }, + }, + meta: { filters }, + }; + } + ), createAppearance: apiActionCreator( "CREATE_APPEARANCE", @@ -712,7 +738,7 @@ export const api = { }) ), fetchMoreSourceMediaExcerpts: apiActionCreator( - "FETCH_SOURCE_MEDIA_EXCERPTS", + "FETCH_MORE_SOURCE_MEDIA_EXCERPTS", serviceRoutes.readMediaExcerpts, (continuationToken: ContinuationToken) => ({ queryStringParams: { continuationToken }, diff --git a/premiser-ui/src/components/mediaExcerpts/MediaExcerptViewer.tsx b/premiser-ui/src/components/mediaExcerpts/MediaExcerptViewer.tsx index 10e896b3..5b8983e7 100644 --- a/premiser-ui/src/components/mediaExcerpts/MediaExcerptViewer.tsx +++ b/premiser-ui/src/components/mediaExcerpts/MediaExcerptViewer.tsx @@ -1,5 +1,6 @@ import React from "react"; import { MaterialSymbol } from "react-material-symbols"; +import { Moment } from "moment"; import { MediaExcerptView, @@ -16,13 +17,19 @@ import CreationInfo from "../creationInfo/CreationInfo"; import config from "../../config"; import "./MediaExcerptViewer.scss"; -import { Moment } from "moment"; +import { useAppDispatch } from "@/hooks"; +import { makeExtensionHighlightOnClickUrlLocatorCallback } from "@/extensionCallbacks"; +import { OnClickUrlLocator } from "@/types"; interface Props { + id: string; mediaExcerpt: MediaExcerptView; } export default function MediaExcerptViewer({ mediaExcerpt }: Props) { + const dispatch = useAppDispatch(); + const onClickUrlLocator = + makeExtensionHighlightOnClickUrlLocatorCallback(dispatch); return (
{mediaExcerpt.locators.urlLocators.map((urlLocator: UrlLocatorView) => (
  • - {toAnchorElement(mediaExcerpt, urlLocator)} + {toAnchorElement(mediaExcerpt, urlLocator, onClickUrlLocator)}
  • ))} @@ -60,7 +67,8 @@ export default function MediaExcerptViewer({ mediaExcerpt }: Props) { function toAnchorElement( mediaExcerpt: MediaExcerptView, - urlLocator: UrlLocatorView + urlLocator: UrlLocatorView, + onClickUrlLocator: OnClickUrlLocator ) { const displayUrl = urlLocator.url.url; const textFragmentUrl = @@ -82,8 +90,12 @@ function toAnchorElement( /> ) : null; + function onClick(e: React.MouseEvent) { + onClickUrlLocator(e, mediaExcerpt, urlLocator); + } + return ( - + {displayUrl} {confirmationStatus} {creationInfo} ); diff --git a/premiser-ui/src/extensionCallbacks.ts b/premiser-ui/src/extensionCallbacks.ts index f4bedbde..c829d982 100644 --- a/premiser-ui/src/extensionCallbacks.ts +++ b/premiser-ui/src/extensionCallbacks.ts @@ -1,10 +1,15 @@ import { MouseEvent } from "react"; -import { JustificationView, UrlOut } from "howdju-common"; +import { + JustificationView, + MediaExcerptView, + UrlLocator, + UrlOut, +} from "howdju-common"; import { actions, inIframe } from "howdju-client-common"; import { AppDispatch } from "./setupStore"; -import { OnClickJustificationWritQuoteUrl } from "./types"; +import { OnClickJustificationWritQuoteUrl, OnClickUrlLocator } from "./types"; export function makeExtensionHighlightOnClickWritQuoteUrlCallback( dispatch: AppDispatch @@ -24,3 +29,25 @@ export function makeExtensionHighlightOnClickWritQuoteUrlCallback( dispatch(actions.extension.highlightTarget(justification, url)); }; } + +export function makeExtensionHighlightOnClickUrlLocatorCallback( + dispatch: AppDispatch +): OnClickUrlLocator { + // A method for top-level components that want to highlight justifications using the extension + return function extensionHighlightingOnClickUrlLocator( + event: MouseEvent, + mediaExcerpt: MediaExcerptView, + urlLocator: UrlLocator + ) { + // If we aren't in the extension iframe, then allow the native behavior of the link click + if (!inIframe()) { + return; + } + if (!urlLocator.anchors?.length) { + return; + } + // Otherwise prevent click from navigating and instead update the page hosting the extension iframe + event.preventDefault(); + dispatch(actions.extension.highlightUrlLocator(mediaExcerpt, urlLocator)); + }; +} diff --git a/premiser-ui/src/pages/mediaExcerptsSearch/MediaExcerptsSearchPage.tsx b/premiser-ui/src/pages/mediaExcerptsSearch/MediaExcerptsSearchPage.tsx new file mode 100644 index 00000000..4c457eb3 --- /dev/null +++ b/premiser-ui/src/pages/mediaExcerptsSearch/MediaExcerptsSearchPage.tsx @@ -0,0 +1,117 @@ +import React, { MouseEvent, useEffect } from "react"; +import { useLocation } from "react-router"; +import FlipMove from "react-flip-move"; +import { Button, CircularProgress } from "react-md"; +import { isArray, mapValues, pick, map, isEmpty } from "lodash"; +import queryString from "query-string"; + +import { + MediaExcerptSearchFilter, + MediaExcerptSearchFilterKeys, +} from "howdju-common"; + +import { api } from "../../actions"; +import config from "../../config"; +import { useAppDispatch, useAppSelector, useAppEntitySelector } from "@/hooks"; +import FlipMoveWrapper from "@/FlipMoveWrapper"; +import MediaExcerptCard from "@/components/mediaExcerpts/MediaExcerptCard"; +import { mediaExcerptsSchema } from "@/normalizationSchemas"; + +const fetchCount = 20; + +export default function MediaExcerptsSearchPage() { + const location = useLocation(); + const dispatch = useAppDispatch(); + useEffect(() => { + const filters = extractFilters(location.search); + dispatch(api.fetchMediaExcerpts(filters, fetchCount)); + }, [location, dispatch]); + + const pageState = useAppSelector((state) => state.mediaExcerptsSearchPage); + const { isFetching, continuationToken } = pageState; + const mediaExcerpts = useAppEntitySelector( + pageState.mediaExcerpts, + mediaExcerptsSchema + ); + + const fetchMore = (event: MouseEvent) => { + event.preventDefault(); + const filters = extractFilters(location.search); + dispatch(api.fetchMediaExcerpts(filters, fetchCount, continuationToken)); + }; + + const fetchMoreButton = ( +