diff --git a/components/featureFlags.ts b/components/featureFlags.ts index a7e011629..f6214a5ca 100644 --- a/components/featureFlags.ts +++ b/components/featureFlags.ts @@ -13,7 +13,9 @@ export const FeatureFlags = z.object({ /** Lobbying Table */ lobbyingTable: z.boolean().default(false), /** LLM Bill Summary and Tags **/ - showLLMFeatures: z.boolean().default(false) + showLLMFeatures: z.boolean().default(false), + /** Hearings and Transcriptions **/ + hearingsAndTranscriptions: z.boolean().default(false) }) export type FeatureFlags = z.infer @@ -32,7 +34,8 @@ const defaults: Record = { billTracker: true, followOrg: true, lobbyingTable: false, - showLLMFeatures: true + showLLMFeatures: true, + hearingsAndTranscriptions: true }, production: { testimonyDiffing: false, @@ -40,7 +43,8 @@ const defaults: Record = { billTracker: false, followOrg: true, lobbyingTable: false, - showLLMFeatures: true + showLLMFeatures: true, + hearingsAndTranscriptions: false }, test: { testimonyDiffing: false, @@ -48,7 +52,8 @@ const defaults: Record = { billTracker: false, followOrg: true, lobbyingTable: false, - showLLMFeatures: true + showLLMFeatures: true, + hearingsAndTranscriptions: true } } diff --git a/components/hearing/HearingDetails.tsx b/components/hearing/HearingDetails.tsx new file mode 100644 index 000000000..1249ebdf5 --- /dev/null +++ b/components/hearing/HearingDetails.tsx @@ -0,0 +1,157 @@ +import { doc, getDoc } from "firebase/firestore" +import { Trans, useTranslation } from "next-i18next" +import { useCallback, useEffect, useRef, useState } from "react" +import styled from "styled-components" +import { Col, Container, Image, Row } from "../bootstrap" +import { HearingSidebar } from "./HearingSidebar" +import { Transcriptions } from "./Transcriptions" +import { firestore } from "components/firebase" +import * as links from "components/links" + +export const CommitteeButton = styled.button` + border-radius: 12px; + font-size: 12px; +` + +const LegalContainer = styled(Container)` + background-color: white; +` + +const VideoChild = styled.video` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; +` + +const VideoParent = styled.div` + position: relative; + width: 100%; + padding-top: 56.25%; /* For 16:9 aspect ratio */ + overflow: hidden; +` + +export const HearingDetails = ({ + hearingId +}: { + hearingId: string | string[] | undefined +}) => { + const { t } = useTranslation(["common", "hearing"]) + + const videoRef = useRef(null) + function setCurTimeVideo(value: number) { + videoRef.current ? (videoRef.current.currentTime = value) : null + } + + const eventId = `hearing-${hearingId}` + + const [committeeCode, setCommitteeCode] = useState("") + const [committeeName, setCommitteeName] = useState("") + const [description, setDescription] = useState("") + const [generalCourtNumber, setGeneralCourtNumber] = useState("") + const [hearingDate, setHearingDate] = useState("") + const [videoTranscriptionId, setVideoTranscriptionId] = useState("") + const [videoURL, setVideoURL] = useState("") + + const hearingData = useCallback(async () => { + const hearing = await getDoc(doc(firestore, `events/${eventId}`)) + const docData = hearing.data() + + setCommitteeCode(docData?.content.HearingHost.CommitteeCode) + setCommitteeName(docData?.content.Name) + setDescription(docData?.content.Description) + setGeneralCourtNumber(docData?.content.HearingHost.GeneralCourtNumber) + setHearingDate(docData?.content.EventDate) + setVideoTranscriptionId(docData?.videoTranscriptionId) + setVideoURL(docData?.videoURL) + }, [eventId]) + + useEffect(() => { + hearingData() + }, [hearingData]) + + return ( + +

+ {t("hearing")} {hearingId} +

+ +
{description}
+ + {committeeName ? ( + + +   {committeeName}   + + + ) : ( + <> + )} + +
+ + + + +
+ {t("bill.smart_tag")} + {t("bill.smart_disclaimer2")} +
+ + + + + ]} + /> + +
+
+ + {videoURL ? ( + + + + ) : ( + + {t("no_video_on_file")} + + )} + + + + +
+ +
+
+
+ ) +} diff --git a/components/hearing/HearingSidebar.tsx b/components/hearing/HearingSidebar.tsx new file mode 100644 index 000000000..e8dfffe2f --- /dev/null +++ b/components/hearing/HearingSidebar.tsx @@ -0,0 +1,215 @@ +import { doc, getDoc } from "firebase/firestore" +import { useTranslation } from "next-i18next" +import { useCallback, useEffect, useState } from "react" +import styled from "styled-components" +import { CommitteeButton } from "./HearingDetails" +import { firestore } from "components/firebase" +import * as links from "components/links" +import { LabeledIcon } from "components/shared" + +interface Legislator { + Details: string + GeneralCourtNumber: number + MemberCode: string +} + +interface Members { + id: string + name: string +} + +const SidebarBody = styled.div` + background-color: white; +` + +const SidebarBottom = styled.div` + background-color: white; + border-bottom-left-radius: 0.75rem; + border-bottom-right-radius: 0.75rem; + height: 11px; +` + +const SidebarHeader = styled.div` + background-color: #c0c4dc; + border-top-left-radius: 0.75rem; + border-top-right-radius: 0.75rem; + padding-top: 9px; +` + +const SidebarSubbody = styled.div` + font-size: 0.85rem; +` + +export const HearingSidebar = ({ + committeeCode, + generalCourtNumber, + hearingDate +}: { + committeeCode: string + generalCourtNumber: string + hearingDate: string +}) => { + const { t } = useTranslation(["common", "hearing"]) + + const dateObject = new Date(hearingDate) + const formattedDate = dateObject.toLocaleDateString("en-US", { + month: "long", + day: "2-digit", + year: "numeric" + }) + let dateCheck = false + if (formattedDate !== `Invalid Date`) { + dateCheck = true + } + + const [houseChairName, setHouseChairName] = useState("") + const [houseChairperson, setHouseChairperson] = useState() + const [members, setMembers] = useState() + const [senateChairName, setSenateChairName] = useState("") + const [senateChairperson, setSenateChairperson] = useState() + const [showMembers, setShowMembers] = useState(false) + + const toggleMembers = () => { + setShowMembers(!showMembers) + } + + const committeeData = useCallback(async () => { + const committee = await getDoc( + doc( + firestore, + `generalCourts/${generalCourtNumber}/committees/${committeeCode}` + ) + ) + const docData = committee.data() + + setHouseChairperson(docData?.content.HouseChairperson) + setSenateChairperson(docData?.content.SenateChairperson) + + const memberData: Members[] = docData?.members ?? [] + + const houseMembers = docData?.content?.HouseChairperson?.MemberCode + const houseMember = memberData.find(member => member.id === houseMembers) + let houseName = "" + houseMember && (houseName = houseMember.name) + + const senateMembers = docData?.content?.SenateChairperson?.MemberCode + const senateMember = memberData.find(member => member.id === senateMembers) + let senateName = "" + senateMember && (senateName = senateMember.name) + + setHouseChairName(houseName) + setSenateChairName(senateName) + setMembers(memberData) + }, [committeeCode, generalCourtNumber]) + + useEffect(() => { + committeeCode && generalCourtNumber ? committeeData() : null + }, [committeeCode, committeeData, generalCourtNumber]) + + return ( + <> + + {t("hearing_details")} + + + {dateCheck ? ( + + + {t("recording_date")} + +
{formattedDate}
+
+ ) : ( + <> + )} + + {committeeCode && ( + + {t("committee_members")} + + {t("chairs")} +
+ {houseChairperson && ( + + {houseChairName} + + } + /> + )} +
+
+ {senateChairperson && ( + + {senateChairName} + + } + /> + )} +
+ + {members ? ( + <> +
+ +   {showMembers ? "Show less" : "Show more"}   + +
+ + {showMembers ? ( + <> + {t("members")} +
+ {members.map((member: Members, index: number) => { + if ( + member.name !== houseChairName && + member.name !== senateChairName + ) { + return ( + + {member.name} + + } + /> + ) + } + })} +
+ + ) : ( + <> + )} + + ) : ( + <> + )} +
+
+ )} + + + ) +} diff --git a/components/hearing/Transcriptions.tsx b/components/hearing/Transcriptions.tsx new file mode 100644 index 000000000..674e1ade5 --- /dev/null +++ b/components/hearing/Transcriptions.tsx @@ -0,0 +1,159 @@ +import { collection, getDocs, orderBy, query } from "firebase/firestore" +import { useTranslation } from "next-i18next" +import React, { useCallback, useEffect, useState } from "react" +import styled from "styled-components" +import { Col, Container, Row } from "../bootstrap" +import { firestore } from "components/firebase" + +type Paragraph = { + confidence: number + end: number + start: number + text: string +} + +const ErrorContainer = styled(Container)` + background-color: white; +` + +const TimestampButton = styled.button` + border-radius: 12px; + width: min-content; +` + +const TimestampCol = styled.div` + width: 100px; +` + +const TranscriptionRow = styled(Row)` + &:first-child { + border-top-left-radius: 0.75rem; + border-top-right-radius: 0.75rem; + } + &:nth-child(even) { + /* background-color: #c0c4dc; */ + /* use #c0c4dc for selected rows when Search is implemented*/ + background-color: #e8ecf4; + } + &:nth-child(odd) { + background-color: white; + } + &:last-child { + border-bottom-left-radius: 0.75rem; + border-bottom-right-radius: 0.75rem; + } +` + +export const Transcriptions = ({ + setCurTimeVideo, + videoTranscriptionId +}: { + setCurTimeVideo: any + videoTranscriptionId: string +}) => { + const { t } = useTranslation(["common", "hearing"]) + + const vid = videoTranscriptionId || "prevent FirebaseError" + + const subscriptionRef = collection( + firestore, + `transcriptions/${vid}/paragraphs` + ) + + const [transcriptData, setTranscriptData] = useState([]) + + const fetchTranscriptionData = useCallback(async () => { + let docList: any[] = [] + + const q = query(subscriptionRef, orderBy("start")) + const querySnapshot = await getDocs(q) + + querySnapshot.forEach(doc => { + // doc.data() is never undefined for query doc snapshots + docList.push(doc.data()) + }) + + if (transcriptData.length === 0 && docList.length != 0) { + setTranscriptData(docList) + } + }, [subscriptionRef, transcriptData]) + + useEffect(() => { + fetchTranscriptionData() + }, [fetchTranscriptionData]) + + return ( + <> + {transcriptData.length > 0 ? ( + + {transcriptData.map((element: Paragraph, index: number) => ( + + ))} + + ) : ( + +
{t("transcription_not_on_file")}
+
+ )} + + ) +} + +function TranscriptItem({ + element, + setCurTimeVideo +}: { + element: Paragraph + setCurTimeVideo: any +}) { + const handleClick = (val: number) => { + const valSeconds = val / 1000 + /* data from backend is in milliseconds + + needs to be converted to seconds to + set currentTime property of