continue work on styling and form helpers

This commit is contained in:
Sky Johnson 2024-10-04 18:43:22 -05:00
parent 9da77307af
commit 2263134e55
18 changed files with 519 additions and 82 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -63,6 +63,7 @@ if ($database === AUTH || $database === 'reset') {
password TEXT NOT NULL,
auth INT NOT NULL DEFAULT 0,
char_id INTEGER NOT NULL DEFAULT 0,
char_slots INTEGER NOT NULL DEFAULT 3,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME DEFAULT CURRENT_TIMESTAMP
)');
@ -406,14 +407,14 @@ if ($database === LIVE || $database === 'reset') {
// Player inventory
$db->exec('DROP TABLE IF EXISTS char_inventory');
$r = $db->exec('CREATE TABLE inventory (
$r = $db->exec('CREATE TABLE char_inventory (
char_id INTEGER NOT NULL,
item_id INTEGER NOT NULL
)');
// Create an index for char_id
$db->exec('CREATE INDEX idx_inventory_char_id ON inventory (char_id)');
$db->exec('CREATE INDEX idx_inventory_char_id ON char_inventory (char_id)');
created_or_error($r, 'inventory');
created_or_error($r, 'char_inventory');
// Player wallet
$db->exec('DROP TABLE IF EXISTS wallets');
@ -429,7 +430,7 @@ if ($database === LIVE || $database === 'reset') {
// Player bank
$db->exec('DROP TABLE IF EXISTS char_bank');
$r = $db->exec('CREATE TABLE bank (
$r = $db->exec('CREATE TABLE char_bank (
char_id INTEGER NOT NULL,
slots INTEGER NOT NULL DEFAULT 5,
silver INTEGER NOT NULL DEFAULT 0,
@ -438,22 +439,22 @@ if ($database === LIVE || $database === 'reset') {
last_collected DATETIME DEFAULT CURRENT_TIMESTAMP
)');
// Create an index for char_id
$db->exec('CREATE INDEX idx_bank_char_id ON bank (char_id)');
$db->exec('CREATE INDEX idx_bank_char_id ON char_bank (char_id)');
created_or_error($r, 'bank');
created_or_error($r, 'char_bank');
// Banked items
$db->exec('DROP TABLE IF EXISTS char_banked_items');
$r = $db->exec('CREATE TABLE banked_items (
$r = $db->exec('CREATE TABLE char_banked_items (
char_id INTEGER NOT NULL,
item_id INTEGER NOT NULL
)');
// Create an index for char_id
$db->exec('CREATE INDEX idx_banked_items_char_id ON banked_items (char_id)');
$db->exec('CREATE INDEX idx_banked_items_char_id ON char_banked_items (char_id)');
// Create an index for item_id
$db->exec('CREATE INDEX idx_banked_items_item_id ON banked_items (item_id)');
$db->exec('CREATE INDEX idx_banked_items_item_id ON char_banked_items (item_id)');
created_or_error($r, 'banked_items');
created_or_error($r, 'char_banked_items');
// Towns
$db->exec('DROP TABLE IF EXISTS towns');

View File

@ -74,6 +74,19 @@ body {
border-color: #32373E #24282D #212429;
}
}
&.danger {
background-color: #e57373;
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(139, 0, 0, 0.1));
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
border: 1px solid;
border-color: #d32f2f #c62828 #b71c1c;
&:hover {
background-color: #d95c5c;
border-color: #b71c1c #a52727 #8e1f1f;
}
}
}
header {
@ -150,7 +163,7 @@ footer {
height: 34px;
color: white;
gap: 1rem;
background-image: url('/assets/img/deco-bar2.jpg');
background-image: url('/assets/img/bar.jpg');
& > div {
display: flex;
@ -169,6 +182,11 @@ span.badge {
color: #111111;
border-radius: 0.25rem;
padding: 0.1rem 0.25rem;
&.dark {
background-color: #444c55;
color: white;
}
}
.my-1 { margin-bottom: 0.25rem; margin-top: 0.25rem; }
@ -265,3 +283,63 @@ span.badge {
color: #666;
font-family: monospace;
}
#center section {
&:not(:last-child) {
padding-bottom: 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
}
h1:has(.badge), h2:has(.badge), h3:has(.badge), h4:has(.badge), h5:has(.badge), h6:has(.badge) {
display: flex;
align-items: center;
& > .badge {
margin-left: 0.5rem;
}
}
.alert {
position: relative;
min-height: 1rem;
margin: 1rem 0;
background: #f8f8f9;
padding: 0.5rem 1rem;
line-height: 1.4285rem;
color: rgba(0, 0, 0, .87);
transition: opacity .1s ease, color .1s ease, background .1s ease, box-shadow .1s ease;
border-radius: .28571429rem;
box-shadow: 0 0 0 1px rgba(34, 36, 38, .22) inset, 0 0 0 0 transparent;
&.success {
background-color: #f0f9eb;
color: #2c662d;
border-color: #b3dc9d;
}
&.danger {
background-color: #f9e9eb;
color: #9f3a38;
border-color: #e0b4b4;
}
&.warning {
background-color: #fff8e1;
color: #573a08;
border-color: #f9e79f;
}
&.info {
background-color: #f0f9fb;
color: #2c7fba;
border-color: #b3d7f9;
}
&.dark {
background-color: #f0f0f0;
color: #2c2c2c;
border-color: #b3b3b3;
}
}

View File

@ -21,3 +21,124 @@
transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s, -webkit-box-shadow ease-in-out .15s;
}
/*
.radio-block {
& > input[type="radio"] {
display: none;
}
& > label {
cursor: pointer;
display: inline-block;
border: none;
font-size: 1rem;
background: #f7f8fa linear-gradient(rgba(255, 255, 255, 0), rgba(0, 0, 0, 0.1));
box-shadow: 0 1px 0 1px rgba(255, 255, 255, 0.3) inset, 0 0 0 1px #adb2bb inset;
color: #111111;
padding: 0.5rem 1rem 0.5rem;
text-align: center;
border-radius: 3px;
user-select: none;
text-decoration: none;
transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, background 0.1s ease;
-webkit-tap-highlight-color: transparent;
&:hover {
background-color: #e0e0e0;
background-image: linear-gradient(rgba(255, 255, 255, 0), rgba(0, 0, 0, 0.1));
box-shadow: 0 1px 0 1px rgba(255, 255, 255, 0.3) inset, 0 0 0 1px #adb2bb inset;
color: rgba(0, 0, 0, 0.8);
}
& > .badge {
background-color: #444c55;
color: white;
}
}
&.active > label {
background-color: #444c55;
color: #ffffff;
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1));
border: 1px solid;
border-color: #3D444C #2F353B #2C3137;
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
& > .badge {
background-color: #f7f8fa;
color: #111111;
}
}
/* When the radio button is checked, change the background color of the label
& > input[type="radio"]:checked + label {
background-color: #5a9bd4;
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(60, 100, 150, 0.1));
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
border: 1px solid;
border-color: #4a8ab0 #3a7a9c #2a6a88;
}
/* When the radio button is disabled, show a normal cursor
& > input[type="radio"]:disabled + label {
cursor: default;
}
}
*/
.radio-block-2 {
& > input[type="radio"] {
display: none;
}
& > label {
cursor: pointer;
display: block;
}
}
.character-select > .radio-block {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 0.15rem;
& > input[type="radio"] {
display: none;
}
& > label {
display: flex;
align-items: center;
width: 100%;
border-radius: 0.15rem;
cursor: pointer;
transition: color, background-color 0.2s ease;
padding: 0.5rem;
&:hover {
background-color: black;
color: white;
}
& > .badge {
margin-left: 0.25rem;
}
}
&.active > label {
background-color: black;
color: white;
}
/* When the radio button is checked, change the background color of the label */
& > input[type="radio"]:checked + label {
background-color: #f4cc67;
color: #111111;
}
/* When the radio button is disabled, show a normal cursor */
& > input[type="radio"]:disabled + label {
cursor: default;
}
}

View File

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@ -28,10 +28,11 @@ router_post($r, '/auth/logout', 'auth_controller_logout_post');
/*
Characters
*/
router_get($r, '/characters', 'char_controller_select_get');
router_post($r, '/character/create', 'char_controller_create_post');
router_post($r, '/character/select', 'auth_controller_change_character_post');
router_get($r, '/characters', 'char_controller_list_get');
router_post($r, '/characters', 'char_controller_list_post');
router_get($r, '/character/create-first', 'char_controller_create_first_get');
router_post($r, '/character/create', 'char_controller_create_post');
router_post($r, '/character/delete', 'char_controller_delete_post');
/*
Router

View File

@ -38,3 +38,27 @@ function c_debug_query_log(): string
{
return render('components/debug_query_log');
}
/**
* Render the character select radio buttons.
*/
function c_char_select_box(int $id, array $char): string
{
return render('components/char_select_box', ['id' => $id, 'char' => $char]);
}
/**
* Render an alert with a given type and message.
*/
function c_alert(string $t, string $m): string
{
return "<div class=\"alert $t\">$m</div>";
}
/**
* Generate a form field.
*/
function c_form_field(string $type, string $name, string $id, string $placeholder, bool $required = false): string
{
return render('components/form_field', ['type' => $type, 'name' => $name, 'id' => $id, 'placeholder' => $placeholder, 'required' => $required]);
}

View File

@ -56,7 +56,7 @@ function auth_controller_register_post(): void
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
flash('errors', $errors);
flash('alert-registration', ['errors', $errors]);
redirect('/auth/register');
}
@ -76,7 +76,7 @@ function auth_controller_register_post(): void
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
flash('errors', $errors);
flash('alert-registration', ['errors', $errors]);
redirect('/auth/register');
}
@ -170,28 +170,3 @@ function auth_controller_logout_post(): void
set_cookie('remember_me', '', 1);
redirect('/');
}
/**
* Changes the user's currently selected character.
*/
function auth_controller_change_character_post(): void
{
auth_only();
must_have_character();
csrf_ensure();
$char_id = (int) ($_POST['char_id'] ?? 0);
// If the character ID is the current character, do nothing.
if ($char_id === $_SESSION['user']['char_id']) redirect('/');
// Make sure the character ID is valid.
if (char_exists($char_id) === false) throw new Exception('Invalid character ID. (acccp)');
// Make sure the user owns the character.
if (char_belongs_to_user($char_id, $_SESSION['user']['id']) === false) router_error(999);
change_user_character($char_id);
redirect('/');
}

View File

@ -3,14 +3,110 @@
/**
* Display a list of characters for the currently logged in user.
*/
function char_controller_select_get(): void
function char_controller_list_get(): void
{
auth_only();
must_have_character();
$chars = char_list(user('id'));
echo render('layouts/basic', ['view' => 'pages/chars/select', 'chars' => $chars, 'activeTab' => nav_tabs['chars']]);
echo render('layouts/basic', ['view' => 'pages/chars/list', 'chars' => $chars, 'activeTab' => nav_tabs['chars']]);
}
/**
* Handle an action from the character list page.
*/
function char_controller_list_post(): void
{
auth_only();
must_have_character();
csrf_ensure();
$char_id = (int) ($_POST['char_id'] ?? 0);
$action = $_POST['action'] ?? '';
// If the character ID is not a number, or the action is not a string, return a 400.
if (!is_numeric($char_id) || !is_string($action)) router_error(400);
// If the action is not one of the allowed actions, return a 400.
if (!in_array($action, ['select', 'delete'])) router_error(400);
// If the action is to select a character, change the user's selected character.
if ($action === 'select') {
// If the character ID is the current character, do nothing.
if ($char_id === $_SESSION['user']['char_id'] || $char_id === 0) {
flash('info', 'You are already using that character.');
redirect('/characters');
}
// Make sure the character ID is valid.
if (char_exists($char_id) === false) throw new Exception('Invalid character ID. (acccp)');
// Make sure the user owns the character.
if (!char_belongs_to_user($char_id, $_SESSION['user']['id'])) router_error(999);
change_user_character($char_id);
flash('success', 'Switched to character ' . char('name') . '!');
}
// If the action is to delete a character, move to the confirmation page.
if ($action === 'delete') {
// Make sure the character ID is valid.
if (char_exists($char_id) === false) throw new Exception('Invalid character ID. (accdp)');
// Make sure the user owns the character.
if (!char_belongs_to_user($char_id, $_SESSION['user']['id'])) router_error(999);
$char = char_find($char_id);
echo render('layouts/basic', ['view' => 'pages/chars/delete', 'char' => $char, 'activeTab' => nav_tabs['chars']]);
return;
}
redirect('/characters');
}
/**
* Delete a character for the currently logged in user.
*/
function char_controller_delete_post(): void
{
auth_only();
must_have_character();
csrf_ensure();
$char_id = (int) ($_POST['char_id'] ?? 0);
$name = $_POST['name'] ?? '';
// If the character ID is not a number, return a 400.
if (!is_numeric($char_id)) router_error(400);
// Make sure the character ID is valid.
if (char_exists($char_id) === false) throw new Exception('Invalid character ID. (acddp)');
// Make sure the user owns the character.
if (!char_belongs_to_user($char_id, $_SESSION['user']['id'])) router_error(999);
$char = char_find($char_id);
// Confirm the name matches the name of the character.
if ($char['name'] !== $_POST['name']) {
flash('error', 'Failed to delete character. Name confirmation did not match.');
redirect('/characters');
}
// Delete the character
char_delete($char_id);
// If the character being deleted is the currently selected character, select the first character.
if ($_SESSION['user']['char_id'] === $char_id) {
$chars = char_list(user('id'));
if (count($chars) > 0) change_user_character($chars[0]['id']);
}
flash('error', 'Character ' . $char['name'] . ' deleted.');
redirect('/characters');
}
/**
@ -57,8 +153,8 @@ function char_controller_create_post(): void
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
flash('errors', $errors);
redirect('/');
flash('alert-cl2', ['errors', $errors]);
redirect('/characters');
}
// Create the character
@ -72,5 +168,7 @@ function char_controller_create_post(): void
// Set the character as the user's selected character
change_user_character($char);
redirect('/');
flash('alert_character_list_1', ['success', 'Character ' . $name . ' created!']);
redirect('/characters');
}

View File

@ -37,7 +37,7 @@ function redirect(string $location): void
}
/**
* Flash a message to the session, or retrieve an existing flash value.
* Flash data to the session, or retrieve an existing flash value. Returns false if the key does not exist.
*/
function flash(string $key, mixed $value = ''): mixed
{
@ -186,3 +186,13 @@ function wallet(string $field = ''): array|int|false
if ($field === '') return $GLOBALS['wallet'];
return $GLOBALS['wallet'][$field] ?? false;
}
/**
* Format an array of strings to a ul element.
*/
function array_to_ul(array $array): string
{
$html = '';
foreach ($array as $item) $html .= "<li>$item</li>";
return "<ul>$html</ul>";
}

View File

@ -72,23 +72,6 @@ function char_gear_create(int $char_id, array $initialGear = []): void
}
}
/**
* Create the character's bank account. The bank stores items and currency, with an interest rate based on
* the bank account's tier. The bank account has a limited number of slots, which can be increased by upgrading
* the bank account. The bank account starts with 0 silver and 5 slots.
*/
function char_bank_create(int $char_id, int $slots = 5, int $silver = 0, int $tier = 0): void
{
if (db_query(db_live(), "INSERT INTO char_banks (char_id, slots, silver, tier) VALUES (:p, :s, :si, :t)", [
':p' => $char_id,
':s' => $slots,
':si' => $silver,
':t' => $tier
]) === false) {
throw new Exception('Failed to create character bank. (cbc)');
}
}
/**
* Get a charcter by their ID. Returns the character's data as an associative array.
*/
@ -161,3 +144,89 @@ function char_belongs_to_user(int $char_id, int $user_id): bool
if ($char === false) throw new Exception('Character not found. (cbtu)');
return $char['user_id'] === $user_id;
}
/**
* Delete a character by their ID. This will delete all associated data tables as well.
*/
function char_delete(int $char_id): void
{
// Delete the character
if (db_query(db_live(), "DELETE FROM characters WHERE id = :p", [':p' => $char_id]) === false) {
throw new Exception('Failed to delete character. (cd)');
}
// Get item IDs from the character's inventory
$items = db_query(db_live(), "SELECT item_id FROM char_inventory WHERE char_id = :p", [':p' => $char_id]);
// delete the character's inventory and items
while ($row = $items->fetchArray(SQLITE3_ASSOC)) {
if (db_query(db_live(), "DELETE FROM char_inventory WHERE char_id = :c", [':c' => $char_id]) === false) {
throw new Exception('Failed to delete character inventory. (cd)');
}
if (db_query(db_live(), "DELETE FROM items WHERE id = :p", [':p' => $row['id']]) === false) {
throw new Exception('Failed to delete character item slots. (cd)');
}
}
// Delete the character's location
if (db_query(db_live(), "DELETE FROM char_locations WHERE char_id = :p", [':p' => $char_id]) === false) {
throw new Exception('Failed to delete character location. (cd)');
}
// Delete the character's gear
if (db_query(db_live(), "DELETE FROM char_gear WHERE char_id = :p", [':p' => $char_id]) === false) {
throw new Exception('Failed to delete character gear. (cd)');
}
// Delete the character's bank
if (db_query(db_live(), "DELETE FROM char_bank WHERE char_id = :p", [':p' => $char_id]) === false) {
throw new Exception('Failed to delete character bank. (cd)');
}
// Delete character's banked items
if (db_query(db_live(), "DELETE FROM char_banked_items WHERE char_id = :p", [':p' => $char_id]) === false) {
throw new Exception('Failed to delete character bank items. (cd)');
}
// Delete the user's guild membership
if (db_query(db_live(), "DELETE FROM guild_members WHERE char_id = :p", [':p' => $char_id]) === false) {
throw new Exception('Failed to delete character guild membership. (cd)');
}
// if the character was a guild leader, hand leadership to the next highest ranking member
$guild = db_query(db_live(), "SELECT id FROM guilds WHERE leader_id = :p", [':p' => $char_id])->fetchArray(SQLITE3_ASSOC);
if ($guild !== false) {
$members = db_query(db_live(), "SELECT char_id FROM guild_members WHERE guild_id = :p ORDER BY rank DESC", [':p' => $guild['id']]);
$newLeader = $members->fetchArray(SQLITE3_ASSOC);
if ($newLeader !== false) {
db_query(db_live(), "UPDATE guilds SET leader_id = :p WHERE id = :g", [':p' => $newLeader['char_id'], ':g' => $guild['id']]);
}
}
// Get a list of all pve fight IDs.
$pve = db_query(db_fights(), "SELECT id FROM pve WHERE char_id = :p", [':p' => $char_id]);
// Get a list of all pvp fight IDs.
$pvp = db_query(db_fights(), "SELECT id FROM pvp WHERE char1_id = :p OR char2_id = :p", [':p' => $char_id]);
// Delete all pve fights
while ($row = $pve->fetchArray(SQLITE3_ASSOC)) {
if (db_query(db_fights(), "DELETE FROM pve WHERE id = :p", [':p' => $row['id']]) === false) {
throw new Exception('Failed to delete pve fight. (cd)');
}
if (db_query(db_fights(), "DELETE FROM pve_logs WHERE fight_id = :p", [':p' => $row['id']]) === false) {
throw new Exception('Failed to delete pve fight logs. (cd)');
}
}
// Delete all pvp fights
while ($row = $pvp->fetchArray(SQLITE3_ASSOC)) {
if (db_query(db_fights(), "DELETE FROM pvp WHERE id = :p", [':p' => $row['id']]) === false) {
throw new Exception('Failed to delete pvp fight. (cd)');
}
if (db_query(db_fights(), "DELETE FROM pvp_logs WHERE fight_id = :p", [':p' => $row['id']]) === false) {
throw new Exception('Failed to delete pvp fight logs. (cd)');
}
}
}

View File

@ -37,9 +37,8 @@ function user_delete(string|int $user): SQLite3Result|false
}
/**
* Creates a character's wallet. A character's wallet is where they store their currencies. Can optionally specify the
* starting balances of the wallet. Returns the created wallet's ID. If a currency is set to -1, the starting_silver
* or starting_star_gems fields from the env will be used.
* Creates an account wallet. Can optionally specify the starting balances of the wallet. Returns the created wallet's
* ID. If a currency is set to -1, the starting_silver or starting_star_gems fields from the env will be used.
*/
function wallet_create(int $user_id, int $silver = -1, int $starGems = -1): void
{

View File

@ -0,0 +1,7 @@
<div class="radio-block <?= $id === user('char_id') ? 'active' : '' ?>">
<input type="radio" name="char_id" value="<?= $id ?>" id="char_<?= $id ?>"<?= $id === user('char_id') ? 'disabled' : '' ?>>
<label for="char_<?= $id ?>">
<?= $char['name'] ?>
<span class="badge"><?= $char['level'] ?></span>
</label>
</div>

View File

@ -0,0 +1,24 @@
<div class="container-960">
<h1 class="my-4">Delete Character</h1>
<p class="mb-2">
<b>Warning!</b> This action is irreversible. Once you delete a character, it is gone forever. All items, banked items and currency,
fight records, and other data associated with the character will be lost.
</p>
<p class="mb-4">
Are you <b>absolutely sure</b> you want to <b>delete this character</b>? If so, type the character's name below to confirm.
Otherwise, click the back button to cancel.
</p>
<form action="/character/delete" method="post">
<?= csrf_field() ?>
<input type="hidden" name="char_id" value="<?= $char['id'] ?>">
<label for="name">Type <b><?= $char['name'] ?></b> below.</label>
<input id="name" class="form control mb-2" type="text" name="name" placeholder="Character Name">
<button class="ui button danger" type="submit">Delete</button>
<a class="ui button" href="/characters">Back</a>
</form>
</div>

View File

@ -0,0 +1,45 @@
<section>
<?php
if (($f = flash('alert_character_list_1')) !== false) echo c_alert($f[0], $f[1]);
?>
<h1>Characters <span class="badge"><?= count($chars) . '/' . user('char_slots') ?></span></h1>
<?php
if (count($chars) > 0): ?>
<form action="/characters" method="post">
<input type="hidden" name="csrf" value="<?= csrf() ?>">
<div class="my-4 character-select">
<?php foreach ($chars as $id => $char) echo c_char_select_box($id, $char); ?>
</div>
<button type="submit" class="ui button primary" name="action" value="select">Select</button>
<button type="submit" class="ui button danger" name="action" value="delete">Delete</button>
</form>
<?php else: ?>
<!-- Should never see this particular message. If you have, there's a bug. -->
<p>You have no characters.</p>
<?php endif; ?>
</section>
<section>
<?php
if (($f = flash('alert_character_list_2')) !== false) echo c_alert($f[0], $f[1]);
if (($f = flash('errors_create_character')) !== false) echo c_alert('danger', array_to_ul($f));
?>
<?php if (user('char_slots') > count($chars)): ?>
<h2>Create a new character</h2>
<?php $num_slots_left = user('char_slots') - count($chars); ?>
<p>You have <b><?= $num_slots_left ?> <?= $num_slots_left === 1 ? 'slot' : 'slots' ?></b> left.</p>
<form action="/character/create" method="post">
<input type="hidden" name="csrf" value="<?= csrf() ?>">
<div class="my-4">
<input class="form control" type="text" name="name" id="name" placeholder="Character name" required>
</div>
<button type="submit" class="ui button secondary">Create</button>
</form>
<?php endif; ?>
</section>

View File

@ -1,15 +0,0 @@
<h1>Characters</h1>
<?php
if (count($chars) > 0): ?>
<form action="/character/select" method="post">
<input type="hidden" name="csrf" value="<?= csrf() ?>">
<?php foreach ($chars as $id => $char): ?>
<input type="radio" name="char_id" value="<?= $id ?>" id="char_<?= $id ?>">
<label for="char_<?= $id ?>"><?= $char['name'] ?> (Level <?= $char['level'] ?>)</label><br>
<?php endforeach; ?>
<input type="submit" value="Select Character">
</form>
<?php else: ?>
<!-- Should never see this particular message. If you have, there's a bug. -->
<p>You have no characters.</p>
<?php endif; ?>