BareGit

Fix card/series/game management UI styling. Add a game “Going Home”.

Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Sep 8 16:14:47 2026 -0700
Commit: d969e04e279f9751c19615ae663b2ea21fa42063

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 &lt;Set&gt;"), 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.