diff --git a/.env.example b/.env.example index 25add1752..ddd0f5698 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ TEST_ADMIN_USERNAME=your_admin_email@example.com TEST_ADMIN_PASSWORD=your_admin_password APP_API_URL=http://localhost:3000 +DIGITAL_DEMOCRACY_API_KEY=your_digital_democracy_api_key diff --git a/components/bootstrap.ts b/components/bootstrap.ts index 1fa768584..f231fb79b 100644 --- a/components/bootstrap.ts +++ b/components/bootstrap.ts @@ -4,6 +4,7 @@ export { default as Alert } from "react-bootstrap/Alert" export { default as Badge } from "react-bootstrap/Badge" export { default as Button } from "react-bootstrap/Button" export { default as Card } from "react-bootstrap/Card" +export { default as Carousel } from "react-bootstrap/Carousel" export { default as Col } from "react-bootstrap/Col" export { default as Collapse } from "react-bootstrap/Collapse" export { default as Container } from "react-bootstrap/Container" diff --git a/components/hearing/DDHearingDetails.tsx b/components/hearing/DDHearingDetails.tsx new file mode 100644 index 000000000..c9b4e1177 --- /dev/null +++ b/components/hearing/DDHearingDetails.tsx @@ -0,0 +1,169 @@ +import { useTranslation } from "next-i18next" +import { useEffect, useState } from "react" +import styled from "styled-components" +import { Carousel, Col, Container, Row } from "../bootstrap" +import { Back } from "../shared/CommonComponents" +import { Internal } from "../links" +import { HearingSidebar } from "./HearingSidebar" +import { DDHearing, DDUtterance, speakerName } from "./digitalDemocracyApi" + +// Hardcoded for hearing 279802 for testing; production needs a real hid -> committee/general court lookup +const COMMITTEE_CODE = "SJ42" +const GENERAL_COURT_NUMBER = "194" + +const ddApiPersonIdToMemberId: Record = { + 211022: "PRF0", // Paul Feeney + 211000: "MSD1", // Michael Day + 211028: "RCF0", // Ryan Fattman + 210993: "K_H1", // Kate Hogan + 210961: "DTV1", // David Vieira + 210945: "CFF0", // Cindy Friedman + 210928: "BRF0", // Barry Finegold + 210931: "BPC0", // Brendan Crighton + 210924: "AHP1", // Alice Peisch + 210964: "FAM1" // Frank Moran +} + +const VideoWrapper = styled.div` + max-width: 700px; + margin: 0 auto; +` + +/* padding-top % is relative to VideoWrapper's width, so max-width must live there, not here */ +const VideoParent = styled.div` + position: relative; + width: 100%; + padding-top: 56.25%; /* For 16:9 aspect ratio */ + overflow: hidden; +` + +const VideoChild = styled.video` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; +` + +const TranscriptContainer = styled(Container)` + background-color: var(--maple-surface-base); + max-height: 500px; + overflow-y: auto; +` + +const TranscriptRow = styled(Row)` + &:nth-child(even) { + background-color: white; + } + &:nth-child(odd) { + background-color: var(--maple-surface-transcript-stripe); + } +` + +const Speaker = styled.div` + font-weight: 600; + color: var(--maple-text-strong); +` + +export const DDHearingDetails = ({ hearing }: { hearing: DDHearing }) => { + const { t } = useTranslation(["common", "hearing"]) + const [utterances, setUtterances] = useState(null) + + useEffect(() => { + let cancelled = false + ;(async function () { + const res = await fetch(`/api/hearings/${hearing.hid}/utterances`) + if (!res.ok || cancelled) return + const body: { utterances: DDUtterance[] } = await res.json() + if (!cancelled) setUtterances(body.utterances) + })() + return () => { + cancelled = true + } + }, [hearing.hid]) + + const videos = [...hearing.hearing_videos].sort( + (a, b) => (a.start_time ?? a.uid) - (b.start_time ?? b.uid) + ) + + return ( + + + + {t("back_to_hearings")} + + + +

{hearing.title}

+ + + + {videos.length > 0 ? ( + 1} + controls={videos.length > 1} + > + {videos.map(video => ( + + + + + + + + ))} + + ) : null} + + + {utterances === null ? ( +
+ {t("transcript_loading", { ns: "hearing" })} +
+ ) : ( + utterances.map(utterance => { + const name = + speakerName(utterance) ?? + t("unknown_speaker", { ns: "hearing" }) + const memberId = + utterance.person_type === "legislator" && + utterance.pid !== null + ? ddApiPersonIdToMemberId[utterance.pid] + : undefined + + return ( + + + {memberId ? ( + + {name} + + ) : ( + name + )} + +
{utterance.content}
+
+ ) + }) + )} +
+ + +
+ +
+
+
+ ) +} diff --git a/components/hearing/digitalDemocracyApi.ts b/components/hearing/digitalDemocracyApi.ts new file mode 100644 index 000000000..de288629a --- /dev/null +++ b/components/hearing/digitalDemocracyApi.ts @@ -0,0 +1,87 @@ +const API_KEY = process.env.DIGITAL_DEMOCRACY_API_KEY ?? "" + +const BASE_URL = "https://api.digitaldemocracy.org" + +export type DDHearingVideo = { + file_id: string + uid: number + video_url: string + // Not present on all responses; falls back to `uid` ordering when absent. + start_time?: number +} + +export type DDHearing = { + hid: number + title: string + date: string + session_year: string + state: string + hearing_video_thumbnail: string | null + hearing_videos: DDHearingVideo[] +} + +type DDHearingResponse = { + data: { + hearing: DDHearing + agenda: unknown[] + } +} + +export type DDUtterance = { + content: string + first: string | null + last: string | null + person_type: string + pid: number | null + hid: number + uid: number + file_id: string + timestamp: number + date: number +} + +type DDUtterancesResponse = { + data: { + utterances: DDUtterance[] + } + page: number + per_page: number + total_pages: number + total_results: number +} + +function headers() { + return { "x-api-key": API_KEY } +} + +export async function fetchDDHearing( + hid: string | number +): Promise { + const res = await fetch(`${BASE_URL}/legacy/ma/hearing/${hid}`, { + headers: headers() + }) + if (!res.ok) return null + + const body: DDHearingResponse = await res.json() + return body.data?.hearing ?? null +} + +export async function fetchDDUtterances( + hid: string | number +): Promise { + const res = await fetch( + `${BASE_URL}/legacy/ma/elasticsearch/utterances?hid=${hid}&per_page=500`, + { headers: headers() } + ) + if (!res.ok) return [] + + const body: DDUtterancesResponse = await res.json() + const utterances = body.data?.utterances ?? [] + return [...utterances].sort((a, b) => a.timestamp - b.timestamp) +} + +export function speakerName(utterance: DDUtterance): string | null { + const { first, last } = utterance + if (!first && !last) return null + return [first, last].filter(Boolean).join(" ") +} diff --git a/pages/api/hearings/[hid]/utterances.ts b/pages/api/hearings/[hid]/utterances.ts new file mode 100644 index 000000000..a46f6dc8e --- /dev/null +++ b/pages/api/hearings/[hid]/utterances.ts @@ -0,0 +1,28 @@ +import { NextApiRequest, NextApiResponse } from "next" +import { z } from "zod" +import { fetchDDUtterances } from "components/hearing/digitalDemocracyApi" + +const QuerySchema = z.object({ hid: z.coerce.number() }) + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== "GET") { + res.status(404).end() + return + } + + const query = QuerySchema.safeParse(req.query) + if (!query.success) { + res.status(400).json({ error: "Invalid hid" }) + return + } + + const utterances = await fetchDDUtterances(query.data.hid) + res.setHeader( + "Cache-Control", + "public, s-maxage=3600, stale-while-revalidate=3600" + ) + res.status(200).json({ utterances }) +} diff --git a/pages/hearings/[hid].tsx b/pages/hearings/[hid].tsx new file mode 100644 index 000000000..5fa569f8a --- /dev/null +++ b/pages/hearings/[hid].tsx @@ -0,0 +1,45 @@ +import { GetServerSideProps } from "next" +import { serverSideTranslations } from "next-i18next/serverSideTranslations" +import { z } from "zod" +import { createPage } from "../../components/page" +import { DDHearingDetails } from "components/hearing/DDHearingDetails" +import { + DDHearing, + fetchDDHearing +} from "components/hearing/digitalDemocracyApi" + +const Query = z.object({ hid: z.coerce.number() }) + +export default createPage<{ hearing: DDHearing }>({ + titleI18nKey: "navigation.hearing", + Page: ({ hearing }) => { + return + } +}) + +export const getServerSideProps: GetServerSideProps = async ctx => { + ctx.res.setHeader( + "Cache-Control", + "public, s-maxage=3600, stale-while-revalidate=3600" + ) + + const locale = ctx.locale ?? ctx.defaultLocale ?? "en" + + const query = Query.safeParse(ctx.query) + if (!query.success) return { notFound: true } + + const hearing = await fetchDDHearing(query.data.hid) + if (!hearing) return { notFound: true } + + return { + props: { + hearing, + ...(await serverSideTranslations(locale, [ + "auth", + "common", + "footer", + "hearing" + ])) + } + } +} diff --git a/public/locales/en/hearing.json b/public/locales/en/hearing.json index bfd82a69c..48f1c2087 100644 --- a/public/locales/en/hearing.json +++ b/public/locales/en/hearing.json @@ -28,6 +28,7 @@ "see_less": "See less", "senate_chair": "Senate Chair", "transcript_loading": "Loading transcript for this hearing...", + "unknown_speaker": "Unknown speaker", "video_and_transcription_feature_callout": "Hearing Video + Transcription", "view_bill": "View Bill Details", "view_votes": "View Committee Votes",