Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/common/defaultNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export default function DefaultNavbar() {
<Link href='/changelog'>
<p>Changelog</p>
</Link>
<Link href='/ledger'>
<p>Ledger</p>
</Link>
<Navbar.Link href={playerWiki} target="_blank">
<div className="flex flex-row gap-1">
<p>Player&apos;s wiki</p>
Expand All @@ -73,5 +76,3 @@ export default function DefaultNavbar() {
</Navbar>
)
}


15 changes: 15 additions & 0 deletions app/ledger/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {LedgerApiProvider} from "../../context/ledger/LedgerApiProvider";
import LedgerPresentation from "./presentation";
import {LedgerTableProvider} from "../../context/ledger/LedgerDataTableProvider";

const LedgerPage = () => {
return (
<LedgerApiProvider>
<LedgerTableProvider>
<LedgerPresentation />
</LedgerTableProvider>
</LedgerApiProvider>
)
}

export default LedgerPage;
83 changes: 83 additions & 0 deletions app/ledger/presentation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container>
<PageHeading>Funding Ledger</PageHeading>
<div className="flex justify-between gap-4">
<Panel className="rounded-lg w-50">
<div className="text-lg font-semibold text-gray-200">Current Balance</div>
<div className="text-3xl font-bold text-green-400">
${currentBalance}
</div>
<p className="mt-2 text-sm text-gray-400">
This is the amount currently available in Unitystation’s project fund.
It updates manually after we receive a donation or withdraw from Patreon.
</p>
<p className="mt-2 text-sm text-gray-400">
If your donation is not listed yet, it will appear soon once we update the ledger.
</p>
</Panel>
<Panel className="rounded-lg">
<div className="text-lg font-semibold text-gray-200 mb-2">
Where does our funding come from?
</div>

<p className="text-sm text-gray-400 mb-4">
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.
</p>

<div className="flex gap-4 items-center mt-4">
<a
href={PATREON_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 text-white bg-[#ff424d] rounded hover:bg-[#e63946] transition"
>
<RiPatreonFill size={20} />
Support us on Patreon
</a>

<a
href={PAYPAL_DONATION_URL}
className="flex items-center gap-2 px-4 py-2 text-white bg-[#00457C] rounded hover:bg-[#003a6b] transition"
>
<FaPaypal size={20} />
Donate via PayPal
</a>
</div>
</Panel>
</div>

<DataTable columns={content.columns} data={content.data} />

//TODO: make this shit a generic component and stylise it
<div className="flex justify-between p-5">
<div className="flex-1">
{hasPreviousPage && (
<button className="hover:!text-blue-700" onClick={goToPreviousPage}>Previous</button>
)}
</div>

<div className="flex-1 text-right">
{hasNextPage && (
<button className="hover:!text-blue-700" onClick={goToNextPage}>Next</button>
)}
</div>
</div>
</Container>
);
}
7 changes: 7 additions & 0 deletions components/atoms/TableCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {ReactNode} from "react";

const TableCell: React.FC<{ children: ReactNode }> = ({ children }) => (
<td className="p-2 border-b border-slate-700">{children}</td>
);

export default TableCell;
34 changes: 34 additions & 0 deletions components/atoms/TableHeaderCell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<th
className={classes}
onClick={sortable ? onClick : undefined}
>
{children}
{sortable && (
<span className="ml-1 text-xs">{active ? (dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
)}
</th>
)
}

export default TableHeaderCell;
30 changes: 30 additions & 0 deletions components/molecules/TableHeaderRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {Column} from "./TableRow";
import TableHeaderCell, {SortDirection} from "../atoms/TableHeaderCell";

const TableHeaderRow = <T,>({
columns,
sortBy,
sortDir,
setSort,
}: {
columns: Column<T>[];
sortBy: number | null;
sortDir: SortDirection;
setSort: (col: number) => void;
}) => (
<tr>
{columns.map((col, i) => (
<TableHeaderCell
key={i}
sortable={!!col.sortFn}
active={sortBy === i}
dir={sortDir}
onClick={() => col.sortFn && setSort(i)}
>
{col.header}
</TableHeaderCell>
))}
</tr>
);

export default TableHeaderRow;
18 changes: 18 additions & 0 deletions components/molecules/TableRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {ReactNode} from "react";
import TableCell from "../atoms/TableCell";

export interface Column<T> {
header: string;
cell: (row: T) => ReactNode;
sortFn?: (a: T, b: T) => number;
}

const TableRow = <T,>({ columns, row }: { columns: Column<T>[]; row: T }) => (
<tr>
{columns.map((col, i) => (
<TableCell key={i}>{col.cell(row)}</TableCell>
))}
</tr>
);

export default TableRow;
65 changes: 65 additions & 0 deletions components/organisms/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -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<T> {
columns: Column<T>[];
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<T>({
columns,
data,
defaultSort,
onSortChange,
}: DataTableProps<T>) {
const [sortBy, setSortBy] = useState<number | null>(
defaultSort ? defaultSort.column : null,
);
const [sortDir, setSortDir] = useState<SortDirection>(
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 (
<table className="w-full border-collapse bg-gray-900">
<thead>
<TableHeaderRow
columns={columns}
sortBy={sortBy}
sortDir={sortDir}
setSort={handleSort}
/>
</thead>
<tbody>
{sorted.map((row, idx) => (
<TableRow key={idx} columns={columns} row={row} />
))}
</tbody>
</table>
);
}

export default DataTable;
68 changes: 68 additions & 0 deletions context/ledger/LedgerApiProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<LedgerApiResults | undefined>(undefined);

const BASE = "https://ledger.unitystation.org";

export const LedgerApiProvider = ({ children }: { children: ReactNode }) => {
const [fetchResult, setFetchResult] = useState<LedgerResponse | null>(null);
const [pageUrl, setPageUrl] = useState<string>("");
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(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<LedgerResponse>(pageUrl || url);
setFetchResult(res);
if (isInitialLoad) {
setCurrentBalance(res.results[0]?.balance_after || "0.00");
setIsInitialLoad(false);
}
};

void fetchData();
}, [pageUrl]);

return (
<LedgerApiContext.Provider
value={{
goToNextPage,
goToPreviousPage,
hasNextPage,
hasPreviousPage,
results: fetchResult?.results ?? [],
currentBalance,
}}
>
{children}
</LedgerApiContext.Provider>
);
};

export const useLedgerApiProvider = (): LedgerApiResults => {
const ctx = useContext(LedgerApiContext);
if (!ctx) throw new Error('useLedger must be used within a LedgerProvider');
return ctx;
};
Loading
Loading