diff --git a/server/config/runtime.exs b/server/config/runtime.exs
index 1e04c8ddd..05335c6c8 100644
--- a/server/config/runtime.exs
+++ b/server/config/runtime.exs
@@ -23,6 +23,9 @@ end
config :orcasite, :prod_host, System.get_env("PROD_HOST_URL", "live.orcasound.net")
+config :orcasite, :shipnoise_api_url,
+ System.get_env("SHIPNOISE_API_URL", "http://localhost:5000")
+
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
@@ -58,6 +61,13 @@ if config_env() == :prod do
config :orcasite, :feed_stream_queue_url, System.get_env("FEED_STREAM_QUEUE_URL")
end
+ # Shipnoise backend API URL (the separate shipnoise API service)
+ # Requests to /api/shipnoise/* are proxied to this URL
+ # Example: SHIPNOISE_API_URL=https://api.shipnoise.net
+ if System.get_env("SHIPNOISE_API_URL", "") != "" do
+ config :orcasite, :shipnoise_api_url, System.get_env("SHIPNOISE_API_URL")
+ end
+
config :orcasite, OrcasiteWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex
index 47f212a1f..142f1c478 100644
--- a/server/lib/orcasite_web/router.ex
+++ b/server/lib/orcasite_web/router.ex
@@ -62,6 +62,20 @@ defmodule OrcasiteWeb.Router do
plug AshGraphql.Plug
end
+ # Proxy shipnoise backend API (separate service) — before the catch-all NextJS forward
+ scope "/api/shipnoise" do
+ pipe_through(:api)
+
+ forward("/", ReverseProxyPlug,
+ upstream: &__MODULE__.shipnoise_upstream/0,
+ error_callback: &__MODULE__.log_reverse_proxy_error/1
+ )
+ end
+
+ def shipnoise_upstream do
+ Application.get_env(:orcasite, :shipnoise_api_url, "http://localhost:5000")
+ end
+
scope "/api/json" do
pipe_through(:api)
diff --git a/ui/.env.development b/ui/.env.development
index 4ae376b91..23a66d871 100644
--- a/ui/.env.development
+++ b/ui/.env.development
@@ -1,3 +1,5 @@
NEXT_PUBLIC_GQL_ENDPOINT='http://localhost:${SERVER_PORT:-4000}/graphql'
NEXT_PUBLIC_SOCKET_ENDPOINT='ws://localhost:${SERVER_PORT:-4000}/socket'
-NEXT_PUBLIC_S3_BUCKET='dev-streaming-orcasound-net'
\ No newline at end of file
+NEXT_PUBLIC_S3_BUCKET='dev-streaming-orcasound-net'
+NEXT_PUBLIC_SHIPNOISE_API_URL='/api/shipnoise'
+SHIPNOISE_API_URL='http://localhost:5000'
\ No newline at end of file
diff --git a/ui/public/shipnoise/Logo.png b/ui/public/shipnoise/Logo.png
new file mode 100644
index 000000000..5cc13ce4d
Binary files /dev/null and b/ui/public/shipnoise/Logo.png differ
diff --git a/ui/public/shipnoise/Search.svg b/ui/public/shipnoise/Search.svg
new file mode 100644
index 000000000..abd52f3c2
--- /dev/null
+++ b/ui/public/shipnoise/Search.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/public/shipnoise/VesselIcon.png b/ui/public/shipnoise/VesselIcon.png
new file mode 100644
index 000000000..97897e516
Binary files /dev/null and b/ui/public/shipnoise/VesselIcon.png differ
diff --git a/ui/public/shipnoise/Warning.svg b/ui/public/shipnoise/Warning.svg
new file mode 100644
index 000000000..dffd936ba
--- /dev/null
+++ b/ui/public/shipnoise/Warning.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/public/shipnoise/delete.svg b/ui/public/shipnoise/delete.svg
new file mode 100644
index 000000000..5b330544e
--- /dev/null
+++ b/ui/public/shipnoise/delete.svg
@@ -0,0 +1,10 @@
+
diff --git a/ui/public/shipnoise/playbutton.svg b/ui/public/shipnoise/playbutton.svg
new file mode 100644
index 000000000..025f3fdfa
--- /dev/null
+++ b/ui/public/shipnoise/playbutton.svg
@@ -0,0 +1,4 @@
+
diff --git a/ui/public/shipnoise/up.svg b/ui/public/shipnoise/up.svg
new file mode 100644
index 000000000..91fa12789
--- /dev/null
+++ b/ui/public/shipnoise/up.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/src/components/Shipnoise/AvailableRecordings.tsx b/ui/src/components/Shipnoise/AvailableRecordings.tsx
new file mode 100644
index 000000000..d32d7a1e2
--- /dev/null
+++ b/ui/src/components/Shipnoise/AvailableRecordings.tsx
@@ -0,0 +1,356 @@
+import { Box, IconButton, Paper, Stack, Typography } from "@mui/material";
+import React, { useEffect, useMemo, useState } from "react";
+
+import ShipnoiseDetectionsPlayer from "@/components/Shipnoise/ShipnoiseDetectionsPlayer";
+
+export type RecordingEntry = {
+ id?: string;
+ vessel?: string | null;
+ mmsi?: string | null;
+ location: string;
+ date?: string;
+ time?: string;
+ timestamp?: string | null;
+ cpaDistanceMeters?: number | null;
+ noiseLevelDb?: number | null;
+ hlsUrl?: string | null;
+ startOffsetSec?: number | null;
+ endOffsetSec?: number | null;
+};
+
+interface AvailableRecordingsProps {
+ recordings?: RecordingEntry[];
+}
+
+declare global {
+ interface Window {
+ mcpopup?: { open: () => void };
+ mc4wp?: { forms: { show: () => void } };
+ }
+}
+
+const MAILCHIMP_SCRIPT =
+ "https://chimpstatic.com/mcjs-connected/js/users/30e5b89b891e7b961c63e7d39/2318c630b0adc777855362be3.js";
+
+const PREFERRED_LOCATIONS = [
+ "Sunset Bay",
+ "Bush Point",
+ "Port Townsend",
+ "Orcasound Lab",
+];
+
+const AvailableRecordings: React.FC = ({
+ recordings = [],
+}) => {
+ const safeRecordings = useMemo(() => {
+ return recordings
+ .filter((record) => {
+ return (
+ typeof record.hlsUrl === "string" &&
+ record.hlsUrl.trim().length > 0 &&
+ record.startOffsetSec != null &&
+ record.endOffsetSec != null
+ );
+ })
+ .map((record) => ({
+ ...record,
+ location: record.location || "Unknown location",
+ }));
+ }, [recordings]);
+
+ const [expandedLocations, setExpandedLocations] = useState>(
+ new Set(),
+ );
+
+ const openHydrophoneLocation = (label: string) => {
+ const normalizedLabel = label?.trim();
+ if (!normalizedLabel) return;
+ const acceptedLabel = normalizedLabel
+ .toLowerCase()
+ .replace(/\s+/g, "-")
+ .replace(/[^\w-]/g, "");
+ const url = `https://live.orcasound.net/listen/${acceptedLabel}`;
+ window.open(url, "_blank");
+ };
+
+ useEffect(() => {
+ const existingScripts = document.querySelectorAll(
+ 'script[src*="chimpstatic"]',
+ );
+ existingScripts.forEach((s) => s.remove());
+
+ const script = document.createElement("script");
+ script.id = "mcjs";
+ script.src = MAILCHIMP_SCRIPT;
+ script.async = true;
+ document.body.appendChild(script);
+ }, []);
+
+ const groupedLocations = useMemo(() => {
+ const grouped: Record = {};
+ safeRecordings.forEach((record) => {
+ if (!grouped[record.location]) grouped[record.location] = [];
+ grouped[record.location].push(record);
+ });
+
+ const getTimestamp = (record: RecordingEntry) => {
+ if (record.timestamp) {
+ const ts = new Date(record.timestamp).getTime();
+ if (!isNaN(ts)) return ts;
+ }
+ if (record.date) {
+ const dateTimeStr = record.time
+ ? `${record.date} ${record.time}`
+ : record.date;
+ const ts = new Date(dateTimeStr).getTime();
+ if (!isNaN(ts)) return ts;
+ }
+ return 0;
+ };
+
+ const sortDesc = (items: RecordingEntry[]) =>
+ [...items].sort((a, b) => getTimestamp(b) - getTimestamp(a));
+
+ const preferred = PREFERRED_LOCATIONS.map((label) => ({
+ label,
+ recordings: sortDesc(grouped[label] ?? []),
+ }));
+
+ const others = Object.keys(grouped)
+ .filter((label) => !PREFERRED_LOCATIONS.includes(label))
+ .map((label) => ({ label, recordings: sortDesc(grouped[label]) }));
+
+ return [...preferred, ...others].sort(
+ (a, b) => b.recordings.length - a.recordings.length,
+ );
+ }, [safeRecordings]);
+
+ const totalRecordings = safeRecordings.length;
+ const vesselIdDisplay = totalRecordings > 0 ? safeRecordings[0].vessel : null;
+ const recordingsLabel = totalRecordings
+ ? `(${totalRecordings} recording${totalRecordings === 1 ? "" : "s"})`
+ : "";
+
+ const handleToggleLocation = (location: string, hasRecordings: boolean) => {
+ if (!hasRecordings) return;
+ setExpandedLocations((prev) => {
+ const next = new Set(prev);
+ if (next.has(location)) next.delete(location);
+ else next.add(location);
+ return next;
+ });
+ };
+
+ if (totalRecordings === 0) return null;
+
+ return (
+
+
+ {/* Header Bar */}
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+ Explore Recordings of Vessel
+ {vesselIdDisplay && (
+ <>
+ {` ${vesselIdDisplay}`}
+ {recordingsLabel && (
+
+ {" "}
+ {recordingsLabel}
+
+ )}
+ >
+ )}
+
+
+
+ {/* Location Accordions */}
+
+ {groupedLocations.map(({ label, recordings: groupedRecordings }) => {
+ const isExpanded = expandedLocations.has(label);
+ const hasRecordings = groupedRecordings.length > 0;
+ const countLabel = groupedRecordings.length;
+
+ return (
+
+
+
+ openHydrophoneLocation(label)}
+ sx={{
+ cursor: "pointer",
+ border: "none",
+ background: "transparent",
+ padding: 0,
+ color: "inherit",
+ font: "inherit",
+ textDecoration: "none",
+ "&:hover": { textDecoration: "underline" },
+ }}
+ >
+ {label}
+ {" "}
+
+ ({countLabel} recording{countLabel === 1 ? "" : "s"})
+
+
+
+ {hasRecordings ? (
+
+ handleToggleLocation(label, hasRecordings)
+ }
+ aria-expanded={isExpanded}
+ aria-label={
+ isExpanded ? `Collapse ${label}` : `Expand ${label}`
+ }
+ sx={{
+ width: 24,
+ height: 24,
+ p: 0,
+ transform: isExpanded
+ ? "rotate(0deg)"
+ : "rotate(180deg)",
+ transition: "transform 0.2s ease",
+ }}
+ >
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ ) : (
+
+ )}
+
+
+
+ {isExpanded && hasRecordings && (
+
+ {groupedRecordings.map((rec, idx) => {
+ const uniqueKey = rec.id ?? `rec-${idx}`;
+
+ return (
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+};
+
+export default AvailableRecordings;
diff --git a/ui/src/components/Shipnoise/Banner.tsx b/ui/src/components/Shipnoise/Banner.tsx
new file mode 100644
index 000000000..4c9d198b9
--- /dev/null
+++ b/ui/src/components/Shipnoise/Banner.tsx
@@ -0,0 +1,182 @@
+import { Box, Button, Link, Stack, Typography } from "@mui/material";
+import { useState } from "react";
+
+const Banner = () => {
+ const [showReportForm, setShowReportForm] = useState(false);
+
+ const openReportForm = () => setShowReportForm(true);
+ const closeReportForm = () => setShowReportForm(false);
+
+ return (
+ <>
+
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ Shipnoise
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showReportForm && (
+
+
+
+
+
+
+
+ )}
+ >
+ );
+};
+
+export default Banner;
diff --git a/ui/src/components/Shipnoise/SelectionPanel.tsx b/ui/src/components/Shipnoise/SelectionPanel.tsx
new file mode 100644
index 000000000..170f6add1
--- /dev/null
+++ b/ui/src/components/Shipnoise/SelectionPanel.tsx
@@ -0,0 +1,554 @@
+import {
+ Box,
+ Button,
+ IconButton,
+ InputAdornment,
+ List,
+ ListItemButton,
+ ListItemText,
+ Paper,
+ Stack,
+ TextField,
+ Typography,
+} from "@mui/material";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import AvailableRecordings, {
+ type RecordingEntry,
+} from "@/components/Shipnoise/AvailableRecordings";
+import { useDebounce } from "@/hooks/useDebounce";
+import {
+ type ClipApiResult,
+ type ClipsSearchParams,
+ formatShipName,
+ formatTitleCase,
+ normalizeNameForSearch,
+ SITE_LABELS,
+ useClipsSearch,
+ useVesselSearch,
+ type VesselOption,
+} from "@/hooks/useShipnoiseApi";
+
+declare global {
+ interface Window {
+ gtag?: (...args: unknown[]) => void;
+ }
+}
+
+type WarningInfo = {
+ icon: string;
+ content: React.ReactNode;
+};
+
+interface VesselInputProps {
+ options: VesselOption[];
+ onChange: (option: VesselOption | null) => void;
+ placeholder: string;
+ value?: string;
+ onInputChange?: (value: string) => void;
+}
+
+const VesselInput: React.FC = ({
+ options,
+ onChange,
+ placeholder,
+ value,
+ onInputChange,
+}) => {
+ const [filteredOptions, setFilteredOptions] = useState([]);
+ const [showOptions, setShowOptions] = useState(false);
+ const [suppressAutoOpen, setSuppressAutoOpen] = useState(true);
+ const inputValue = value ?? "";
+ const latestInputRef = useRef(inputValue);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ latestInputRef.current = inputValue;
+ }, [inputValue]);
+
+ const updateFilteredOptions = useCallback(
+ (val: string, optionList: VesselOption[]) => {
+ const normalizedVal = normalizeNameForSearch(val);
+ if (!normalizedVal.length) {
+ setFilteredOptions([]);
+ setShowOptions(false);
+ return;
+ }
+ const filtered = optionList.filter((opt) =>
+ normalizeNameForSearch(opt.name).includes(normalizedVal),
+ );
+ setFilteredOptions(filtered);
+ setShowOptions(!suppressAutoOpen && filtered.length > 0);
+ },
+ [suppressAutoOpen],
+ );
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const val = e.target.value;
+ setSuppressAutoOpen(false);
+ onInputChange?.(val);
+ updateFilteredOptions(val, options);
+ };
+
+ useEffect(() => {
+ updateFilteredOptions(latestInputRef.current, options);
+ }, [options, updateFilteredOptions]);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(event.target as Node)
+ ) {
+ setShowOptions(false);
+ }
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ const handleInputFocus = () => {
+ if (!suppressAutoOpen && filteredOptions.length > 0) setShowOptions(true);
+ };
+
+ const handleSelect = (option: VesselOption) => {
+ setSuppressAutoOpen(true);
+ setShowOptions(false);
+ setFilteredOptions([]);
+ onChange(option);
+ onInputChange?.(option.name);
+ };
+
+ const handleClear = () => {
+ setSuppressAutoOpen(false);
+ setFilteredOptions([]);
+ setShowOptions(false);
+ onChange(null);
+ onInputChange?.("");
+ };
+
+ return (
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+ ) : null,
+ }}
+ sx={{
+ "& .MuiOutlinedInput-root": {
+ height: 42,
+ borderRadius: "4px",
+ backgroundColor: "white",
+ "& fieldset": { borderColor: "#d1d5db" },
+ "&:hover fieldset": { borderColor: "#111827" },
+ "&.Mui-focused fieldset": { borderColor: "#111827" },
+ },
+ "& .MuiOutlinedInput-input": { px: { xs: 2, sm: 2.5 }, py: 1 },
+ }}
+ />
+ {showOptions && filteredOptions.length > 0 && (
+
+
+ {filteredOptions.map((opt, idx) => (
+ handleSelect(opt)}>
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+const normalizeClip = (clip: ClipApiResult): RecordingEntry => {
+ const siteKey = clip.site?.replace(/\s+/g, "_").toLowerCase();
+ const locationLabel =
+ (siteKey && SITE_LABELS[siteKey]) ||
+ (clip.site
+ ? formatTitleCase(clip.site.replace(/[_\s]+/g, " "))
+ : "Unknown site");
+
+ const vesselName =
+ formatShipName(clip.shipname) ??
+ formatShipName(clip.mmsi ?? "") ??
+ clip.shipname ??
+ clip.mmsi ??
+ "Unknown vessel";
+
+ return {
+ vessel: vesselName,
+ mmsi: clip.mmsi ?? undefined,
+ location: locationLabel,
+ date: clip.date_utc,
+ time: undefined,
+ timestamp: clip.t_cpa ?? null,
+ cpaDistanceMeters: clip.cpa_distance_m ?? undefined,
+ noiseLevelDb: undefined,
+ hlsUrl: clip.hls_url ?? null,
+ startOffsetSec: clip.start_offset_sec ?? null,
+ endOffsetSec: clip.end_offset_sec ?? null,
+ };
+};
+
+const SelectionPanel = () => {
+ const [selectedVessel, setSelectedVessel] = useState(
+ null,
+ );
+ const [vesselInputValue, setVesselInputValue] = useState("");
+ const [warningInfo, setWarningInfo] = useState(null);
+ const [hideDropdownSignal, setHideDropdownSignal] = useState(0);
+ const [searchParams, setSearchParams] = useState(
+ null,
+ );
+
+ const debouncedInput = useDebounce(vesselInputValue, 300);
+ const { data: vesselOptions = [] } = useVesselSearch(debouncedInput);
+ const clipsQuery = useClipsSearch(searchParams);
+
+ const recordings = useMemo(() => {
+ if (!clipsQuery.data) return [];
+ const clips = Array.isArray(clipsQuery.data.results)
+ ? clipsQuery.data.results
+ : [];
+ return clips.map(normalizeClip);
+ }, [clipsQuery.data]);
+
+ const dateRangeLabel = useMemo(() => {
+ if (!clipsQuery.data) return undefined;
+ const payload = clipsQuery.data;
+ if (typeof payload.date_range_label === "string")
+ return payload.date_range_label;
+ if (payload.start_date && payload.end_date) {
+ return payload.start_date === payload.end_date
+ ? payload.start_date
+ : `${payload.start_date} – ${payload.end_date}`;
+ }
+ return undefined;
+ }, [clipsQuery.data]);
+
+ const lastDataUpdatedAt = useRef(0);
+ const selectedVesselRef = useRef(selectedVessel);
+ selectedVesselRef.current = selectedVessel;
+ const vesselInputRef = useRef(vesselInputValue);
+ vesselInputRef.current = vesselInputValue;
+
+ useEffect(() => {
+ if (!clipsQuery.data || clipsQuery.isFetching) return;
+ if (clipsQuery.dataUpdatedAt === lastDataUpdatedAt.current) return;
+ lastDataUpdatedAt.current = clipsQuery.dataUpdatedAt;
+
+ if (recordings.length === 0) {
+ setWarningInfo({
+ icon: "/shipnoise/Warning.svg",
+ content: dateRangeLabel
+ ? `No recordings match that vessel between ${dateRangeLabel}.`
+ : "No recordings found for that vessel.",
+ });
+ } else {
+ setWarningInfo(null);
+ setHideDropdownSignal((prev) => prev + 1);
+
+ const vesselLabel =
+ (selectedVesselRef.current?.name ?? vesselInputRef.current.trim()) ||
+ "ALL";
+ window.gtag?.("event", "vessel_search", {
+ event_category: "selection_panel",
+ event_label: vesselLabel,
+ vessel: vesselLabel,
+ site: "ALL_SITES",
+ date: new Date().toISOString().slice(0, 10),
+ date_window: dateRangeLabel ?? "LAST_60_DAYS",
+ date_range_label: dateRangeLabel ?? "LAST_60_DAYS",
+ });
+ }
+ }, [
+ clipsQuery.data,
+ clipsQuery.isFetching,
+ clipsQuery.dataUpdatedAt,
+ recordings.length,
+ dateRangeLabel,
+ ]);
+
+ useEffect(() => {
+ if (!clipsQuery.error) return;
+ const message = (clipsQuery.error as Error).message?.includes(
+ "NEXT_PUBLIC_SHIPNOISE_API_URL",
+ )
+ ? "Search unavailable: backend URL is not configured."
+ : "Unable to load recordings right now. Please try again.";
+ setWarningInfo({ icon: "/shipnoise/Warning.svg", content: message });
+ }, [clipsQuery.error]);
+
+ const handleVesselInputChange = (value: string) => {
+ setVesselInputValue(value);
+ setWarningInfo(null);
+ const normalizedValue = normalizeNameForSearch(value);
+ const match = vesselOptions.find(
+ (opt) => normalizeNameForSearch(opt.name) === normalizedValue,
+ );
+ setSelectedVessel(match ?? null);
+ };
+
+ const handleVesselSelect = (option: VesselOption | null) => {
+ setSelectedVessel(option);
+ setVesselInputValue(option?.name ?? "");
+ setWarningInfo(null);
+ };
+
+ const handleSearchClick = () => {
+ setWarningInfo(null);
+ const searchDateIso = new Date().toISOString().slice(0, 10);
+ const startDateObj = new Date(searchDateIso);
+ startDateObj.setUTCDate(startDateObj.getUTCDate() - 59);
+ setSearchParams({
+ shipname: vesselInputValue.trim(),
+ startDate: startDateObj.toISOString().slice(0, 10),
+ endDate: searchDateIso,
+ });
+ };
+
+ const showRecordings = recordings.length > 0 && !clipsQuery.isFetching;
+ const isSearching = clipsQuery.isFetching;
+
+ return (
+
+
+
+ {/* Search inputs */}
+
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ Explore Shipnoise Recordings
+
+
+
+ Enter a vessel name to discover and listen to its underwater
+ sound recordings
+
+
+
+
+
+
+
+ Vessel Name
+
+
+
+
+
+
+
+
+
+
+
+
+ {warningInfo && (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+
+ {warningInfo?.content}
+
+
+
+
+
+
+
+ {showRecordings && }
+
+
+
+ );
+};
+
+export default SelectionPanel;
diff --git a/ui/src/components/Shipnoise/ShipnoiseDetectionsPlayer.tsx b/ui/src/components/Shipnoise/ShipnoiseDetectionsPlayer.tsx
new file mode 100644
index 000000000..d92fae5b6
--- /dev/null
+++ b/ui/src/components/Shipnoise/ShipnoiseDetectionsPlayer.tsx
@@ -0,0 +1,239 @@
+import { Box, IconButton, Slider, Typography } from "@mui/material";
+import dynamic from "next/dynamic";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import { type VideoJSPlayer } from "@/components/Player/VideoJS";
+
+const VideoJS = dynamic(() => import("@/components/Player/VideoJS"));
+
+interface ShipnoiseDetectionsPlayerProps {
+ hlsUrl: string;
+ startOffsetSec: number;
+ endOffsetSec: number;
+ timestamp?: string | null;
+ date?: string;
+}
+
+const PLAY_BUTTON_SIZE = 48;
+
+const formattedSeconds = (seconds: number) => {
+ const mm = Math.floor(seconds / 60);
+ const ss = seconds % 60;
+ return `${mm.toString().padStart(2, "0")}:${ss.toFixed(0).padStart(2, "0")}`;
+};
+
+const ShipnoiseDetectionsPlayer: React.FC = ({
+ hlsUrl,
+ startOffsetSec,
+ endOffsetSec,
+ timestamp,
+ date,
+}) => {
+ const playerRef = useRef(null);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [playerTime, setPlayerTime] = useState(startOffsetSec);
+
+ const sliderMax = endOffsetSec - startOffsetSec;
+ const sliderValue = playerTime - startOffsetSec;
+
+ const marks = useMemo(() => {
+ const result: { value: number; label: string }[] = [];
+ const totalSec = endOffsetSec - startOffsetSec;
+ for (let m = 1; m * 60 < totalSec; m++) {
+ result.push({ value: m * 60, label: String(m) });
+ }
+ return result;
+ }, [startOffsetSec, endOffsetSec]);
+
+ const videoJsOptions = useMemo(
+ () => ({
+ autoplay: false,
+ sources: [{ src: hlsUrl, type: "application/x-mpegURL" }],
+ }),
+ [hlsUrl],
+ );
+
+ useEffect(() => {
+ setPlayerTime(startOffsetSec);
+ setIsPlaying(false);
+ }, [hlsUrl, startOffsetSec]);
+
+ const handlePlayerReady = useCallback(
+ (player: VideoJSPlayer) => {
+ playerRef.current = player;
+
+ player.on("playing", () => {
+ setIsPlaying(true);
+ const t = player.currentTime() ?? 0;
+ if (t < startOffsetSec || t > endOffsetSec) {
+ player.currentTime(startOffsetSec);
+ setPlayerTime(startOffsetSec);
+ }
+ });
+
+ player.on("pause", () => setIsPlaying(false));
+ player.currentTime(startOffsetSec);
+
+ player.on("timeupdate", () => {
+ const t = player.currentTime() ?? 0;
+ if (t > endOffsetSec) {
+ player.currentTime(startOffsetSec);
+ setPlayerTime(startOffsetSec);
+ } else {
+ setPlayerTime(t);
+ }
+ });
+ },
+ [startOffsetSec, endOffsetSec],
+ );
+
+ const handlePlayPauseClick = useCallback(() => {
+ const player = playerRef.current;
+ if (!player) return;
+
+ if (isPlaying) {
+ player.pause();
+ } else {
+ player.play()?.catch(() => {});
+ }
+ }, [isPlaying]);
+
+ const handleSliderChange = useCallback(
+ (_e: Event, v: number | number[]) => {
+ const player = playerRef.current;
+ player?.pause();
+ if (typeof v !== "number") return;
+ player?.currentTime(v + startOffsetSec);
+ setPlayerTime(v + startOffsetSec);
+ },
+ [startOffsetSec],
+ );
+
+ const handleSliderChangeCommitted = useCallback(
+ (
+ _e: Event | React.SyntheticEvent,
+ v: number | number[],
+ ) => {
+ if (typeof v !== "number") return;
+ const player = playerRef.current;
+ player?.currentTime(v + startOffsetSec);
+ player?.play()?.catch(() => {});
+ },
+ [startOffsetSec],
+ );
+
+ const formattedDateTime = useMemo(() => {
+ const pacificDate = new Intl.DateTimeFormat("en-US", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ timeZone: "America/Los_Angeles",
+ });
+ const pacificTime = new Intl.DateTimeFormat("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: true,
+ timeZone: "America/Los_Angeles",
+ timeZoneName: "short",
+ });
+
+ if (timestamp) {
+ const parsed = new Date(timestamp);
+ if (!Number.isNaN(parsed.getTime())) {
+ return `${pacificDate.format(parsed)} | ${pacificTime.format(parsed)}`;
+ }
+ }
+ return date ?? "";
+ }, [timestamp, date]);
+
+ return (
+
+
+
+
+
+
+
+ {isPlaying ? (
+
+
+
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+
+
+
+ {formattedDateTime && (
+
+ {formattedDateTime}
+
+ )}
+
+
+ `${(v + startOffsetSec).toFixed(1)} s`}
+ step={0.1}
+ max={sliderMax}
+ value={sliderValue}
+ marks={marks}
+ onChange={handleSliderChange}
+ onChangeCommitted={handleSliderChangeCommitted}
+ aria-label="Playback position"
+ sx={{ color: "#002447" }}
+ />
+
+
+
+
+ {formattedSeconds(Number((playerTime - startOffsetSec).toFixed(0)))}
+
+
+ {formattedSeconds(
+ Number((endOffsetSec - startOffsetSec).toFixed(0)),
+ )}
+
+
+
+
+ );
+};
+
+export default ShipnoiseDetectionsPlayer;
diff --git a/ui/src/components/Shipnoise/ShipnoiseLayout.tsx b/ui/src/components/Shipnoise/ShipnoiseLayout.tsx
new file mode 100644
index 000000000..13dcc0383
--- /dev/null
+++ b/ui/src/components/Shipnoise/ShipnoiseLayout.tsx
@@ -0,0 +1,24 @@
+import { Box } from "@mui/material";
+import { ReactElement } from "react";
+
+import Banner from "@/components/Shipnoise/Banner";
+
+function ShipnoiseLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+export function getShipnoiseLayout(page: ReactElement) {
+ return {page};
+}
diff --git a/ui/src/hooks/useDebounce.ts b/ui/src/hooks/useDebounce.ts
new file mode 100644
index 000000000..7a583741d
--- /dev/null
+++ b/ui/src/hooks/useDebounce.ts
@@ -0,0 +1,10 @@
+import { useEffect, useState } from "react";
+
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delay);
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+ return debouncedValue;
+}
diff --git a/ui/src/hooks/useShipnoiseApi.ts b/ui/src/hooks/useShipnoiseApi.ts
new file mode 100644
index 000000000..10005b643
--- /dev/null
+++ b/ui/src/hooks/useShipnoiseApi.ts
@@ -0,0 +1,179 @@
+import {
+ keepPreviousData,
+ useQuery,
+ type UseQueryResult,
+} from "@tanstack/react-query";
+
+// ─── Types ───────────────────────────────────────────────────────────
+
+export type VesselOption = {
+ name: string;
+};
+
+export type ClipApiResult = {
+ site: string;
+ date_utc?: string;
+ mmsi?: string | null;
+ shipname?: string | null;
+ audio_urls?: string[] | null;
+ cpa_distance_m?: number | null;
+ t_cpa?: string | null;
+ center_segment_index?: number;
+ hls_url?: string | null;
+ start_offset_sec?: number | null;
+ end_offset_sec?: number | null;
+};
+
+export type ClipsSearchResponse = {
+ count: number;
+ start_date?: string;
+ end_date?: string;
+ date_range_label?: string;
+ shipname_query?: string;
+ sites?: string[];
+ limit_per_site?: number;
+ results: ClipApiResult[];
+};
+
+export type VesselSearchResponse = {
+ results: string[];
+};
+
+export interface ClipsSearchParams {
+ shipname: string;
+ startDate: string;
+ endDate: string;
+ sites?: string[];
+ limitPerSite?: number;
+}
+
+// ─── Site Config ─────────────────────────────────────────────────────
+
+export const SITE_LABELS: Record = {
+ bush_point: "Bush Point",
+ orcasound_lab: "Orcasound Lab",
+ port_townsend: "Port Townsend",
+ sunset_bay: "Sunset Bay",
+};
+
+export const SITE_VALUES = Object.keys(SITE_LABELS);
+
+// ─── Helpers ─────────────────────────────────────────────────────────
+
+const CLIPS_API_BASE_URL = process.env.NEXT_PUBLIC_SHIPNOISE_API_URL?.replace(
+ /\/$/,
+ "",
+);
+
+export const buildBackendUrl = (
+ path: string,
+ params: URLSearchParams,
+): string | null => {
+ if (!CLIPS_API_BASE_URL) return null;
+ const queryString = params.toString();
+ return `${CLIPS_API_BASE_URL}${path}${queryString ? `?${queryString}` : ""}`;
+};
+
+export const formatShipName = (value?: string | null): string | undefined => {
+ if (!value) return undefined;
+ const cleaned = value.replace(/[_\s]+/g, " ").trim();
+ if (!cleaned) return undefined;
+ return cleaned
+ .split(" ")
+ .filter(Boolean)
+ .map(
+ (segment) =>
+ segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase(),
+ )
+ .join(" ");
+};
+
+export const normalizeNameForSearch = (value: string): string =>
+ value
+ .replace(/[_\s]+/g, " ")
+ .trim()
+ .toLowerCase();
+
+export const formatTitleCase = (value: string): string =>
+ value
+ .split(" ")
+ .filter(Boolean)
+ .map(
+ (segment) =>
+ segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase(),
+ )
+ .join(" ");
+
+// ─── Fetch Functions ─────────────────────────────────────────────────
+
+export async function fetchVesselSuggestions(
+ query: string,
+ signal?: AbortSignal,
+): Promise {
+ const params = new URLSearchParams({ q: query, limit: "20" });
+ const url = buildBackendUrl("/vessels/search", params);
+ if (!url) throw new Error("NEXT_PUBLIC_SHIPNOISE_API_URL is not configured");
+
+ const response = await fetch(url, { signal });
+ if (!response.ok)
+ throw new Error(`Suggestion request failed with ${response.status}`);
+
+ const payload: VesselSearchResponse = await response.json();
+ const names = Array.isArray(payload.results) ? payload.results : [];
+ return names.map((name) => ({ name: formatShipName(name) ?? name }));
+}
+
+export async function fetchClipsSearch(
+ params: ClipsSearchParams,
+ signal?: AbortSignal,
+): Promise {
+ const searchParams = new URLSearchParams({
+ shipname: params.shipname,
+ start_date: params.startDate,
+ end_date: params.endDate,
+ limit_per_site: String(params.limitPerSite ?? 5),
+ });
+ (params.sites ?? SITE_VALUES).forEach((site) =>
+ searchParams.append("sites", site),
+ );
+
+ const url = buildBackendUrl("/clips/search", searchParams);
+ if (!url) throw new Error("NEXT_PUBLIC_SHIPNOISE_API_URL is not configured");
+
+ const response = await fetch(url, { signal });
+ if (!response.ok)
+ throw new Error(`Clip search failed with ${response.status}`);
+
+ return response.json();
+}
+
+// ─── React Query Hooks ───────────────────────────────────────────────
+
+export function useVesselSearch(
+ query: string,
+): UseQueryResult {
+ const normalized = normalizeNameForSearch(query);
+
+ return useQuery({
+ queryKey: ["vessels", "search", normalized],
+ queryFn: ({ signal }) => fetchVesselSuggestions(normalized, signal),
+ enabled: normalized.length > 0,
+ placeholderData: keepPreviousData,
+ staleTime: 60_000,
+ });
+}
+
+export function useClipsSearch(
+ params: ClipsSearchParams | null,
+): UseQueryResult {
+ return useQuery({
+ queryKey: ["clips", "search", params],
+ queryFn: ({ signal }) => {
+ if (!params) throw new Error("No search params");
+ return fetchClipsSearch(params, signal);
+ },
+ enabled: !!params,
+ staleTime: 30_000,
+ retry: 1,
+ });
+}
diff --git a/ui/src/middleware.ts b/ui/src/middleware.ts
new file mode 100644
index 000000000..5d6450654
--- /dev/null
+++ b/ui/src/middleware.ts
@@ -0,0 +1,24 @@
+import { NextRequest, NextResponse } from "next/server";
+
+export function middleware(request: NextRequest) {
+ const host = request.headers.get("host") || "";
+ // Also check x-forwarded-host, which is set by Phoenix's ReverseProxyPlug
+ const forwardedHost = request.headers.get("x-forwarded-host") || "";
+ const isShipnoiseHost =
+ host.includes("shipnoise.net") || forwardedHost.includes("shipnoise.net");
+
+ // Rewrite requests from shipnoise.net to /shipnoise pages
+ if (isShipnoiseHost) {
+ const url = request.nextUrl.clone();
+ if (!url.pathname.startsWith("/shipnoise")) {
+ url.pathname = `/shipnoise${url.pathname === "/" ? "" : url.pathname}`;
+ return NextResponse.rewrite(url);
+ }
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ["/((?!_next|api|static|favicon).*)"],
+};
diff --git a/ui/src/pages/api/shipnoise-issues.ts b/ui/src/pages/api/shipnoise-issues.ts
new file mode 100644
index 000000000..c506a00ab
--- /dev/null
+++ b/ui/src/pages/api/shipnoise-issues.ts
@@ -0,0 +1,51 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+const SHEET_API_URL =
+ "https://script.google.com/macros/s/AKfycbx6kn3zYIzmLLVXEAhJxW7jna-QsRwSJgvSIZvvaQOz9gvnC97tdgeXuL0MtzvET_qD/exec";
+
+const FETCH_TIMEOUT_MS = 10000;
+
+export default async function handler(
+ _req: NextApiRequest,
+ res: NextApiResponse,
+) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(SHEET_API_URL, {
+ cache: "no-store",
+ signal: controller.signal,
+ } as RequestInit);
+
+ if (!response.ok) {
+ clearTimeout(timeout);
+ throw new Error(`Upstream request failed with ${response.status}`);
+ }
+
+ const rawBody = await response.text();
+ clearTimeout(timeout);
+
+ try {
+ const sanitized = rawBody.replace(/^\)\]\}'/, "").trim();
+ if (!sanitized) {
+ return res.status(200).json({ data: [] });
+ }
+ const parsed = JSON.parse(sanitized);
+ return res.status(200).json(parsed);
+ } catch (parseError) {
+ console.error("Upstream returned non-JSON payload:", parseError);
+ return res.status(502).json({
+ error: "Upstream response was not valid JSON",
+ });
+ }
+ } catch (error) {
+ clearTimeout(timeout);
+ if (error instanceof Error && error.name === "AbortError") {
+ console.error("Sheet request timed out");
+ return res.status(504).json({ error: "Request to Sheet timed out" });
+ }
+ console.error("Failed to fetch Sheet data:", error);
+ return res.status(500).json({ error: "Failed to fetch Sheet data" });
+ }
+}
diff --git a/ui/src/pages/api/shipnoise/[...path].ts b/ui/src/pages/api/shipnoise/[...path].ts
new file mode 100644
index 000000000..22b798c13
--- /dev/null
+++ b/ui/src/pages/api/shipnoise/[...path].ts
@@ -0,0 +1,56 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+// Server-side env var (not exposed to browser)
+const SHIPNOISE_API_URL =
+ process.env.SHIPNOISE_API_URL || "https://orca-shipnoise-sjdtow.fly.dev";
+
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse,
+) {
+ const { path, ...query } = req.query;
+
+ // Reconstruct the upstream path
+ const pathStr = Array.isArray(path) ? path.join("/") : (path ?? "");
+
+ // Forward all remaining query params
+ const queryString = new URLSearchParams(
+ Object.entries(query).flatMap(([k, v]) =>
+ Array.isArray(v) ? v.map((val) => [k, val]) : [[k, v ?? ""]],
+ ),
+ ).toString();
+
+ const upstreamUrl = `${SHIPNOISE_API_URL}/${pathStr}${queryString ? `?${queryString}` : ""}`;
+
+ try {
+ const response = await fetch(upstreamUrl, {
+ method: req.method,
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body:
+ req.method !== "GET" && req.method !== "HEAD"
+ ? JSON.stringify(req.body)
+ : undefined,
+ });
+
+ const contentType = response.headers.get("content-type") ?? "";
+ const hasBody = response.status !== 204 && response.status !== 304;
+
+ if (!hasBody) {
+ return res.status(response.status).end();
+ }
+
+ if (contentType.includes("application/json")) {
+ const data = await response.json();
+ return res.status(response.status).json(data);
+ }
+
+ const text = await response.text();
+ return res.status(response.status).send(text);
+ } catch (error) {
+ console.error("Shipnoise proxy error:", error);
+ return res.status(502).json({ error: "Failed to reach shipnoise backend" });
+ }
+}
diff --git a/ui/src/pages/shipnoise/index.tsx b/ui/src/pages/shipnoise/index.tsx
new file mode 100644
index 000000000..bf9216c84
--- /dev/null
+++ b/ui/src/pages/shipnoise/index.tsx
@@ -0,0 +1,28 @@
+import Head from "next/head";
+import { ReactElement } from "react";
+
+import SelectionPanel from "@/components/Shipnoise/SelectionPanel";
+import { getShipnoiseLayout } from "@/components/Shipnoise/ShipnoiseLayout";
+import { type NextPageWithLayout } from "@/pages/_app";
+
+const ShipnoisePage: NextPageWithLayout = () => {
+ return (
+ <>
+
+ Shipnoise
+
+
+ {/* SelectionPanel manages its own layout and padding */}
+
+ >
+ );
+};
+
+ShipnoisePage.getLayout = function getLayout(page: ReactElement) {
+ return getShipnoiseLayout(page);
+};
+
+export default ShipnoisePage;
diff --git a/ui/src/pages/shipnoise/report.tsx b/ui/src/pages/shipnoise/report.tsx
new file mode 100644
index 000000000..17944329c
--- /dev/null
+++ b/ui/src/pages/shipnoise/report.tsx
@@ -0,0 +1,628 @@
+import {
+ Box,
+ Button,
+ Container,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ Typography,
+} from "@mui/material";
+import Head from "next/head";
+import Link from "next/link";
+import {
+ ReactElement,
+ type ReactNode,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+import { getShipnoiseLayout } from "@/components/Shipnoise/ShipnoiseLayout";
+import { type NextPageWithLayout } from "@/pages/_app";
+
+interface Issue {
+ [key: string]: string | number | boolean | null;
+}
+
+type DisplayField = {
+ label: string;
+ content: ReactNode;
+};
+
+const CHECKBOX_FIELDS = [
+ {
+ key: "Untitled checkboxes field (Bug/Software Malfunction)",
+ label: "Bug / Software Malfunction",
+ },
+ {
+ key: "Untitled checkboxes field (Data Inaccuracy)",
+ label: "Data Inaccuracy",
+ },
+ {
+ key: "Untitled checkboxes field (Performance Issue (Slow/Unresponsive))",
+ label: "Performance Issue",
+ },
+ {
+ key: "Untitled checkboxes field (User Interface/Experience Issue)",
+ label: "UI / UX Issue",
+ },
+ {
+ key: "Untitled checkboxes field (Security Vulnerability)",
+ label: "Security Vulnerability",
+ },
+ {
+ key: "Untitled checkboxes field (Feature Request/Suggestion)",
+ label: "Feature Request",
+ },
+ {
+ key: "Untitled checkboxes field (Other (Please describe in detail below))",
+ label: "Other",
+ },
+];
+
+const DETAIL_FIELDS = [
+ { key: "Submission ID", label: "Submission ID" },
+ { key: "Respondent ID", label: "Respondent ID" },
+ { key: "Submitted at", label: "Submitted At" },
+ {
+ key: "What is the nature of the error you are reporting?",
+ label: "Nature of Error",
+ },
+ {
+ key: "Please describe the error in detail. What were you doing when the error occurred, and what was the unexpected behavior?",
+ label: "Error Details",
+ },
+ {
+ key: "What is the expected behavior when performing the actions that led to the error?",
+ label: "Expected Behavior",
+ },
+ {
+ key: "Have you found any workarounds for this error? If yes, please describe them.",
+ label: "Workarounds",
+ },
+ {
+ key: "If possible, please upload any relevant screenshots or error logs.",
+ label: "Attachments",
+ },
+];
+
+const DATE_FIELD_KEYS = [
+ "Submitted at",
+ "Submitted At",
+ "Timestamp",
+ "timestamp",
+] as const;
+const ATTACHMENT_FIELD_KEYS = new Set([
+ "If possible, please upload any relevant screenshots or error logs.",
+ "Attachments",
+]);
+
+function getIssueTimestamp(issue: Issue | null | undefined): number {
+ if (!issue || typeof issue !== "object") return 0;
+ for (const key of DATE_FIELD_KEYS) {
+ const timestamp = parseTimestamp(issue[key]);
+ if (timestamp !== null) return timestamp;
+ }
+ return 0;
+}
+
+function formatDateValue(raw: unknown): string {
+ const timestamp = parseTimestamp(raw);
+ if (timestamp === null) return "—";
+ return formatUtcTimestamp(timestamp);
+}
+
+function parseTimestamp(raw: unknown): number | null {
+ if (raw === null || raw === undefined) return null;
+ if (typeof raw === "number") {
+ if (!Number.isFinite(raw)) return null;
+ return normalizeEpoch(raw);
+ }
+ if (typeof raw === "string") {
+ const trimmed = raw.trim();
+ if (!trimmed) return null;
+ const direct = new Date(trimmed);
+ if (!Number.isNaN(direct.getTime())) return direct.getTime();
+ const asNumber = Number(trimmed);
+ if (!Number.isNaN(asNumber)) return normalizeEpoch(asNumber);
+ }
+ return null;
+}
+
+function normalizeEpoch(value: number): number | null {
+ if (!Number.isFinite(value)) return null;
+ const asMillis = new Date(value);
+ if (!Number.isNaN(asMillis.getTime()) && asMillis.getUTCFullYear() >= 2000) {
+ return asMillis.getTime();
+ }
+ const asSeconds = new Date(value * 1000);
+ if (!Number.isNaN(asSeconds.getTime())) return asSeconds.getTime();
+ if (!Number.isNaN(asMillis.getTime())) return asMillis.getTime();
+ return null;
+}
+
+function formatUtcTimestamp(epochMs: number): string {
+ const date = new Date(epochMs);
+ if (Number.isNaN(date.getTime())) return "—";
+ const year = date.getUTCFullYear();
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(date.getUTCDate()).padStart(2, "0");
+ const hours = String(date.getUTCHours()).padStart(2, "0");
+ const minutes = String(date.getUTCMinutes()).padStart(2, "0");
+ const seconds = String(date.getUTCSeconds()).padStart(2, "0");
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} UTC+0`;
+}
+
+function toDisplayString(value: unknown): string {
+ if (value === null || value === undefined) return "—";
+ if (typeof value === "string") return value.trim() || "—";
+ if (typeof value === "number" || typeof value === "boolean")
+ return String(value);
+ if (Array.isArray(value))
+ return (
+ value
+ .map((v) => toDisplayString(v))
+ .filter((v) => v !== "—")
+ .join(", ") || "—"
+ );
+ return JSON.stringify(value);
+}
+
+function isTruthy(value: unknown): boolean {
+ if (value === null || value === undefined) return false;
+ if (typeof value === "boolean") return value;
+ if (typeof value === "string") {
+ const normalized = value.trim().toLowerCase();
+ return normalized === "true" || normalized === "yes" || normalized === "1";
+ }
+ if (typeof value === "number") return value !== 0;
+ return false;
+}
+
+function extractUrls(text: string): string[] {
+ const regex = /https?:\/\/[^\s)]+/g;
+ const matches = text.match(regex);
+ if (!matches) return [];
+ return matches.map((url) => url.replace(/[.,)]+$/, ""));
+}
+
+function collectAttachmentUrls(raw: unknown): string[] {
+ if (!raw) return [];
+ const fromArray = Array.isArray(raw)
+ ? raw
+ .map((item) =>
+ typeof item === "string" ? item : toDisplayString(item),
+ )
+ .flatMap((item) => extractUrls(item))
+ : extractUrls(typeof raw === "string" ? raw : toDisplayString(raw));
+ return fromArray.filter(Boolean);
+}
+
+function renderAttachmentValue(raw: unknown): ReactNode {
+ const urls = collectAttachmentUrls(raw);
+ if (!urls.length) return {toDisplayString(raw)};
+
+ return urls.map((url, index) => (
+
+
+ View attachment {urls.length > 1 ? index + 1 : ""}
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ ));
+}
+
+function renderFieldValue(fieldKey: string, raw: unknown): ReactNode {
+ if (
+ DATE_FIELD_KEYS.map((k) => k.toLowerCase()).includes(fieldKey.toLowerCase())
+ ) {
+ return {formatDateValue(raw)};
+ }
+ if (ATTACHMENT_FIELD_KEYS.has(fieldKey)) {
+ return renderAttachmentValue(raw);
+ }
+ return {toDisplayString(raw)};
+}
+
+function buildDisplayFields(issue: Issue): DisplayField[] {
+ const entries: DisplayField[] = DETAIL_FIELDS.map(({ key, label }) => ({
+ label,
+ content: renderFieldValue(key, issue[key]),
+ }));
+
+ const checkboxSelections = CHECKBOX_FIELDS.filter(({ key }) =>
+ isTruthy(issue[key]),
+ ).map(({ label }) => label);
+
+ entries.splice(4, 0, {
+ label: "Error Categories",
+ content: (
+
+ {checkboxSelections.length
+ ? checkboxSelections.join(", ")
+ : "None selected"}
+
+ ),
+ });
+
+ return entries;
+}
+
+const ShipnoiseReportPage: NextPageWithLayout = () => {
+ const [issues, setIssues] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(false);
+ const [selected, setSelected] = useState(null);
+ const dialogRef = useRef(null);
+
+ useEffect(() => {
+ if (!selected) return;
+ dialogRef.current?.focus();
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setSelected(null);
+ };
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [selected]);
+
+ useEffect(() => {
+ async function load() {
+ try {
+ const res = await fetch("/api/shipnoise-issues");
+ if (!res.ok)
+ throw new Error(`Request failed with status ${res.status}`);
+ const data = await res.json();
+ const issues = Array.isArray(data) ? data : (data?.data ?? []);
+ const sorted = [...issues].sort(
+ (a, b) => getIssueTimestamp(b) - getIssueTimestamp(a),
+ );
+ setIssues(sorted);
+ } catch (err) {
+ console.error("Fetch error:", err);
+ setError(true);
+ } finally {
+ setLoading(false);
+ }
+ }
+ load();
+ }, []);
+
+ if (loading) {
+ return (
+
+
+ Loading data...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ Failed to load data. Please try again later.
+
+
+ );
+ }
+
+ return (
+ <>
+
+ Shipnoise — Issue Reports
+
+
+ {/* Sub-header */}
+
+
+
+ ← Back to Shipnoise
+
+
+
+ Issue Report Dashboard
+
+
+ Total: {issues.length} submissions
+
+
+
+
+
+
+
+
+
+
+
+
+ {[
+ "Submission ID",
+ "Submitted At",
+ "Nature of Error",
+ "Action",
+ ].map((h, i) => (
+
+ {h}
+
+ ))}
+
+
+
+ {issues.map((issue, i) => (
+ setSelected(issue)}
+ sx={{ cursor: "pointer" }}
+ >
+
+ {toDisplayString(
+ issue["Submission ID"] ?? issue.ID ?? i + 1,
+ )}
+
+
+ {formatDateValue(
+ issue["Submitted at"] ?? issue.Timestamp ?? null,
+ )}
+
+
+ {toDisplayString(
+ issue[
+ "What is the nature of the error you are reporting?"
+ ] ?? "(no title)",
+ )}
+
+
+
+
+
+ ))}
+
+
+
+
+
+ {/* Modal */}
+ {selected && (
+
+
+
+
+
+ Issue Details —{" "}
+ {toDisplayString(selected["Submission ID"])}
+
+
+ Submitted:{" "}
+ {formatDateValue(
+ selected["Submitted at"] ?? selected.Timestamp ?? null,
+ )}
+
+
+
+
+
+
+ {buildDisplayFields(selected).map(({ label, content }) => (
+
+
+ {label}
+
+
+ {content}
+
+
+ ))}
+
+
+
+ )}
+
+
+ >
+ );
+};
+
+ShipnoiseReportPage.getLayout = function getLayout(page: ReactElement) {
+ return getShipnoiseLayout(page);
+};
+
+export default ShipnoiseReportPage;