From 5b89dfb465f07b75c942063fc247bfc0f5367719 Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Sun, 20 Apr 2025 02:51:20 -0400 Subject: [PATCH] feat: first iteration of the new ledger page --- app/(account)/confirm-email/[token]/page.tsx | 2 +- app/(account)/logout/page.tsx | 2 +- app/(home)/latestNews.tsx | 2 + app/common/defaultNavbar.tsx | 5 +- app/ledger/page.tsx | 15 ++++ app/ledger/presentation.tsx | 83 +++++++++++++++++ components/atoms/TableCell.tsx | 7 ++ components/atoms/TableHeaderCell.tsx | 34 +++++++ components/molecules/TableHeaderRow.tsx | 30 +++++++ components/molecules/TableRow.tsx | 18 ++++ components/organisms/DataTable.tsx | 65 ++++++++++++++ context/ledger/LedgerApiProvider.tsx | 68 ++++++++++++++ context/ledger/LedgerDataTableProvider.tsx | 95 ++++++++++++++++++++ types/ledger/ledgerResponse.ts | 17 ++++ utils/urlContants.ts | 3 +- 15 files changed, 441 insertions(+), 5 deletions(-) create mode 100644 app/ledger/page.tsx create mode 100644 app/ledger/presentation.tsx create mode 100644 components/atoms/TableCell.tsx create mode 100644 components/atoms/TableHeaderCell.tsx create mode 100644 components/molecules/TableHeaderRow.tsx create mode 100644 components/molecules/TableRow.tsx create mode 100644 components/organisms/DataTable.tsx create mode 100644 context/ledger/LedgerApiProvider.tsx create mode 100644 context/ledger/LedgerDataTableProvider.tsx create mode 100644 types/ledger/ledgerResponse.ts diff --git a/app/(account)/confirm-email/[token]/page.tsx b/app/(account)/confirm-email/[token]/page.tsx index acb087a..58761c3 100644 --- a/app/(account)/confirm-email/[token]/page.tsx +++ b/app/(account)/confirm-email/[token]/page.tsx @@ -20,7 +20,7 @@ const MailConfirmationPage = () => { fetchData().then(r => { setResponse(r); }); - }, []); + }, [token]); if (!token) { return
diff --git a/app/(account)/logout/page.tsx b/app/(account)/logout/page.tsx index 766d1d7..32c6d0f 100644 --- a/app/(account)/logout/page.tsx +++ b/app/(account)/logout/page.tsx @@ -11,7 +11,7 @@ const LogoutPage = () => { authState.logout().then(() => { redirect("login"); }); - }, []); + }, [authState]); } export default LogoutPage; \ No newline at end of file diff --git a/app/(home)/latestNews.tsx b/app/(home)/latestNews.tsx index 23854bd..f4da1f9 100644 --- a/app/(home)/latestNews.tsx +++ b/app/(home)/latestNews.tsx @@ -3,6 +3,7 @@ import classNames from "classnames"; import Container from "../common/uiLibrary/container"; import PageSectionTitle from "../common/uiLibrary/pageSectionTitle"; import LinkButton from "../common/uiLibrary/linkButton"; +import Image from "next/image"; interface PostPreviewCardProps { post: BlogPost, @@ -30,6 +31,7 @@ const PostPreviewImage = ({post, isMain = false, className}: PostPreviewCardProp return (
+ {/* eslint-disable-next-line @next/next/no-img-element */} {post.title}

Changelog

+ +

Ledger

+

Player's wiki

@@ -73,5 +76,3 @@ export default function DefaultNavbar() { ) } - - diff --git a/app/ledger/page.tsx b/app/ledger/page.tsx new file mode 100644 index 0000000..00960cd --- /dev/null +++ b/app/ledger/page.tsx @@ -0,0 +1,15 @@ +import {LedgerApiProvider} from "../../context/ledger/LedgerApiProvider"; +import LedgerPresentation from "./presentation"; +import {LedgerTableProvider} from "../../context/ledger/LedgerDataTableProvider"; + +const LedgerPage = () => { + return ( + + + + + + ) +} + +export default LedgerPage; \ No newline at end of file diff --git a/app/ledger/presentation.tsx b/app/ledger/presentation.tsx new file mode 100644 index 0000000..0eb2cd8 --- /dev/null +++ b/app/ledger/presentation.tsx @@ -0,0 +1,83 @@ +'use client'; + +import DataTable from "../../components/organisms/DataTable"; +import {useLedgerTableContext} from "../../context/ledger/LedgerDataTableProvider"; +import Container from "../common/uiLibrary/container"; +import PageHeading from "../common/uiLibrary/PageHeading"; +import {useLedgerApiProvider} from "../../context/ledger/LedgerApiProvider"; +import Panel from "../common/uiLibrary/panel"; +import {RiPatreonFill} from "react-icons/ri"; +import {FaPaypal} from "react-icons/fa"; +import {PATREON_URL, PAYPAL_DONATION_URL} from "../../utils/urlContants"; + +export default function LedgerPresentation() { + const content = useLedgerTableContext(); + const {hasNextPage, hasPreviousPage, goToPreviousPage, goToNextPage, currentBalance} = useLedgerApiProvider(); + + return ( + + Funding Ledger +
+ +
Current Balance
+
+ ${currentBalance} +
+

+ This is the amount currently available in Unitystation’s project fund. + It updates manually after we receive a donation or withdraw from Patreon. +

+

+ If your donation is not listed yet, it will appear soon once we update the ledger. +

+
+ +
+ Where does our funding come from? +
+ +

+ Unitystation is sustained entirely through community support; whether by backing us on Patreon or sending direct donations. Every contribution helps cover hosting, development, and infrastructure. +

+ + +
+
+ + + + {/*TODO: make this shit a generic component and stylise it*/} +
+
+ {hasPreviousPage && ( + + )} +
+ +
+ {hasNextPage && ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/atoms/TableCell.tsx b/components/atoms/TableCell.tsx new file mode 100644 index 0000000..9d488d8 --- /dev/null +++ b/components/atoms/TableCell.tsx @@ -0,0 +1,7 @@ +import {ReactNode} from "react"; + +const TableCell: React.FC<{ children: ReactNode }> = ({ children }) => ( + {children} +); + +export default TableCell; \ No newline at end of file diff --git a/components/atoms/TableHeaderCell.tsx b/components/atoms/TableHeaderCell.tsx new file mode 100644 index 0000000..2b77236 --- /dev/null +++ b/components/atoms/TableHeaderCell.tsx @@ -0,0 +1,34 @@ +import {ReactNode} from "react"; +import classNames from "classnames"; + +export type SortDirection = 'asc' | 'desc'; + +const TableHeaderCell: React.FC<{ + children: ReactNode; + sortable: boolean; + active: boolean; + dir: SortDirection; + onClick?: () => void; +}> = ({ children, sortable, active, dir, onClick }) => { + + const classes = classNames( + "p-2 bg-gray-800 text-left select-none", + { + "cursor-pointer": sortable, + } + ) + + return ( + + {children} + {sortable && ( + {active ? (dir === 'asc' ? '▲' : '▼') : '⇅'} + )} + + ) +} + +export default TableHeaderCell; \ No newline at end of file diff --git a/components/molecules/TableHeaderRow.tsx b/components/molecules/TableHeaderRow.tsx new file mode 100644 index 0000000..7a0b3e4 --- /dev/null +++ b/components/molecules/TableHeaderRow.tsx @@ -0,0 +1,30 @@ +import {Column} from "./TableRow"; +import TableHeaderCell, {SortDirection} from "../atoms/TableHeaderCell"; + +const TableHeaderRow = ({ + columns, + sortBy, + sortDir, + setSort, + }: { + columns: Column[]; + sortBy: number | null; + sortDir: SortDirection; + setSort: (col: number) => void; +}) => ( + + {columns.map((col, i) => ( + col.sortFn && setSort(i)} + > + {col.header} + + ))} + +); + +export default TableHeaderRow; \ No newline at end of file diff --git a/components/molecules/TableRow.tsx b/components/molecules/TableRow.tsx new file mode 100644 index 0000000..d268d21 --- /dev/null +++ b/components/molecules/TableRow.tsx @@ -0,0 +1,18 @@ +import {ReactNode} from "react"; +import TableCell from "../atoms/TableCell"; + +export interface Column { + header: string; + cell: (row: T) => ReactNode; + sortFn?: (a: T, b: T) => number; +} + +const TableRow = ({ columns, row }: { columns: Column[]; row: T }) => ( + + {columns.map((col, i) => ( + {col.cell(row)} + ))} + +); + +export default TableRow; \ No newline at end of file diff --git a/components/organisms/DataTable.tsx b/components/organisms/DataTable.tsx new file mode 100644 index 0000000..500be77 --- /dev/null +++ b/components/organisms/DataTable.tsx @@ -0,0 +1,65 @@ +'use client'; + +import React, {useState} from "react"; +import {SortDirection} from "../atoms/TableHeaderCell"; +import TableRow, {Column} from "../molecules/TableRow"; +import TableHeaderRow from "../molecules/TableHeaderRow"; + +export interface DataTableProps { + columns: Column[]; + data: T[]; + /** initial column index and direction */ + defaultSort?: { column: number; direction: SortDirection }; + /** bubble sort changes upward if you need it */ + onSortChange?: (col: number, dir: SortDirection) => void; +} + +function DataTable({ + columns, + data, + defaultSort, + onSortChange, + }: DataTableProps) { + const [sortBy, setSortBy] = useState( + defaultSort ? defaultSort.column : null, + ); + const [sortDir, setSortDir] = useState( + defaultSort ? defaultSort.direction : 'asc', + ); + + const handleSort = (col: number) => { + const dir: SortDirection = + sortBy === col && sortDir === 'asc' ? 'desc' : 'asc'; + setSortBy(col); + setSortDir(dir); + onSortChange?.(col, dir); + }; + + const sorted = React.useMemo(() => { + if (sortBy === null) return data; + const col = columns[sortBy]; + if (!col.sortFn) return data; + const copied = [...data].sort(col.sortFn); + return sortDir === 'asc' ? copied : copied.reverse(); + }, [data, sortBy, sortDir, columns]); + + return ( + + + + + + {sorted.map((row, idx) => ( + + ))} + +
+ ); +} + +export default DataTable; \ No newline at end of file diff --git a/context/ledger/LedgerApiProvider.tsx b/context/ledger/LedgerApiProvider.tsx new file mode 100644 index 0000000..ef4ffba --- /dev/null +++ b/context/ledger/LedgerApiProvider.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React, { + createContext, useContext, useEffect, useState, ReactNode, +} from 'react'; +import { LedgerData, LedgerResponse } from '../../types/ledger/ledgerResponse'; +import fetchOfType from '../../utils/fetchOfType'; + +export interface LedgerApiResults { + goToNextPage: () => void; + goToPreviousPage: () => void; + hasNextPage: boolean; + hasPreviousPage: boolean; + results: LedgerData[]; + currentBalance: string; +} + +const LedgerApiContext = createContext(undefined); + +const BASE = "https://ledger.unitystation.org"; + +export const LedgerApiProvider = ({ children }: { children: ReactNode }) => { + const [fetchResult, setFetchResult] = useState(null); + const [pageUrl, setPageUrl] = useState(""); + const [isInitialLoad, setIsInitialLoad] = useState(true); + const [currentBalance, setCurrentBalance] = useState("0.00"); + + const hasNextPage = !!fetchResult?.next; + const hasPreviousPage = !!fetchResult?.previous; + + const goToNextPage = () => fetchResult?.next && setPageUrl(fetchResult.next); + const goToPreviousPage = () => fetchResult?.previous && setPageUrl(fetchResult.previous); + + useEffect(() => { + const url = `${BASE}/movements/`; + const fetchData = async () => { + const res = await fetchOfType(pageUrl || url); + setFetchResult(res); + if (isInitialLoad) { + setCurrentBalance(res.results[0]?.balance_after || "0.00"); + setIsInitialLoad(false); + } + }; + + void fetchData(); + }, [pageUrl]); + + return ( + + {children} + + ); +}; + +export const useLedgerApiProvider = (): LedgerApiResults => { + const ctx = useContext(LedgerApiContext); + if (!ctx) throw new Error('useLedger must be used within a LedgerProvider'); + return ctx; +}; diff --git a/context/ledger/LedgerDataTableProvider.tsx b/context/ledger/LedgerDataTableProvider.tsx new file mode 100644 index 0000000..d964654 --- /dev/null +++ b/context/ledger/LedgerDataTableProvider.tsx @@ -0,0 +1,95 @@ +'use client'; + +import {createContext, ReactNode, useContext} from "react"; +import {DataTableProps} from "../../components/organisms/DataTable"; +import {LedgerData} from "../../types/ledger/ledgerResponse"; +import {useLedgerApiProvider} from "./LedgerApiProvider"; +import {GoInfo, GoLinkExternal} from "react-icons/go"; + + +const LedgerTableContext = createContext | undefined>(undefined); + +export const LedgerTableProvider = ({ children }: { children: ReactNode }) => { + const {results} = useLedgerApiProvider(); + + const processDate = (date: string): ReactNode => { + return Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: 'long', + year: 'numeric', + }).format(new Date(date)); + } + + const processDescription = (description: string, notes: string): ReactNode => { + return ( +
+ + {description} + + {notes && ( + + )} +
+ + ); + } + + const processAmount = (amount: string, type: 'income' | 'expense'): ReactNode => { + const usd = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + + const numeric = Number.parseFloat(amount); + const formatted = usd.format(numeric); + + const colour = type === 'income' ? 'text-green-500' : 'text-red-500'; + return {formatted}; + } + + const processLink = (link: string): ReactNode => { + if (link) { + return + } else { + return <> + } + } + + const data: DataTableProps = { + columns: [ + { + header: "Date", + cell: row => processDate(row.created_at) + }, + { + header: "Description", + cell: row => processDescription(row.description, row.notes || "") + }, + { + header: "Amount (USD)", + cell: row => processAmount(row.amount_usd, row.type) + }, + { + header: "Link", + cell: row => processLink(row.link || "") + } + ], + data: results + }; + + return ( + + {children} + + ); +} + +export const useLedgerTableContext = () => { + const context = useContext(LedgerTableContext); + if (!context) { + throw new Error('useLedgerTableContext must be used within a LedgerTableProvider'); + } + return context; +} \ No newline at end of file diff --git a/types/ledger/ledgerResponse.ts b/types/ledger/ledgerResponse.ts new file mode 100644 index 0000000..73d977a --- /dev/null +++ b/types/ledger/ledgerResponse.ts @@ -0,0 +1,17 @@ +export interface LedgerData { + id: number; + type: 'expense' | 'income'; + description: string; + notes: string | null; + amount_usd: string; + created_at: string; + balance_after: string; + link: string | null; +} + +export interface LedgerResponse { + count: number; + next: string | null; + previous: string | null; + results: LedgerData[]; +} \ No newline at end of file diff --git a/utils/urlContants.ts b/utils/urlContants.ts index d400dfe..6b11da6 100644 --- a/utils/urlContants.ts +++ b/utils/urlContants.ts @@ -1,4 +1,5 @@ export const GITHUB_URL = 'https://github.com/unitystation/unitystation'; export const DISCORD_INVITE_URL = 'https://discord.com/invite/tFcTpBp'; export const PATREON_URL = 'https://www.patreon.com/unitystation'; -export const GITHUB_RELEASES_URL = 'https://github.com/unitystation/stationhub/releases/latest'; \ No newline at end of file +export const GITHUB_RELEASES_URL = 'https://github.com/unitystation/stationhub/releases/latest'; +export const PAYPAL_DONATION_URL = 'https://www.paypal.com/donate/?hosted_button_id=SLGV34ZBQYTA2'; \ No newline at end of file