BareGit

Add card creation form. Update style to use fullscreen main content with floating elements

Author: MetroWind <chris.corsair@gmail.com>
Date: Sat Aug 22 16:16:27 2026 -0700
Commit: c5bf9f77c95e8d915429c6af9acb1cc0e858164c

Changes

diff --git a/src/app.cpp b/src/app.cpp
index 696e5c0..af5f2ee 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -245,10 +245,49 @@ App::App(
                 callback_arguments.front()->get<std::string>(),
                 arguments);
         });
+    card_form_template_ = templates_.parse_template("card_form.html");
     card_index_template_ = templates_.parse_template("card_index.html");
     card_view_template_ = templates_.parse_template("card_view.html");
 }
 
+void App::handleCardNew(
+    [[maybe_unused]] const Request& request,
+    Response& response)
+{
+    const inja::json template_data = {
+        {"action_url", urlFor("cards")},
+        {"back_url", urlFor("card-index")},
+        {"foil_url", ""},
+        {"front_url", urlFor("static", {"card_placeholder.svg"})},
+        {"model_url", urlFor("static", {"foil/model/card.obj"})},
+        {"preview_script_url",
+         urlFor("static", {"foil/card_preview.js"})},
+        {"shader_fragment_url",
+         urlFor("static", {"foil/frag-shader.glsl"})},
+        {"shader_vertex_url",
+         urlFor("static", {"foil/vert-shader.glsl"})},
+        {"spectral_lut_url",
+         urlFor("static", {"foil/spectral_xyz.bin"})},
+        {"thumbnail_long_side", config_.thumbnail_long_side},
+        {"title", "Create card · Card Collection"},
+    };
+
+    try
+    {
+        response.status = 200;
+        response.set_content(
+            templates_.render(card_form_template_, template_data),
+            "text/html; charset=utf-8");
+    }
+    catch(const std::exception& error)
+    {
+        spdlog::error(
+            "Failed to render the create-card form: {}",
+            error.what());
+        respondInternalError(response);
+    }
+}
+
 void App::handleCardView(
     const Request& request,
     Response& response)
@@ -547,6 +586,9 @@ void App::setup()
     server.Get(
         getPath("card-index"),
         std::bind_front(&App::handleCardIndex, this));
+    server.Get(
+        getPath("card-new"),
+        std::bind_front(&App::handleCardNew, this));
     server.Get(
         getPath("card", {"id"}),
         std::bind_front(&App::handleCardView, this));
diff --git a/src/app.h b/src/app.h
index 8b19d7c..eb9ab3f 100644
--- a/src/app.h
+++ b/src/app.h
@@ -38,6 +38,9 @@ public:
     /// Render the card index.
     void handleCardIndex(const Request& request, Response& response);
 
+    /// Render the create-card form.
+    void handleCardNew(const Request& request, Response& response);
+
     /// Render one read-only card page.
     void handleCardView(const Request& request, Response& response);
 
@@ -57,6 +60,7 @@ private:
     std::unique_ptr<DataSourceInterface> data_source_;
     UrlBuilder url_builder_;
     inja::Environment templates_;
+    inja::Template card_form_template_;
     inja::Template card_index_template_;
     inja::Template card_view_template_;
 };
diff --git a/static/card_form.js b/static/card_form.js
new file mode 100644
index 0000000..080bcc1
--- /dev/null
+++ b/static/card_form.js
@@ -0,0 +1,57 @@
+/** Show only the image-source fields selected for the create-card form. */
+function updateSourceMode()
+{
+    const selected_mode = document.querySelector(
+        'input[name="source_mode"]:checked').value;
+    const uses_files = selected_mode == "files";
+    const file_fields = document.getElementById("FileSourceFields");
+    const url_fields = document.getElementById("UrlSourceFields");
+    file_fields.hidden = !uses_files;
+    url_fields.hidden = uses_files;
+
+    for(const input of file_fields.querySelectorAll("input"))
+    {
+        input.disabled = !uses_files;
+    }
+    for(const input of url_fields.querySelectorAll("input"))
+    {
+        input.disabled = uses_files;
+    }
+    document.getElementById("CardFrontFile").required = uses_files;
+    document.getElementById("CardFrontUrl").required = !uses_files;
+}
+
+/** Display the selected image filename beside one file input. */
+function updateFileMetadata(input, output)
+{
+    const file = input.files[0];
+    output.textContent = file == null ? "No file selected" : file.name;
+}
+
+/** Connect the image-source controls after the form is available. */
+function initializeCardForm()
+{
+    for(const input of document.querySelectorAll(
+        'input[name="source_mode"]'))
+    {
+        input.addEventListener("change", updateSourceMode);
+    }
+
+    const front_input = document.getElementById("CardFrontFile");
+    const foil_input = document.getElementById("CardFoilFile");
+    front_input.addEventListener("change", function updateFrontMetadata()
+    {
+        updateFileMetadata(
+            front_input,
+            document.getElementById("CardFrontFileMeta"));
+    });
+    foil_input.addEventListener("change", function updateFoilMetadata()
+    {
+        updateFileMetadata(
+            foil_input,
+            document.getElementById("CardFoilFileMeta"));
+    });
+    updateSourceMode();
+}
+
+window.addEventListener("DOMContentLoaded", initializeCardForm);
diff --git a/static/css/styles.css b/static/css/styles.css
index 4a989d9..0f2727e 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -625,6 +625,204 @@ h1 {
     font-weight: 800;
 }
 
+.card-form-panel h1 {
+    font-size: clamp(2.6rem, 5vw, 4rem);
+}
+
+.card-form {
+    margin-top: 2rem;
+}
+
+.form-section {
+    display: grid;
+    gap: 1rem;
+    margin: 0;
+    padding: 1.75rem 0;
+    border: 0;
+    border-top: 1px solid rgb(99 95 105 / 12%);
+}
+
+.form-section h2,
+.form-section legend {
+    margin: 0;
+    padding: 0;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 1rem;
+    font-weight: 900;
+}
+
+.image-source-section legend {
+    margin-bottom: 1rem;
+}
+
+.form-field {
+    display: grid;
+    gap: 0.45rem;
+    color: var(--foreground);
+    font-size: 0.84rem;
+    font-weight: 800;
+}
+
+.form-field em {
+    color: var(--muted);
+    font-size: 0.74rem;
+    font-style: normal;
+    font-weight: 600;
+}
+
+.form-field input,
+.form-field select,
+.form-field textarea {
+    width: 100%;
+    min-height: 3.25rem;
+    border: 0;
+    border-radius: 1.15rem;
+    background: #efebf5;
+    box-shadow:
+        inset 7px 7px 15px rgb(185 177 198 / 55%),
+        inset -7px -7px 15px rgb(255 255 255 / 90%);
+    color: var(--foreground);
+    font: inherit;
+    font-weight: 600;
+}
+
+.form-field input,
+.form-field select {
+    padding: 0.75rem 1rem;
+}
+
+.form-field textarea {
+    min-height: 6rem;
+    padding: 1rem;
+    resize: vertical;
+    line-height: 1.5;
+}
+
+.form-field input:focus,
+.form-field select:focus,
+.form-field textarea:focus {
+    background: rgb(255 255 255 / 84%);
+    outline: 0.25rem solid rgb(124 58 237 / 20%);
+}
+
+.image-field input[type="file"] {
+    padding: 0.45rem;
+    color: var(--muted);
+}
+
+.image-field input[type="file"]::file-selector-button {
+    min-height: 2.4rem;
+    margin-right: 0.7rem;
+    padding: 0.45rem 0.8rem;
+    border: 0;
+    border-radius: 0.85rem;
+    background: rgb(124 58 237 / 11%);
+    color: var(--violet);
+    font: inherit;
+    font-weight: 800;
+    cursor: pointer;
+}
+
+.image-field small,
+.form-hint {
+    margin: 0;
+    color: var(--muted);
+    font-size: 0.76rem;
+    font-weight: 600;
+}
+
+.source-toggle {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 0.35rem;
+    padding: 0.4rem;
+    border-radius: 1.35rem;
+    background: #efebf5;
+    box-shadow: var(--clay-pressed-shadow);
+}
+
+.source-toggle label {
+    position: relative;
+    cursor: pointer;
+}
+
+.source-toggle input {
+    position: absolute;
+    opacity: 0;
+}
+
+.source-toggle span {
+    display: grid;
+    min-height: 2.75rem;
+    place-items: center;
+    border-radius: 1rem;
+    color: var(--muted);
+    font-size: 0.8rem;
+    font-weight: 800;
+    transition:
+        color 200ms ease,
+        box-shadow 200ms ease,
+        transform 200ms ease;
+}
+
+.source-toggle input:checked + span {
+    background: linear-gradient(145deg, var(--violet-light), var(--violet));
+    box-shadow:
+        7px 7px 14px rgb(139 92 246 / 24%),
+        -5px -5px 12px rgb(255 255 255 / 60%),
+        inset 3px 3px 6px rgb(255 255 255 / 35%),
+        inset -3px -3px 6px rgb(0 0 0 / 8%);
+    color: #ffffff;
+}
+
+.source-toggle input:focus-visible + span {
+    outline: 0.25rem solid rgb(124 58 237 / 30%);
+    outline-offset: 0.2rem;
+}
+
+.source-fields {
+    display: grid;
+    gap: 1rem;
+}
+
+.source-fields[hidden] {
+    display: none;
+}
+
+.form-submit {
+    width: 100%;
+    min-height: 3.5rem;
+    margin-top: 0.25rem;
+    border: 0;
+    border-radius: 1.25rem;
+    background: linear-gradient(145deg, var(--violet-light), var(--violet));
+    box-shadow: var(--clay-button-shadow);
+    color: #ffffff;
+    font: inherit;
+    font-weight: 800;
+    cursor: pointer;
+    transition:
+        box-shadow 200ms ease,
+        transform 200ms ease;
+}
+
+.form-submit:hover {
+    box-shadow: var(--clay-card-shadow-hover);
+    transform: translateY(-0.2rem);
+}
+
+.form-submit:active {
+    box-shadow: var(--clay-pressed-shadow);
+    transform: scale(0.92);
+}
+
+.form-status {
+    min-height: 1.5rem;
+    margin: 0.75rem 0 0;
+    color: var(--muted);
+    font-size: 0.8rem;
+}
+
 :focus-visible {
     outline: 0.25rem solid rgb(124 58 237 / 30%);
     outline-offset: 0.2rem;
diff --git a/templates/card_form.html b/templates/card_form.html
new file mode 100644
index 0000000..2f2476b
--- /dev/null
+++ b/templates/card_form.html
@@ -0,0 +1,137 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<div class="card-view card-form-view">
+    <section class="card-preview-stage" aria-labelledby="PreviewHeading">
+        <div class="preview-heading">
+            <p id="PreviewHeading" class="eyebrow">Live preview</p>
+            <p id="PreviewStatus" class="preview-status" role="status"
+               aria-live="polite">Loading card preview…</p>
+        </div>
+        <canvas id="GLCanvas" aria-label="New card preview"></canvas>
+        <p class="preview-hint">Move the pointer over the card to tilt it.</p>
+    </section>
+
+    <aside class="card-info-panel card-form-panel"
+           aria-labelledby="CardFormHeading">
+        <a class="back-link" href="{{ back_url }}">← All cards</a>
+        <p class="card-view-id">New addition</p>
+        <h1 id="CardFormHeading">Create card</h1>
+
+        <form id="CardForm" class="card-form" method="post"
+              action="{{ action_url }}" enctype="multipart/form-data">
+            <section class="form-section" aria-labelledby="IdentityHeading">
+                <h2 id="IdentityHeading">Identity</h2>
+                <label class="form-field" for="CardGame">
+                    <span>Game</span>
+                    <select id="CardGame" name="game">
+                        <option value="">Loose card</option>
+                    </select>
+                </label>
+                <label class="form-field" for="CardName">
+                    <span>Name</span>
+                    <input id="CardName" name="name" type="text"
+                           maxlength="200" autocomplete="off" required>
+                </label>
+                <label class="form-field" for="CardRarity">
+                    <span>Rarity</span>
+                    <input id="CardRarity" name="rarity" type="number"
+                           min="0" step="1" value="0" required>
+                </label>
+            </section>
+
+            <fieldset class="form-section image-source-section">
+                <legend>Card images</legend>
+                <div class="source-toggle">
+                    <label>
+                        <input type="radio" name="source_mode"
+                               value="files" checked>
+                        <span>Local files</span>
+                    </label>
+                    <label>
+                        <input type="radio" name="source_mode"
+                               value="urls">
+                        <span>Image URLs</span>
+                    </label>
+                </div>
+
+                <div id="FileSourceFields" class="source-fields">
+                    <label class="form-field image-field"
+                           for="CardFrontFile">
+                        <span>Front artwork</span>
+                        <input id="CardFrontFile" name="front" type="file"
+                               accept="image/png,image/jpeg,image/webp,.avif"
+                               required>
+                        <small id="CardFrontFileMeta">No file selected</small>
+                    </label>
+                    <label class="form-field image-field"
+                           for="CardFoilFile">
+                        <span>Foil control <em>Optional</em></span>
+                        <input id="CardFoilFile" name="foil" type="file"
+                               accept="image/png,image/webp,image/avif">
+                        <small id="CardFoilFileMeta">No file selected</small>
+                    </label>
+                </div>
+
+                <div id="UrlSourceFields" class="source-fields" hidden>
+                    <label class="form-field" for="CardFrontUrl">
+                        <span>Front artwork URL</span>
+                        <input id="CardFrontUrl" name="front_url" type="url"
+                               inputmode="url" disabled>
+                    </label>
+                    <label class="form-field" for="CardFoilUrl">
+                        <span>Foil control URL <em>Optional</em></span>
+                        <input id="CardFoilUrl" name="foil_url" type="url"
+                               inputmode="url" disabled>
+                    </label>
+                    <p class="form-hint">
+                        The remote server must allow cross-origin image
+                        access. URLs will be fetched by your browser.
+                    </p>
+                </div>
+            </fieldset>
+
+            <section class="form-section"
+                     aria-labelledby="DescriptionHeading">
+                <h2 id="DescriptionHeading">Description</h2>
+                <label class="form-field" for="CardShortDescription">
+                    <span>Short description <em>Optional</em></span>
+                    <textarea id="CardShortDescription"
+                              name="short_description" rows="3"></textarea>
+                </label>
+                <label class="form-field" for="CardLongDescription">
+                    <span>Long description <em>Optional</em></span>
+                    <textarea id="CardLongDescription"
+                              name="long_description" rows="7"></textarea>
+                </label>
+                <p class="form-hint">Descriptions support Markdown.</p>
+            </section>
+
+            <button class="form-submit" type="submit">Create card</button>
+            <p id="CardFormStatus" class="form-status" role="status"
+               aria-live="polite"></p>
+        </form>
+    </aside>
+</div>
+
+<script id="CardPageData" type="application/json">
+{
+    "mode": "create",
+    "front_url": "{{ front_url }}",
+    "foil_url": null,
+    "model_url": "{{ model_url }}",
+    "vertex_shader_url": "{{ shader_vertex_url }}",
+    "fragment_shader_url": "{{ shader_fragment_url }}",
+    "spectral_lut_url": "{{ spectral_lut_url }}",
+    "thumbnail_long_side": {{ thumbnail_long_side }}
+}
+</script>
+{% endblock %}
+
+{% block scripts %}
+<script src="{{ url_for("static", "foil/gl-matrix-min.js") }}"></script>
+<script src="{{ url_for("static", "foil/libwebgl.js") }}"></script>
+<script src="{{ url_for("static", "foil/obj.js") }}"></script>
+<script src="{{ preview_script_url }}"></script>
+<script src="{{ url_for("static", "card_form.js") }}"></script>
+{% endblock %}
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index ac17ad7..425fe37 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -142,6 +142,32 @@ TEST(AppTest, RendersCardIndex)
     EXPECT_LT(second_position, tenth_position);
 }
 
+/// Verify the create-card page renders its multipart form and preview.
+TEST(AppTest, RendersCardCreationForm)
+{
+    App app(
+        makeConfig("https://example.test/collection/"),
+        emptyDataSource());
+    App::Request request;
+    App::Response response;
+
+    app.handleCardNew(request, response);
+
+    EXPECT_EQ(response.status, 200);
+    EXPECT_NE(response.body.find("id=\"CardForm\""), std::string::npos);
+    EXPECT_NE(response.body.find("method=\"post\""), std::string::npos);
+    EXPECT_NE(
+        response.body.find(
+            "action=\"https://example.test/collection/cards\""),
+        std::string::npos);
+    EXPECT_NE(response.body.find("enctype=\"multipart/form-data\""),
+              std::string::npos);
+    EXPECT_NE(response.body.find("name=\"front\""), std::string::npos);
+    EXPECT_NE(response.body.find("name=\"foil\""), std::string::npos);
+    EXPECT_NE(response.body.find("\"mode\": \"create\""),
+              std::string::npos);
+}
+
 /// Verify a card page renders fake metadata and read-only preview data.
 TEST(AppTest, RendersCardView)
 {