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 */} + Vessel + + + 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 Logo + + 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 */} + Clear + + + ) : 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 */} + Search + + 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 + Warning + )} + + + {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 + Play + )} + + + + + {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;