diff --git a/newswires/app/controllers/QueryController.scala b/newswires/app/controllers/QueryController.scala index 86b36332..9123cfdb 100644 --- a/newswires/app/controllers/QueryController.scala +++ b/newswires/app/controllers/QueryController.scala @@ -73,11 +73,12 @@ class QueryController( Ok(Json.toJson(results)) } - def item(id: Int): Action[AnyContent] = AuthAction { - FingerpostWireEntry.get(id) match { - case Some(entry) => Ok(Json.toJson(entry)) - case None => NotFound + def item(id: Int, maybeFreeTextQuery: Option[String]): Action[AnyContent] = + AuthAction { + FingerpostWireEntry.get(id, maybeFreeTextQuery) match { + case Some(entry) => Ok(Json.toJson(entry)) + case None => NotFound + } } - } } diff --git a/newswires/app/db/FingerpostWireEntry.scala b/newswires/app/db/FingerpostWireEntry.scala index 6d5086d4..6290617b 100644 --- a/newswires/app/db/FingerpostWireEntry.scala +++ b/newswires/app/db/FingerpostWireEntry.scala @@ -65,7 +65,8 @@ case class FingerpostWireEntry( id: Long, externalId: String, ingestedAt: ZonedDateTime, - content: FingerpostWire + content: FingerpostWire, + highlight: Option[String] = None ) object FingerpostWireEntry @@ -76,7 +77,14 @@ object FingerpostWireEntry Json.format[FingerpostWireEntry] override val columns = - Seq("id", "external_id", "ingested_at", "content", "combined_textsearch") + Seq( + "id", + "external_id", + "ingested_at", + "content", + "combined_textsearch", + "highlight" + ) val syn = this.syntax("fm") private val selectAllStatement = sqls""" @@ -93,15 +101,24 @@ object FingerpostWireEntry rs.long(fm.id), rs.string(fm.externalId), rs.zonedDateTime(fm.ingestedAt), - Json.parse(rs.string(fm.content)).as[FingerpostWire] + Json.parse(rs.string(fm.content)).as[FingerpostWire], + rs.stringOpt(fm.column("highlight")) ) private def clamp(low: Int, x: Int, high: Int): Int = math.min(math.max(x, low), high) - def get(id: Int): Option[FingerpostWireEntry] = DB readOnly { + def get( + id: Int, + maybeFreeTextQuery: Option[String] = Some("alpaca") + ): Option[FingerpostWireEntry] = DB readOnly { + val highlightsClause = maybeFreeTextQuery match { + case Some(query) => + sqls", ts_headline('english', ${syn.content}->>'body_text', websearch_to_tsquery('english', $query), 'HighlightAll=true, StartSel=, StopSel=') AS ${syn.resultName.highlight}" + case None => sqls", '' AS ${syn.resultName.highlight}" + } implicit session => - sql"""| SELECT ${FingerpostWireEntry.syn.result.*} + sql"""| SELECT $selectAllStatement $highlightsClause | FROM ${FingerpostWireEntry as syn} | WHERE ${FingerpostWireEntry.syn.id} = $id |""".stripMargin @@ -248,14 +265,20 @@ object FingerpostWireEntry case whereParts => sqls"WHERE ${sqls.joinWithAnd(whereParts: _*)}" } - val query = sql"""| SELECT $selectAllStatement + val highlightsClause = search.text match { + case Some(query) => + sqls", ts_headline('english', ${syn.content}->>'body_text', websearch_to_tsquery('english', $query)) AS ${syn.resultName.highlight}" + case None => sqls", '' AS ${syn.resultName.highlight}" + } + + val query = sql"""| SELECT $selectAllStatement $highlightsClause | FROM ${FingerpostWireEntry as syn} | $whereClause | ORDER BY ${FingerpostWireEntry.syn.ingestedAt} DESC | LIMIT $effectivePageSize | """.stripMargin -// logger.info(s"QUERY: ${query.statement}; PARAMS: ${query.parameters}") + logger.info(s"QUERY: ${query.statement}; PARAMS: ${query.parameters}") val results = query .map(FingerpostWireEntry(syn.resultName)) diff --git a/newswires/client/src/Item.tsx b/newswires/client/src/Item.tsx index 1693c05d..07779cc9 100644 --- a/newswires/client/src/Item.tsx +++ b/newswires/client/src/Item.tsx @@ -12,16 +12,23 @@ import { EuiTitle, useGeneratedHtmlId, } from '@elastic/eui'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { pandaFetch } from './panda-session'; import { type WireData, WireDataSchema } from './sharedTypes'; +import { paramsToQuerystring } from './urlState'; +import { useSavedItems } from './useSavedItemsList'; import { useSearch } from './useSearch'; import { WireDetail } from './WireDetail'; export const Item = ({ id }: { id: string }) => { - const { handleDeselectItem } = useSearch(); + const { handleDeselectItem, config } = useSearch(); + const { savedItems, addSaved, removeSaved } = useSavedItems(); const [itemData, setItemData] = useState(undefined); + const isSaved = useMemo( + () => savedItems.map((s) => s.id).includes(id), + [savedItems, id], + ); const [error, setError] = useState(undefined); const currentUrl = window.location.href; @@ -31,9 +38,17 @@ export const Item = ({ id }: { id: string }) => { prefix: 'pushedFlyoutTitle', }); + const maybeSearchParams = useMemo(() => { + const q = config.query.q; + if (q) { + return paramsToQuerystring({ q, supplier: [] }); + } + return ''; + }, [config.query.q]); + useEffect(() => { // fetch item data from /api/item/:id - pandaFetch(`/api/item/${id}`) + pandaFetch(`/api/item/${id}${maybeSearchParams}`) .then((res) => { if (res.status === 404) { throw new Error('Item not found'); @@ -85,7 +100,37 @@ export const Item = ({ id }: { id: string }) => { aria-label="send to composer" size="s" /> - + {isSaved ? ( + + removeSaved({ + id: id, + headline: + itemData.content.headline ?? + itemData.content.slug ?? + '', + }) + } + /> + ) : ( + + addSaved({ + id: id, + headline: + itemData.content.headline ?? + itemData.content.slug ?? + '', + }) + } + /> + )} diff --git a/newswires/client/src/SideNav.tsx b/newswires/client/src/SideNav.tsx index fd9c3eb7..6d89266f 100644 --- a/newswires/client/src/SideNav.tsx +++ b/newswires/client/src/SideNav.tsx @@ -18,6 +18,7 @@ import { useCallback, useMemo, useState } from 'react'; import { SearchBox } from './SearchBox'; import { brandColours } from './sharedStyles'; import type { Query } from './sharedTypes'; +import { useSavedItems } from './useSavedItemsList'; import { useSearch } from './useSearch'; function decideLabelForQueryBadge(query: Query): string { @@ -44,7 +45,14 @@ export const SideNav = () => { const [navIsOpen, setNavIsOpen] = useState(false); const theme = useEuiTheme(); - const { state, config, handleEnterQuery, toggleAutoUpdate } = useSearch(); + const { + state, + config, + handleEnterQuery, + handleSelectItem, + toggleAutoUpdate, + } = useSearch(); + const { savedItems: saved } = useSavedItems(); const searchHistory = state.successfulQueryHistory; const activeSuppliers = useMemo( @@ -204,31 +212,61 @@ export const SideNav = () => { })} - + {searchHistoryItems.length === 0 ? ( {'No history yet'} ) : ( - {searchHistoryItems.map(({ label, query, resultsCount }) => { - return ( - { - handleEnterQuery(query); - }} - onClickAriaLabel="Apply filters" - > - {label}{' '} - - {resultsCount === 30 ? '30+' : resultsCount} + {searchHistoryItems + .slice(0, 6) + .map(({ label, query, resultsCount }) => { + return ( + { + handleEnterQuery(query); + }} + onClickAriaLabel="Apply filters" + > + {label}{' '} + + {resultsCount === 30 ? '30+' : resultsCount} + - - ); - })} + ); + })} )} + + {saved.length === 0 ? ( + {'No saved items'} + ) : ( + + {saved.map((item) => ( + handleSelectItem(item.id.toString())} + size="s" + /> + ))} + + )} +
diff --git a/newswires/client/src/WireDetail.tsx b/newswires/client/src/WireDetail.tsx index ed0ed8f8..4dc7847c 100644 --- a/newswires/client/src/WireDetail.tsx +++ b/newswires/client/src/WireDetail.tsx @@ -8,6 +8,7 @@ import { EuiFlexItem, EuiScreenReaderLive, EuiSpacer, + useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; import { Fragment, useMemo } from 'react'; @@ -21,6 +22,7 @@ export const WireDetail = ({ wire: WireData; isShowingJson: boolean; }) => { + const theme = useEuiTheme(); const { byline, keywords, usage } = wire.content; const safeBodyText = useMemo(() => { @@ -29,6 +31,14 @@ export const WireDetail = ({ : undefined; }, [wire]); + const safeHighlightText = useMemo(() => { + return wire.highlight.trim().length > 0 + ? sanitizeHtml(wire.highlight) + : undefined; + }, [wire]); + + const bodyTextContent = safeHighlightText ?? safeBodyText; + const nonEmptyKeywords = useMemo( () => keywords?.filter((keyword) => keyword.trim().length > 0) ?? [], [keywords], @@ -54,7 +64,16 @@ export const WireDetail = ({ - + {byline && ( <> Byline @@ -87,12 +106,12 @@ export const WireDetail = ({ )} - {safeBodyText && ( + {bodyTextContent && ( <> Body text
diff --git a/newswires/client/src/WireItemTable.tsx b/newswires/client/src/WireItemTable.tsx index b720ad77..81e91bdc 100644 --- a/newswires/client/src/WireItemTable.tsx +++ b/newswires/client/src/WireItemTable.tsx @@ -1,4 +1,5 @@ import { + EuiButtonIcon, EuiFlexGroup, euiScreenReaderOnly, EuiTable, @@ -13,8 +14,11 @@ import { useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; +import { useMemo } from 'react'; +import sanitizeHtml from 'sanitize-html'; import { formatTimestamp } from './formatTimestamp'; import type { WireData } from './sharedTypes'; +import { useSavedItems } from './useSavedItemsList'; import { useSearch } from './useSearch'; const fadeOutBackground = css` @@ -35,7 +39,10 @@ export const WireItemTable = ({ wires }: { wires: WireData[] }) => { const selectedWireId = config.itemId; return ( - + { Version Created - {wires.map(({ id, content, isFromRefresh }) => ( + {wires.map(({ id, content, isFromRefresh, highlight }) => ( @@ -63,21 +71,29 @@ export const WireItemTable = ({ wires }: { wires: WireData[] }) => { const WireDataRow = ({ id, content, + highlight, selected, isFromRefresh, handleSelect, }: { id: number; content: WireData['content']; + highlight: string; selected: boolean; isFromRefresh: boolean; handleSelect: (id: string) => void; }) => { const theme = useEuiTheme(); + const { savedItems: saved, addSaved, removeSaved } = useSavedItems(); + const primaryBgColor = useEuiBackgroundColor('primary'); const accentBgColor = useEuiBackgroundColor('accent'); const hasSlug = content.slug && content.slug.length > 0; + const isSaved = useMemo( + () => saved.map((s) => s.id).includes(id.toString()), + [saved, id], + ); return ( {content.headline}

)} + {highlight.trim().length > 0 && ( + +

+ + )} @@ -118,7 +147,6 @@ const WireDataRow = ({ + + {isSaved ? ( + ) => { + e.stopPropagation(); + removeSaved({ + id: id.toString(), + headline: content.slug ?? content.headline ?? '', + }); + }} + /> + ) : ( + ) => { + e.stopPropagation(); + addSaved({ + id: id.toString(), + headline: content.slug ?? content.headline ?? '', + }); + }} + /> + )} + ); }; diff --git a/newswires/client/src/icons.ts b/newswires/client/src/icons.ts index 65c6af9e..6c80c197 100644 --- a/newswires/client/src/icons.ts +++ b/newswires/client/src/icons.ts @@ -26,6 +26,8 @@ import { icon as popout } from '@elastic/eui/es/components/icon/assets/popout'; import { icon as refresh } from '@elastic/eui/es/components/icon/assets/refresh'; import { icon as returnKey } from '@elastic/eui/es/components/icon/assets/return_key'; import { icon as search } from '@elastic/eui/es/components/icon/assets/search'; +import { icon as starEmpty } from '@elastic/eui/es/components/icon/assets/star_empty'; +import { icon as starFilled } from '@elastic/eui/es/components/icon/assets/star_filled'; import { icon as warning } from '@elastic/eui/es/components/icon/assets/warning'; import { appendIconComponentCache } from '@elastic/eui/es/components/icon/icon'; @@ -57,4 +59,6 @@ appendIconComponentCache({ link, copyClipboard, popout, + starEmpty, + starFilled, }); diff --git a/newswires/client/src/main.tsx b/newswires/client/src/main.tsx index 93e6b700..798070f2 100644 --- a/newswires/client/src/main.tsx +++ b/newswires/client/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App.tsx'; import './icons'; +import { SavedItemsListContextProvider } from './useSavedItemsList.tsx'; import { SearchContextProvider } from './useSearch.tsx'; const toolsDomain = window.location.hostname.substring( @@ -14,7 +15,9 @@ document.head.appendChild(script); createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/newswires/client/src/sharedTypes.ts b/newswires/client/src/sharedTypes.ts index 0fedf0e5..fe352398 100644 --- a/newswires/client/src/sharedTypes.ts +++ b/newswires/client/src/sharedTypes.ts @@ -31,6 +31,7 @@ export const WireDataSchema = z.object({ externalId: z.string(), ingestedAt: z.string(), content: FingerpostContentSchema, + highlight: z.string(), isFromRefresh: z.boolean().default(false), }); diff --git a/newswires/client/src/useSavedItemsList.tsx b/newswires/client/src/useSavedItemsList.tsx new file mode 100644 index 00000000..ac8dc0c0 --- /dev/null +++ b/newswires/client/src/useSavedItemsList.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react'; +import { createContext, useContext, useState } from 'react'; +import { z } from 'zod'; + +const savedItemSchema = z.object({ id: z.string(), headline: z.string() }); +type SavedItem = z.infer; +const savedSchema = z.array(savedItemSchema); +type Saved = z.infer; + +const loadSavedFromStorage = () => { + const storedSaved = localStorage.getItem('saved'); + const parsedSavedResult = savedSchema.safeParse( + JSON.parse(storedSaved ?? '[]'), + ); + if (parsedSavedResult.success) { + return parsedSavedResult.data; + } + return []; +}; + +const SavedContext = createContext< + | { + savedItems: Saved; + addSaved: (itemToAdd: SavedItem) => void; + removeSaved: (itemToRemove: SavedItem) => void; + } + | undefined +>(undefined); + +export const SavedItemsListContextProvider = ({ + children, +}: { + children: ReactNode; +}) => { + const [savedItems, setSavedItems] = useState(loadSavedFromStorage()); + + const addSaved = (itemToAdd: SavedItem) => { + setSavedItems((prev: Saved) => { + const newSaved = [itemToAdd, ...prev]; + localStorage.setItem('saved', JSON.stringify(newSaved)); + return newSaved; + }); + }; + + const removeSaved = (itemToRemove: SavedItem) => { + setSavedItems((prev) => { + const newSaved = prev.filter((i) => i.id !== itemToRemove.id); + localStorage.setItem('saved', JSON.stringify(newSaved)); + return newSaved; + }); + }; + + return ( + + {children} + + ); +}; + +// Custom hook to use the saved context +export const useSavedItems = () => { + const context = useContext(SavedContext); + if (!context) { + throw new Error('useSaved must be used within a SavedContextProvider'); + } + return context; +}; diff --git a/newswires/conf/routes b/newswires/conf/routes index 9c7b20c2..5bba7940 100644 --- a/newswires/conf/routes +++ b/newswires/conf/routes @@ -9,7 +9,7 @@ GET /feed controllers.ViteController.index() GET /item/*id controllers.ViteController.item(id: String) GET /api/search controllers.QueryController.query(q: Option[String], keywords: Option[String], supplier: List[String], beforeId: Option[Int], sinceId: Option[Int]) GET /api/keywords controllers.QueryController.keywords(inLastHours: Option[Int], limit:Option[Int]) -GET /api/item/*id controllers.QueryController.item(id: Int) +GET /api/item/*id controllers.QueryController.item(id: Int, q: Option[String]) GET /oauthCallback controllers.AuthController.oauthCallback()