diff --git a/configs/config.js b/configs/config.js index 074add3..c0ebecf 100644 --- a/configs/config.js +++ b/configs/config.js @@ -1,4 +1,4 @@ window.__APP_CONFIG__ = { - backendBaseUrl: "https://leda.sao.ru", - adminBaseUrl: "https://leda.sao.ru" + backendBaseUrl: "https://leda.kraysent.dev", + adminBaseUrl: "https://leda.kraysent.dev" }; diff --git a/src/App.tsx b/src/App.tsx index 81bc457..99a06a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,8 @@ import { Layout } from "./components/ui/Layout"; import { SearchBar } from "./components/ui/Searchbar"; import { LoginPage } from "./pages/Login"; import { TableDetailsPage } from "./pages/TableDetails"; +import { AdminPage } from "./pages/Admin"; +import { AdminMergePgcPage } from "./pages/AdminMergePgc"; function App() { return ( @@ -45,6 +47,8 @@ function App() { element={} /> } /> + } /> + } /> } /> +
Name
+
{name}
+ + ); + + const raField = equatorial ? ( + <> +
RA
+
+ + + +
+ + ) : null; + + const decField = equatorial ? ( + <> +
Dec
+
+ + + +
+ + ) : null; + + const redshiftField = redshift ? ( + <> +
Redshift
+
+ + {redshift.z.toFixed(5)} + +
+ + ) : null; + + if (layout === "columnar") { + return ( +
+
{nameField}
+ {raField &&
{raField}
} + {decField &&
{decField}
} + {redshiftField &&
{redshiftField}
} +
+ ); + } + + return ( +
+ {nameField} + {raField} + {decField} + {redshiftField} +
+ ); +} diff --git a/src/components/core/SuggestibleInput.tsx b/src/components/core/SuggestibleInput.tsx new file mode 100644 index 0000000..eb63ad3 --- /dev/null +++ b/src/components/core/SuggestibleInput.tsx @@ -0,0 +1,64 @@ +import { + KeyboardEventHandler, + FocusEventHandler, + ReactElement, + ReactNode, +} from "react"; +import classNames from "classnames"; + +interface SuggestibleInputProps { + value: string; + onChange: (value: string) => void; + getSuggestions: (value: string) => ReactNode[]; + placeholder?: string; + disabled?: boolean; + className?: string; + onKeyDown?: KeyboardEventHandler; + onFocus?: FocusEventHandler; + onBlur?: FocusEventHandler; +} + +export function SuggestibleInput({ + value, + onChange, + getSuggestions, + placeholder, + disabled, + className, + onKeyDown, + onFocus, + onBlur, +}: SuggestibleInputProps): ReactElement { + const suggestions = getSuggestions(value); + + return ( +
+ onChange(event.target.value)} + onKeyDown={onKeyDown} + onFocus={onFocus} + onBlur={onBlur} + className={classNames( + "bg-surface-2 border border-border rounded px-3 py-2 w-full text-sm text-primary placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent", + className, + )} + /> + {suggestions.length > 0 ? ( +
    + {suggestions.map((node, index) => ( +
  • + {node} +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/ui/Navbar.tsx b/src/components/ui/Navbar.tsx index 16d022a..81dbed1 100644 --- a/src/components/ui/Navbar.tsx +++ b/src/components/ui/Navbar.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { NavLink, useNavigate } from "react-router-dom"; import { MdAccountTree, + MdAdminPanelSettings, MdInfo, MdLogin, MdLogout, @@ -33,6 +34,13 @@ const navItems = [ }, ]; +const adminNavItem = { + to: "/admin", + icon: , + label: "Admin", + end: false, +}; + const configuredProductionWeb = "https://leda.sao.ru"; function openCurrentPathOnOrigin(productionWebInput: string): void { @@ -106,6 +114,19 @@ export function Navbar() { ))} + {isLoggedIn() ? ( + + + sidebarRailControlClassName(isActive) + } + > + {adminNavItem.icon} + + + ) : null}
{showOpenProductionButton ? ( diff --git a/src/components/ui/Searchbar.tsx b/src/components/ui/Searchbar.tsx index 4653b88..59f4f9f 100644 --- a/src/components/ui/Searchbar.tsx +++ b/src/components/ui/Searchbar.tsx @@ -1,7 +1,8 @@ -import { ReactElement, useState } from "react"; +import { ReactElement, ReactNode, useState } from "react"; import { Link, NavigateFunction, useNavigate } from "react-router-dom"; import classNames from "classnames"; import { Button } from "../core/Button"; +import { SuggestibleInput } from "../core/SuggestibleInput"; import { formatCoordinateInspectHint, inspectCoordinateQuery, @@ -60,7 +61,6 @@ export function SearchBar({ const [focused, setFocused] = useState(false); const navigate = useNavigate(); const onSearchHandler = onSearch ?? searchHandler(navigate); - const suggestion = focused ? searchSuggestion(searchQuery) : null; function handleSubmit() { if (searchQuery.trim()) { @@ -68,6 +68,35 @@ export function SearchBar({ } } + function getSuggestions(value: string): ReactNode[] { + if (!focused) { + return []; + } + const suggestion = searchSuggestion(value); + if (!suggestion) { + return []; + } + const nodes: ReactNode[] = [ +
+ {suggestion.primary} +
, + ]; + if (suggestion.secondary) { + nodes.push( +
+ {suggestion.secondary} +
, + ); + } + return nodes; + } + return (
-
- + setSearchQuery(e.target.value)} + onChange={setSearchQuery} + getSuggestions={getSuggestions} + placeholder="Search for an object..." + className="h-10 px-2 py-1" onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} onKeyDown={(e) => { @@ -108,14 +137,6 @@ export function SearchBar({ } }} /> - {suggestion ? ( -
-
{suggestion.primary}
- {suggestion.secondary ? ( -
{suggestion.secondary}
- ) : null} -
- ) : null}
+ )); + } + + return ( +
+

{label}

+ { + if (event.key === "Enter") { + event.preventDefault(); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + if (nameResults.length > 0) { + selectResult(nameResults[0]); + return; + } + void runSearch(query); + } + if (event.key === "Escape") { + setNameResults([]); + } + }} + /> + {error ? ( +

+ {error} +

+ ) : null} + {selection ? ( +
+
+ + PGC {selection.object.pgc} + + +
+ + {objectLabel(selection.object)} + + } + /> +
+ ) : null} +
+ ); +} + +export function AdminMergePgcPage(): ReactElement { + const [target, setTarget] = useState(null); + const [source, setSource] = useState(null); + const [merging, setMerging] = useState(false); + const [mergeError, setMergeError] = useState(null); + const [mergeSuccess, setMergeSuccess] = useState(null); + + useEffect(() => { + document.title = "Merge PGC objects | HyperLEDA"; + }, []); + + if (!isLoggedIn()) { + return ; + } + + const samePgc = + target !== null && + source !== null && + target.object.pgc === source.object.pgc; + const canMerge = target !== null && source !== null && !samePgc && !merging; + + const skySources: SkySource[] = []; + if (target) { + const sky = objectToSkySource(target.object, "target"); + if (sky) { + skySources.push(sky); + } + } + if (source) { + const sky = objectToSkySource(source.object, "source"); + if (sky) { + skySources.push(sky); + } + } + + const skyView = + target && source && skySources.length > 0 + ? skyViewForSources(skySources) + : null; + + async function handleMerge(): Promise { + if (!target || !source || target.object.pgc === source.object.pgc) { + return; + } + + setMerging(true); + setMergeError(null); + setMergeSuccess(null); + + try { + const response = await mergePgcs({ + client: adminClient, + body: { + target_pgc: target.object.pgc, + source_pgcs: [source.object.pgc], + }, + }); + + if (response.error || !response.data?.data) { + throw new Error( + typeof response.error === "object" + ? JSON.stringify(response.error) + : String(response.error || "Unknown error"), + ); + } + + const result = response.data.data; + setMergeSuccess( + `Merged PGC ${result.merged_pgcs.join(", ")} into PGC ${result.target_pgc}. Reassigned ${result.reassigned_records} record(s).`, + ); + setSource(null); + } catch (err) { + setMergeError(err instanceof Error ? err.message : String(err)); + } finally { + setMerging(false); + } + } + + return ( +
+
+

Merge PGC objects

+

+ Select a target PGC (survives) and a source PGC (records are + reassigned, then the source disappears). You probably will want to + rerun import to layer 2 after this operation to update references. +

+
+ +
+ { + setTarget(selection); + setMergeError(null); + setMergeSuccess(null); + }} + disabled={merging} + /> + { + setSource(selection); + setMergeError(null); + setMergeSuccess(null); + }} + disabled={merging} + /> +
+ + {samePgc ? ( +

+ Target and source must be different PGC numbers. +

+ ) : null} + + {skyView ? ( + + ) : null} + +
+ + {mergeError ? ( +

+ {mergeError} +

+ ) : null} + {mergeSuccess ? ( +

+ {mergeSuccess} +

+ ) : null} +
+
+ ); +} diff --git a/src/pages/RecordCrossmatchDetails.tsx b/src/pages/RecordCrossmatchDetails.tsx index d9482e3..66e7cd9 100644 --- a/src/pages/RecordCrossmatchDetails.tsx +++ b/src/pages/RecordCrossmatchDetails.tsx @@ -1,4 +1,4 @@ -import { ReactElement, ReactNode, useEffect, useState } from "react"; +import { ReactElement, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import { AladinViewer } from "../components/core/Aladin"; import { Loading } from "../components/core/Loading"; @@ -18,7 +18,6 @@ import { PgcCandidate, Schema as AdminSchema, StatusesPayload, - Catalogs, } from "../clients/admin/types.gen"; import { Schema as BackendSchema } from "../clients/backend/types.gen"; import { getResource } from "../resources/resources"; @@ -30,11 +29,7 @@ import { useDataFetching } from "../hooks/useDataFetching"; import { adminClient } from "../clients/config"; import { Button } from "../components/core/Button"; import { isLoggedIn } from "../auth/token"; -import { - Declination, - QuantityWithError, - RightAscension, -} from "../components/core/Astronomy"; +import { ObjectSummary } from "../components/catalogs/ObjectSummary"; import classNames from "classnames"; import { MdAdd, MdClose } from "react-icons/md"; @@ -145,87 +140,6 @@ function convertCandidatesToAdditionalSources( : candidateSources; } -function ObjectSummary({ - catalogs, - schema, - name, - layout = "rows", -}: { - catalogs: Catalogs; - schema: BackendSchema; - name: ReactNode; - layout?: "rows" | "columnar"; -}): ReactElement { - const equatorial = catalogs?.coordinates?.equatorial; - const redshift = catalogs?.redshift; - - const nameField = ( - <> -
Name
-
{name}
- - ); - - const raField = equatorial ? ( - <> -
RA
-
- - - -
- - ) : null; - - const decField = equatorial ? ( - <> -
Dec
-
- - - -
- - ) : null; - - const redshiftField = redshift ? ( - <> -
Redshift
-
- - {redshift.z.toFixed(5)} - -
- - ) : null; - - if (layout === "columnar") { - return ( -
-
{nameField}
- {raField &&
{raField}
} - {decField &&
{decField}
} - {redshiftField &&
{redshiftField}
} -
- ); - } - - return ( -
- {nameField} - {raField} - {decField} - {redshiftField} -
- ); -} - type ResolutionChoice = "new" | number; interface ResolutionSelectorProps { diff --git a/vite.config.ts b/vite.config.ts index 6afa6f7..1a1b73c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,11 +8,11 @@ export default defineConfig({ server: { proxy: { "/api": { - target: "https://leda.sao.ru", + target: "https://leda.kraysent.dev", changeOrigin: true, }, "/admin": { - target: "https://leda.sao.ru", + target: "https://leda.kraysent.dev", changeOrigin: true, }, },