Enhance README and refactor code structure for improved functionality
🚀 Deploy website via FTP / 🎉 Deploy (push) Failing after 8s
🚀 Deploy website via FTP / 🎉 Deploy (push) Failing after 8s
- Updated README.md with detailed project description, features, and installation instructions. - Refactored functions.php to include configuration settings and improved database handling. - Modified index.php for better user experience and added pagination controls. - Introduced new utility functions for API handling and database interactions. - Added CSS styles for improved layout and visibility of elements. - Removed vault.csv as data is now managed through the database. - Implemented FTP deployment workflow for automated deployment. - Added exception handling classes for better error management. - Created JavaScript functions for pagination of transaction records.
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
name: 🚀 Deploy website via FTP
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
web-deploy:
|
||||||
|
name: 🎉 Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ !contains(github.event.head_commit.message, '#skipGA') }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 🚚 Get latest code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 📂 Sync files
|
||||||
|
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
|
||||||
|
with:
|
||||||
|
# dry-run: true
|
||||||
|
server: ftpupload.net
|
||||||
|
port: 21
|
||||||
|
protocol: ftps
|
||||||
|
username: if0_38156084
|
||||||
|
password: ${{ secrets.ftp_password }}
|
||||||
|
server-dir: /solowebworks.com/htdocs/tvt/
|
||||||
|
exclude: |
|
||||||
|
**/.git*
|
||||||
|
**/.git*/**
|
||||||
|
**/data/**
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.vscode/
|
||||||
|
backup/
|
||||||
|
debug.log
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
@@ -1,38 +1,112 @@
|
|||||||
# Torn Vault Tracker
|
# <img alt="Torn Vault Tracker icon" src="favicon.png" width="30" style="vertical-align:bottom; margin-right:.5rem;"> Torn Vault Tracker
|
||||||
|
|
||||||
Track vault balances for shared private island vaults in [Torn City](https://torn.com/).
|

|
||||||
|

|
||||||
|
[<img height="20" alt="Deployed with FTP Deploy Action" src="https://img.shields.io/badge/Deployed With-FTP DEPLOY ACTION-%3CCOLOR%3E?style=for-the-badge&color=0077b6">](https://github.com/SamKirkland/FTP-Deploy-Action)
|
||||||
|
|
||||||
|
A web-based tracking system for monitoring shared vault transactions in the [Torn City MMORPG](https://torn.com/).
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
Uses the [Torn API](https://www.torn.com/api.html) to pul the most recent vault transactions for you and your spouse. Note that in order to access the "Log" endpoint, the keys used must be created with Full Access permissions.
|
This tool automatically tracks deposits and withdrawals from your Torn City private island vault using the official Torn API. It provides real-time balance tracking, available vault space calculations, and a transaction history display for multiple users.
|
||||||
|
|
||||||
## Getting Started
|
Key Features:
|
||||||
|
|
||||||
### Dependencies
|
- 🕒 Automatic transaction synchronization via Torn API
|
||||||
|
- 💰 Real-time balance calculations (individual & combined)
|
||||||
|
- 📊 Color-coded transaction history (green deposits/red withdrawals)
|
||||||
|
- 🛡️ Secure API key management
|
||||||
|
- 📈 Vault space monitoring with individual user shares
|
||||||
|
- 💾 SQLite database for persistent storage
|
||||||
|
|
||||||
* Any reasonable LAMP stack (developed on Ubuntu 20.04 with Apache and PHP 8.1.10)
|
## Features
|
||||||
|
|
||||||
### Installing
|
### Core Functionality
|
||||||
|
|
||||||
* Create folder in your server's web root, for example, 'tvt'.
|
- Automated API polling for vault transaction logs
|
||||||
* Upload/clone this repo into your folder.
|
- Duplicate transaction prevention
|
||||||
* Edit `functions.php` to add API keys and Torn user names for you and your spouse.
|
- Responsive HTML/CSS interface
|
||||||
* Edit `vault.csv` to set initial balances for each user (see notes below).
|
- Error handling for API failures and database issues
|
||||||
* Open the tracker in any web browser.
|
- Automatic database schema creation and history population
|
||||||
* Style as you see fit in `style.css` (Optional, but if you make something cool, let me know!).
|
|
||||||
|
|
||||||
### Initial vault balance(s)
|
### Financial Tracking
|
||||||
Currently, there's no way to get past vault transactions via the API. If you're starting new, you can use the vault balance before your spouse adds their first deposit. For existing vaults, you'll need to figure out the balances the hard way. Once you have the balances, put them into the `vault.csv` with the proper user names. The timestamp field doesn't matter, and can be left at the current value.
|
|
||||||
|
|
||||||
### Automating transaction updates
|
- Individual user balances
|
||||||
|
- Total vault balance calculation
|
||||||
|
- Remaining vault space tracking (500M per user / 1B total)
|
||||||
|
- Historical transaction records with timestamps
|
||||||
|
|
||||||
I recommend using an external site monitoring service such as [cronitor.io](https://cronitor.io) set to check the URL of your server. A check rate of 15 minutes should be more than enough, since you can always force a refresh by simply opening the site in your browser.
|
## Installation
|
||||||
|
|
||||||
## Authors
|
### Requirements
|
||||||
|
|
||||||
Keith Solomon / [ZarathosX75](https://www.torn.com/profiles.php?XID=2606457) on [Torn](https://torn.com/)
|
- Web server with PHP 8.1+
|
||||||
|
- SQLite3 extension enabled
|
||||||
|
- Write permissions for web server user
|
||||||
|
|
||||||
## Version History
|
> [!NOTE]
|
||||||
|
> If you need hosting, I recommend [InfinityFree](https://www.infinityfree.com/). They have a robust free hosting package that has everything you need to run this project (I run my personal copy of this project on their service).
|
||||||
|
|
||||||
* 0.1
|
### Setup Steps
|
||||||
* Initial Release
|
|
||||||
|
1. Upload the project files to your web hosting. You can do this with an FTP client or by using `git` directly on the server (if your host supports it).
|
||||||
|
- To use `git`: Clone repository to web directory (the command as written will put all files in a `tvt` directory):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/tvt.git
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Open `config.php` and configure the API keys and other settings.
|
||||||
|
3. Access the project in your web browser to initialize the database and start tracking transactions. The first run will create the database and import the vault transactions for both users.
|
||||||
|
|
||||||
|
Depending on the number of transactions, this may take some time (my dev install took about 25 seconds to pull in ~2,100 transactions). The browser will refresh when the process is complete.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> API keys **must** have "Full Access" permissions to access `log` endpoints.
|
||||||
|
|
||||||
|
Edit `config.php` to set up API access:
|
||||||
|
|
||||||
|
- `username1` and `username2` should be changed to your usernames.
|
||||||
|
|
||||||
|
```php
|
||||||
|
const USER_KEYS = [
|
||||||
|
'username1' => 'your_api_key_here',
|
||||||
|
'username2' => 'partner_api_key_here'
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
- If you want to change the number of rows per page in the transactions table (default is 15), change the `ROWS_PER_PAGE` constant
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Access the tracker through your web browser after setup and configuration:
|
||||||
|
|
||||||
|
- Transactions will automatically sync on page load going forward
|
||||||
|
- Balance calculations update in real-time
|
||||||
|
- Recent transactions display in chronological order
|
||||||
|
|
||||||
|
### Contribution
|
||||||
|
|
||||||
|
We welcome contributions! Please follow these guidelines:
|
||||||
|
|
||||||
|
- Fork the repository
|
||||||
|
- Create feature branch (git checkout -b feature/improvement)
|
||||||
|
- Follow existing PHP coding standards
|
||||||
|
- Test changes thoroughly
|
||||||
|
- Submit a pull request
|
||||||
|
|
||||||
|
### Credits
|
||||||
|
|
||||||
|
Primary Author: [Keith Solomon](https://KeithSolomon.net)
|
||||||
|
(_[ZarathosX75](https://www.torn.com/profiles.php?XID=2606457) in Torn City_)
|
||||||
|
|
||||||
|
### Acknowledgements
|
||||||
|
|
||||||
|
- [Torn City API documentation](https://www.torn.com/api.html)
|
||||||
|
- [Torn City API Development Forum](https://www.torn.com/forums.php?p=forums&f=63&b=0&a=0)
|
||||||
|
- [Roboto font by Christian Robertson](https://fonts.google.com/specimen/Roboto)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Disclaimer:** This project is not affiliated with or endorsed by Torn City
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
// Configuration file for settings like database and API keys
|
||||||
|
|
||||||
|
// Database settings
|
||||||
|
define('DB_DSN', 'sqlite:data/vault.db'); // Path to your SQLite database
|
||||||
|
define('DB_USER', null); // Not needed for SQLite
|
||||||
|
define('DB_PASSWORD', null); // Not needed for SQLite
|
||||||
|
|
||||||
|
// User-specific keys for accessing logs
|
||||||
|
const USER_KEYS = [
|
||||||
|
'zarathos' => 'y2ypLEEIOweEXC7x',
|
||||||
|
'symos' => 'lne9TZLuqvFXtK3V'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Number of rows per page
|
||||||
|
define('ROWS_PER_PAGE', 25);
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
+168
-198
@@ -1,229 +1,199 @@
|
|||||||
<?php
|
<?php
|
||||||
// Set usernames and keys here
|
// Include settings from a separate configuration file
|
||||||
const USER1_KEY = 'user1Key';
|
require_once __DIR__ . '/config.php';
|
||||||
const USER2_KEY = 'user2Key';
|
|
||||||
|
|
||||||
const USER1_NAME = 'user1';
|
// Include utility functions
|
||||||
const USER2_NAME = 'user2';
|
require_once 'includes/exceptions.php';
|
||||||
|
require_once 'includes/utilities.php';
|
||||||
|
|
||||||
// Set locale for monetary functions.
|
/** Returns a PDO connection to the database
|
||||||
setlocale(LC_MONETARY, "en_US");
|
* This function creates the database connection and creates the database
|
||||||
|
* file if it doesn't exist, and the vault table if it doesn't exist.
|
||||||
|
*
|
||||||
|
* @return PDO
|
||||||
|
*/
|
||||||
|
function getDatabaseConnection() {
|
||||||
|
static $pdo = null;
|
||||||
|
|
||||||
// Open CSV file in read & append mode ('a+')
|
// SQL to create the vault table if it doesn't exist
|
||||||
$fp = fopen('vault.csv', 'a+');
|
$createTableSQL = "CREATE TABLE IF NOT EXISTS vault (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL
|
||||||
|
);";
|
||||||
|
|
||||||
// Read transactions from CSV
|
if ($pdo === null) {
|
||||||
$csvVault = [];
|
try {
|
||||||
$csv = [];
|
$pdo = new PDO(DB_DSN, DB_USER, DB_PASSWORD);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
$lines = file('vault.csv', FILE_IGNORE_NEW_LINES);
|
$pdo->exec($createTableSQL);
|
||||||
|
} catch (PDOException $e) {
|
||||||
foreach ($lines as $key => $value) {
|
die("Database connection failed: " . $e->getMessage());
|
||||||
$csv[$key] = str_getcsv($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user log entries.
|
|
||||||
$user1Txn = getLog(USER1_NAME, $csv);
|
|
||||||
$user2Txn = getLog(USER2_NAME, $csv);
|
|
||||||
|
|
||||||
// Merge user arrays into transaction array
|
|
||||||
$txnVault = array_merge($user1Txn, $user2Txn);
|
|
||||||
|
|
||||||
// Build array of entries not already in the CSV
|
|
||||||
foreach ($txnVault as $key => $value) {
|
|
||||||
$csvComp = searchForId($value['timestamp'], $csv);
|
|
||||||
|
|
||||||
if (!$csvComp) {
|
|
||||||
$csvVault[] = $value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store new trasnactions in CSV
|
return $pdo;
|
||||||
foreach ($csvVault as $fields) {
|
|
||||||
fputcsv($fp, $fields);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($fp);
|
/** Pulls and stores all vault transaction logs for a user from the Torn API.
|
||||||
|
*
|
||||||
|
* This function retrieves all transaction logs related to vault deposits and
|
||||||
|
* withdrawals for a specified user. It continues fetching logs until no more
|
||||||
|
* entries are available, and stores each entry in the database. Each log entry
|
||||||
|
* is uniquely identified by its ID and includes details such as timestamp,
|
||||||
|
* description, and amount. The function requires the user's API key to
|
||||||
|
* authenticate requests to the Torn API.
|
||||||
|
*
|
||||||
|
* @param string $user The user whose transaction logs are to be retrieved and stored.
|
||||||
|
*/
|
||||||
|
function firstRun($user) {
|
||||||
|
$apiKey = USER_KEYS[$user];
|
||||||
|
|
||||||
// searchForID(): Serach CSV array for timestamp ID
|
$pdo = getDatabaseConnection();
|
||||||
function searchForId($id, $array) {
|
|
||||||
foreach ($array as $key => $val) {
|
$to = time();
|
||||||
if ($val[1] == $id) {
|
|
||||||
return true;
|
do {
|
||||||
}
|
$url = "https://api.torn.com/v2/user?selections=log&log=5850,5851&to=$to";
|
||||||
|
$ch = curl_init($url);
|
||||||
|
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
|
'accept: application/json',
|
||||||
|
"Authorization: ApiKey $apiKey"
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if (empty($data['log'])) { break; }
|
||||||
|
|
||||||
|
foreach ($data['log'] as $key => $entry) {
|
||||||
|
$description = $entry['title'];
|
||||||
|
$timestamp = $entry['timestamp'];
|
||||||
|
$amount = isset($entry['data']['deposited']) ? $entry['data']['deposited'] : -$entry['data']['withdrawn'];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO vault (ID, user, timestamp, description, amount) VALUES (:id, :user, :timestamp, :description, :amount)');
|
||||||
|
|
||||||
|
$stmt->bindValue(':id', $key);
|
||||||
|
$stmt->bindValue(':user', $user);
|
||||||
|
$stmt->bindValue(':timestamp', $timestamp);
|
||||||
|
$stmt->bindValue(':description', $description);
|
||||||
|
$stmt->bindValue(':amount', $amount);
|
||||||
|
|
||||||
|
$stmt->execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
$to = end($data['log'])['timestamp'];
|
||||||
|
} while (true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatMoney(): Format raw transaction vaule to display as money
|
/** Retrieves the user's log entries from the Torn API
|
||||||
// cents: 0=never, 1=if needed, 2=always
|
*
|
||||||
function formatMoney($number, $cents = 1) {
|
* @param string $user The user to retrieve the log for
|
||||||
if (is_numeric($number)) {
|
* @param boolean $debug Whether to output debug information (default: false)
|
||||||
if (!$number) {
|
*
|
||||||
$money = ($cents == 2 ? '0.00' : '0');
|
* @throws Exception If the user does not have an API key configured,
|
||||||
|
* if the API call fails, or if the log data is invalid
|
||||||
|
*/
|
||||||
|
function getLog($user, $debug = false) {
|
||||||
|
$pdo = getDatabaseConnection();
|
||||||
|
|
||||||
|
ensureUserHasApiKey($user);
|
||||||
|
|
||||||
|
$stmt = $pdo->query("SELECT MAX(timestamp) AS max_timestamp FROM vault WHERE user = '$user'");
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$from = $row['max_timestamp'];
|
||||||
|
$to = time();
|
||||||
|
|
||||||
|
$apiKey = USER_KEYS[$user];
|
||||||
|
$apiEndpoint = "https://api.torn.com/user/?selections=log&log=5850,5851&to=$to&from=$from&key=$apiKey";
|
||||||
|
|
||||||
|
$responseData = executeApiCall($apiEndpoint);
|
||||||
|
validateApiResponse($responseData);
|
||||||
|
|
||||||
|
$checkStmt = prepareCheckStatement($pdo);
|
||||||
|
$insertStmt = prepareInsertStatement($pdo);
|
||||||
|
|
||||||
|
processLogEntries($responseData['log'], $user, $checkStmt, $insertStmt, $debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate the current balance for a user or all users
|
||||||
|
*
|
||||||
|
* @param string $name (optional) The user to get the balance for. If not provided, all users' balances will be added.
|
||||||
|
*
|
||||||
|
* @return string The balance as a formatted string
|
||||||
|
*/
|
||||||
|
function generateBalance($name=null) {
|
||||||
|
$balance = vaultLoop($name);
|
||||||
|
|
||||||
|
return number_format($balance, 0); // Format the balance as an integer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate the available vault space for a user or all users.
|
||||||
|
*
|
||||||
|
* This function calculates the remaining space in the vault based on the
|
||||||
|
* transactions for a specific user or all users. If a user is specified,
|
||||||
|
* the available space is calculated against a limit of 500,000,000.
|
||||||
|
* If no user is specified, the available space is calculated against a
|
||||||
|
* total vault limit of 1,000,000,000.
|
||||||
|
*
|
||||||
|
* @param string|null $name (optional) The user to calculate the vault space for.
|
||||||
|
* If not provided, calculates for all users.
|
||||||
|
*
|
||||||
|
* @return string The available vault space as a formatted string.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getSpace($name=null) {
|
||||||
|
$space = vaultLoop($name);
|
||||||
|
|
||||||
|
if ($name === null) {
|
||||||
|
$space = 1000000000 - $space;
|
||||||
} else {
|
} else {
|
||||||
if (floor($number) == $number) {
|
$space = 500000000 - $space;
|
||||||
$money = number_format($number, ($cents == 2 ? 2 : 0));
|
|
||||||
} else {
|
|
||||||
$money = number_format(round($number, 2), ($cents == 0 ? 0 : 2));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (substr($number,0,1) == '-') {
|
return number_format($space, 0); // Format the balance as an integer
|
||||||
$money = ltrim($money, '-');
|
|
||||||
$sign = '-$';
|
|
||||||
} else {
|
|
||||||
$sign = '$';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $sign.$money;
|
/** Builds an HTML table of all transactions in the vault
|
||||||
}
|
*
|
||||||
}
|
* Loops over all records in the vault and builds a table of the user, timestamp,
|
||||||
|
* description and amount of each transaction. Amounts are formatted as integers.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function buildTable () {
|
||||||
|
$records = fetchVaultRecords();
|
||||||
|
|
||||||
// getLog(): Get log entries for user. Returns array.
|
foreach ($records as $entry) {
|
||||||
function getLog($user, $csv) {
|
$user = $entry['user'];
|
||||||
$tornURL = 'https://api.torn.com/user/?selections=log&key=';
|
$timestamp = $entry['timestamp'];
|
||||||
$i = 0;
|
$description = $entry['description'];
|
||||||
|
$amount = $entry['amount'];
|
||||||
|
$sign = getSign($amount);
|
||||||
|
|
||||||
if ($user == USER1_NAME) {
|
if ($description == 'Vault withdraw') {
|
||||||
$usrKey = USER1_KEY;
|
|
||||||
$user = USER1_NAME;
|
|
||||||
} else {
|
|
||||||
$usrKey = USER2_KEY;
|
|
||||||
$user = USER2_NAME;
|
|
||||||
}
|
|
||||||
|
|
||||||
$usrURL = $tornURL . $usrKey;
|
|
||||||
$usrLog = json_decode(file_get_contents($usrURL));
|
|
||||||
$usrData = $usrLog->log;
|
|
||||||
$usrVault = [];
|
|
||||||
|
|
||||||
foreach ($usrData as $entry) {
|
|
||||||
if ($entry->log == 5851) {
|
|
||||||
$usrVault[$i]['user'] = $user;
|
|
||||||
$usrVault[$i]['timestamp'] = $entry->timestamp;
|
|
||||||
$usrVault[$i]['operation'] = $entry->title;
|
|
||||||
$usrVault[$i]['amount'] = '-'.$entry->data->withdrawn;
|
|
||||||
} elseif ($entry->log == 5850) {
|
|
||||||
$usrVault[$i]['user'] = $user;
|
|
||||||
$usrVault[$i]['timestamp'] = $entry->timestamp;
|
|
||||||
$usrVault[$i]['operation'] = $entry->title;
|
|
||||||
$usrVault[$i]['amount'] = $entry->data->deposited;
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $usrVault;
|
|
||||||
}
|
|
||||||
|
|
||||||
// vaultTxns(): Show all vault transactions
|
|
||||||
function vaultTxns($csv) {
|
|
||||||
$output = '';
|
|
||||||
|
|
||||||
$output .= '<table id="data">';
|
|
||||||
$output .= ' <thead>';
|
|
||||||
$output .= ' <td><h4>User</h4></td>';
|
|
||||||
$output .= ' <td><h4>Timestamp</h4></td>';
|
|
||||||
$output .= ' <td><h4>Operation</h4></td>';
|
|
||||||
$output .= ' <td><h4>Amount</h4></td>';
|
|
||||||
$output .= ' </thead>';
|
|
||||||
|
|
||||||
$output .= ' <tbody>';
|
|
||||||
|
|
||||||
$csv = array_reverse($csv);
|
|
||||||
|
|
||||||
foreach ($csv as $entry) {
|
|
||||||
if ($entry[2] == 'Vault withdraw') {
|
|
||||||
$class = 'debit';
|
$class = 'debit';
|
||||||
|
$amount = substr($amount, 1); // Remove the negative sign
|
||||||
} else {
|
} else {
|
||||||
$class = 'credit';
|
$class = 'credit';
|
||||||
}
|
}
|
||||||
|
|
||||||
$output .= '<tr class="' . $class . '">';
|
$amount = number_format($amount, 0); // Format the amount as an integer
|
||||||
$output .= '<td>';
|
|
||||||
$output .= $entry[0]; // User
|
|
||||||
$output .= '</td>';
|
|
||||||
$output .= '<td>';
|
|
||||||
$output .= date("d/m/Y", $entry[1]); // Transaction Date
|
|
||||||
$output .= '</td>';
|
|
||||||
$output .= '<td>';
|
|
||||||
$output .= $entry[2]; // Transaction Type
|
|
||||||
$output .= '</td>';
|
|
||||||
$output .= '<td>';
|
|
||||||
$output .= formatMoney($entry[3]); // Transaction Amount
|
|
||||||
$output .= '</td>';
|
|
||||||
$output .= '</tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$output .= ' </tbody>';
|
echo '<tr class="'.$class.'">';
|
||||||
|
echo '<td>'.$user.'</td>';
|
||||||
$output .= '</table>';
|
echo '<td>'.date("F j, Y / H:i", $timestamp).'</td>';
|
||||||
|
echo '<td>'.$description.'</td>';
|
||||||
echo $output;
|
echo '<td>'.$sign.$amount.'</td>';
|
||||||
}
|
echo '</tr>';
|
||||||
|
|
||||||
// splitVault(): split CSV array into two separate arrays based on the user
|
|
||||||
function splitVault($csv, $field, $user): array {
|
|
||||||
$usrVault = [];
|
|
||||||
$usr2Vault = [];
|
|
||||||
|
|
||||||
foreach ($csv as $entry) {
|
|
||||||
if (isset($entry[$field]) && $entry[$field] == $user) {
|
|
||||||
$usr1Vault[] = $entry;
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $usr1Vault;
|
|
||||||
}
|
|
||||||
|
|
||||||
// arrBalance(): Calculate balance of arrays
|
|
||||||
function arrBalance($array, $field) {
|
|
||||||
$total = 0;
|
|
||||||
|
|
||||||
foreach ($array as $item) {
|
|
||||||
$total += $item[$field];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $total;
|
|
||||||
}
|
|
||||||
|
|
||||||
// vaultBalance(): Calculate overall vault balance
|
|
||||||
function vaultBalance($csv) {
|
|
||||||
$vaultBalance = arrBalance($csv,3);
|
|
||||||
$vaultSpace = formatMoney(1000000000-$vaultBalance);
|
|
||||||
|
|
||||||
echo '<section class="vault">';
|
|
||||||
echo ' <h2>Vault Balance: '.formatMoney($vaultBalance).'</h2>';
|
|
||||||
echo ' <h3>Vault space left: '.$vaultSpace.'</h3>';
|
|
||||||
echo '</section>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// userBalances(): Calculate per-user vault balances
|
|
||||||
function userBalances($csv) {
|
|
||||||
$usr1 = 'Zarathos';
|
|
||||||
$usr1Vault = splitVault($csv, '0', $usr1);
|
|
||||||
$usr1Balance = arrBalance($usr1Vault,3);
|
|
||||||
$usr1Share = formatMoney(500000000-$usr1Balance);
|
|
||||||
|
|
||||||
$usr2 = 'Symos';
|
|
||||||
$usr2Vault = splitVault($csv, '0', $usr2);
|
|
||||||
$usr2Balance = arrBalance($usr2Vault,3);
|
|
||||||
$usr2Share = formatMoney(500000000-$usr2Balance);
|
|
||||||
|
|
||||||
echo '<section class="user1">';
|
|
||||||
echo ' <h3>'.$usr1.' balance: '.formatMoney($usr1Balance).'</h3>';
|
|
||||||
echo ' <h4>'.$usr1.' share left: '.$usr1Share.'</h4>';
|
|
||||||
echo '</section>';
|
|
||||||
|
|
||||||
echo '<section class="user2">';
|
|
||||||
echo ' <h3>'.$usr2.' balance: '.formatMoney($usr2Balance).'</h3>';
|
|
||||||
echo ' <h4>'.$usr2.' share left: '.$usr2Share.'</h4>';
|
|
||||||
echo '</section>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
class ApiKeyMissingException extends Exception {
|
||||||
|
public function __construct($message, $code = 0, Throwable $previous = null) {
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CurlErrorException extends Exception {
|
||||||
|
public function __construct($message, $code = 0, Throwable $previous = null) {
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JsonDataException extends Exception {
|
||||||
|
public function __construct($message, $code = 0, Throwable $previous = null) {
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiValidationException extends Exception {
|
||||||
|
public function __construct($message, $code = 0, Throwable $previous = null) {
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
/** Checks if the database is new and empty.
|
||||||
|
*
|
||||||
|
* This function checks if the 'vault' table in the database has any entries.
|
||||||
|
* If it does not, it returns true, indicating that the database is new and
|
||||||
|
* empty. Otherwise, it returns false.
|
||||||
|
*
|
||||||
|
* @return boolean True if the database is new and empty, false otherwise.
|
||||||
|
*/
|
||||||
|
function dbNew() {
|
||||||
|
$pdo = getDatabaseConnection();
|
||||||
|
$stmt = $pdo->query("SELECT COUNT(*) FROM vault");
|
||||||
|
|
||||||
|
return $stmt->fetchColumn() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensures that a user has an API key configured.
|
||||||
|
*
|
||||||
|
* @param string $user The user to check the API key for
|
||||||
|
*
|
||||||
|
* @throws Exception If the user does not have an API key configured
|
||||||
|
*/
|
||||||
|
function ensureUserHasApiKey($user) {
|
||||||
|
if (!array_key_exists($user, USER_KEYS)) {
|
||||||
|
throw new ApiKeyMissingException("User does not have an API key configured.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Executes a GET request to the given API endpoint and returns the JSON response as an associative array.
|
||||||
|
*
|
||||||
|
* @param string $apiEndpoint The URL of the API endpoint to call
|
||||||
|
*
|
||||||
|
* @throws Exception If the API call fails or if the response is invalid JSON
|
||||||
|
*
|
||||||
|
* @return array The JSON response from the API
|
||||||
|
*/
|
||||||
|
function executeApiCall($apiEndpoint) {
|
||||||
|
$headers = ["Content-Type: application/json"];
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $apiEndpoint);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
|
||||||
|
if (curl_errno($ch)) {
|
||||||
|
throw new CurlErrorException("cURL error: " . curl_error($ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$responseData = json_decode($response, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
throw new JsonDataException("Failed to decode JSON response: " . json_last_error_msg());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $responseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates the API response data for the presence of a log array.
|
||||||
|
*
|
||||||
|
* @param array $responseData The API response data to validate.
|
||||||
|
*
|
||||||
|
* @throws Exception If the log data is missing or not an array.
|
||||||
|
*/
|
||||||
|
function validateApiResponse($responseData) {
|
||||||
|
if (!isset($responseData['log']) || !is_array($responseData['log'])) {
|
||||||
|
throw new ApiValidationException("Invalid log data received from the API.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepare a PDO statement for checking if a given vault transaction already exists in the database.
|
||||||
|
*
|
||||||
|
* @param PDO $pdo The PDO instance to prepare the statement with
|
||||||
|
*
|
||||||
|
* @return PDOStatement The prepared statement
|
||||||
|
*/
|
||||||
|
function prepareCheckStatement($pdo) {
|
||||||
|
$checkQuery = "SELECT COUNT(*) FROM vault WHERE user = :user AND timestamp = :timestamp AND amount = :amount";
|
||||||
|
return $pdo->prepare($checkQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepare a PDO statement for inserting a new vault transaction into the database.
|
||||||
|
*
|
||||||
|
* @param PDO $pdo The PDO instance to prepare the statement with
|
||||||
|
*
|
||||||
|
* @return PDOStatement The prepared statement
|
||||||
|
*/
|
||||||
|
function prepareInsertStatement($pdo) {
|
||||||
|
$insertQuery = "INSERT INTO vault (ID, user, timestamp, description, amount) VALUES (:id, :user, :timestamp, :description, :amount)";
|
||||||
|
return $pdo->prepare($insertQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process an array of log entries retrieved from the Torn API.
|
||||||
|
*
|
||||||
|
* Goes through each log entry and checks if it's a vault deposit or withdrawal.
|
||||||
|
* If it is, it checks if the entry already exists in the database. If it
|
||||||
|
* doesn't, it inserts the entry into the database.
|
||||||
|
*
|
||||||
|
* @param array $logEntries The array of log entries to process
|
||||||
|
* @param string $user The user whose log entries are being processed
|
||||||
|
* @param PDOStatement $checkStmt A prepared statement to check if an entry
|
||||||
|
* already exists in the database
|
||||||
|
* @param PDOStatement $insertStmt A prepared statement to insert a new entry
|
||||||
|
* into the database
|
||||||
|
* @param boolean $debug Whether to output debug information (default: false)
|
||||||
|
*/
|
||||||
|
function processLogEntries($logEntries, $user, $checkStmt, $insertStmt, $debug) {
|
||||||
|
foreach ($logEntries as $key =>$entry) {
|
||||||
|
if ($debug) {
|
||||||
|
$logMessage = "Raw entry:\n" . print_r($entry, true);
|
||||||
|
file_put_contents(__DIR__ . '/debug.log', $logMessage, FILE_APPEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = $entry['timestamp'];
|
||||||
|
$description = $entry['title'];
|
||||||
|
$amount = $entry['log'] === 5850 ? $entry['data']['deposited'] : -$entry['data']['withdrawn'];
|
||||||
|
|
||||||
|
if ($debug) {
|
||||||
|
$logMessage = "Vault entry:\n\tUser: $user,\n\tTimestamp: $timestamp,\n\tDescription: $description,\n\tAmount: $amount\n";
|
||||||
|
file_put_contents(__DIR__ . '/debug.log', $logMessage, FILE_APPEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$checkStmt->execute([
|
||||||
|
':user' => $user,
|
||||||
|
':timestamp' => $timestamp,
|
||||||
|
':amount' => $amount
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($checkStmt->fetchColumn() == 0) {
|
||||||
|
$insertStmt->execute([
|
||||||
|
':id' => $key,
|
||||||
|
':user' => $user,
|
||||||
|
':timestamp' => $timestamp,
|
||||||
|
':description' => $description,
|
||||||
|
':amount' => $amount
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch vault records from the database
|
||||||
|
* If a user is provided, only that user's records
|
||||||
|
* are returned. Otherwise, all records are returned.
|
||||||
|
*
|
||||||
|
* @param string $user (optional) The user to filter by
|
||||||
|
*
|
||||||
|
* @return array An array of records, or an empty array on error
|
||||||
|
*/
|
||||||
|
function fetchVaultRecords($user = null) {
|
||||||
|
$pdo = getDatabaseConnection();
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$query = "SELECT * FROM vault WHERE user = :user ORDER BY timestamp DESC";
|
||||||
|
$params = [':user' => $user];
|
||||||
|
} else {
|
||||||
|
$query = "SELECT * FROM vault ORDER BY timestamp DESC";
|
||||||
|
$params = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare($query);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo "Error fetching records: " . $e->getMessage();
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Iterate over all vault records for the given user (or all users if no user is given),
|
||||||
|
*
|
||||||
|
* @param string|null $name (optional) The user to loop over records for.
|
||||||
|
* If not provided, all users' records will be looped over.
|
||||||
|
*
|
||||||
|
* @return int The total balance of the given user (or all users if no user is given).
|
||||||
|
*/
|
||||||
|
function vaultLoop ($name=null) {
|
||||||
|
$vault = fetchVaultRecords($name);
|
||||||
|
$balance = 0;
|
||||||
|
|
||||||
|
foreach ($vault as $entry) { $balance += $entry['amount']; }
|
||||||
|
|
||||||
|
return $balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a string indicating the sign of a number, with a dollar symbol.
|
||||||
|
*
|
||||||
|
* @param int|float $number The number to check.
|
||||||
|
*
|
||||||
|
* @return string A string containing a dollar sign and a negative sign if the
|
||||||
|
* number is negative.
|
||||||
|
*/
|
||||||
|
function getSign($number) {
|
||||||
|
if (substr($number, 0, 1) == '-') { return '-$'; }
|
||||||
|
|
||||||
|
return '$';
|
||||||
|
}
|
||||||
@@ -1,4 +1,18 @@
|
|||||||
<?php include('functions.php'); ?>
|
<?php
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
include_once __DIR__ . '/functions.php';
|
||||||
|
|
||||||
|
if (dbNew()) {
|
||||||
|
foreach (USER_KEYS as $key => $value) {
|
||||||
|
firstRun($key);
|
||||||
|
}
|
||||||
|
header('Location: /');
|
||||||
|
} else {
|
||||||
|
foreach (USER_KEYS as $key => $value) {
|
||||||
|
getLog($key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -8,6 +22,7 @@
|
|||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Torn Vault Tracker</title>
|
<title>Torn Vault Tracker</title>
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png">
|
||||||
|
|
||||||
<link rel="stylesheet" href="style.css?v=<?php echo filemtime('style.css'); ?>">
|
<link rel="stylesheet" href="style.css?v=<?php echo filemtime('style.css'); ?>">
|
||||||
|
|
||||||
@@ -23,20 +38,55 @@
|
|||||||
<div class="gridLeft">
|
<div class="gridLeft">
|
||||||
<h1>Transactions</h1>
|
<h1>Transactions</h1>
|
||||||
|
|
||||||
<?php vaultTxns($csv); ?>
|
<table id="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><h4>User</h4></th>
|
||||||
|
<th><h4>Date / Time (TCT)</h4></th>
|
||||||
|
<th><h4>Operation</h4></th>
|
||||||
|
<th><h4>Amount</h4></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<?php buildTable(); ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div id="pagination-controls">
|
||||||
|
<button onclick="changePage(-999)">«</button>
|
||||||
|
<button onclick="changePage(-1)">‹</button>
|
||||||
|
<div id="page-info">Page x of y</div>
|
||||||
|
<button onclick="changePage(1)">›</button>
|
||||||
|
<button onclick="changePage(999)">»</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gridRght">
|
<div class="gridRight">
|
||||||
<h1>Balances</h1>
|
<h1>Balances</h1>
|
||||||
|
|
||||||
|
<section class="vault">
|
||||||
|
<h2>Vault Balance: $<?php echo generateBalance(); ?></h2>
|
||||||
|
<h3>Space left: $<?php echo getSpace(); ?></h3>
|
||||||
|
</section>
|
||||||
|
|
||||||
<?php vaultBalance($csv); ?>
|
<h2>Shares</h2>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<?php userBalances($csv); ?>
|
<?php foreach (USER_KEYS as $key => $value) { ?>
|
||||||
|
<section class="user">
|
||||||
|
<h3><?php echo ucfirst($key); ?> balance: $<?php echo generateBalance($key); ?></h3>
|
||||||
|
<h4>Share left: $<?php echo getSpace($key); ?></h4>
|
||||||
|
</section>
|
||||||
|
<?php } ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
<script>
|
||||||
|
let rowsPerPage = <?php echo ROWS_PER_PAGE; ?>; // Set in config.php
|
||||||
|
|
||||||
|
paginate();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
let currentPage = 1;
|
||||||
|
|
||||||
|
function paginate() {
|
||||||
|
const table = document.getElementById('data');
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
const totalPages = Math.ceil(rows.length / rowsPerPage);
|
||||||
|
|
||||||
|
// Hide all rows
|
||||||
|
rows.forEach(row => row.style.display = 'none');
|
||||||
|
|
||||||
|
// Show only the rows for the current page
|
||||||
|
let startRow = (currentPage - 1) * rowsPerPage;
|
||||||
|
let endRow = Math.min(currentPage * rowsPerPage, rows.length);
|
||||||
|
|
||||||
|
for (let i = startRow; i < endRow; i++) {
|
||||||
|
rows[i].style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pagination info
|
||||||
|
document.getElementById('page-info').textContent = `Page ${currentPage} of ${totalPages}`;
|
||||||
|
|
||||||
|
// Optionally hide the "Previous" button on the first page and the "Next" button on the last page
|
||||||
|
document.querySelector('#pagination-controls > button:nth-child(1)').disabled = currentPage === 1;
|
||||||
|
document.querySelector('#pagination-controls > button:nth-child(2)').disabled = currentPage === 1;
|
||||||
|
document.querySelector('#pagination-controls > button:nth-child(4)').disabled = currentPage === totalPages;
|
||||||
|
document.querySelector('#pagination-controls > button:nth-child(5)').disabled = currentPage === totalPages;
|
||||||
|
|
||||||
|
// Make the table visible after pagination is applied
|
||||||
|
table.style.visibility = 'visible';
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(direction) {
|
||||||
|
const totalPages = Math.ceil(document.querySelectorAll('#data tbody tr').length / rowsPerPage);
|
||||||
|
|
||||||
|
if (direction === -999) {
|
||||||
|
currentPage = 1; // Go to first page
|
||||||
|
} else if (direction === 999) {
|
||||||
|
currentPage = totalPages; // Go to last page
|
||||||
|
} else if ((direction === -1 && currentPage > 1) || (direction === 1 && currentPage < totalPages)) {
|
||||||
|
currentPage += direction; // Move one step
|
||||||
|
}
|
||||||
|
|
||||||
|
paginate();
|
||||||
|
}
|
||||||
@@ -10,22 +10,18 @@ body {
|
|||||||
padding: 0 1em;
|
padding: 0 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
table {
|
||||||
display: grid;
|
color: #000;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
visibility: hidden;
|
||||||
grid-template-rows: 1fr;
|
width: 100%;
|
||||||
grid-column-gap: 4em;
|
|
||||||
grid-row-gap: 0px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
table { color: #000; width: 100%; }
|
table, tr, td, th {
|
||||||
|
|
||||||
table, tr, td {
|
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
thead td {
|
thead th {
|
||||||
background: #ccc;
|
background: #ccc;
|
||||||
color: #000;
|
color: #000;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -40,10 +36,25 @@ tr.credit { background-color: var(--green); color: #fff; }
|
|||||||
|
|
||||||
td { padding: .15em 1em; }
|
td { padding: .15em 1em; }
|
||||||
|
|
||||||
h1 { }
|
#pagination-controls {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1em;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
.vault h2, h3, h4 { margin: 0; }
|
h2, h3, h4 { margin: 0; }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
grid-column-gap: 4em;
|
||||||
|
grid-row-gap: 0px;
|
||||||
|
margin-top: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault { margin: 0 0 3em; }
|
||||||
|
|
||||||
.vault h3, h4 { font-style: italic; }
|
.vault h3, h4 { font-style: italic; }
|
||||||
|
|
||||||
.vault { margin: 0 0 1em; }
|
|
||||||
Reference in New Issue
Block a user