diff --git a/package.json b/package.json index eb67434..ff6a70a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@typescript-eslint/parser": "^8.46.3", "axios": "^1.13.2", "bootstrap": "^5.3.8", + "dompurify": "^3.3.1", "react": "^19.1.1", "react-bootstrap": "^2.10.10", "react-datepicker": "^8.9.0", @@ -29,6 +30,7 @@ "devDependencies": { "@babel/eslint-parser": "^7.28.5", "@eslint/js": "^9.39.1", + "@types/dompurify": "^3.2.0", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", diff --git a/src/Root.tsx b/src/Root.tsx index 9b44189..6531ca4 100644 --- a/src/Root.tsx +++ b/src/Root.tsx @@ -10,6 +10,7 @@ import EpicDataPage from './pages/EpicDataPage.tsx'; import EpicDataPostPage from './pages/EpicDataPostPage.tsx'; import HomePage from './pages/HomePage.tsx'; import ImageOfTheDayPage from './pages/ImageOfTheDayPage.tsx'; +import SearchItemPage from './pages/SearchItemPage.tsx'; function Root() { return ( @@ -25,6 +26,7 @@ function Root() { } /> } /> } /> + } /> diff --git a/src/api/nasaEpic.api.ts b/src/api/nasaEpic.api.ts index 97ee065..1270863 100644 --- a/src/api/nasaEpic.api.ts +++ b/src/api/nasaEpic.api.ts @@ -2,17 +2,12 @@ import axios from 'axios'; import type { NasaEpicDataType, NasaEpicDataTypesForDates } from '../types/NasaEpicDataTypes.ts'; -//const API_KEY = import.meta.env.NASA_API_KEY ?? 'DEMO_KEY'; - export const nasaEpicApi = axios.create({ baseURL: `https://epic.gsfc.nasa.gov/api/natural/`, headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, - /*params: { - api_key: API_KEY, - }*/ }); export async function getNasaEpicData(): Promise { diff --git a/src/api/nasaImageAndVideoLibrary.api.ts b/src/api/nasaImageAndVideoLibrary.api.ts new file mode 100644 index 0000000..5f392aa --- /dev/null +++ b/src/api/nasaImageAndVideoLibrary.api.ts @@ -0,0 +1,36 @@ +import axios from 'axios'; + +import type { + NasaImageAndVideoLibraryItemAssetType, + NasaImageAndVideoLibraryItemMetadataType, + NasaImageAndVideoLibraryType, +} from '../types/NasaImageAndVideoLibraryType.ts'; + +export const nasaImageAndVideoLibraryApi = axios.create({ + baseURL: `https://images-api.nasa.gov`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, +}); + +export async function searchNasaLibrary(query: string): Promise { + const res = await nasaImageAndVideoLibraryApi.get('/search', { + params: { q: query }, + }); + return res.data; +} + +export async function searchNasaLibraryAsset(nasaId: string): Promise { + const res = await nasaImageAndVideoLibraryApi.get( + `/asset/${encodeURIComponent(nasaId)}`, + ); + return res.data; +} + +export async function searchNasaLibraryMetadata(nasaId: string): Promise { + const res1 = await nasaImageAndVideoLibraryApi.get<{ location: string }>(`/metadata/${encodeURIComponent(nasaId)}`); + const location: string = res1.data.location; + const res2 = await axios.get(location); + return res2.data; +} diff --git a/src/components/NavigationBar.module.css b/src/components/NavigationBar.module.css index 8dfd117..841da9a 100644 --- a/src/components/NavigationBar.module.css +++ b/src/components/NavigationBar.module.css @@ -33,6 +33,7 @@ margin-right: auto; max-width: 130px; width: 100%; + position: relative; } /* --------------------------------------------------------- */ diff --git a/src/components/NavigationBar.tsx b/src/components/NavigationBar.tsx index 17a799b..a715f7e 100644 --- a/src/components/NavigationBar.tsx +++ b/src/components/NavigationBar.tsx @@ -1,17 +1,54 @@ +import { type FormEvent, useState } from 'react'; import { Form } from 'react-bootstrap'; import Container from 'react-bootstrap/Container'; import Image from 'react-bootstrap/Image'; import Nav from 'react-bootstrap/Nav'; import Navbar from 'react-bootstrap/Navbar'; +import { searchNasaLibrary } from '../api/nasaImageAndVideoLibrary.api.ts'; +import type { NasaImageAndVideoLibraryType } from '../types/NasaImageAndVideoLibraryType.ts'; import style from './NavigationBar.module.css'; +import SearchResults from './search/SearchResults.tsx'; function NavigationBar() { + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + const [showResults, setShowResults] = useState(false); + + const searchHandler = (e: FormEvent) => { + e.preventDefault(); + + searchNasaLibrary(query) + .then((results) => { + setResults(results); + setShowResults(true); + }) + .catch((error) => { + console.error('Error fetching search results:', error); + }); + }; + + const searchClosedOrSearched = () => { + setShowResults(false); + setQuery(''); + setResults(null); + }; + return ( -
- + void searchHandler(e)}> + setQuery(e.target.value)} + /> + {showResults ? ( + + ) : null} Logo diff --git a/src/components/search/SafeHtml.module.css b/src/components/search/SafeHtml.module.css new file mode 100644 index 0000000..63d0506 --- /dev/null +++ b/src/components/search/SafeHtml.module.css @@ -0,0 +1,20 @@ +.formattedText { + /* 1. This preserves the \n characters as actual line breaks */ + white-space: pre-wrap; + + /* 2. Standard formatting */ + font-family: inherit; + line-height: 1.6; + color: inherit; +} + +/* 3. Make sure the links inside look nice and don't overflow */ +.formattedText a { + color: #0d6efd; /* Bootstrap Blue */ + text-decoration: none; + word-break: break-word; /* Prevents long URLs from breaking mobile layout */ +} + +.formattedText a:hover { + text-decoration: underline; +} diff --git a/src/components/search/SafeHtml.tsx b/src/components/search/SafeHtml.tsx new file mode 100644 index 0000000..f03fc1d --- /dev/null +++ b/src/components/search/SafeHtml.tsx @@ -0,0 +1,32 @@ +import DOMPurify from 'dompurify'; + +import style from './SafeHtml.module.css'; + +interface SafeHtmlProps { + htmlContent?: string; +} + +interface DomPurifyConfig { + ALLOWED_TAGS?: string[]; + ALLOWED_ATTR?: string[]; + [key: string]: unknown; +} + +const SafeHtml = ({ htmlContent }: SafeHtmlProps) => { + const sanitizeFn = ( + DOMPurify as unknown as { + sanitize: (dirty: string, config?: DomPurifyConfig) => string; + } + ).sanitize; + + const DOMPURIFY_CONFIG: DomPurifyConfig = { + ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'code', 'pre'], + ALLOWED_ATTR: ['href', 'title', 'target', 'rel', 'class'], + }; + + const cleanHtml: string = sanitizeFn(htmlContent ?? '', DOMPURIFY_CONFIG); + + return
; +}; + +export default SafeHtml; diff --git a/src/components/search/SearchResults.module.css b/src/components/search/SearchResults.module.css new file mode 100644 index 0000000..59b5aa4 --- /dev/null +++ b/src/components/search/SearchResults.module.css @@ -0,0 +1,25 @@ +.searchResultsContainer { + position: absolute; + top: 100%; + left: 0; + width: 250px; + max-height: 400px; + overflow-y: auto; + background-color: #fff; + border: 1px solid #ced4da; + border-radius: 0.375rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + z-index: 1000; + margin-top: 5px; +} + +.searchResultsItem { + padding: 10px; + color: #212529; + border-bottom: 1px solid #e9ecef; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s; + text-decoration: none; + display: block; +} diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx new file mode 100644 index 0000000..0110a43 --- /dev/null +++ b/src/components/search/SearchResults.tsx @@ -0,0 +1,65 @@ +import { useNavigate } from 'react-router'; + +import type { + NasaImageAndVideoLibraryItemType, + NasaImageAndVideoLibraryType, +} from '../../types/NasaImageAndVideoLibraryType.ts'; +import style from './SearchResults.module.css'; + +interface SearchResultsProps { + results: NasaImageAndVideoLibraryType | null; + searchClosedOrSearched: () => void; +} + +function SearchResults(props: SearchResultsProps) { + const { results, searchClosedOrSearched } = props; + const items: NasaImageAndVideoLibraryItemType[] = results ? getDistinctItemsByTitle(results.collection.items) : []; + const navigate = useNavigate(); + + const clickedItem = async (item: NasaImageAndVideoLibraryItemType) => { + searchClosedOrSearched(); + await navigate(`/search/item/${item.data[0].nasa_id}`); + }; + + return ( +
+ {items.length === 0 ? ( +

Error FIX IT

+ ) : ( + /*Only the top 5 distinct based on title*/ + items.slice(0, 5).map((item, i) => ( +
void clickedItem(item)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + if (e.key === ' ') { + e.preventDefault(); + } + void clickedItem(item); + } + }} + > +

{item.data[0].title}

+
+ )) + )} +
+ ); +} + +function getDistinctItemsByTitle(items: NasaImageAndVideoLibraryItemType[]): NasaImageAndVideoLibraryItemType[] { + const titleMap = new Map(); + for (const item of items) { + const title = item.data[0].title; + if (!titleMap.has(title)) { + titleMap.set(title, item); + } + } + return Array.from(titleMap.values()); +} + +export default SearchResults; diff --git a/src/hooks/useNasaItem.ts b/src/hooks/useNasaItem.ts new file mode 100644 index 0000000..c082311 --- /dev/null +++ b/src/hooks/useNasaItem.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; + +import { searchNasaLibraryAsset, searchNasaLibraryMetadata } from '../api/nasaImageAndVideoLibrary.api.ts'; +import type { + NasaImageAndVideoLibraryItemAssetType, + NasaImageAndVideoLibraryItemMetadataType, +} from '../types/NasaImageAndVideoLibraryType.ts'; + +function returnImagesOnly(assets: NasaImageAndVideoLibraryItemAssetType) { + return assets.collection.items.filter((item) => { + return item.href.endsWith('.jpg') || item.href.endsWith('.png') || item.href.endsWith('.jpeg'); + }); +} + +export function useNasaItem(nasaId: string | undefined) { + const [metadata, setMetadata] = useState(null); + const [assets, setAssets] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [image, setImage] = useState(null); + + useEffect(() => { + if (!nasaId) return; + + let isMounted = true; + + const fetchData = async () => { + setLoading(true); + setError(null); + try { + const [metadataResponse, assetsResponse] = await Promise.all([ + searchNasaLibraryMetadata(nasaId), + searchNasaLibraryAsset(nasaId), + ]); + if (isMounted) { + setMetadata(metadataResponse); + setAssets(assetsResponse); + const images = returnImagesOnly(assetsResponse); + setImage(images.length > 0 ? images[0].href : null); + } + } catch (err) { + if (isMounted) { + setError((err as Error).message); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + void fetchData(); + + return () => { + isMounted = false; + }; + }, [nasaId]); + + return { metadata, assets, image, loading, error }; +} diff --git a/src/pages/SearchItem.module.css b/src/pages/SearchItem.module.css new file mode 100644 index 0000000..8fe44e6 --- /dev/null +++ b/src/pages/SearchItem.module.css @@ -0,0 +1,49 @@ +.searchItemContainer { + margin-left: 10rem; + margin-top: 5rem; + display: flex; + flex-direction: row; +} + +.leftDiv { + flex: 1; + margin-right: 2rem; +} + +.rightDiv { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + margin-right: 9rem; + position: relative; +} + +.image { + width: 80%; /* fill container width */ + height: auto; /* preserve aspect ratio */ + max-height: 60rem; /* prevents extreme vertical stretching */ + object-fit: contain; +} + +/* --------------------------------------------------------- */ +/* MOBILE & TABLET FIX (Screen width < 992px) */ +/* --------------------------------------------------------- */ +@media (max-width: 992px) { + .searchItemContainer { + flex-direction: column; + margin-left: 2.8rem; + margin-right: 2.8rem; + margin-top: 2rem; + } + + .leftDiv { + margin-right: 0; + margin-top: 2rem; + } + + .rightDiv { + margin-right: 0 !important; + position: relative; + } +} diff --git a/src/pages/SearchItemPage.tsx b/src/pages/SearchItemPage.tsx new file mode 100644 index 0000000..16f6896 --- /dev/null +++ b/src/pages/SearchItemPage.tsx @@ -0,0 +1,40 @@ +import { Spinner } from 'react-bootstrap'; +import { useParams } from 'react-router'; + +import SafeHtml from '../components/search/SafeHtml.tsx'; +import { useNasaItem } from '../hooks/useNasaItem.ts'; +import style from './SearchItem.module.css'; + +function SearchItemPage() { + const { nasaId } = useParams(); + const { metadata, image, loading, error } = useNasaItem(nasaId); + + if (loading) { + return ( +
+ + Loading... + +
+ ); + } + + return ( +
+ {error ?

Error

: null} +
+

{metadata?.['AVAIL:Title']}

+ +
+
+ {image ? ( +
+ {metadata?.['AVAIL:Title']} +
+ ) : null} +
+
+ ); +} + +export default SearchItemPage; diff --git a/src/types/NasaImageAndVideoLibraryType.ts b/src/types/NasaImageAndVideoLibraryType.ts new file mode 100644 index 0000000..5eb73e5 --- /dev/null +++ b/src/types/NasaImageAndVideoLibraryType.ts @@ -0,0 +1,58 @@ +export interface NasaImageAndVideoLibraryType { + collection: { + version: string; + href: string; + items: NasaImageAndVideoLibraryItemType[]; + metadata: { + total_hits: number; + }; + links: { + href: string; + rel: string; + prompt: string; + }[]; + }; +} + +export interface NasaImageAndVideoLibraryItemType { + href: string; + data: { + center: string; + date_created: string; + description: string; + description_508?: string; + keywords: string[]; + location?: string; + media_type: string; + nasa_id: string; + photographer?: string; + title: string; + }[]; + links: { + href: string; + rel: string; + render: string; + width: number; + size: number; + height: number; + }[]; +} + +export interface NasaImageAndVideoLibraryItemAssetType { + collection: { + version: string; + href: string; + items: { + href: string; + }[]; + }; +} + +export interface NasaImageAndVideoLibraryItemMetadataType { + 'AVAIL:Description': string; + 'AVAIL:Title': string; + 'AVAIL:Keywords': string[]; + 'AVAIL:Location': string; + 'AVAIL:DateCreated': string; + 'AVAIL:Photographer': string; +} diff --git a/yarn.lock b/yarn.lock index f63171c..a493a9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -754,6 +754,13 @@ dependencies: "@babel/types" "^7.28.2" +"@types/dompurify@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.2.0.tgz#56610bf3e4250df57744d61fbd95422e07dfb840" + integrity sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg== + dependencies: + dompurify "*" + "@types/estree@1.0.8", "@types/estree@^1.0.6": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" @@ -793,6 +800,11 @@ dependencies: csstype "^3.0.2" +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/warning@^3.0.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.3.tgz#d1884c8cc4a426d1ac117ca2611bf333834c6798" @@ -1316,6 +1328,13 @@ dom-helpers@^5.0.1, dom-helpers@^5.2.0, dom-helpers@^5.2.1: "@babel/runtime" "^7.8.7" csstype "^3.0.2" +dompurify@*, dompurify@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.3.1.tgz#c7e1ddebfe3301eacd6c0c12a4af284936dbbb86" + integrity sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q== + optionalDependencies: + "@types/trusted-types" "^2.0.7" + dunder-proto@^1.0.0, dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"