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..eb359cf
--- /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 && (
+ Previous
+ )}
+
+
+
+ {hasNextPage && (
+ Next
+ )}
+
+
+
+ );
+}
\ 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..e171768
--- /dev/null
+++ b/context/ledger/LedgerDataTableProvider.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import {createContext, ReactNode, useContext, useEffect, useState} 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
From 8216f2bd97db845ae967537e4d999c44aa92929a 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 2/3] 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/ledger/presentation.tsx | 2 +-
context/ledger/LedgerDataTableProvider.tsx | 2 +-
5 files changed, 6 insertions(+), 4 deletions(-)
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 */}
- //TODO: make this shit a generic component and stylise it
+ {/*TODO: make this shit a generic component and stylise it*/}
{hasPreviousPage && (
diff --git a/context/ledger/LedgerDataTableProvider.tsx b/context/ledger/LedgerDataTableProvider.tsx
index e171768..d964654 100644
--- a/context/ledger/LedgerDataTableProvider.tsx
+++ b/context/ledger/LedgerDataTableProvider.tsx
@@ -1,6 +1,6 @@
'use client';
-import {createContext, ReactNode, useContext, useEffect, useState} from "react";
+import {createContext, ReactNode, useContext} from "react";
import {DataTableProps} from "../../components/organisms/DataTable";
import {LedgerData} from "../../types/ledger/ledgerResponse";
import {useLedgerApiProvider} from "./LedgerApiProvider";
From b0c3064f17d18cffac95b9ccf2c0e16342b451a4 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 22 Jul 2025 06:15:06 +0000
Subject: [PATCH 3/3] chore(deps): bump form-data from 4.0.2 to 4.0.4
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.2 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.2...v4.0.4)
---
updated-dependencies:
- dependency-name: form-data
dependency-version: 4.0.4
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
---
package-lock.json | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 52f239d..359c920 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5662,13 +5662,15 @@
}
},
"node_modules/form-data": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
- "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {