diff --git a/apps/web/app/ledamot/[id]/page.tsx b/apps/web/app/ledamot/[id]/page.tsx new file mode 100644 index 000000000..7c76327d5 --- /dev/null +++ b/apps/web/app/ledamot/[id]/page.tsx @@ -0,0 +1,50 @@ +import getMember from "@lib/api/member/get-member"; +import getMembers from "@lib/api/member/get-members"; +import { ERROR_404_TITLE } from "@lib/constants"; +import { notFound } from "next/navigation"; + +interface PageProps { + params: { + id: string; + }; +} + +export async function generateMetadata({ params: { id } }: PageProps) { + const member = await getMember(id); + + if (!member) { + return { + title: ERROR_404_TITLE, + }; + } + const memberName = `${member?.firstName ?? ""} ${ + member?.lastName ?? "" + }`.trim(); + return { + title: `${!!memberName ? `${memberName} |` : ""} Ledamot | Partiguiden`, + description: `Här kan du ta reda på information om ledamot${ + !!memberName ? ` ${memberName}` : "" + }. Se vilka dokument som hen har varit med och skapat och samt voteringsnärvaro.`, + }; +} + +export default async function MemberPage({ params: { id } }: PageProps) { + const member = await getMember(id); + if (!member) { + return notFound(); + } + // TODO: Implement + return ( +

+ {member.firstName} {member.lastName} +

+ ); +} + +export async function generateStaticParams() { + const members = await getMembers(); + + return members.map((member) => ({ + id: member.id, + })); +} diff --git a/apps/web/app/ledamot/filter-toggles.ts b/apps/web/app/ledamot/filter-toggles.ts new file mode 100644 index 000000000..117ca2a18 --- /dev/null +++ b/apps/web/app/ledamot/filter-toggles.ts @@ -0,0 +1,27 @@ +import type { FilterToggle } from "@components/filter/filter-context"; +import type { MemberParty } from "@lib/api/parliament/types"; +import { Party } from "@partiguiden/party-data/types"; +import { partyNames } from "@partiguiden/party-data/utils"; + +const order = [ + Party.C, + Party.KD, + Party.L, + Party.MP, + Party.M, + Party.S, + Party.SD, + Party.V, + "-", +] as const; + +export const partyFilterToggles: FilterToggle = order.reduce( + (prev, party) => ({ + ...prev, + [party]: { + title: party === "-" ? "Partilösa" : partyNames[party], + value: false, + }, + }), + {} as FilterToggle, +); diff --git a/apps/web/app/ledamot/member-card.tsx b/apps/web/app/ledamot/member-card.tsx new file mode 100644 index 000000000..44f2d6e04 --- /dev/null +++ b/apps/web/app/ledamot/member-card.tsx @@ -0,0 +1,53 @@ +import MemberImage from "@components/parliament/member-image"; +import type { MemberListEntry } from "@lib/api/member/types"; +import { partyLogo } from "@lib/assets"; +import { routes } from "@lib/navigation"; +import Image from "next/image"; +import Link from "next/link"; +import { twMerge } from "tailwind-merge"; + +interface Props { + member: MemberListEntry; +} + +export default function MemberCard({ member }: Props) { + return ( + +
+
Valkrets
+
{member.district}
+
Ålder
+
{member.age}
+
+ + {member.party !== "-" && ( + Partisymbol + )} + +
+ {member.firstName} {member.lastName} +
+ + ); +} diff --git a/apps/web/app/ledamot/member-list.tsx b/apps/web/app/ledamot/member-list.tsx new file mode 100644 index 000000000..c3df818ea --- /dev/null +++ b/apps/web/app/ledamot/member-list.tsx @@ -0,0 +1,73 @@ +"use client"; +import type { MemberListEntry } from "@lib/api/member/types"; +import MemberCard from "./member-card"; +import { useFilterContext } from "@components/filter/filter-context"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { MemberParty } from "@lib/api/parliament/types"; + +interface Props { + members: MemberListEntry[]; +} + +const MEMBERS_STEP = 18; + +export default function MemberList({ members }: Props) { + const endRef = useRef(null); + const [amount, setAmount] = useState(MEMBERS_STEP); + const { search, activeToggles } = useFilterContext(); + + const filteredMembers = useMemo(() => { + const inParty = activeToggles.length + ? members.filter((member) => activeToggles.includes(member.party)) + : members; + const inSearch = search + ? members.filter((member) => + `${member.firstName} ${member.lastName}` + .toLocaleLowerCase() + .includes(search.toLocaleLowerCase()), + ) + : inParty; + return inSearch; + }, [search, activeToggles, members]); + + useEffect(() => { + setAmount(MEMBERS_STEP); + }, [search, activeToggles, members]); + + useEffect(() => { + if (!endRef.current) { + return; + } + const observer = new IntersectionObserver( + ([endElement]) => { + if (endElement.isIntersecting) { + setAmount((prevValue) => + prevValue < filteredMembers.length + ? prevValue + MEMBERS_STEP + : prevValue, + ); + } + }, + { + rootMargin: "40px 0px 0px 0px", + }, + ); + + observer.observe(endRef.current); + + return () => { + observer.disconnect(); + }; + }, [filteredMembers]); + + return ( +
    + {filteredMembers.slice(0, amount).map((member) => ( +
  • + +
  • + ))} +
    +
+ ); +} diff --git a/apps/web/app/ledamot/page.tsx b/apps/web/app/ledamot/page.tsx new file mode 100644 index 000000000..de48ecadf --- /dev/null +++ b/apps/web/app/ledamot/page.tsx @@ -0,0 +1,32 @@ +import PageTitle from "@components/common/page-title"; +import Filter from "@components/filter"; +import getMembers from "@lib/api/member/get-members"; +import MemberList from "./member-list"; +import { FilterContextProvider } from "@components/filter/filter-context"; +import { partyFilterToggles } from "./filter-toggles"; + +export const metadata = { + title: "Riksdagsledamöter | Partiguiden", + description: + "Här kan du ta reda på information om ledamöterna i riksdagen, samt se vilka ledamöter är aktiva för varje parti", +}; + +// Revalidate data at most once per day (60 * 60 * 24)s +export const revalidate = 86400; + +export default async function MembersPage() { + const members = await getMembers(); + + return ( +
+ Riksdagsledamöter + +
+ + + + +
+
+ ); +} diff --git a/apps/web/app/parti/[party]/leader.tsx b/apps/web/app/parti/[party]/leader.tsx index eb0cd9c6c..3456f26f7 100644 --- a/apps/web/app/parti/[party]/leader.tsx +++ b/apps/web/app/parti/[party]/leader.tsx @@ -1,5 +1,5 @@ "use client"; -import type { Leader } from "@lib/api/types/member"; +import type { Leader } from "@lib/api/member/types"; import Link from "next/link"; import { routes } from "@lib/navigation"; import MemberImage from "@components/parliament/member-image"; @@ -14,7 +14,7 @@ export default function Leader({ leader }: LeaderProps) { href={routes.member(leader.id)} className="hover:bg-background-elevated-light dark:hover:bg-background-elevated-dark-200 grid gap-2 rounded py-2 text-center transition-colors" > - +

{leader.firstName} {leader.lastName} diff --git a/apps/web/app/parti/[party]/page.tsx b/apps/web/app/parti/[party]/page.tsx index dfb5b7bf6..84f8dc198 100644 --- a/apps/web/app/parti/[party]/page.tsx +++ b/apps/web/app/parti/[party]/page.tsx @@ -5,25 +5,29 @@ import ExternalLink from "@components/common/external-link"; import PageTitle from "@components/common/page-title"; import SocialMediaShare from "@components/common/social-media-share"; import PartyIcon from "@components/party/icon"; -import { partyController } from "@lib/api/controllers/party"; import { ERROR_404_TITLE } from "@lib/constants"; import { Party } from "@partiguiden/party-data/types"; -import { getPartyName } from "@partiguiden/party-data/utils"; +import { partyNames } from "@partiguiden/party-data/utils"; import { notFound } from "next/navigation"; import Leader from "./leader"; +import { getParty } from "@lib/api/party/get-party"; interface PageProps { params: { - party: Party; + party: Lowercase; }; } -export async function generateMetadata({ params: { party } }: PageProps) { +export async function generateMetadata({ + params: { party: partyAbbreviation }, +}: PageProps) { + const party = partyAbbreviation.toUpperCase() as Party; + if (!Object.values(Party).includes(party)) { return { title: ERROR_404_TITLE }; } - const partyName = getPartyName(party); + const partyName = partyNames[party]; return { title: `${partyName} | Party | Partiguiden`, @@ -32,12 +36,14 @@ export async function generateMetadata({ params: { party } }: PageProps) { } export default async function PartyPage({ - params: { party: partyAbbreviation }, + params: { party: partyAbbreviationLowercase }, }: PageProps) { + const partyAbbreviation = + partyAbbreviationLowercase.toLocaleUpperCase() as Party; if (!Object.values(Party).includes(partyAbbreviation)) { return notFound(); } - const party = await partyController(partyAbbreviation); + const party = await getParty(partyAbbreviation); return (

@@ -103,3 +109,11 @@ export default async function PartyPage({
); } + +export async function generateStaticParams() { + const parties = Object.values(Party); + + return parties.map((party) => ({ + party: party.toLocaleLowerCase(), + })); +} diff --git a/apps/web/app/standpunkter/[id]/page.tsx b/apps/web/app/standpunkter/[id]/page.tsx index 9e240dbc3..1755818c1 100644 --- a/apps/web/app/standpunkter/[id]/page.tsx +++ b/apps/web/app/standpunkter/[id]/page.tsx @@ -3,6 +3,7 @@ import type { Party } from "@partiguiden/party-data/types"; import { getStandpointsForSubject, getSubject, + getSubjects, } from "@partiguiden/party-data/reader"; import { ERROR_404_TITLE } from "@lib/constants"; import PageTitle from "@components/common/page-title"; @@ -85,3 +86,11 @@ export default function Standpoints({ params: { id } }: PageProps) { ); } + +export async function generateStaticParams() { + const subjects = getSubjects(); + + return subjects.map((subject) => ({ + id: subject.id, + })); +} diff --git a/apps/web/app/standpunkter/[id]/party-standpoints.tsx b/apps/web/app/standpunkter/[id]/party-standpoints.tsx index 975731fd5..a21ab9b80 100644 --- a/apps/web/app/standpunkter/[id]/party-standpoints.tsx +++ b/apps/web/app/standpunkter/[id]/party-standpoints.tsx @@ -11,7 +11,7 @@ import { } from "@lib/styles/party"; import type { Standpoint } from "@partiguiden/party-data/types"; import type { Party } from "@partiguiden/party-data/types"; -import { getPartyName } from "@partiguiden/party-data/utils"; +import { partyNames } from "@partiguiden/party-data/utils"; import { useState } from "react"; import { twMerge } from "tailwind-merge"; @@ -36,7 +36,7 @@ export default function PartyStandpoints({ onClick={handleClick} className={`${borderBottom[party]} flex w-full items-center justify-between border-b-2 py-3 pl-2 text-start text-3xl font-light`} > - {getPartyName(party)} + {partyNames[party]} {links?.map((link) => ( - <> +
  • {link.title}
  • - +
    ))}
  • {current}
  • diff --git a/apps/web/components/filter/filter-context.tsx b/apps/web/components/filter/filter-context.tsx new file mode 100644 index 000000000..17fb6c052 --- /dev/null +++ b/apps/web/components/filter/filter-context.tsx @@ -0,0 +1,72 @@ +"use client"; +import { createContext, useContext, useMemo, useState } from "react"; + +interface ToggleValue { + title: string; + value: boolean; +} + +export type FilterToggle = Record; + +interface IFilterContext { + search: string; + toggles: FilterToggle; + activeToggles: K[]; + updateSearch: (search: string) => void; + updateToggle: (key: K, value: boolean) => void; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const FilterContext = createContext>({ + search: "", + toggles: {}, + activeToggles: [], + updateSearch: () => {}, + updateToggle: () => {}, +}); + +type FilterContextProps = React.PropsWithChildren<{ + initialSearch?: string; + initialToggles: Record; +}>; + +export function FilterContextProvider({ + children, + initialSearch = "", + initialToggles, +}: FilterContextProps) { + const [search, setSearch] = useState(initialSearch); + const [toggles, setToggles] = useState(initialToggles); + + function updateSearch(newSearch: string) { + setSearch(newSearch); + } + + function updateToggle(key: K, value: boolean) { + setToggles((prevState) => { + const newState = { ...prevState }; + newState[key].value = value; + return newState; + }); + } + + const activeToggles = useMemo(() => { + return Object.entries(toggles) + .filter((entry) => entry[1].value) + .map((entry) => entry[0]) as K[]; + }, [toggles]); + + return ( + + {children} + + ); +} + +export function useFilterContext(): IFilterContext { + return useContext>(FilterContext); +} + +export default FilterContext; diff --git a/apps/web/components/filter/index.tsx b/apps/web/components/filter/index.tsx new file mode 100644 index 000000000..7487ee1db --- /dev/null +++ b/apps/web/components/filter/index.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { twMerge } from "tailwind-merge"; +import SearchFilter from "./search"; +import SearchToggles from "./toggles"; +import { useEffect, useState } from "react"; +import { + AdjustmentsHorizontalIcon, + XMarkIcon, +} from "@heroicons/react/24/solid"; + +export default function Filter() { + const [drawerVisible, setDrawerVisible] = useState(false); + + useEffect(() => { + if (drawerVisible) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + }, [drawerVisible]); + + function openDrawer() { + setDrawerVisible(true); + } + + function closeDrawer() { + setDrawerVisible(false); + } + + return ( +
    + + ); +} diff --git a/apps/web/components/filter/search.tsx b/apps/web/components/filter/search.tsx new file mode 100644 index 000000000..5783c8cc0 --- /dev/null +++ b/apps/web/components/filter/search.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { MagnifyingGlassIcon } from "@heroicons/react/24/solid"; +import { twMerge } from "tailwind-merge"; +import { useFilterContext } from "./filter-context"; + +export default function SearchFilter() { + const { search, updateSearch } = useFilterContext(); + + function handleChange(event: React.ChangeEvent) { + updateSearch(event.target.value); + } + + return ( +
    + + +
    + ); +} diff --git a/apps/web/components/filter/toggles.tsx b/apps/web/components/filter/toggles.tsx new file mode 100644 index 000000000..c01c443e7 --- /dev/null +++ b/apps/web/components/filter/toggles.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { twMerge } from "tailwind-merge"; +import { useFilterContext } from "./filter-context"; +import CheckMark from "@components/icons/check-mark"; + +export default function SearchToggles() { + const { toggles, updateToggle } = useFilterContext(); + + return ( +
    + {Object.entries(toggles).map(([key, { title, value }]) => ( +
    + + +
    + ))} +
    + ); +} diff --git a/apps/web/components/header/drawer-navigation.tsx b/apps/web/components/header/drawer-navigation.tsx index 25d01db5a..02fc9d8c0 100644 --- a/apps/web/components/header/drawer-navigation.tsx +++ b/apps/web/components/header/drawer-navigation.tsx @@ -33,8 +33,8 @@ export default function DrawerNavigation() { />
    diff --git a/apps/web/components/header/header.tsx b/apps/web/components/header/header.tsx index 665a947d1..62f61ebff 100644 --- a/apps/web/components/header/header.tsx +++ b/apps/web/components/header/header.tsx @@ -9,8 +9,8 @@ export default function Header() { return ( -
    -
    +
    +
    diff --git a/apps/web/components/header/tab-entry.tsx b/apps/web/components/header/tab-entry.tsx index f62bc9d94..1310529f7 100644 --- a/apps/web/components/header/tab-entry.tsx +++ b/apps/web/components/header/tab-entry.tsx @@ -11,7 +11,8 @@ import { twMerge } from "tailwind-merge"; const tabClassName = twMerge( "min-w-[90px] flex-shrink-0 whitespace-nowrap p-4 text-sm uppercase hover:opacity-80", "border-primary-light dark:border-primary-elevated-light", - "aria-current-page:border-b-2", + "aria-current-page:border-b-2 outline-none", + "focus:border-b-2 focus:border-primary-dark focus:dark:border-primary", ); interface DropdownProps { diff --git a/apps/web/components/header/tab-navigation.tsx b/apps/web/components/header/tab-navigation.tsx index 4af262ae3..58e2db48d 100644 --- a/apps/web/components/header/tab-navigation.tsx +++ b/apps/web/components/header/tab-navigation.tsx @@ -1,34 +1,21 @@ "use client"; import { mainNavigation } from "@lib/navigation"; -import { useCallback, useLayoutEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ArrowRightIcon, ArrowLeftIcon } from "@heroicons/react/24/solid"; import TabEntry from "./tab-entry"; const SCROLL_STEP = 300; +const leftDivId = "navbar-left-boundary"; +const rightDivId = "navbar-right-boundary"; export default function TabNavigation() { + const leftDivRef = useRef(null); + const rightDivRef = useRef(null); const [showLeftButton, setShowLeftButton] = useState(false); const [showRightButton, setShowRightButton] = useState(false); const navRef = useRef(null); - const onScroll = useCallback>((event) => { - if (event.currentTarget.scrollLeft > 0) { - setShowLeftButton(true); - } else if (event.currentTarget.scrollLeft <= 0) { - setShowLeftButton(false); - } - - if ( - event.currentTarget.scrollWidth - event.currentTarget.scrollLeft <= - event.currentTarget.clientWidth - ) { - setShowRightButton(false); - } else { - setShowRightButton(true); - } - }, []); - const handleScrollRight = useCallback< React.MouseEventHandler >(() => { @@ -41,22 +28,33 @@ export default function TabNavigation() { navRef.current?.scrollBy({ left: -SCROLL_STEP }); }, []); - useLayoutEffect(() => { - function resizeHandler() { - if (navRef.current?.clientWidth === navRef.current?.scrollWidth) { - setShowRightButton(false); - } else if ( - navRef.current && - navRef.current?.clientWidth < navRef.current?.scrollWidth - ) { - setShowRightButton(true); - } + useEffect(() => { + if (!leftDivRef.current || !rightDivRef.current) { + return; } + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.target.id === rightDivId) { + if (entry.isIntersecting) { + setShowRightButton(false); + return; + } + setShowRightButton(true); + } + if (entry.target.id === leftDivId) { + if (entry.isIntersecting) { + setShowLeftButton(false); + return; + } + setShowLeftButton(true); + } + }); + }); + observer.observe(leftDivRef.current); + observer.observe(rightDivRef.current); - window.addEventListener("resize", resizeHandler); - resizeHandler(); return () => { - window.removeEventListener("resize", resizeHandler); + observer.disconnect(); }; }, []); @@ -72,11 +70,12 @@ export default function TabNavigation() { {showRightButton ? (