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)
{