Skip to content

Commit 58000fc

Browse files
committed
feat: first iteration of the new ledger page
1 parent 9716025 commit 58000fc

File tree

12 files changed

+437
-3
lines changed

12 files changed

+437
-3
lines changed

app/common/defaultNavbar.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export default function DefaultNavbar() {
5757
<Link href='/changelog'>
5858
<p>Changelog</p>
5959
</Link>
60+
<Link href='/ledger'>
61+
<p>Ledger</p>
62+
</Link>
6063
<Navbar.Link href={playerWiki} target="_blank">
6164
<div className="flex flex-row gap-1">
6265
<p>Player&apos;s wiki</p>
@@ -73,5 +76,3 @@ export default function DefaultNavbar() {
7376
</Navbar>
7477
)
7578
}
76-
77-

app/ledger/page.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {LedgerApiProvider} from "../../context/ledger/LedgerApiProvider";
2+
import LedgerPresentation from "./presentation";
3+
import {LedgerTableProvider} from "../../context/ledger/LedgerDataTableProvider";
4+
5+
const LedgerPage = () => {
6+
return (
7+
<LedgerApiProvider>
8+
<LedgerTableProvider>
9+
<LedgerPresentation />
10+
</LedgerTableProvider>
11+
</LedgerApiProvider>
12+
)
13+
}
14+
15+
export default LedgerPage;

app/ledger/presentation.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use client';
2+
3+
import DataTable from "../../components/organisms/DataTable";
4+
import {useLedgerTableContext} from "../../context/ledger/LedgerDataTableProvider";
5+
import Container from "../common/uiLibrary/container";
6+
import PageHeading from "../common/uiLibrary/PageHeading";
7+
import {useLedgerApiProvider} from "../../context/ledger/LedgerApiProvider";
8+
import Panel from "../common/uiLibrary/panel";
9+
import {RiPatreonFill} from "react-icons/ri";
10+
import {FaPaypal} from "react-icons/fa";
11+
import {PATREON_URL, PAYPAL_DONATION_URL} from "../../utils/urlContants";
12+
13+
export default function LedgerPresentation() {
14+
const content = useLedgerTableContext();
15+
const {hasNextPage, hasPreviousPage, goToPreviousPage, goToNextPage, currentBalance} = useLedgerApiProvider();
16+
17+
return (
18+
<Container>
19+
<PageHeading>Funding Ledger</PageHeading>
20+
<div className="flex justify-between gap-4">
21+
<Panel className="rounded-lg w-50">
22+
<div className="text-lg font-semibold text-gray-200">Current Balance</div>
23+
<div className="text-3xl font-bold text-green-400">
24+
${currentBalance}
25+
</div>
26+
<p className="mt-2 text-sm text-gray-400">
27+
This is the amount currently available in Unitystation’s project fund.
28+
It updates manually after we receive a donation or withdraw from Patreon.
29+
</p>
30+
<p className="mt-2 text-sm text-gray-400">
31+
If your donation is not listed yet, it will appear soon once we update the ledger.
32+
</p>
33+
</Panel>
34+
<Panel className="rounded-lg">
35+
<div className="text-lg font-semibold text-gray-200 mb-2">
36+
Where does our funding come from?
37+
</div>
38+
39+
<p className="text-sm text-gray-400 mb-4">
40+
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.
41+
</p>
42+
43+
<div className="flex gap-4 items-center mt-4">
44+
<a
45+
href={PATREON_URL}
46+
target="_blank"
47+
rel="noopener noreferrer"
48+
className="flex items-center gap-2 px-4 py-2 text-white bg-[#ff424d] rounded hover:bg-[#e63946] transition"
49+
>
50+
<RiPatreonFill size={20} />
51+
Support us on Patreon
52+
</a>
53+
54+
<a
55+
href={PAYPAL_DONATION_URL}
56+
className="flex items-center gap-2 px-4 py-2 text-white bg-[#00457C] rounded hover:bg-[#003a6b] transition"
57+
>
58+
<FaPaypal size={20} />
59+
Donate via PayPal
60+
</a>
61+
</div>
62+
</Panel>
63+
</div>
64+
65+
<DataTable columns={content.columns} data={content.data} />
66+
67+
//TODO: make this shit a generic component and stylise it
68+
<div className="flex justify-between p-5">
69+
<div className="flex-1">
70+
{hasPreviousPage && (
71+
<button className="hover:!text-blue-700" onClick={goToPreviousPage}>Previous</button>
72+
)}
73+
</div>
74+
75+
<div className="flex-1 text-right">
76+
{hasNextPage && (
77+
<button className="hover:!text-blue-700" onClick={goToNextPage}>Next</button>
78+
)}
79+
</div>
80+
</div>
81+
</Container>
82+
);
83+
}

components/atoms/TableCell.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {ReactNode} from "react";
2+
3+
const TableCell: React.FC<{ children: ReactNode }> = ({ children }) => (
4+
<td className="p-2 border-b border-slate-700">{children}</td>
5+
);
6+
7+
export default TableCell;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {ReactNode} from "react";
2+
import classNames from "classnames";
3+
4+
export type SortDirection = 'asc' | 'desc';
5+
6+
const TableHeaderCell: React.FC<{
7+
children: ReactNode;
8+
sortable: boolean;
9+
active: boolean;
10+
dir: SortDirection;
11+
onClick?: () => void;
12+
}> = ({ children, sortable, active, dir, onClick }) => {
13+
14+
const classes = classNames(
15+
"p-2 bg-gray-800 text-left select-none",
16+
{
17+
"cursor-pointer": sortable,
18+
}
19+
)
20+
21+
return (
22+
<th
23+
className={classes}
24+
onClick={sortable ? onClick : undefined}
25+
>
26+
{children}
27+
{sortable && (
28+
<span className="ml-1 text-xs">{active ? (dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
29+
)}
30+
</th>
31+
)
32+
}
33+
34+
export default TableHeaderCell;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {Column} from "./TableRow";
2+
import TableHeaderCell, {SortDirection} from "../atoms/TableHeaderCell";
3+
4+
const TableHeaderRow = <T,>({
5+
columns,
6+
sortBy,
7+
sortDir,
8+
setSort,
9+
}: {
10+
columns: Column<T>[];
11+
sortBy: number | null;
12+
sortDir: SortDirection;
13+
setSort: (col: number) => void;
14+
}) => (
15+
<tr>
16+
{columns.map((col, i) => (
17+
<TableHeaderCell
18+
key={i}
19+
sortable={!!col.sortFn}
20+
active={sortBy === i}
21+
dir={sortDir}
22+
onClick={() => col.sortFn && setSort(i)}
23+
>
24+
{col.header}
25+
</TableHeaderCell>
26+
))}
27+
</tr>
28+
);
29+
30+
export default TableHeaderRow;

components/molecules/TableRow.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {ReactNode} from "react";
2+
import TableCell from "../atoms/TableCell";
3+
4+
export interface Column<T> {
5+
header: string;
6+
cell: (row: T) => ReactNode;
7+
sortFn?: (a: T, b: T) => number;
8+
}
9+
10+
const TableRow = <T,>({ columns, row }: { columns: Column<T>[]; row: T }) => (
11+
<tr>
12+
{columns.map((col, i) => (
13+
<TableCell key={i}>{col.cell(row)}</TableCell>
14+
))}
15+
</tr>
16+
);
17+
18+
export default TableRow;

components/organisms/DataTable.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
'use client';
2+
3+
import React, {useState} from "react";
4+
import {SortDirection} from "../atoms/TableHeaderCell";
5+
import TableRow, {Column} from "../molecules/TableRow";
6+
import TableHeaderRow from "../molecules/TableHeaderRow";
7+
8+
export interface DataTableProps<T> {
9+
columns: Column<T>[];
10+
data: T[];
11+
/** initial column index and direction */
12+
defaultSort?: { column: number; direction: SortDirection };
13+
/** bubble sort changes upward if you need it */
14+
onSortChange?: (col: number, dir: SortDirection) => void;
15+
}
16+
17+
function DataTable<T>({
18+
columns,
19+
data,
20+
defaultSort,
21+
onSortChange,
22+
}: DataTableProps<T>) {
23+
const [sortBy, setSortBy] = useState<number | null>(
24+
defaultSort ? defaultSort.column : null,
25+
);
26+
const [sortDir, setSortDir] = useState<SortDirection>(
27+
defaultSort ? defaultSort.direction : 'asc',
28+
);
29+
30+
const handleSort = (col: number) => {
31+
const dir: SortDirection =
32+
sortBy === col && sortDir === 'asc' ? 'desc' : 'asc';
33+
setSortBy(col);
34+
setSortDir(dir);
35+
onSortChange?.(col, dir);
36+
};
37+
38+
const sorted = React.useMemo(() => {
39+
if (sortBy === null) return data;
40+
const col = columns[sortBy];
41+
if (!col.sortFn) return data;
42+
const copied = [...data].sort(col.sortFn);
43+
return sortDir === 'asc' ? copied : copied.reverse();
44+
}, [data, sortBy, sortDir, columns]);
45+
46+
return (
47+
<table className="w-full border-collapse bg-gray-900">
48+
<thead>
49+
<TableHeaderRow
50+
columns={columns}
51+
sortBy={sortBy}
52+
sortDir={sortDir}
53+
setSort={handleSort}
54+
/>
55+
</thead>
56+
<tbody>
57+
{sorted.map((row, idx) => (
58+
<TableRow key={idx} columns={columns} row={row} />
59+
))}
60+
</tbody>
61+
</table>
62+
);
63+
}
64+
65+
export default DataTable;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use client';
2+
3+
import React, {
4+
createContext, useContext, useEffect, useState, ReactNode,
5+
} from 'react';
6+
import { LedgerData, LedgerResponse } from '../../types/ledger/ledgerResponse';
7+
import fetchOfType from '../../utils/fetchOfType';
8+
9+
export interface LedgerApiResults {
10+
goToNextPage: () => void;
11+
goToPreviousPage: () => void;
12+
hasNextPage: boolean;
13+
hasPreviousPage: boolean;
14+
results: LedgerData[];
15+
currentBalance: string;
16+
}
17+
18+
const LedgerApiContext = createContext<LedgerApiResults | undefined>(undefined);
19+
20+
const BASE = "https://ledger.unitystation.org";
21+
22+
export const LedgerApiProvider = ({ children }: { children: ReactNode }) => {
23+
const [fetchResult, setFetchResult] = useState<LedgerResponse | null>(null);
24+
const [pageUrl, setPageUrl] = useState<string>("");
25+
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
26+
const [currentBalance, setCurrentBalance] = useState("0.00");
27+
28+
const hasNextPage = !!fetchResult?.next;
29+
const hasPreviousPage = !!fetchResult?.previous;
30+
31+
const goToNextPage = () => fetchResult?.next && setPageUrl(fetchResult.next);
32+
const goToPreviousPage = () => fetchResult?.previous && setPageUrl(fetchResult.previous);
33+
34+
useEffect(() => {
35+
const url = `${BASE}/movements/`;
36+
const fetchData = async () => {
37+
const res = await fetchOfType<LedgerResponse>(pageUrl || url);
38+
setFetchResult(res);
39+
if (isInitialLoad) {
40+
setCurrentBalance(res.results[0]?.balance_after || "0.00");
41+
setIsInitialLoad(false);
42+
}
43+
};
44+
45+
void fetchData();
46+
}, [pageUrl]);
47+
48+
return (
49+
<LedgerApiContext.Provider
50+
value={{
51+
goToNextPage,
52+
goToPreviousPage,
53+
hasNextPage,
54+
hasPreviousPage,
55+
results: fetchResult?.results ?? [],
56+
currentBalance,
57+
}}
58+
>
59+
{children}
60+
</LedgerApiContext.Provider>
61+
);
62+
};
63+
64+
export const useLedgerApiProvider = (): LedgerApiResults => {
65+
const ctx = useContext(LedgerApiContext);
66+
if (!ctx) throw new Error('useLedger must be used within a LedgerProvider');
67+
return ctx;
68+
};

0 commit comments

Comments
 (0)