diff --git a/app/mayor/[year]/page.tsx b/app/mayor/[year]/page.tsx new file mode 100644 index 00000000..3350ec73 --- /dev/null +++ b/app/mayor/[year]/page.tsx @@ -0,0 +1,23 @@ +import { Metadata } from "next"; +import { YearlyMayor } from "../../../components/Mayor/YearlyMayor"; +import { Container } from "react-bootstrap"; +import NavBar from "../../../components/NavBar/NavBar"; +import Search from "../../../components/Search/Search"; + +export const metadata: Metadata = { + title: "Mayor Flips", + description: "A list of items that are affected by the current or upcoming mayor.", +}; + +export default async function MayorPage({ params }: { params: { year: string } }) { + const year = params.year; + return ( + + + +

Mayor Data for Year {year}

+
+ +
+ ); +} diff --git a/app/mayor/all/page.tsx b/app/mayor/all/page.tsx new file mode 100644 index 00000000..7c8d36bb --- /dev/null +++ b/app/mayor/all/page.tsx @@ -0,0 +1,22 @@ +import { Metadata } from "next"; +import { AllMayors } from "../../../components/Mayor/AllMayors"; +import { Container } from "react-bootstrap"; +import NavBar from "../../../components/NavBar/NavBar"; +import Search from "../../../components/Search/Search"; + +export const metadata: Metadata = { + title: "All Mayor Flips", + description: "A list of all mayor flips from the last 5 years.", +}; + +export default function AllMayorPage() { + return ( + + + +

All Mayors

+
+ +
+ ); +} diff --git a/components/Mayor/AllMayors.tsx b/components/Mayor/AllMayors.tsx new file mode 100644 index 00000000..dc6b5c07 --- /dev/null +++ b/components/Mayor/AllMayors.tsx @@ -0,0 +1,33 @@ +"use client"; +import { useMemo } from "react"; +import { useGetApiMayor } from "../../api/_generated/skyApi"; +import { MayorDetailsDisplay } from "./MayorDetailsDisplay"; + +const FIVE_YEARS_IN_MS = 5 * 365 * 24 * 60 * 60 * 1000; +const NOW = new Date() +const FIVE_YEARS_AGO = new Date(Date.now() - FIVE_YEARS_IN_MS); +NOW.setHours(0, 0, 0, 0); +FIVE_YEARS_AGO.setHours(0, 0, 0, 0); + +export function AllMayors() { + const { from, to } = useMemo(() => { + return { + from: FIVE_YEARS_AGO.toISOString(), + to: NOW.toISOString() + }; + }, []); + + const { data: mayorData, isLoading, error } = useGetApiMayor({ from, to }); + + if (isLoading) { + return
Loading...
; + } + + if (error) { + return
Error loading mayor data
; + } + + const elections = mayorData?.data ? (Array.isArray(mayorData.data) ? mayorData.data : [mayorData.data]) : []; + + return ; +} \ No newline at end of file diff --git a/components/Mayor/MayorDetails.module.css b/components/Mayor/MayorDetails.module.css new file mode 100644 index 00000000..bb488001 --- /dev/null +++ b/components/Mayor/MayorDetails.module.css @@ -0,0 +1,105 @@ +.container { + padding: 2rem; + max-width: 1200px; + margin: 2rem auto; + background-color: #1a1a1a; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + color: #f0f0f0; +} + +.container h1 { + text-align: center; + color: #00aaff; + margin-bottom: 1rem; +} + +.container h2 { + text-align: center; + color: #00aaff; + margin-bottom: 2rem; +} + +.table { + width: 100%; + border-collapse: collapse; + margin-top: 2rem; +} + +.table th, .table td { + border: 1px solid #333; + padding: 12px 15px; + text-align: left; +} + +.table th { + background-color: #003366; + color: #ffffff; + font-weight: bold; + text-transform: uppercase; +} + +.table tr:nth-child(even) { + background-color: #2a2a2a; +} + +.table tr:hover { + background-color: #3a3a3a; +} + +.yearLinks { + display: flex; + justify-content: center; + gap: 1rem; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.yearLinks a { + text-decoration: none; + color: #f0f0f0; + background-color: #0055a4; + padding: 0.5rem 1rem; + border-radius: 5px; + transition: background-color 0.3s ease; +} + +.yearLinks a:hover { + background-color: #0077cc; + text-decoration: none; +} + +.customAccordion { + --bs-accordion-bg: #1a1a1a; + --bs-accordion-color: #f0f0f0; + --bs-accordion-border-color: #333; + --bs-accordion-active-bg: #222; + --bs-accordion-active-color: #00aaff; + --bs-accordion-btn-focus-box-shadow: none; +} + +.customAccordion :global(.accordion-button) { + font-weight: 500; +} + +.customAccordion :global(.accordion-button:not(.collapsed)::after) { + filter: invert(1); +} + +@media (max-width: 768px) { + .container { + padding: 1rem; + } + + .table th, .table td { + padding: 8px; + } + + .yearLinks { + gap: 0.5rem; + } + + .yearLinks a { + padding: 0.4rem 0.8rem; + } +} diff --git a/components/Mayor/MayorDetails.tsx b/components/Mayor/MayorDetails.tsx new file mode 100644 index 00000000..78eef8e9 --- /dev/null +++ b/components/Mayor/MayorDetails.tsx @@ -0,0 +1,107 @@ +"use client"; +import { useGetApiMayor, useGetApiMayorYear } from "../../api/_generated/skyApi"; +import styles from "./MayorDetails.module.css"; +import Link from "next/link"; +import { Accordion, Card, Col, Container, Row } from "react-bootstrap"; +import { CoflnetSkyMayorModelsModelElectionPeriod } from "../../api/_generated/skyApi.schemas"; + +type Props = { + year?: string; +} + +function MayorElectionPeriod({ election }: { election: CoflnetSkyMayorModelsModelElectionPeriod }) { + return ( + + + + Election in year {election.year} + + + + + + +
Candidates
+
    + {election.candidates?.map(candidate => ( +
  • {candidate.name}
  • + ))} +
+ + +
Winner
+ {election.winner ? ( +
+

Name: {election.winner.name}

+

Key: {election.winner.key}

+
Perks:
+
    + {election.winner.perks?.map(perk => ( +
  • {perk.name}: {perk.description}
  • + ))} +
+
+ ) :

No winner declared.

} + +
+
+
+
+ ) +} + + +export default function MayorDetails({ year }: Props) { + const { data: mayorData, isLoading: isMayorLoading, error: mayorError } = useGetApiMayor(undefined, + { + query: { + enabled: !year, + }, + } + ); + + const { data: mayorYearData, isLoading: isMayorYearLoading, error: mayorYearError } = useGetApiMayorYear( + parseInt(year || new Date().getFullYear().toString(), 10), + { + query: { + enabled: !!year, + }, + } + ); + + const isLoading = isMayorLoading || isMayorYearLoading; + const error = mayorError || mayorYearError; + const data = year ? mayorYearData : mayorData; + + if (isLoading) { + return
Loading...
; + } + + if (error) { + return
Error loading mayor data
; + } + + const elections = data?.data ? (Array.isArray(data.data) ? data.data : [data.data]) : []; + const years = Array.from(new Set(elections.map(e => e.year))); + + return ( + +

Mayor Details

+ {year &&

Year: {year}

} + +
+ {years.map(y => ( + + {y} + + ))} +
+ + + {elections.map((election, index) => ( + + ))} + +
+ ); +} diff --git a/components/Mayor/MayorDetailsDisplay.tsx b/components/Mayor/MayorDetailsDisplay.tsx new file mode 100644 index 00000000..f3b65142 --- /dev/null +++ b/components/Mayor/MayorDetailsDisplay.tsx @@ -0,0 +1,162 @@ +"use client"; +import { useState, useRef, useMemo } from "react"; +import styles from "./MayorDetails.module.css"; +import { Accordion, Card, Col, Row, ListGroup, Badge, Form, Button, InputGroup } from "react-bootstrap"; +import { CoflnetSkyMayorModelsModelElectionPeriod } from "../../api/_generated/skyApi.schemas"; +import { getMinecraftColorCodedElement } from "../../utils/Formatter"; + +type MayorElectionPeriodProps = { + election: CoflnetSkyMayorModelsModelElectionPeriod; + isSingleYear?: boolean; +} + +function MayorElectionPeriod({ election, isSingleYear }: MayorElectionPeriodProps) { + const initialCandidateKey = election.winner?.key ?? election.candidates?.[0]?.key ?? null; + const [selectedCandidateKey, setSelectedCandidateKey] = useState(initialCandidateKey); + + const selectedCandidate = election.candidates?.find(c => c.key === selectedCandidateKey) ?? election.winner; + + const renderContent = () => ( +
+ + +
Candidates
+ + {election.candidates?.map(candidate => ( + setSelectedCandidateKey(candidate.key ?? null)} + className={`px-3 py-2 mb-2 rounded border ${selectedCandidateKey === candidate.key ? 'bg-primary text-white border-primary shadow' : 'bg-transparent text-light border-secondary opacity-75'}`} + style={{ cursor: 'pointer', transition: 'all 0.2s ease' }} + > +
+ {candidate.name} + {election.winner?.key === candidate.key && ( + Winner + )} +
+
+ ))} +
+ + +
+ {selectedCandidateKey === election.winner?.key ? "Election Winner" : "Candidate Details"} +
+ {selectedCandidate ? ( +
+

{selectedCandidate.name}

+ +
Mayor Perks
+ + {selectedCandidate.perks?.map(perk => ( + + + + {perk.name} + + {getMinecraftColorCodedElement(perk.description ?? '')} + + + + + ))} + +
+ ) : ( +
+ No candidate selected or data is missing. +
+ )} + +
+
+ ); + + if (isSingleYear) { + return ( + + + {renderContent()} + + + ); + } + + return ( + + + Election in year {election.year} + + + {renderContent()} + + + ) +} + +type Props = { + elections: CoflnetSkyMayorModelsModelElectionPeriod[]; + isSingleYear?: boolean; + year?: string; +} + +export function MayorDetailsDisplay({ elections, isSingleYear, year }: Props) { + const defaultKey = elections.length > 0 ? elections[0].year.toString() : "0"; + const [activeKey, setActiveKey] = useState(defaultKey); + const searchInputRef = useRef(null); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + const searchYear = searchInputRef.current?.value; + if (searchYear) { + setActiveKey(searchYear); + const el = document.getElementById(`year-item-${searchYear}`); + if (el) { + const yOffset = -20; + const y = el.getBoundingClientRect().top + window.scrollY + yOffset; + window.scrollTo({ top: y, behavior: 'smooth' }); + } + } + }; + + const accordionItems = useMemo(() => { + return elections.map((election) => ( + + )); + }, [elections]); + + return ( +
+ {!isSingleYear && ( +
+
+ Jump to Year: + + + + +
+
+ )} + + {isSingleYear ? ( +
+ {elections.map((election) => ( + + ))} +
+ ) : ( + setActiveKey(k || "")} className={styles.customAccordion}> + {accordionItems} + + )} +
+ ); +} \ No newline at end of file diff --git a/components/Mayor/YearlyMayor.tsx b/components/Mayor/YearlyMayor.tsx new file mode 100644 index 00000000..86356aba --- /dev/null +++ b/components/Mayor/YearlyMayor.tsx @@ -0,0 +1,28 @@ +"use client"; +import { useGetApiMayorYear } from "../../api/_generated/skyApi"; +import { MayorDetailsDisplay } from "./MayorDetailsDisplay"; + +type Props = { + year: string; +}; + +export function YearlyMayor({ year }: Props) { + const yearInt = parseInt(year, 10); + const { data: mayorYearData, isLoading, error } = useGetApiMayorYear(yearInt, { + query: { + enabled: !isNaN(yearInt), + }, + }); + + if (isLoading) { + return
Loading...
; + } + + if (error) { + return
Error loading mayor data for year {year}
; + } + + const elections = mayorYearData?.data ? (Array.isArray(mayorYearData.data) ? mayorYearData.data : [mayorYearData.data]) : []; + + return ; +} \ No newline at end of file