diff --git a/makefile b/makefile index a3f2062..307f74a 100644 --- a/makefile +++ b/makefile @@ -8,6 +8,7 @@ build: gen: yarn run openapi-ts -i http://dm2.sao.ru:81/api/openapi.json -o ./src/clients/backend + yarn run openapi-ts -i http://dm2.sao.ru:81/admin/api/openapi.json -o ./src/clients/admin image-build: docker build . -t ghcr.io/hyperleda/hyperleda-webapp:$(GIT_VERSION) diff --git a/package.json b/package.json index b2072fa..f5b443c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "classnames": "^2.5.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-icons": "^5.5.0", "react-markdown": "^10.0.1", "react-router-dom": "^7.2.0", "remark-gfm": "^4.0.1", @@ -37,4 +38,4 @@ "typescript-eslint": "^8.24.1", "vite": "^6.2.0" } -} \ No newline at end of file +} diff --git a/src/.gitignore b/src/.gitignore index d49256b..1d760ce 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1 +1,2 @@ -clients/backend \ No newline at end of file +clients/backend +clients/admin diff --git a/src/App.tsx b/src/App.tsx index 49c8337..061a44d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,8 @@ import { Footer } from "./components/ui/footer"; import { HomePage } from "./pages/Home"; import { SearchResultsPage } from "./pages/SearchResults"; import { ObjectDetailsPage } from "./pages/ObjectDetails"; +import { NotFoundPage } from "./pages/NotFound"; +import { TableDetailsPage } from "./pages/TableDetails"; function Layout({ children }: { children: React.ReactNode }) { return ( @@ -41,6 +43,18 @@ function App() { } /> + + + + } + /> + } + /> ); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 59d196b..4781feb 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -22,8 +22,8 @@ export const Button: React.FC = ({ onClick={onClick} disabled={disabled} className={classNames( - "px-4 py-2 flex items-center bg-blue-500 text-white font-semibold rounded hover:bg-blue-600 transition disabled:bg-gray-400 disabled:cursor-not-allowed", - className + "px-2 py-2 box-border flex items-center font-semibold border-1 border-[#1a1a1a] rounded-lg bg-[#1a1a1a] hover:border-[#646cff] transition-colors duration-300 active:border-white", + className, )} > {children} diff --git a/src/components/ui/common-table.tsx b/src/components/ui/common-table.tsx index 6bea113..646a389 100644 --- a/src/components/ui/common-table.tsx +++ b/src/components/ui/common-table.tsx @@ -1,13 +1,14 @@ import React from "react"; import classNames from "classnames"; -interface Column { +export interface Column { name: string; + renderCell?: (value: any) => React.ReactNode; } interface CommonTableProps { columns: Column[]; - data: Record[]; + data: Record[]; className?: string; tableClassName?: string; headerClassName?: string; @@ -21,15 +22,24 @@ export const CommonTable: React.FC = ({ data, className = "", tableClassName = "", - headerClassName = "", - columnHeaderClassName = "", - cellClassName = "", + headerClassName = "bg-gray-700 border-gray-600", + columnHeaderClassName = "bg-gray-600 text-white", + cellClassName = "text-gray-200", children, }) => { - const renderCell = (value: string | number | undefined): React.ReactNode => { + const renderCell = (value: any, column: Column): React.ReactNode => { + if (column.renderCell) { + return column.renderCell(value); + } + if (value === undefined || value === null) { return NULL; } + + if (React.isValidElement(value)) { + return value; + } + return {String(value)}; }; @@ -49,7 +59,7 @@ export const CommonTable: React.FC = ({ @@ -64,8 +74,7 @@ export const CommonTable: React.FC = ({ {columns.map((column) => { @@ -74,11 +83,11 @@ export const CommonTable: React.FC = ({ - {renderCell(cellValue)} + {renderCell(cellValue, column)} ); })} diff --git a/src/components/ui/footer.tsx b/src/components/ui/footer.tsx index db5f73a..29c433a 100644 --- a/src/components/ui/footer.tsx +++ b/src/components/ui/footer.tsx @@ -10,7 +10,7 @@ Old version: http://leda.univ-lyon1.fr/ `; export function Footer() { - const [isCollapsed, setIsCollapsed] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(true); const toggleCollapse = () => { setIsCollapsed(!isCollapsed); diff --git a/src/index.css b/src/index.css index 06dfb17..8ed283d 100644 --- a/src/index.css +++ b/src/index.css @@ -36,25 +36,6 @@ h1 { line-height: 1.1; } -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - @media (prefers-color-scheme: light) { :root { color: #213547; diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx new file mode 100644 index 0000000..d2a448a --- /dev/null +++ b/src/pages/NotFound.tsx @@ -0,0 +1,30 @@ +import { useNavigate } from "react-router-dom"; +import { Button } from "../components/ui/button"; + +export function NotFoundPage() { + const navigate = useNavigate(); + + return ( +
+
+
+

404

+

+ Page Not Found +

+

+ The page you're looking for doesn't exist or has been moved. +

+
+
+ +
+
+
+ ); +} diff --git a/src/pages/ObjectDetails.tsx b/src/pages/ObjectDetails.tsx index 8b779e8..baa7602 100644 --- a/src/pages/ObjectDetails.tsx +++ b/src/pages/ObjectDetails.tsx @@ -153,7 +153,7 @@ export const ObjectDetailsPage: React.FC = () => { ]; return ( -
+
{object.catalogs?.coordinates?.equatorial && ( {

Coordinates

Celestial coordinates of the object

@@ -189,9 +186,6 @@ export const ObjectDetailsPage: React.FC = () => {

Redshift

Redshift measurements

@@ -202,9 +196,6 @@ export const ObjectDetailsPage: React.FC = () => {

Velocity

Velocity measurements with respect to different apexes

diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx new file mode 100644 index 0000000..370ebfb --- /dev/null +++ b/src/pages/TableDetails.tsx @@ -0,0 +1,212 @@ +import React, { ReactElement, useEffect, useState } from "react"; +import { GetTableResponse, HttpValidationError, Bibliography } from "../clients/admin/types.gen"; +import { getTableAdminApiV1TableGet } from "../clients/admin/sdk.gen"; +import { useNavigate, useParams } from "react-router-dom"; +import { CommonTable, Column } from "../components/ui/common-table"; +import { Button } from "../components/ui/button"; +import { MdContentCopy, MdCheck } from "react-icons/md"; + +interface CopyButtonProps { + children: ReactElement; + textToCopy: string; +} + +const CopyButton: React.FC = ({ children, textToCopy }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(textToCopy); + setCopied(true); + setTimeout(() => setCopied(false), 1000); + } catch (err) { + console.error('Failed to copy text: ', err); + } + }; + + return ( +
+
{children}
+ +
+ ); +}; + +function renderBibliography(bib: Bibliography): ReactElement { + var authors = "" + + if (bib.authors.length >= 1) { + authors += bib.authors[0] + } + if (bib.authors.length >= 2) { + authors += " et al." + } + + authors += ` ${bib.year}` + + const targetLink = "https://ui.adsabs.harvard.edu/abs/" + bib.bibcode + "/abstract" + + return +
{bib.bibcode} | {authors}: "{bib.title}"
+
+} + +function renderTime(time: string): string { + const dt = new Date(time as string); + + return dt.toString() +} + +function renderUCD(ucd: string | undefined | null): ReactElement { + if (!ucd) { + return
+ } + + var words: ReactElement[] = [] + + ucd.split(";").forEach((word, index) => { + words.push( +
{word}
+ ) + }); + + return +
{words}
+
+} + +function renderColumnName(name: string): ReactElement { + return +

{name}

+
+} + +const renderTableDetails = (tableName: string, table: GetTableResponse) => { + const infoColumns = [ + { name: "Parameter" }, + { name: "Value" } + ] + + const infoValues = [ + { + Parameter: "Table ID", + Value: table.id, + }, + { + Parameter: "Source paper", + Value: renderBibliography(table.bibliography), + }, + { + Parameter: "Number of records", + Value: table.rows_num + }, + { + Parameter: "Type of data", + Value: table.meta.datatype + }, + { + Parameter: "Modification time", + Value: renderTime(table.meta.modification_dt as string) + } + ] + + const columnInfoColumns: Column[] = [ + { name: "Name", renderCell: renderColumnName }, + { name: "Description" }, + { name: "Unit" }, + { name: "UCD", renderCell: renderUCD }, + ] + + var columnInfoValues: any[] = [] + + table.column_info.forEach(col => { + columnInfoValues.push({ + Name: col.name, + Description: col.description, + Unit: col.unit, + UCD: col.ucd, + }) + }); + + return
+ +

{table.description}

+

{tableName}

+
+ +

Column information

+
+
+} + +export const TableDetailsPage: React.FC = () => { + const { tableName } = useParams<{ tableName: string }>(); + const [table, setTable] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + const fetchData = async () => { + if (!tableName) { + navigate("/"); + return; + } + + try { + const response = await getTableAdminApiV1TableGet({ query: { table_name: tableName } }) + if (response.error) { + setError(response.error) + return + } + + if (response.data) { + setTable(response.data.data) + } + } catch (err) { + console.log("Error fetching table", err) + } finally { + setLoading(false); + } + } + + fetchData(); + }, [tableName, navigate]) + + const renderNotFound = () => ( +
+

Table not found.

+
+ ); + + const renderError = (error: HttpValidationError) => ( +
+

{error.detail?.toString()}

+
+ ); + + return ( +
+ {loading ? ( +
+

Loading...

+
+ ) : table ? ( + renderTableDetails(tableName ?? "", table) + ) : error ? ( + renderError(error) + ) : ( + renderNotFound() + )} +
+ ); +} \ No newline at end of file