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 && (
+
+
+
+ )}
+
+ {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