diff --git a/apps/web/app/api/member/[id]/documents/[page]/route.ts b/apps/web/app/api/member/[id]/documents/[page]/route.ts new file mode 100644 index 000000000..b64c12c58 --- /dev/null +++ b/apps/web/app/api/member/[id]/documents/[page]/route.ts @@ -0,0 +1,22 @@ +import getMemberDocuments from "@lib/api/documents/get-member-documents"; + +interface Payload { + params: { + id: string; + page: string; + }; +} + +export async function GET( + _request: Request, + { params: { id, page } }: Payload, +) { + const pageInt = parseInt(page); + + if (Number.isNaN(pageInt)) { + return new Response("Invalid page", { status: 400 }); + } + + const memberDocuments = await getMemberDocuments({ id, page: pageInt }); + return Response.json(memberDocuments); +} diff --git a/apps/web/app/cookie-policy/page.tsx b/apps/web/app/cookie-policy/page.tsx index 57bb426a2..7ab09e23c 100644 --- a/apps/web/app/cookie-policy/page.tsx +++ b/apps/web/app/cookie-policy/page.tsx @@ -1,4 +1,4 @@ -import { Card } from "@components/card"; +import { Card } from "@components/common/card"; import Container from "@components/common/container"; import PageTitle from "@components/common/page-title"; import { CodeBracketIcon } from "@heroicons/react/24/solid"; diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index 253d8f1f5..2e3007c70 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -1,7 +1,7 @@ "use client"; -import { PrimaryButton } from "@components/button"; -import { Card } from "@components/card"; +import { PrimaryButton } from "@components/common/button"; +import { Card } from "@components/common/card"; import Container from "@components/common/container"; import PageTitle from "@components/common/page-title"; import { ExclamationCircleIcon } from "@heroicons/react/24/solid"; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 36b09ca0b..0bbddff8a 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,6 +5,7 @@ import Head from "./head"; import Footer from "./footer"; import Header from "@components/header/header"; import { ThemeProvider } from "@components/providers/theme-provider"; +import { twMerge } from "tailwind-merge"; const roboto = Roboto({ weight: ["300", "400", "500", "700"], @@ -18,7 +19,10 @@ export default function RootLayout({ children }: PropsWithChildren) {
diff --git a/apps/web/app/ledamot/[id]/biography-entry.tsx b/apps/web/app/ledamot/[id]/biography-entry.tsx new file mode 100644 index 000000000..4de60b74e --- /dev/null +++ b/apps/web/app/ledamot/[id]/biography-entry.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { ChevronDownIcon } from "@heroicons/react/24/solid"; +import type { Information } from "@lib/api/member/types"; +import { useState } from "react"; + +interface Props { + information: Information; +} + +export default function BiographyEntry({ information }: Props) { + const [isOpen, setIsOpen] = useState(false); + + function toggleEntry() { + setIsOpen((prevState) => !prevState); + } + + return ( + + ); +} diff --git a/apps/web/app/ledamot/[id]/biography.tsx b/apps/web/app/ledamot/[id]/biography.tsx new file mode 100644 index 000000000..8af125f53 --- /dev/null +++ b/apps/web/app/ledamot/[id]/biography.tsx @@ -0,0 +1,27 @@ +import { Card } from "@components/common/card"; +import type { Information } from "@lib/api/member/types"; +import BiographyEntry from "./biography-entry"; + +interface Props { + memberInformation: Information[]; +} + +export default function Biography({ memberInformation }: Props) { + if (memberInformation.length === 0) { + return null; + } + + return ( + +

Biografi

+
+ {memberInformation.map((info) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/app/ledamot/[id]/documents.tsx b/apps/web/app/ledamot/[id]/documents.tsx new file mode 100644 index 000000000..6b6c4393a --- /dev/null +++ b/apps/web/app/ledamot/[id]/documents.tsx @@ -0,0 +1,73 @@ +"use client"; +import { Card, CommitteeHeader } from "@components/common/card"; +import Pagination from "@components/common/pagination"; +import type { MemberDocument, MemberDocuments } from "@lib/api/member/types"; +import { routes } from "@lib/navigation"; +import Link from "next/link"; +import { useRef, useState } from "react"; + +interface DocumentProps { + document: MemberDocument; +} + +function Document({ document }: DocumentProps) { + return ( + + + +
+

+ {document.title} +

+

{document.altTitle}

+

+ {document.subtitle} +

+
+
+ + ); +} + +interface Props { + memberId: string; + initialDocuments: MemberDocuments; +} + +export default function Documents({ initialDocuments, memberId }: Props) { + const containerRef = useRef(null); + const [page, setPage] = useState(1); + const [documents, setDocuments] = useState(initialDocuments.documents); + + async function changePage(page: number) { + const response = await fetch( + `${window.location.origin}${routes.api.memberDocument(memberId, page)}`, + { next: { revalidate: 60 * 60 * 24 } }, + ); + const newDocuments: MemberDocuments = await response.json(); + setPage(page); + setDocuments(newDocuments.documents); + containerRef.current?.scrollIntoView(); + } + + return ( +
+ + {documents.map((document) => ( + + ))} + +
+ ); +} diff --git a/apps/web/app/ledamot/[id]/page.tsx b/apps/web/app/ledamot/[id]/page.tsx index 7c76327d5..51988bff3 100644 --- a/apps/web/app/ledamot/[id]/page.tsx +++ b/apps/web/app/ledamot/[id]/page.tsx @@ -2,6 +2,16 @@ 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"; +import Profile from "./profile"; +import BreadcrumbsSocialMediaShare from "@components/common/breadcrumbs-social-media-share"; +import { routes } from "@lib/navigation"; +import Container from "@components/common/container"; +import Statistics from "./statistics"; +import getMemberWithAbsence from "@lib/api/member/get-member-with-absence"; +import getMemberDocuments from "@lib/api/documents/get-member-documents"; +import Biography from "./biography"; +import Tabs from "./tabs"; +import getMemberTwitterFeed from "@lib/api/wikidata/get-member-twitter-feed"; interface PageProps { params: { @@ -29,15 +39,44 @@ export async function generateMetadata({ params: { id } }: PageProps) { } export default async function MemberPage({ params: { id } }: PageProps) { - const member = await getMember(id); + const memberPromise = getMemberWithAbsence(id); + const memberDocumentsPromise = getMemberDocuments({ id, page: 1 }); + const memberTwitterPromise = getMemberTwitterFeed(id); + const [member, memberDocuments, memberTwitter] = await Promise.all([ + memberPromise, + memberDocumentsPromise, + memberTwitterPromise, + ]); + if (!member) { return notFound(); } - // TODO: Implement + return ( -

- {member.firstName} {member.lastName} -

+
+ + + + + + + +
); } diff --git a/apps/web/app/ledamot/[id]/profile.tsx b/apps/web/app/ledamot/[id]/profile.tsx new file mode 100644 index 000000000..1004945ac --- /dev/null +++ b/apps/web/app/ledamot/[id]/profile.tsx @@ -0,0 +1,27 @@ +import MemberImage from "@components/parliament/member-image"; +import type { MemberResponse } from "@lib/api/member/types"; + +interface ProfileProps { + member: MemberResponse; +} + +export default function Profile({ member }: ProfileProps) { + return ( +
+
+ +
+ {member.status} + {member.isLeader && " och partiledare"} +
+

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

+
{member.age} år
+
+ ); +} diff --git a/apps/web/app/ledamot/[id]/statistics.tsx b/apps/web/app/ledamot/[id]/statistics.tsx new file mode 100644 index 000000000..80cc51eee --- /dev/null +++ b/apps/web/app/ledamot/[id]/statistics.tsx @@ -0,0 +1,51 @@ +import { Card } from "@components/common/card"; +import type { MemberDetailedResponse } from "@lib/api/member/types"; +import { twMerge } from "tailwind-merge"; + +interface StatisticsCardProps { + value: string; + description: string; + className?: string; +} +function StatisticsCard({ + value, + description, + className, +}: StatisticsCardProps) { + return ( + +
{value}
+
{description}
+
+ ); +} + +interface Props { + absence: MemberDetailedResponse["absence"]; + documentCount: number; +} + +export default function Statistics({ absence, documentCount }: Props) { + return ( +
+ {absence.mandatePeriod.value !== null && ( + + )} + {absence.parliamentYear.value !== null && ( + + )} + +
+ ); +} diff --git a/apps/web/app/ledamot/[id]/tabs.tsx b/apps/web/app/ledamot/[id]/tabs.tsx new file mode 100644 index 000000000..d0d17ddde --- /dev/null +++ b/apps/web/app/ledamot/[id]/tabs.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { Card } from "@components/common/card"; +import type { MemberDocuments } from "@lib/api/member/types"; +import { useState } from "react"; +import Documents from "./documents"; +import type { TwitterResult } from "@lib/api/wikidata/types"; +import TwitterFeed from "./twitter-feed"; + +enum Tab { + Document = "document-tab", + Twitter = "twitter-tab", +} + +interface Props { + memberId: string; + initialDocuments: MemberDocuments; + twitterFeed?: TwitterResult; +} + +export default function Tabs({ + memberId, + initialDocuments, + twitterFeed, +}: Props) { + const [activeTab, setActiveTab] = useState(Tab.Document); + + function setTab(event: React.MouseEvent) { + if ("id" in event.target) { + setActiveTab(event.target.id as Tab); + } + } + + return ( + +
+ + +
+ {activeTab === Tab.Document && ( + + )} + {activeTab === Tab.Twitter && } +
+ ); +} diff --git a/apps/web/app/ledamot/[id]/twitter-feed.tsx b/apps/web/app/ledamot/[id]/twitter-feed.tsx new file mode 100644 index 000000000..4da1fa5cb --- /dev/null +++ b/apps/web/app/ledamot/[id]/twitter-feed.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Loading from "@components/common/loading"; +import type { TwitterResult } from "@lib/api/wikidata/types"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + twttr: any; + } +} + +interface Props { + twitterFeed?: TwitterResult; +} + +export default function TwitterFeed({ twitterFeed }: Props) { + const theme = useTheme(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const twitterHandle = twitterFeed?.twitterHandle.value; + if (twitterHandle) { + setLoading(true); + const container = document.getElementById("twitterContainer"); + if (container) { + container.innerHTML = ""; + } + window.twttr.widgets + .createTimeline( + { + sourceType: "profile", + screenName: twitterHandle, + }, + container, + { + theme: theme.theme, + chrome: "nofooter noborders", + }, + ) + .then(() => { + setLoading(false); + }); + } else { + setLoading(false); + } + }, [theme.theme, twitterFeed?.twitterHandle.value]); + + const twitterHandle = twitterFeed?.twitterHandle.value; + + if (!twitterHandle) { + return

Inget Twitterkonto hittades.

; + } + + return ( + <> +
+ {loading && } + + ); +} diff --git a/apps/web/app/loading.tsx b/apps/web/app/loading.tsx index 83f5a4ab5..ad605dbb3 100644 --- a/apps/web/app/loading.tsx +++ b/apps/web/app/loading.tsx @@ -1,23 +1,5 @@ -export default function Loading() { - return ( -
- - Loading... -
- ); +import Loading from "@components/common/loading"; + +export default function LoadingPage() { + return ; } diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index 8c1ba8c0d..3f7de4a95 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -1,4 +1,4 @@ -import { Card } from "@components/card"; +import { Card } from "@components/common/card"; import Container from "@components/common/container"; import PageTitle from "@components/common/page-title"; import { ExclamationCircleIcon } from "@heroicons/react/24/solid"; diff --git a/apps/web/app/om-oss/page.tsx b/apps/web/app/om-oss/page.tsx index 5ab591caf..1cfaf76a5 100644 --- a/apps/web/app/om-oss/page.tsx +++ b/apps/web/app/om-oss/page.tsx @@ -1,6 +1,6 @@ import { githubFrontend, githubProfile, linkedIn } from "@lib/socials"; import PageTitle from "@components/common/page-title"; -import { Card } from "@components/card"; +import { Card } from "@components/common/card"; import React from "react"; import { InformationCircleIcon } from "@heroicons/react/24/solid"; import ExternalLink from "@components/common/external-link"; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 09fadb736..3bd1220a5 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,9 +1,10 @@ -import { Card } from "@components/card"; +import { Card } from "@components/common/card"; import Typed from "@components/common/typed"; import Link from "next/link"; import { routes } from "@lib/navigation"; import PageTitle from "@components/common/page-title"; import Container from "@components/common/container"; +import { twMerge } from "tailwind-merge"; export const metadata = { title: "Partiguiden | Rösta rätt", @@ -53,7 +54,10 @@ export default function IndexPage() { {subject.name} diff --git a/apps/web/app/parti/[party]/page.tsx b/apps/web/app/parti/[party]/page.tsx index 84f8dc198..64093c622 100644 --- a/apps/web/app/parti/[party]/page.tsx +++ b/apps/web/app/parti/[party]/page.tsx @@ -1,4 +1,4 @@ -import { Card } from "@components/card"; +import { Card } from "@components/common/card"; import Container from "@components/common/container"; import { Divider } from "@components/common/divider"; import ExternalLink from "@components/common/external-link"; diff --git a/apps/web/app/standpunkter/[id]/party-standpoints.tsx b/apps/web/app/standpunkter/[id]/party-standpoints.tsx index a21ab9b80..8a3977907 100644 --- a/apps/web/app/standpunkter/[id]/party-standpoints.tsx +++ b/apps/web/app/standpunkter/[id]/party-standpoints.tsx @@ -1,13 +1,13 @@ "use client"; -import { Card } from "@components/card"; +import { Card } from "@components/common/card"; import { ChevronUpIcon } from "@heroicons/react/24/solid"; import { dateString } from "@lib/dates"; import { - backgroundHover, - borderBottom, - marker, - textColor, + partyBackgroundHover, + partyBorderBottom, + partyMarker, + partyTextColor, } from "@lib/styles/party"; import type { Standpoint } from "@partiguiden/party-data/types"; import type { Party } from "@partiguiden/party-data/types"; @@ -34,13 +34,14 @@ export default function PartyStandpoints({ <> {visible && ( @@ -50,7 +51,7 @@ export default function PartyStandpoints({

{standpoint.title}

{standpoint.opinions.length > 0 ? (