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 api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import health from "./health";
import notifications from "./notifications";
import orcid from "./orcid";
import userProfile from "./user-profile";

const app = new Hono<{
Expand Down Expand Up @@ -36,6 +37,7 @@ app.use(
// Public endpoints (no auth required)
app.route("/health", health);
app.route("/user-profile", userProfile);
app.route("/orcid", orcid);

// Middleware for endpoints that require sign-in (better-auth)
app.use("*", async (c, next) => {
Expand Down
30 changes: 30 additions & 0 deletions api/src/orcid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Hono } from "hono";
import { ContentfulStatusCode } from "hono/utils/http-status";

const app = new Hono<{ Bindings: Env }>();

// Return a display name for the given orcid URI. We proxy it through our API to avoid CORS issues
app.get("/display-name", async (c) => {
const orcid = c.req.query("orcid");
if (!orcid) {
return c.json({ error: "Missing 'orcid' query parameter" }, 400);
}

try {
const response = await fetch(`${orcid}/public-record.json`);
if (!response.ok) {
return c.json(
{ error: "Failed to fetch ORCID record" },
(response?.status as ContentfulStatusCode) || 500,
);
}
const data = await response.json();
const displayName = data?.displayName;
return c.json({ displayName });
} catch (error) {
console.error("Error fetching ORCID display name:", error);
return c.json({ error: "Internal server error" }, 500);
}
});

export default app;
22 changes: 11 additions & 11 deletions frontend/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,9 @@
});

it("should handle edge case with undefined/invalid input", () => {
expect(isNanopubUri(undefined as any)).toBe(false);

Check warning on line 123 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(isNanopubUri(null as any)).toBe(false);

Check warning on line 124 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(isNanopubUri({} as any)).toBe(false);

Check warning on line 125 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(isNanopubUri("")).toBe(false);
});

Expand All @@ -147,48 +147,48 @@

it("should extract valid RA hash", () => {
const uri = `https://w3id.org/np/RA${hash}`;
expect(getNanopubHash(uri)).toBe(hash);
expect(getNanopubHash(uri, false)).toBe(hash);
});

it("should extract valid RB hash", () => {
const uri = `https://w3id.org/np/RB${hash}`;
expect(getNanopubHash(uri)).toBe(hash);
expect(getNanopubHash(uri, false)).toBe(hash);
});

it("should extract valid FA hash", () => {
const uri = `https://w3id.org/np/FA${hash}`;
expect(getNanopubHash(uri)).toBe(hash);
expect(getNanopubHash(uri, false)).toBe(hash);
});

it("should work for other domain/urls", () => {
const uri = `https://example.com/RA${hash}`;
expect(getNanopubHash(uri)).toBe(hash);
expect(getNanopubHash(uri, false)).toBe(hash);
});

it("should still work if url is suffixed", () => {
const uri1 = `https://w3id.org/np/FA${hash}/abc`;
expect(getNanopubHash(uri1)).toBe(hash);
expect(getNanopubHash(uri1, false)).toBe(hash);
const uri2 = `https://w3id.org/np/FA${hash}#abc`;
expect(getNanopubHash(uri2)).toBe(hash);
expect(getNanopubHash(uri2, false)).toBe(hash);
const uri3 = `https://w3id.org/np/FA${hash}/`;
expect(getNanopubHash(uri3)).toBe(hash);
expect(getNanopubHash(uri3, false)).toBe(hash);
const uri4 = `https://w3id.org/np/RA${hash}/RA${hash_alt}`;
expect(getNanopubHash(uri4)).toBe(hash);
expect(getNanopubHash(uri4, false)).toBe(hash);
const uri5 = `https://w3id.org/np/RA${hash}/np/RA${hash_alt}`;
expect(getNanopubHash(uri5)).toBe(hash);
expect(getNanopubHash(uri5, false)).toBe(hash);
});

it("should return undefined for invalid hash length", () => {
const shortUri = "https://w3id.org/np/RAshort";
expect(getNanopubHash(shortUri)).toBeUndefined();
expect(getNanopubHash(shortUri, false)).toBeUndefined();
const longUri = `https://w3id.org/np/RA${hash}toolong`;
expect(getNanopubHash(longUri)).toBeUndefined();
expect(getNanopubHash(longUri, false)).toBeUndefined();
});

it("should handle edge case with undefined/invalid input", () => {
expect(getNanopubHash(undefined as any)).toBeUndefined();

Check warning on line 189 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(getNanopubHash(null as any)).toBeUndefined();

Check warning on line 190 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(getNanopubHash({} as any)).toBeUndefined();

Check warning on line 191 in frontend/__tests__/utils.test.ts

View workflow job for this annotation

GitHub Actions / Frontend Checks

Unexpected any. Specify a different type
expect(getNanopubHash("")).toBeUndefined();
});
});
Expand Down
97 changes: 97 additions & 0 deletions frontend/src/components/map-viewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Map Viewer (read-only map)
*
* A read-only Leaflet map that renders a WKT geometry string.
* Used by ViewGeographicalCoverage to display the geographical area.
*
* This is a default export so it can be lazy-loaded.
*/

import { Map, MapTileLayer, MapZoomControl } from "@/components/ui/map";
import { wktToGeoJSON } from "@terraformer/wkt";
import type { LatLngExpression } from "leaflet";
import { useEffect, useRef, useState } from "react";
import { GeoJSON, useMap } from "react-leaflet";

/**
* Inner component that fits the map bounds to the GeoJSON layer
*/
function FitBounds({ geoJson }: { geoJson: GeoJSON.GeoJsonObject }) {
const map = useMap();
const geoJsonRef = useRef<any>(null);

useEffect(() => {
if (geoJsonRef.current && map) {
try {
const bounds = geoJsonRef.current.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [30, 30] });
}
} catch {
// Ignore bounds errors for edge cases
}
}
}, [geoJson, map]);

return (
<GeoJSON
ref={geoJsonRef}
key={JSON.stringify(geoJson)}
data={geoJson as any}
style={{
color: "#0d9488",
weight: 2,
fillColor: "#14b8a6",
fillOpacity: 0.2,
}}
/>
);
}

export interface ReadOnlyMapProps {
wkt: string;
}

export default function ReadOnlyMap({ wkt }: ReadOnlyMapProps) {
const [geoJson, setGeoJson] = useState<GeoJSON.GeoJsonObject | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!wkt) {
setGeoJson(null);
return;
}
try {
const parsed = wktToGeoJSON(wkt);
setGeoJson(parsed as unknown as GeoJSON.GeoJsonObject);
setError(null);
} catch (e) {
console.warn("Failed to parse WKT for map display:", e);
setError("Could not parse geometry");
setGeoJson(null);
}
}, [wkt]);

if (error) {
return (
<div className="flex items-center justify-center h-full bg-muted/30 text-muted-foreground text-sm p-4">
{error}
</div>
);
}

const defaultCenter: LatLngExpression = [20, 0];

return (
<Map
center={defaultCenter}
zoom={2}
className="h-full w-full"
scrollWheelZoom={false}
>
<MapTileLayer />
<MapZoomControl />
{geoJson && <FitBounds geoJson={geoJson} />}
</Map>
);
}
Loading
Loading