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()