Changes
diff --git a/src/app.cpp b/src/app.cpp
index f05057f..ca7e4b5 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -171,6 +171,8 @@ const std::unordered_map<std::string, RouteDefinition> ROUTES = {
{RouteKind::DYNAMIC, {literal("admin"), literal("cards")}}},
{"admin-users",
{RouteKind::DYNAMIC, {literal("admin"), literal("users")}}},
+ {"admin-games",
+ {RouteKind::DYNAMIC, {literal("admin"), literal("games")}}},
{"admin-promote",
{RouteKind::DYNAMIC,
{literal("admin"), literal("users"), placeholder("integer"),
@@ -735,6 +737,8 @@ void App::addNavigationData(inja::json& data, const User& user) const
: "creator-cards")},
{"show_cards", authorization.canCreateCard(user)},
{"show_users", authorization.canAdminister(user)},
+ {"games_url", urlFor("admin-games")},
+ {"series_url", urlFor("series-index")},
{"users_url", urlFor("admin-users")},
};
}
@@ -1178,6 +1182,53 @@ void App::handleAdminUsers(const Request& request, Response& response)
templates_, "user_admin.html", template_data, response);
}
+void App::handleAdminGames(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ if(!identity)
+ {
+ return;
+ }
+ AuthorizationService authorization;
+ if(!authorization.canAdminister(identity->session.user))
+ {
+ response.status = 403;
+ return;
+ }
+
+ inja::json template_games = inja::json::array();
+ for(const GameDefinition* game : games_->games())
+ {
+ inja::json fields = inja::json::array();
+ const std::vector<GameFormField> form_fields = game->formFields();
+ for(const GameFormField& field : form_fields)
+ {
+ fields.push_back({
+ {"input_type", field.input_type},
+ {"label", field.label},
+ {"name", field.name},
+ });
+ }
+ template_games.push_back({
+ {"description", std::string(game->description())},
+ {"display_name", std::string(game->displayName())},
+ {"field_count", form_fields.size()},
+ {"fields", std::move(fields)},
+ {"initial", std::string(game->shortName().substr(0, 1))},
+ {"short_name", std::string(game->shortName())},
+ });
+ }
+ inja::json template_data = {
+ {"create_card_url", urlFor("card-new")},
+ {"games", std::move(template_games)},
+ {"series_url", urlFor("series-index")},
+ {"title", "Games · Card Collection"},
+ };
+ addNavigationData(template_data, identity->session.user);
+ respondTemplate(
+ templates_, "game_admin.html", template_data, response);
+}
+
void App::handleAdminPromote(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
@@ -2405,6 +2456,7 @@ void App::handleCardIndex(
{"descending", descending},
{"descending_url",
urlFor(index_route, {}, {{"sort", "id"}, {"direction", "desc"}})},
+ {"games_url", urlFor("admin-games")},
{"series_url", urlFor("series-index")},
{"title", "Card Collection"},
{"users_url", urlFor("admin-users")},
@@ -2868,6 +2920,9 @@ void App::setup()
server.Get(
getPath("admin-users"),
std::bind_front(&App::handleAdminUsers, this));
+ server.Get(
+ getPath("admin-games"),
+ std::bind_front(&App::handleAdminGames, this));
server.Post(
getPath("admin-promote", {"id"}),
std::bind_front(&App::handleAdminPromote, this));
diff --git a/src/app.h b/src/app.h
index 011e2f9..4d5eacb 100644
--- a/src/app.h
+++ b/src/app.h
@@ -104,6 +104,9 @@ public:
/// Render the administrator's user table.
void handleAdminUsers(const Request& request, Response& response);
+ /// Render the administrator's installed-game inventory.
+ void handleAdminGames(const Request& request, Response& response);
+
/// Permanently promote one player to creator.
void handleAdminPromote(const Request& request, Response& response);
diff --git a/src/going_home.h b/src/going_home.h
new file mode 100644
index 0000000..0c5b66a
--- /dev/null
+++ b/src/going_home.h
@@ -0,0 +1,98 @@
+#pragma once
+
+#include "game_definition.h"
+
+/// Going Home uses the standard card fields without additional metadata.
+class GoingHome final : public GameDefinition
+{
+public:
+ /// Return the permanent public card-ID prefix.
+ std::string_view shortName() const override
+ {
+ return "gh";
+ }
+
+ /// Return the name displayed in game and series selectors.
+ std::string_view displayName() const override
+ {
+ return "Going Home";
+ }
+
+ /// No game description has been supplied yet.
+ std::string_view description() const override
+ {
+ return "";
+ }
+
+ /// Standard card storage is sufficient for this game.
+ mw::E<void> createSchema(
+ [[maybe_unused]] mw::SQLite& database) const override
+ {
+ return {};
+ }
+
+ /// Accept an empty set of game-specific fields.
+ mw::E<std::unique_ptr<GameCardMetadata>> validateMetadata(
+ const FormFields& fields) const override
+ {
+ if(!fields.empty())
+ {
+ return std::unexpected(mw::httpError(
+ 422, "Going Home has no additional card fields"));
+ }
+ return std::make_unique<Metadata>();
+ }
+
+ /// No extra controls are needed on the card form.
+ std::vector<GameFormField> formFields() const override
+ {
+ return {};
+ }
+
+ /// There are no extra values to load when editing a card.
+ mw::E<FormFields> formValues(
+ [[maybe_unused]] mw::SQLite& database,
+ [[maybe_unused]] std::int64_t card_id) const override
+ {
+ return FormFields{};
+ }
+
+ /// Validate metadata ownership without creating extension rows.
+ mw::E<void> insertMetadata(
+ [[maybe_unused]] mw::SQLite& database,
+ [[maybe_unused]] std::int64_t card_id,
+ const GameCardMetadata& metadata) const override
+ {
+ return checkMetadata(metadata);
+ }
+
+ /// Validate metadata ownership without updating extension rows.
+ mw::E<void> updateMetadata(
+ [[maybe_unused]] mw::SQLite& database,
+ [[maybe_unused]] std::int64_t card_id,
+ const GameCardMetadata& metadata) const override
+ {
+ return checkMetadata(metadata);
+ }
+
+ /// Standard card details contain all the information for this game.
+ mw::E<std::vector<DisplayField>> displayFields(
+ [[maybe_unused]] mw::SQLite& database,
+ [[maybe_unused]] std::int64_t card_id) const override
+ {
+ return std::vector<DisplayField>{};
+ }
+
+private:
+ struct Metadata final : public GameCardMetadata {};
+
+ static mw::E<void> checkMetadata(const GameCardMetadata& metadata)
+ {
+ if(dynamic_cast<const Metadata*>(&metadata) == nullptr)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Going Home received metadata of the wrong type"));
+ }
+ return {};
+ }
+};
diff --git a/src/main.cpp b/src/main.cpp
index 18ce9e3..82cdbb9 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -14,6 +14,7 @@
#include "app.h"
#include "clock.h"
#include "game_registry.h"
+#include "going_home.h"
#include "startup.h"
namespace
@@ -110,6 +111,13 @@ int main(int argc, char** argv)
una::version::unicode.update());
auto games = std::make_unique<GameRegistry>();
+ auto registered = games->add(std::make_unique<GoingHome>());
+ if(!registered)
+ {
+ spdlog::error(
+ "Failed to register Going Home: {}", registered.error().msg());
+ return 1;
+ }
const auto startup_time = std::chrono::system_clock::now();
const std::int64_t startup_seconds =
std::chrono::duration_cast<std::chrono::seconds>(
diff --git a/static/css/styles.css b/static/css/styles.css
index 7f238db..59273f9 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -1781,3 +1781,187 @@ h1 {
width: 100%;
}
}
+
+.management-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 1.25rem;
+ margin-bottom: 2.5rem;
+}
+
+.page-heading .management-actions {
+ justify-content: flex-end;
+ margin-bottom: 0;
+}
+
+.games-page {
+ width: 100%;
+}
+
+.compiled-game-note {
+ display: flex;
+ align-items: center;
+ gap: 1.25rem;
+ margin-bottom: 2.5rem;
+ padding: 1.25rem 1.5rem;
+ border: 1px solid rgb(255 255 255 / 82%);
+ border-radius: 2rem;
+ background: rgb(255 255 255 / 70%);
+ box-shadow: var(--clay-card-shadow);
+ backdrop-filter: blur(1.25rem);
+}
+
+.compiled-game-note .clay-icon {
+ margin: 0;
+}
+
+.compiled-game-note strong {
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 1.1rem;
+ font-weight: 900;
+}
+
+.compiled-game-note p {
+ max-width: 56rem;
+ margin: 0.1rem 0 0;
+ color: var(--muted);
+}
+
+.game-admin-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
+ gap: clamp(1.5rem, 3vw, 2.5rem);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.game-admin-card {
+ padding: clamp(1.5rem, 4vw, 2.5rem);
+ border: 1px solid rgb(255 255 255 / 82%);
+ border-radius: 2.5rem;
+ background: rgb(255 255 255 / 70%);
+ box-shadow: var(--clay-card-shadow);
+ backdrop-filter: blur(1.25rem);
+ transition:
+ box-shadow 500ms ease,
+ transform 500ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.game-admin-card:hover {
+ box-shadow: var(--clay-card-shadow-hover);
+ transform: translateY(-0.5rem);
+}
+
+.game-admin-card > header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.game-admin-card .clay-icon {
+ margin: 0;
+ text-transform: uppercase;
+}
+
+.game-admin-card h2 {
+ margin: 0;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 1.6rem;
+ font-weight: 900;
+ line-height: 1.2;
+}
+
+.game-description {
+ margin: 1.5rem 0;
+ color: var(--muted);
+ white-space: pre-wrap;
+}
+
+.game-field-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-weight: 900;
+}
+
+.game-field-heading span {
+ display: grid;
+ min-width: 2rem;
+ min-height: 2rem;
+ place-items: center;
+ border-radius: 50%;
+ background: rgb(124 58 237 / 10%);
+ color: var(--violet);
+ font-size: 0.75rem;
+}
+
+.game-field-list {
+ display: grid;
+ gap: 0.65rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.game-field-list li {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.9rem 1rem;
+ border-radius: 1.25rem;
+ background: #efebf5;
+ box-shadow:
+ inset 5px 5px 11px rgb(185 177 198 / 40%),
+ inset -5px -5px 11px rgb(255 255 255 / 85%);
+}
+
+.game-field-list li > span:first-child {
+ display: grid;
+}
+
+.game-field-list small {
+ color: var(--muted);
+ font-family: ui-monospace, monospace;
+}
+
+.field-type {
+ padding: 0.3rem 0.65rem;
+ border-radius: 999px;
+ background: rgb(14 165 233 / 10%);
+ color: #036b9f;
+ font-size: 0.7rem;
+ font-weight: 900;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+@media(max-width: 42rem) {
+ .management-actions,
+ .page-heading .management-actions {
+ width: 100%;
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .management-actions > .clay-action {
+ width: 100%;
+ }
+
+ .game-admin-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .compiled-game-note {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .game-admin-card {
+ border-radius: 2rem;
+ }
+}
diff --git a/templates/card_index.html b/templates/card_index.html
index be3741e..c92a17b 100644
--- a/templates/card_index.html
+++ b/templates/card_index.html
@@ -15,11 +15,22 @@
</div>
</div>
-<p><a href="{{ create_url }}">Create card</a>
+<div class="management-actions" aria-label="Card administration actions">
+ <a class="clay-action" href="{{ create_url }}">
+ Create card
+ </a>
{% if administrator %}
- · <a href="{{ series_url }}">Manage series</a>
- · <a href="{{ users_url }}">Manage users</a>
-{% endif %}</p>
+ <a class="clay-action clay-action-secondary" href="{{ series_url }}">
+ Manage series
+ </a>
+ <a class="clay-action clay-action-secondary" href="{{ games_url }}">
+ View games
+ </a>
+ <a class="clay-action clay-action-secondary" href="{{ users_url }}">
+ Manage users
+ </a>
+{% endif %}
+</div>
{% if length(cards) == 0 %}
<p class="empty-state">No cards yet.</p>
diff --git a/templates/game_admin.html b/templates/game_admin.html
new file mode 100644
index 0000000..ded4358
--- /dev/null
+++ b/templates/game_admin.html
@@ -0,0 +1,75 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="games-page" aria-labelledby="GamesHeading">
+ <header class="page-heading">
+ <div>
+ <p class="eyebrow">Administration</p>
+ <h1 id="GamesHeading">Games</h1>
+ </div>
+ <div class="management-actions" aria-label="Game actions">
+ <a class="clay-action clay-action-secondary"
+ href="{{ series_url }}">
+ Manage series
+ </a>
+ <a class="clay-action" href="{{ create_card_url }}">
+ Create card
+ </a>
+ </div>
+ </header>
+
+ <div class="compiled-game-note" role="note">
+ <div class="clay-icon clay-icon-blue" aria-hidden="true">⌘</div>
+ <div>
+ <strong>Games are installed with the application</strong>
+ <p>
+ Their fields and database schema are compiled definitions.
+ Deploy a code change to add or alter a game.
+ </p>
+ </div>
+ </div>
+
+ {% if length(games) == 0 %}
+ <div class="empty-panel">
+ <h2>No games installed</h2>
+ <p>Loose cards can still be created without a game definition.</p>
+ </div>
+ {% else %}
+ <ul class="game-admin-grid">
+ {% for game in games %}
+ <li class="game-admin-card">
+ <header>
+ <div class="clay-icon clay-icon-violet" aria-hidden="true">
+ {{ game.initial }}
+ </div>
+ <div>
+ <p class="eyebrow">{{ game.short_name }}</p>
+ <h2>{{ game.display_name }}</h2>
+ </div>
+ </header>
+ <p class="game-description">{{ game.description }}</p>
+ <div class="game-field-heading">
+ <strong>Card fields</strong>
+ <span>{{ game.field_count }}</span>
+ </div>
+ {% if length(game.fields) == 0 %}
+ <p class="muted-copy">This game has no additional fields.</p>
+ {% else %}
+ <ul class="game-field-list">
+ {% for field in game.fields %}
+ <li>
+ <span>
+ <strong>{{ field.label }}</strong>
+ <small>{{ field.name }}</small>
+ </span>
+ <span class="field-type">{{ field.input_type }}</span>
+ </li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+ </li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/layout.html b/templates/layout.html
index 4dde445..79ab006 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -24,6 +24,10 @@
<a class="nav-link" href="{{ navigation.cards_url }}">Cards</a>
{% endif %}
{% if navigation.show_users %}
+ <a class="nav-link" href="{{ navigation.series_url }}">
+ Series
+ </a>
+ <a class="nav-link" href="{{ navigation.games_url }}">Games</a>
<a class="nav-link" href="{{ navigation.users_url }}">Users</a>
{% endif %}
<a class="nav-link" href="{{ url_for("account") }}">Account</a>
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
index fe41775..92c767e 100644
--- a/tests/app_integration_test.cpp
+++ b/tests/app_integration_test.cpp
@@ -13,6 +13,7 @@
#include <httplib.h>
#include "game_registry.h"
+#include "going_home.h"
#include "non_secret_random.h"
#include "startup.h"
#include "test_game.h"
@@ -138,6 +139,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
config.email.link_file = temporary.path() / "latest-link.txt";
auto games = std::make_unique<GameRegistry>();
ASSERT_TRUE(games->add(std::make_unique<TestGame>()));
+ ASSERT_TRUE(games->add(std::make_unique<GoingHome>()));
auto data_source = prepareDataSource(config.database_path, *games);
ASSERT_TRUE(data_source);
const GameDefinition* game = games->find("test");
@@ -286,6 +288,12 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
EXPECT_NE(
administrator_collection->body.find("/collection/admin/cards"),
std::string::npos);
+ EXPECT_NE(
+ administrator_collection->body.find("/collection/admin/series"),
+ std::string::npos);
+ EXPECT_NE(
+ administrator_collection->body.find("/collection/admin/games"),
+ std::string::npos);
EXPECT_NE(
administrator_collection->body.find("/collection/admin/users"),
std::string::npos);
@@ -298,6 +306,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
auto create = client.Get("/collection/cards/new", session_headers);
ASSERT_NE(create, nullptr);
EXPECT_EQ(create->status, 200) << create->body;
+ EXPECT_NE(create->body.find("Going Home"), std::string::npos);
auto edit = client.Get(
"/collection/cards/test-1/edit", session_headers);
ASSERT_NE(edit, nullptr);
@@ -308,6 +317,15 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
ASSERT_NE(series, nullptr);
EXPECT_EQ(series->status, 200) << series->body;
EXPECT_NE(series->body.find("Core <Set>"), std::string::npos);
+ auto games_page = client.Get(
+ "/collection/admin/games", session_headers);
+ ASSERT_NE(games_page, nullptr);
+ EXPECT_EQ(games_page->status, 200) << games_page->body;
+ EXPECT_NE(games_page->body.find("Test Game"), std::string::npos);
+ EXPECT_NE(games_page->body.find("Compiled extension test game."),
+ std::string::npos);
+ EXPECT_NE(games_page->body.find("HP"), std::string::npos);
+ EXPECT_NE(games_page->body.find("Going Home"), std::string::npos);
auto stylesheet = client.Get("/collection/static/css/styles.css");
ASSERT_NE(stylesheet, nullptr);
EXPECT_EQ(stylesheet->status, 200);
@@ -323,7 +341,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
const httplib::UploadFormDataItems create_fields = {
{"csrf_token", csrf_token, "", ""},
- {"game", "", "", ""},
+ {"game", "gh", "", ""},
{"name", "Uploaded card", "", ""},
{"rarity", "3", "", ""},
{"source_mode", "files", "", ""},
@@ -339,6 +357,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
const std::string created_path = location.substr(path_begin);
const std::string public_id = created_path.substr(
std::string("/collection/cards/").size());
+ EXPECT_EQ(public_id, "gh-1");
EXPECT_TRUE(std::filesystem::is_directory(
config.card_storage_root / "published" / public_id));
@@ -357,6 +376,8 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
EXPECT_EQ(edited->status, 303) << edited->body;
auto edited_page = client.Get(created_path, session_headers);
ASSERT_NE(edited_page, nullptr);
+ EXPECT_EQ(edited_page->status, 200) << edited_page->body;
+ EXPECT_NE(edited_page->body.find("Going Home"), std::string::npos);
EXPECT_NE(edited_page->body.find("Edited upload"), std::string::npos);
const httplib::Params csrf_fields = {{"csrf_token", csrf_token}};
@@ -371,7 +392,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
httplib::Params create_series_fields = {
{"csrf_token", csrf_token},
- {"game", "test"},
+ {"game", "gh"},
{"name", "Uploaded series"},
{"description", "Description"},
};
@@ -453,6 +474,12 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
EXPECT_EQ(
player_collection->body.find("/collection/admin/cards"),
std::string::npos);
+ EXPECT_EQ(
+ player_collection->body.find("/collection/admin/series"),
+ std::string::npos);
+ EXPECT_EQ(
+ player_collection->body.find("/collection/admin/games"),
+ std::string::npos);
EXPECT_EQ(
player_collection->body.find("/collection/admin/users"),
std::string::npos);
@@ -464,6 +491,10 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
"/collection/admin/users", player_headers);
ASSERT_NE(player_admin, nullptr);
EXPECT_EQ(player_admin->status, 403);
+ auto player_games = client.Get(
+ "/collection/admin/games", player_headers);
+ ASSERT_NE(player_games, nullptr);
+ EXPECT_EQ(player_games->status, 403);
auto promoted = client.Post(
"/collection/admin/users/2/promote",
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index c572a3d..d15b6fd 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -143,6 +143,9 @@ TEST(AppTest, BuildsNamedUrls)
EXPECT_EQ(
app.urlFor("series-edit", {"42"}),
"https://example.test/collection/admin/series/42/edit");
+ EXPECT_EQ(
+ app.urlFor("admin-games"),
+ "https://example.test/collection/admin/games");
}
/// Verify static relative paths and ordered query values are encoded.