Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -25,6 +26,7 @@ function Root() {
<Route path="/blogpost" element={<BlogPostPage />} />
<Route path="/imageoftheday" element={<ImageOfTheDayPage />} />
<Route path="/epicdata/:epicDataDate" element={<EpicDataPostPage />} />
<Route path="/search/item/:nasaId" element={<SearchItemPage />} />
</Routes>
</main>
<FooterBar />
Expand Down
5 changes: 0 additions & 5 deletions src/api/nasaEpic.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NasaEpicDataType[]> {
Expand Down
36 changes: 36 additions & 0 deletions src/api/nasaImageAndVideoLibrary.api.ts
Original file line number Diff line number Diff line change
@@ -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<NasaImageAndVideoLibraryType> {
const res = await nasaImageAndVideoLibraryApi.get<NasaImageAndVideoLibraryType>('/search', {
params: { q: query },
});
return res.data;
}

export async function searchNasaLibraryAsset(nasaId: string): Promise<NasaImageAndVideoLibraryItemAssetType> {
const res = await nasaImageAndVideoLibraryApi.get<NasaImageAndVideoLibraryItemAssetType>(
`/asset/${encodeURIComponent(nasaId)}`,
);
return res.data;
}

export async function searchNasaLibraryMetadata(nasaId: string): Promise<NasaImageAndVideoLibraryItemMetadataType> {
const res1 = await nasaImageAndVideoLibraryApi.get<{ location: string }>(`/metadata/${encodeURIComponent(nasaId)}`);
const location: string = res1.data.location;
const res2 = await axios.get<NasaImageAndVideoLibraryItemMetadataType>(location);
return res2.data;
}
1 change: 1 addition & 0 deletions src/components/NavigationBar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
margin-right: auto;
max-width: 130px;
width: 100%;
position: relative;
}

/* --------------------------------------------------------- */
Expand Down
41 changes: 39 additions & 2 deletions src/components/NavigationBar.tsx
Original file line number Diff line number Diff line change
@@ -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<NasaImageAndVideoLibraryType | null>(null);
const [showResults, setShowResults] = useState(false);

const searchHandler = (e: FormEvent<HTMLFormElement>) => {
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 (
<Navbar expand="lg" data-bs-theme="dark" className={style.navbarStyle}>
<Container className={style.gridContainer}>
<Form className={`d-flex ${style.searchForm}`}>
<Form.Control type="search" placeholder="Search" className="me-2" aria-label="Search" />
<Form className={`d-flex ${style.searchForm}`} onSubmit={(e) => void searchHandler(e)}>
<Form.Control
type="search"
placeholder="Search"
className="me-2"
aria-label="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{showResults ? (
<SearchResults results={results} searchClosedOrSearched={searchClosedOrSearched} />
) : null}
</Form>
<Navbar.Brand href="/" className={style.brandCentered}>
<Image src="/logo/logo-light.png" alt="Logo" className={style.logoStyle} />
Expand Down
20 changes: 20 additions & 0 deletions src/components/search/SafeHtml.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
32 changes: 32 additions & 0 deletions src/components/search/SafeHtml.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className={style.formattedText} dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
};

export default SafeHtml;
25 changes: 25 additions & 0 deletions src/components/search/SearchResults.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
65 changes: 65 additions & 0 deletions src/components/search/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={style.searchResultsContainer}>
{items.length === 0 ? (
<p>Error FIX IT</p>
) : (
/*Only the top 5 distinct based on title*/
items.slice(0, 5).map((item, i) => (
<div
className={style.searchResultsItem}
key={`${item.data[0].title}-${i}`}
onClick={() => void clickedItem(item)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === ' ') {
e.preventDefault();
}
void clickedItem(item);
}
}}
>
<p>{item.data[0].title}</p>
</div>
))
)}
</div>
);
}

function getDistinctItemsByTitle(items: NasaImageAndVideoLibraryItemType[]): NasaImageAndVideoLibraryItemType[] {
const titleMap = new Map<string, NasaImageAndVideoLibraryItemType>();
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;
60 changes: 60 additions & 0 deletions src/hooks/useNasaItem.ts
Original file line number Diff line number Diff line change
@@ -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<NasaImageAndVideoLibraryItemMetadataType | null>(null);
const [assets, setAssets] = useState<NasaImageAndVideoLibraryItemAssetType | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [image, setImage] = useState<string | null>(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 };
}
Loading