Improved styling using dark theme and improved other functionality.

This commit is contained in:
Andrew Lalis 2022-02-15 13:22:41 +01:00
parent 6183ccbea9
commit 9b8a450234
33 changed files with 510 additions and 95 deletions

21
colors.md Normal file
View File

@ -0,0 +1,21 @@
# Color Scheme!
`#f79256`
![](https://dummyimage.com/200x200/f79156/fff.jpg)
`#fbd1a2`
![](https://dummyimage.com/200x200/fbd2a2/fff.jpg)
`#7dcfb6`
![](https://dummyimage.com/200x200/7dcfb6/fff.jpg)
`#1d4e89`
![](https://dummyimage.com/200x200/1d4e89/fff.jpg)
`#00b2ca`
![](https://dummyimage.com/200x200/00b2ca/fff.jpg)

62
icon.svg Normal file
View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32"
height="32"
viewBox="0 0 8.4666665 8.4666669"
version="1.1"
id="svg5"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
sodipodi:docname="icon.svg"
inkscape:export-filename="A:\Programming\GitHub-andrewlalis\CoyoteCredit\icon_256.png"
inkscape:export-xdpi="768.00006"
inkscape:export-ydpi="768.00006"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:document-units="px"
showgrid="false"
units="px"
inkscape:zoom="16"
inkscape:cx="20.59375"
inkscape:cy="18.21875"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:#e88951;fill-opacity:1;stroke:#e88951;stroke-width:0.757717;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 4.4233884,2.6232631 6.376365,0.64987167 6.0568968,3.5177737 Z"
id="path2086"
sodipodi:nodetypes="cccc" />
<circle
style="fill:#fbd1a2;fill-opacity:1;stroke-width:1.5339;stroke-linecap:round;stroke-linejoin:round"
id="path846"
cx="4.2333336"
cy="5.0400858"
r="3.1597018" />
<path
style="fill:#f79256;fill-opacity:1;stroke:#f79256;stroke-width:0.87652;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 2.4378179,2.9878512 4.6969909,0.70506246 4.3274351,4.0226073 Z"
id="path903"
sodipodi:nodetypes="cccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,50 @@
package nl.andrewl.coyotecredit.ctl;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;
@Controller
@ControllerAdvice
@RequestMapping(path = "/error")
public class ErrorPage implements ErrorController {
@GetMapping
public String get(Model model, HttpServletRequest request) {
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
int statusCode = status == null ? -1 : (Integer) status;
model.addAttribute("statusCode", statusCode);
Object message = request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
String msg;
if (message != null) {
msg = (String) message;
} else {
msg = switch (statusCode) {
case 404 -> "Not found.";
case 400 -> "Bad request.";
case 401 -> "Unauthorized.";
case 403 -> "Forbidden.";
case 500 -> "Internal server error.";
default -> "Unknown error.";
};
}
model.addAttribute("message", msg);
return "error";
}
@ExceptionHandler(ResponseStatusException.class)
public ModelAndView handleException(ResponseStatusException e) {
ModelAndView mav = new ModelAndView("error");
mav.addObject("statusCode", e.getRawStatusCode());
mav.addObject("message", e.getReason());
return mav;
}
}

View File

@ -0,0 +1,24 @@
package nl.andrewl.coyotecredit.ctl;
import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.model.User;
import nl.andrewl.coyotecredit.service.TradeableService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping(path = "/tradeables/{tradeableId}")
@RequiredArgsConstructor
public class TradeablePage {
private final TradeableService tradeableService;
@GetMapping
public String get(Model model, @PathVariable long tradeableId, @AuthenticationPrincipal User user) {
model.addAttribute("tradeable", tradeableService.getTradeable(tradeableId, user));
return "tradeable";
}
}

View File

@ -10,7 +10,8 @@ public record FullAccountData (
String number,
String name,
boolean admin,
boolean userAdmin,
boolean userAdmin,// If the current user is an admin of the exchange this account is in.
boolean userIsOwner,// If the current user is the owner of this account.
ExchangeData exchange,
List<BalanceData> balances,
String totalBalance,

View File

@ -10,6 +10,8 @@ public record FullExchangeData (
String name,
TradeableData primaryTradeable,
List<TradeableData> supportedTradeables,
String totalMarketValue,
int accountCount,
// Account info that's needed for determining if it's possible to do some actions.
boolean accountAdmin,
long accountId

View File

@ -11,7 +11,10 @@ public record TradeableData(
String marketPriceUsd,
String formattedPriceUsd,
String name,
String description
String description,
Long exchangeId,
String exchangeName
) {
public static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#,##0.00");
@ -23,7 +26,9 @@ public record TradeableData(
t.getMarketPriceUsd().toPlainString(),
DECIMAL_FORMAT.format(t.getMarketPriceUsd()),
t.getName(),
t.getDescription()
t.getDescription(),
t.getExchange() == null ? null : t.getExchange().getId(),
t.getExchange() == null ? null : t.getExchange().getName()
);
}
}

View File

@ -19,7 +19,7 @@ public record TransactionData(
t.getFromAmount().toPlainString(),
new TradeableData(t.getTo()),
t.getToAmount().toPlainString(),
t.getTimestamp().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
t.getTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " UTC"
);
}
}

View File

@ -14,4 +14,7 @@ public interface AccountRepository extends JpaRepository<Account, Long> {
List<Account> findAllByUser(User user);
Optional<Account> findByNumber(String number);
Optional<Account> findByUserAndExchange(User user, Exchange exchange);
boolean existsByUserAndExchange(User user, Exchange exchange);
List<Account> findAllByExchange(Exchange e);
}

View File

@ -85,11 +85,23 @@ public class Account {
return null;
}
/**
* Gets the total account balance, in terms of the exchange's primary asset.
* @return The total balance in terms of the exchange's primary asset.
*/
public BigDecimal getTotalBalance() {
BigDecimal totalUsd = new BigDecimal(0);
return getTotalBalanceUsd().divide(getExchange().getPrimaryTradeable().getMarketPriceUsd(), RoundingMode.HALF_UP);
}
/**
* Gets the total account balance, in USD.
* @return The total balance, in USD.
*/
public BigDecimal getTotalBalanceUsd() {
BigDecimal totalUsd = BigDecimal.ZERO;
for (var bal : getBalances()) {
totalUsd = totalUsd.add(bal.getTradeable().getMarketPriceUsd().multiply(bal.getAmount()));
}
return totalUsd.divide(getExchange().getPrimaryTradeable().getMarketPriceUsd(), RoundingMode.HALF_UP);
return totalUsd;
}
}

View File

@ -0,0 +1,36 @@
package nl.andrewl.coyotecredit.model;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* Represents a snapshot in time of an account's total value.
*/
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class AccountValueSnapshot {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private Account account;
@Column(nullable = false, precision = 24, scale = 10)
private BigDecimal amount;
@Column(nullable = false, updatable = false)
private LocalDateTime timestamp;
public AccountValueSnapshot(Account account, LocalDateTime timestamp, BigDecimal amount) {
this.account = account;
this.timestamp = timestamp;
this.amount = amount;
}
}

View File

@ -7,6 +7,7 @@ import nl.andrewl.coyotecredit.dao.TradeableRepository;
import nl.andrewl.coyotecredit.dao.TransactionRepository;
import nl.andrewl.coyotecredit.dao.TransferRepository;
import nl.andrewl.coyotecredit.model.*;
import nl.andrewl.coyotecredit.util.AccountNumberUtils;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@ -52,7 +53,14 @@ public class AccountService {
if (!sender.getUser().getId().equals(user.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
Account recipient = accountRepository.findByNumber(payload.recipientNumber())
String recipientNumber = payload.recipientNumber().trim();
if (!AccountNumberUtils.isValid(recipientNumber)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"The recipient number \"" + recipientNumber + "\" is not valid. Should be of the form 1234-1234-1234-1234."
);
}
Account recipient = accountRepository.findByNumber(recipientNumber)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown recipient."));
Tradeable tradeable = tradeableRepository.findById(payload.tradeableId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown tradeable asset."));
@ -115,6 +123,7 @@ public class AccountService {
account.getName(),
account.isAdmin(),
userAccount.isAdmin(),
account.getUser().getId().equals(user.getId()),
new ExchangeData(
account.getExchange().getId(),
account.getExchange().getName(),

View File

@ -35,8 +35,14 @@ public class ExchangeService {
public FullExchangeData getData(long exchangeId, User user) {
Exchange e = exchangeRepository.findById(exchangeId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
Account account = accountRepository.findByUserAndExchange(user, e)
Account userAccount = accountRepository.findByUserAndExchange(user, e)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
BigDecimal totalValue = BigDecimal.ZERO;
int accountCount = 0;
for (var acc : accountRepository.findAllByExchange(e)) {
totalValue = totalValue.add(acc.getTotalBalance());
accountCount++;
}
return new FullExchangeData(
e.getId(),
e.getName(),
@ -45,8 +51,10 @@ public class ExchangeService {
.map(TradeableData::new)
.sorted(Comparator.comparing(TradeableData::symbol))
.toList(),
account.isAdmin(),
account.getId()
TradeableData.DECIMAL_FORMAT.format(totalValue),
accountCount,
userAccount.isAdmin(),
userAccount.getId()
);
}

View File

@ -0,0 +1,29 @@
package nl.andrewl.coyotecredit.service;
import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.TradeableData;
import nl.andrewl.coyotecredit.dao.AccountRepository;
import nl.andrewl.coyotecredit.dao.TradeableRepository;
import nl.andrewl.coyotecredit.model.Tradeable;
import nl.andrewl.coyotecredit.model.User;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service
@RequiredArgsConstructor
public class TradeableService {
private final TradeableRepository tradeableRepository;
private final AccountRepository accountRepository;
@Transactional(readOnly = true)
public TradeableData getTradeable(long id, User user) {
Tradeable tradeable = tradeableRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (tradeable.getExchange() != null && !accountRepository.existsByUserAndExchange(user, tradeable.getExchange())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
return new TradeableData(tradeable);
}
}

View File

@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
@ -40,7 +41,16 @@ public class TradeableUpdateService {
private String polygonApiKey;
private static final int POLYGON_API_TIMEOUT = 15;
@PostConstruct
public void startupUpdate() {
updatePublicTradeables();
}
@Scheduled(cron = "@midnight")
public void scheduledUpdate() {
updatePublicTradeables();
}
public void updatePublicTradeables() {
List<Tradeable> publicTradeables = tradeableRepository.findAllByExchangeNull();
long delay = 5;

View File

@ -0,0 +1 @@
server.error.whitelabel.enabled=false

View File

@ -26,6 +26,34 @@
font-weight: bold;
}
.currency {
:root {
--color-dark-peach: #f79256;
--color-light-peach: #fbd1a2;
--color-mint: #7dffb6;
--color-faded-blue: #1d4e89;
--color-light-blue: #00b2ca;
}
body{
background-color: black;
color: white;
}
.monospace {
font-family: SpaceMono, monospace;
}
.header-bar {
--bs-bg-opacity: 1;
background-color: var(--color-faded-blue);
background-image: none;
}
.colored-link {
text-decoration: none;
color: var(--color-light-peach);
}
.colored-link:hover {
color: var(--color-mint);
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

File diff suppressed because one or more lines are too long

View File

@ -6,13 +6,35 @@
>
<div id="content" class="container">
<h1>Account <span th:text="${account.number()}"></span></h1>
<p>In <a th:href="@{/exchanges/{id}(id=${account.exchange().id()})}" th:text="${account.exchange().name()}"></a></p>
<p>Total value of <span class="currency" th:text="${account.totalBalance()}"></span>&nbsp;<span th:text="${account.exchange().primaryTradeable()}"></span></p>
<h1 class="display-4">Account <span th:text="${account.number()}"></span></h1>
<h3>Overview</h3>
<div class="card text-white bg-dark mb-3">
<div class="card-body">
<h5 class="card-title">Overview</h5>
<dl class="row">
<dt class="col-sm-6">Number</dt>
<dd class="col-sm-6 monospace" th:text="${account.number()}"></dd>
<dt class="col-sm-6">Account Holder Name</dt>
<dd class="col-sm-6" th:text="${account.name()}"></dd>
<dt class="col-sm-6">Exchange</dt>
<dd class="col-sm-6">
<a class="colored-link" th:href="@{/exchanges/{id}(id=${account.exchange().id()})}" th:text="${account.exchange().name()}"></a>
</dd>
<dt class="col-sm-6">Total Value</dt>
<dd class="col-sm-6">
<span class="monospace" th:text="${account.totalBalance()}"></span>&nbsp;<span th:text="${account.exchange().primaryTradeable()}"></span>
</dd>
</dl>
<a class="btn btn-success" th:href="@{/trade/{account}(account=${account.id()})}">Trade</a>
<a class="btn btn-success" th:href="@{/accounts/{aId}/transfer(aId=${account.id()})}">Transfer</a>
<a class="btn btn-primary" th:if="${account.userAdmin()}" th:href="@{/accounts/{aId}/editBalances(aId=${account.id()})}">Edit Balances</a>
</div>
</div>
<table class="table">
<div class="card text-white bg-dark mb-3">
<div class="card-body">
<h5 class="card-title">Tradeable Assets</h5>
<table class="table table-dark">
<thead>
<tr>
<th>Asset</th>
@ -22,20 +44,21 @@
</thead>
<tbody>
<tr th:each="bal : ${account.balances()}">
<td th:text="${bal.symbol()}"></td>
<td>
<a class="colored-link" th:href="@{/tradeables/{tId}(tId=${bal.id()})}" th:text="${bal.symbol()}"></a>
</td>
<td th:text="${bal.type()}"></td>
<td class="currency" th:text="${bal.amount()}"></td>
<td class="monospace" th:text="${bal.amount()}"></td>
</tr>
</tbody>
</table>
</div>
</div>
<a class="btn btn-success" th:href="@{/trade/{account}(account=${account.id()})}">Trade</a>
<a class="btn btn-success" th:href="@{/accounts/{aId}/transfer(aId=${account.id()})}">Transfer</a>
<a class="btn btn-primary" th:if="${account.userAdmin()}" th:href="@{/accounts/{aId}/editBalances(aId=${account.id()})}">Edit Balances</a>
<div th:if="${!account.recentTransactions().isEmpty()}">
<h3>Recent Transactions</h3>
<table class="table">
<div class="card text-white bg-dark mb-3" th:if="${!account.recentTransactions().isEmpty()}">
<div class="card-body">
<h5 class="card-title">Recent Transactions</h5>
<table class="table table-dark">
<thead>
<tr>
<th>From</th>
@ -47,13 +70,14 @@
</thead>
<tbody>
<tr th:each="tx : ${account.recentTransactions()}">
<td th:text="${tx.from().name()}"></td>
<td class="currency" th:text="${tx.fromAmount()}"></td>
<td th:text="${tx.to().name()}"></td>
<td class="currency" th:text="${tx.toAmount()}"></td>
<td th:text="${tx.from().symbol()}"></td>
<td class="monospace" th:text="${tx.fromAmount()}"></td>
<td th:text="${tx.to().symbol()}"></td>
<td class="monospace" th:text="${tx.toAmount()}"></td>
<td th:text="${tx.timestamp()}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@ -5,6 +5,10 @@
th:replace="~{layout/basic_page :: layout (title='Add Account', content=~{::#content})}"
>
<div id="content" class="container">
<h1 class="display-4">Transfer</h1>
<p class="lead">
Transfer funds to other accounts.
</p>
<form th:action="@{/accounts/{aId}/transfer(aId=${accountId})}" th:method="post">
<div class="mb-3">
<label for="recipientNumberInput" class="form-label">Recipient Account Number</label>
@ -26,8 +30,13 @@
</div>
<div class="mb-3">
<label for="messageTextArea" class="form-label">Message</label>
<textarea class="form-control" name="message" rows="3" id="messageTextArea"></textarea>
<small class="text-muted">Note: Message text is readable by administrators.</small>
<textarea class="form-control" name="message" rows="3" id="messageTextArea" maxlength="1024"></textarea>
</div>
<button type="submit" class="btn btn-success">Submit</button>
<button type="submit" class="btn btn-primary btn-lg">Transfer</button>
<p class="text-danger fw-bold">
Warning! All transfers are final, and cannot be reversed.
</p>
</form>
</div>

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/basic_page :: layout (title='Home', content=~{::#content})}"
>
<div id="content" class="container">
<div class="row justify-content-center">
<div class="col-sm-6">
<h1 class="text-center">
<span class="display-4">Error</span>
<small class="text-muted" th:text="${statusCode}"></small>
</h1>
<p class="lead" th:text="${message}"></p>
</div>
</div>
</div>

View File

@ -7,13 +7,14 @@
<div id="content" class="container">
<h1>Accounts</h1>
<table class="table">
<table class="table table-dark">
<thead>
<tr>
<th>Number</th>
<th>Name</th>
<th>Admin</th>
<th>Balance</th>
<th>Remove</th>
</tr>
</thead>
<tbody>
@ -21,7 +22,7 @@
<td><a th:href="@{/accounts/{id}(id=${account.id()})}" th:text="${account.number()}"></a></td>
<td th:text="${account.name()}"></td>
<td th:text="${account.admin()}"></td>
<td class="currency" th:text="${account.totalBalance()}"></td>
<td class="monospace" th:text="${account.totalBalance()}"></td>
<td><a th:href="@{/exchanges/{eId}/removeAccount/{aId}(eId=${exchangeId}, aId=${account.id()})}">Remove</a></td>
</tr>
</tbody>

View File

@ -5,14 +5,28 @@
th:replace="~{layout/basic_page :: layout (title='Exchange', content=~{::#content})}"
>
<div id="content" class="container">
<h1 th:text="${exchange.name()}"></h1>
<h1 class="display-4" th:text="${exchange.name()}"></h1>
<p>
Primary asset: <span th:text="${exchange.primaryTradeable().name()}"></span>
</p>
<div class="card text-white bg-dark mb-3">
<div class="card-body">
<h5 class="card-title">Overview</h5>
<dl class="row">
<dt class="col-sm-6">Primary Currency</dt>
<dd class="col-sm-6">
<a class="colored-link" th:href="@{/tradeables/{tId}(tId=${exchange.primaryTradeable().id()})}" th:text="${exchange.primaryTradeable().name()}"></a>
</dd>
<dt class="col-sm-6">Total Market Value</dt>
<dd class="col-sm-6">
<span class="monospace" th:text="${exchange.totalMarketValue()}"></span>&nbsp;<span th:text="${exchange.primaryTradeable().symbol()}"></span>
</dd>
<dt class="col-sm-6">Number of Accounts</dt>
<dd class="col-sm-6" th:text="${exchange.accountCount()}"></dd>
</dl>
</div>
</div>
<h3>Tradeable Assets</h3>
<table class="table">
<table class="table table-dark">
<thead>
<tr>
<th>Symbol</th>
@ -25,7 +39,7 @@
<tr th:each="tradeable : ${exchange.supportedTradeables()}">
<td th:text="${tradeable.symbol()}"></td>
<td th:text="${tradeable.type()}"></td>
<td class="currency" th:text="${tradeable.formattedPriceUsd()}"></td>
<td class="monospace" th:text="${tradeable.formattedPriceUsd()}"></td>
<td th:text="${tradeable.name()}"></td>
</tr>
</tbody>

View File

@ -5,8 +5,8 @@
th:replace="~{layout/basic_page :: layout (title='Exchanges', content=~{::#content})}"
>
<div id="content" class="container">
<h1>Exchanges</h1>
<table class="table">
<h1 class="display-4">Exchanges</h1>
<table class="table table-dark">
<thead>
<tr>
<th>Name</th>
@ -18,13 +18,13 @@
<tbody>
<tr th:each="ed : ${exchangeData}">
<td>
<a th:text="${ed.exchange().name()}" th:href="@{/exchanges/{eId}(eId=${ed.exchange().id()})}"></a>
<a class="colored-link" th:text="${ed.exchange().name()}" th:href="@{/exchanges/{eId}(eId=${ed.exchange().id()})}"></a>
</td>
<td th:text="${ed.exchange().primaryTradeable()}"></td>
<td>
<a th:text="${ed.account().number()}" th:href="@{/accounts/{aId}(aId=${ed.account().id()})}"></a>
<a class="colored-link" th:text="${ed.account().number()}" th:href="@{/accounts/{aId}(aId=${ed.account().id()})}"></a>
</td>
<td class="currency" th:text="${ed.account().totalBalance()}"></td>
<td class="monospace" th:text="${ed.account().totalBalance()}"></td>
</tr>
</tbody>
</table>

View File

@ -2,7 +2,6 @@
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
>
<head>
<meta charset="UTF-8">
@ -10,9 +9,12 @@
</head>
<body>
<nav th:fragment="header" class="navbar navbar-expand-lg navbar-light bg-light">
<nav th:fragment="header" class="navbar navbar-expand-lg navbar-dark header-bar">
<div class="container-fluid">
<a class="navbar-brand" href="/">Coyote Credit</a>
<a class="navbar-brand" href="/">
<img src="/static/images/icon_256.png" alt="Coyote Credit" height="24" class="d-inline-block align-text-top"/>
Coyote Credit
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
@ -29,7 +31,7 @@
</li>
</ul>
<form class="d-flex" th:action="@{/logout}" th:method="post">
<button class="btn btn-outline-success" type="submit">Logout</button>
<button class="btn btn-dark" type="submit">Logout</button>
</form>
</div>
</div>

View File

@ -11,13 +11,16 @@
<h1 class="display-4">Welcome to Coyote Credit</h1>
<p class="lead">
A simulated asset trading platform developed for building a stronger understanding of investment and wealth management.
A simulated asset trading platform developed for building a stronger understanding
of investment and wealth management.
</p>
<hr>
<p>
You can visit the <a th:href="@{/exchanges}">Exchanges</a> page to view a list of exchanges that you're participating in.
You can visit the <a class="colored-link" th:href="@{/exchanges}">Exchanges</a> page
to view a list of exchanges that you're participating in. Within an exchange, you
may buy and sell tradeable assets, and transfer funds to other accounts.
</p>
</div>
</div>

View File

@ -9,14 +9,10 @@
<meta charset="UTF-8">
<title th:text="${'Coyote Credit - ' + title}">Coyote Credit</title>
<link rel="stylesheet" href="/static/css/vendor/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/style.css"/>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
crossorigin="anonymous"
>
<link rel="icon" type="image/x-icon" href="/static/images/favicon.ico"/>
</head>
<body>
@ -30,10 +26,6 @@
</div>
</div>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
crossorigin="anonymous"
></script>
<script src="/static/js/vendor/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -5,13 +5,16 @@
th:replace="~{layout/basic_page :: layout (title='Trade', content=~{::#content})}"
>
<div id="content" class="container">
<h1>Trade</h1>
<h1 class="display-4">Trade</h1>
<p class="lead">
Trade currencies, cryptocurrencies, and stocks in this account's exchange.
</p>
<form id="tradeForm" th:action="@{/trade/{account}(account=${data.accountId()})}" method="post">
<input type="hidden" id="exchangeIdInput" th:value="${data.exchangeId()}"/>
<input type="hidden" id="accountIdInput" th:value="${data.accountId()}"/>
<div class="mb-3">
<label for="sellTradeableSelect" class="form-label">Tradeable to Sell</label>
<label for="sellTradeableSelect" class="form-label">Asset to Sell</label>
<select id="sellTradeableSelect" class="form-select">
<option selected hidden>Choose something to sell</option>
<option
@ -33,7 +36,7 @@
</div>
<div class="mb-3">
<label for="buyTradeableSelect" class="form-label">Tradeable to Buy</label>
<label for="buyTradeableSelect" class="form-label">Asset to Buy</label>
<select id="buyTradeableSelect" class="form-select">
<option value="" selected disabled hidden>Choose something to buy</option>
<option
@ -57,7 +60,20 @@
<input type="hidden" name="buyTradeableId"/>
<input type="hidden" name="value"/>
<button id="submitButton" type="button" class="btn btn-primary">Submit</button>
<p class="text-muted">
Select an asset to sell, and an asset to buy, and then simply enter a
value that you wish to sell or buy. Note that some assets, like stocks,
may only be bought and sold in whole-number values. Also note that
prices shown in this trading interface may not be 100% accurate, and you
should consult with the information shown on your exchange's page for
the exact exchange rates.
</p>
<button id="submitButton" type="button" class="btn btn-primary btn-lg">Trade</button>
<p class="text-danger fw-bold">
Warning! All trades are final, and cannot be reversed once the transaction is submitted.
</p>
</form>
<script src="/static/js/trade.js"></script>

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/basic_page :: layout (title='Account', content=~{::#content})}"
>
<div id="content" class="container">
<h1>
<span class="display-4" th:text="${tradeable.name()}"></span>
<small class="text-muted" th:text="${tradeable.symbol()}"></small>
</h1>
<p class="lead" th:text="${tradeable.description()}"></p>
<p>
Currently trading at a market price of
<span class="monospace" th:text="${tradeable.formattedPriceUsd()}"></span>
<strong>USD</strong> for one <strong th:text="${tradeable.symbol()}"></strong>.
</p>
<p th:if="${tradeable.exchangeId() != null}" class="text-muted">
This asset was defined in, and is only tradeable within
<a class="link-secondary" th:href="@{/exchanges/{eId}(eId=${tradeable.exchangeId()})}" th:text="${tradeable.exchangeName()}"></a>.
</p>
</div>

View File

@ -5,9 +5,9 @@
th:replace="~{layout/basic_page :: layout (title='My Profile', content=~{::#content})}"
>
<div id="content" class="container">
<h1>My Profile</h1>
<h1 class="display-4">My Profile</h1>
<table class="table">
<table class="table table-dark">
<tbody>
<tr>
<th scope="row">Username</th>