Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
faab1ce
fix for workflow, now develop only on feature branches
levilevente Nov 10, 2025
cfec977
created page for every date
levilevente Nov 15, 2025
c932cd8
galacticview-frontend#1 Create dedicated page for every EPIC date
levilevente Nov 15, 2025
88bdc57
galacticview-frontend#1 fix ci
levilevente Nov 15, 2025
1632b2c
Merge pull request #1 from levilevente/feature/dl-1-create-dedicated-…
levilevente Nov 16, 2025
b394c23
design progressed
levilevente Nov 16, 2025
9b1f339
added query for the apod api
levilevente Nov 16, 2025
011f6af
design done for the image of the day too
levilevente Nov 16, 2025
189261e
footer fixed
levilevente Nov 17, 2025
5321634
image of the day fixes for the image to fit into a decent space and b…
levilevente Nov 17, 2025
964120e
add separate image of the day page without any plus details in it
levilevente Nov 17, 2025
21abec2
design for epic data page done
levilevente Nov 17, 2025
b1768b8
design for epic data by date page done
levilevente Nov 17, 2025
65f4773
some css extracted into app.css
levilevente Nov 17, 2025
c6e1009
Merge branch 'feature/make-design-consistent' into 'develop'
levilevente Nov 17, 2025
7dcc709
Merge branch 'feature/update-readme' into 'develop'
levilevente Nov 19, 2025
770cdf3
Merge branch 'feature/extract-all-style-components-into-separate-css-…
levilevente Nov 20, 2025
9cf3736
Merge branch 'feature/update-eslint-ruleset' into 'develop'
levilevente Nov 20, 2025
28a2d4e
Readme update (#13)
levilevente Nov 21, 2025
b4b3fe6
Merge branch 'feature/update-eslint-ruleset' into 'develop'
levilevente Nov 21, 2025
786d1b6
Merge branch 'feature/add-magnifying-glass-option-for-the-images' in…
levilevente Nov 22, 2025
7f91b66
workflow update
levilevente Nov 22, 2025
76aad94
Merge branch 'feature/fix-dimension-responsive-related-bugs' into 'de…
levilevente Nov 23, 2025
80cabbb
Merge branch 'feature/navigation-logo-bug-under-992-size-screen' into…
levilevente Nov 23, 2025
32cadea
Merge branch 'feature/integrate-the-ai-agent' into 'develop'
levilevente Dec 3, 2025
3f19465
Merge branch 'main' into develop
levilevente Dec 3, 2025
ee62553
added search api, console logs the searches
levilevente Dec 4, 2025
97a7fb8
lint fix
levilevente Dec 4, 2025
66bcac4
basic search with results top5 displayed done
levilevente Dec 8, 2025
73e7daa
lint fix
levilevente Dec 8, 2025
61259a3
now if clicked on an item it will be console loged
levilevente Dec 8, 2025
97ca921
page for the search result done
levilevente Dec 13, 2025
1fbe0c5
Searched page implemented
levilevente Dec 13, 2025
1bb146b
Added HTML sanitize for descriptions with HTML content in it
levilevente Dec 13, 2025
11662ca
Added config to dompurify, fixed issues
levilevente Dec 13, 2025
992d93b
Merge branch 'feature/search-function' into 'develop'
levilevente Dec 13, 2025
511e80b
Merge branch 'main' into develop
levilevente Dec 13, 2025
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