Changes
diff --git a/config.example.toml b/config.example.toml
index 91739ec..dd2bfa5 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -1,10 +1,10 @@
-base_url = "http://127.0.0.1:8080/"
-listen_address = "127.0.0.1"
+base_url = "http://localhost:8080/"
+listen_address = "localhost"
listen_port = 8080
static_root = "static"
database_path = "var/card_collection.sqlite3"
card_storage_root = "var/cards"
-avif_quality = 75
+avif_quality = 80
thumbnail_long_side = 256
# Immutable after the first database initialization.
diff --git a/src/app.cpp b/src/app.cpp
index 8fec6fd..f05057f 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -725,6 +725,20 @@ bool App::verifyCsrf(
return true;
}
+void App::addNavigationData(inja::json& data, const User& user) const
+{
+ AuthorizationService authorization;
+ data["navigation"] = {
+ {"cards_url", urlFor(
+ user.role == UserRole::ADMINISTRATOR
+ ? "card-index"
+ : "creator-cards")},
+ {"show_cards", authorization.canCreateCard(user)},
+ {"show_users", authorization.canAdminister(user)},
+ {"users_url", urlFor("admin-users")},
+ };
+}
+
void App::handleWelcome(const Request& request, Response& response)
{
auto token = cookieValue(request, "card_collection_session");
@@ -984,14 +998,17 @@ void App::handleAccount(const Request& request, Response& response)
{
return;
}
- respondTemplate(templates_, "account.html", {
+ inja::json template_data = {
{"csrf_token", identity->session.csrf_token},
{"email", identity->session.user.email},
{"logout_url", urlFor("logout")},
{"title", "Account · Card Collection"},
{"username", *identity->session.user.username},
{"username_url", urlFor("account-username")},
- }, response);
+ };
+ addNavigationData(template_data, identity->session.user);
+ respondTemplate(
+ templates_, "account.html", template_data, response);
}
void App::handleAccountUsername(const Request& request, Response& response)
@@ -1082,7 +1099,7 @@ void App::handleCollection(const Request& request, Response& response)
});
}
const bool pull_disabled = pool->empty() || user->stored_pulls == 0;
- respondTemplate(templates_, "collection.html", {
+ inja::json template_data = {
{"available_pulls", user->stored_pulls},
{"csrf_token", identity->session.csrf_token},
{"entries", std::move(entries)},
@@ -1090,7 +1107,10 @@ void App::handleCollection(const Request& request, Response& response)
{"pull_disabled", pull_disabled},
{"pull_url", urlFor("collection-pull")},
{"title", "Collection · Card Collection"},
- }, response);
+ };
+ addNavigationData(template_data, identity->session.user);
+ respondTemplate(
+ templates_, "collection.html", template_data, response);
}
void App::handleCollectionPull(const Request& request, Response& response)
@@ -1148,11 +1168,14 @@ void App::handleAdminUsers(const Request& request, Response& response)
{"username", user.username.value_or("Onboarding")},
});
}
- respondTemplate(templates_, "user_admin.html", {
+ inja::json template_data = {
{"csrf_token", identity->session.csrf_token},
{"title", "Users · Card Collection"},
{"users", std::move(template_users)},
- }, response);
+ };
+ addNavigationData(template_data, identity->session.user);
+ respondTemplate(
+ templates_, "user_admin.html", template_data, response);
}
void App::handleAdminPromote(const Request& request, Response& response)
@@ -1237,7 +1260,7 @@ void App::handleCardNew(
{"selected", false},
});
}
- const inja::json template_data = {
+ inja::json template_data = {
{"action_url", urlFor("cards")},
{"back_url",
urlFor(identity->session.user.role == UserRole::ADMINISTRATOR
@@ -1274,6 +1297,7 @@ void App::handleCardNew(
{"thumbnail_long_side", config_.thumbnail_long_side},
{"title", "Create card · Card Collection"},
};
+ addNavigationData(template_data, identity->session.user);
try
{
@@ -1425,7 +1449,7 @@ void App::handleCardEdit(
});
}
- const inja::json template_data = {
+ inja::json template_data = {
{"action_url", urlFor("card-edit", {public_id})},
{"back_url", urlFor("card", {public_id})},
{"csrf_token", actor->session.csrf_token},
@@ -1459,6 +1483,7 @@ void App::handleCardEdit(
{"thumbnail_long_side", config_.thumbnail_long_side},
{"title", "Edit " + card.name + " · Card Collection"},
};
+ addNavigationData(template_data, actor->session.user);
try
{
@@ -2083,7 +2108,7 @@ void App::handleCardView(
long_description_marker, std::move(*rendered)});
}
- const inja::json template_data = {
+ inja::json template_data = {
{"asset_warning", !missing_assets.empty()},
{"back_url", urlFor("card-index")},
{"display_id", uppercaseAscii(public_id)},
@@ -2114,6 +2139,7 @@ void App::handleCardView(
urlFor("static", {"foil/spectral_xyz.bin"})},
{"title", card.name + " · Card Collection"},
};
+ addNavigationData(template_data, actor->session.user);
try
{
@@ -2179,13 +2205,14 @@ void App::handleCardDeleteConfirm(
}
return;
}
- const inja::json template_data = {
+ inja::json template_data = {
{"action_url", urlFor("card-delete", {parameter->second})},
{"back_url", urlFor("card", {parameter->second})},
{"csrf_token", actor->session.csrf_token},
{"name", (**card).name},
{"title", "Delete card · Card Collection"},
};
+ addNavigationData(template_data, actor->session.user);
try
{
response.status = 200;
@@ -2369,7 +2396,7 @@ void App::handleCardIndex(
const std::string index_route = creator_route
? "creator-cards"
: "card-index";
- const inja::json template_data = {
+ inja::json template_data = {
{"ascending_url",
urlFor(index_route, {}, {{"sort", "id"}, {"direction", "asc"}})},
{"administrator", !creator_route},
@@ -2382,6 +2409,7 @@ void App::handleCardIndex(
{"title", "Card Collection"},
{"users_url", urlFor("admin-users")},
};
+ addNavigationData(template_data, actor->session.user);
try
{
@@ -2452,12 +2480,13 @@ void App::handleSeriesIndex(
{"name", item.name},
});
}
- const inja::json template_data = {
+ inja::json template_data = {
{"can_create", !games_->games().empty()},
{"create_url", urlFor("series-new")},
{"series", std::move(series)},
{"title", "Series · Card Collection"},
};
+ addNavigationData(template_data, actor->session.user);
try
{
response.status = 200;
@@ -2502,7 +2531,7 @@ void App::handleSeriesNew(
{"short_name", std::string(game->shortName())},
});
}
- const inja::json template_data = {
+ inja::json template_data = {
{"action_url", urlFor("series")},
{"back_url", urlFor("series-index")},
{"csrf_token", actor->session.csrf_token},
@@ -2515,6 +2544,7 @@ void App::handleSeriesNew(
{"submit_label", "Create series"},
{"title", "Create series · Card Collection"},
};
+ addNavigationData(template_data, actor->session.user);
try
{
response.status = 200;
@@ -2601,7 +2631,7 @@ void App::handleSeriesEdit(
}
const Series& series = **series_result;
const GameDefinition* game = games_->find(series.game_short_name);
- const inja::json template_data = {
+ inja::json template_data = {
{"action_url", urlFor("series-item", {std::to_string(*id)})},
{"back_url", urlFor("series-index")},
{"csrf_token", actor->session.csrf_token},
@@ -2616,6 +2646,7 @@ void App::handleSeriesEdit(
{"submit_label", "Save changes"},
{"title", "Edit " + series.name + " · Card Collection"},
};
+ addNavigationData(template_data, actor->session.user);
try
{
response.status = 200;
@@ -2706,13 +2737,14 @@ void App::handleSeriesDeleteConfirm(
}
return;
}
- const inja::json template_data = {
+ inja::json template_data = {
{"action_url", urlFor("series-delete", {std::to_string(*id)})},
{"back_url", urlFor("series-index")},
{"csrf_token", actor->session.csrf_token},
{"name", (**series_result).name},
{"title", "Delete series · Card Collection"},
};
+ addNavigationData(template_data, actor->session.user);
try
{
response.status = 200;
diff --git a/src/app.h b/src/app.h
index b5fdccf..011e2f9 100644
--- a/src/app.h
+++ b/src/app.h
@@ -190,6 +190,9 @@ private:
const RequestIdentity& identity,
Response& response) const;
+ /// Add role-aware authenticated navigation links to template data.
+ void addNavigationData(inja::json& data, const User& user) const;
+
/// Set or expire a host-only application cookie.
void setCookie(
Response& response,
diff --git a/src/authentication.cpp b/src/authentication.cpp
index d93014b..603983e 100644
--- a/src/authentication.cpp
+++ b/src/authentication.cpp
@@ -6,6 +6,8 @@
#include <string>
#include <utility>
+#include <spdlog/spdlog.h>
+
#include "email_address.h"
#include "secret_token.h"
@@ -119,6 +121,11 @@ mw::E<void> AuthenticationService::requestEmail(
std::chrono::system_clock::time_point(
std::chrono::seconds(now + CHALLENGE_LIFETIME_SECONDS))};
auto sent = email_sender_.send(email);
+ if(sent)
+ {
+ spdlog::info(
+ "Sent authentication email to {}", address->email);
+ }
transaction = data_source_.beginTransaction();
if(!transaction)
{
diff --git a/static/css/styles.css b/static/css/styles.css
index 6f0ad7a..7f238db 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -1014,30 +1014,770 @@ h1 {
}
}
-.collection-grid {
+.public-main {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
+ place-items: center;
+}
+
+.public-header-note {
+ color: var(--muted);
+ font-size: 0.78rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.welcome-hero {
+ display: grid;
+ width: 100%;
+ min-height: calc(100svh - var(--main-top) - var(--main-bottom));
+ grid-template-columns: minmax(0, 1.05fr) minmax(20rem, 0.95fr);
+ align-items: center;
+ gap: clamp(3rem, 8vw, 8rem);
+}
+
+.welcome-copy {
+ max-width: 48rem;
+}
+
+.welcome-copy h1 {
+ font-size: clamp(3.5rem, 8vw, 7.5rem);
+}
+
+.hero-copy {
+ max-width: 40rem;
+ margin: 2rem 0 2.5rem;
+ color: var(--muted);
+ font-size: clamp(1.05rem, 2vw, 1.35rem);
+}
+
+.clay-action,
+.pull-button,
+.table-action {
+ display: inline-flex;
+ min-height: 3.5rem;
+ align-items: center;
+ justify-content: center;
+ padding: 0.75rem 1.5rem;
+ 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;
+ letter-spacing: 0.02em;
+ text-decoration: none;
+ cursor: pointer;
+ transition:
+ box-shadow 200ms ease,
+ transform 200ms ease;
+}
+
+.clay-action:hover,
+.pull-button:hover:not(:disabled),
+.table-action:hover {
+ box-shadow: var(--clay-card-shadow-hover);
+ transform: translateY(-0.25rem);
+}
+
+.clay-action:active,
+.pull-button:active:not(:disabled),
+.table-action:active {
+ box-shadow: var(--clay-pressed-shadow);
+ transform: scale(0.92);
+}
+
+.clay-action-secondary {
+ background: rgb(255 255 255 / 76%);
+ color: var(--violet);
+}
+
+.welcome-preview {
+ position: relative;
+ display: grid;
+ min-height: 36rem;
+ place-items: center;
+ isolation: isolate;
+}
+
+.welcome-card {
+ width: min(20rem, 68vw);
+ padding: 0.9rem;
+ border: 1px solid rgb(255 255 255 / 85%);
+ border-radius: 2.5rem;
+ background: rgb(255 255 255 / 72%);
+ box-shadow:
+ 24px 28px 48px rgb(139 92 246 / 24%),
+ -14px -14px 32px rgb(255 255 255 / 95%),
+ inset 7px 7px 14px rgb(139 92 246 / 4%),
+ inset -7px -7px 14px rgb(255 255 255 / 100%);
+ backdrop-filter: blur(1.25rem);
+ transform: rotate(4deg);
+ transition:
+ box-shadow 500ms ease,
+ transform 500ms ease;
+}
+
+.welcome-card:hover {
+ box-shadow: var(--clay-card-shadow-hover);
+ transform: translateY(-0.75rem) rotate(1deg) scale(1.02);
+}
+
+.welcome-card img {
+ display: block;
+ width: 100%;
+ aspect-ratio: 5 / 7;
+ border-radius: 1.75rem;
+ object-fit: cover;
+}
+
+.welcome-preview > p {
+ position: absolute;
+ right: 0;
+ bottom: 1rem;
+ left: 0;
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.85rem;
+ font-weight: 700;
+ text-align: center;
+}
+
+.welcome-orbit {
+ position: absolute;
+ z-index: -1;
+ width: 12rem;
+ height: 12rem;
+ border-radius: 50%;
+ filter: blur(0.25rem);
+ opacity: 0.2;
+ animation: clay-float 9s ease-in-out infinite;
+}
+
+.welcome-orbit-pink {
+ top: 2rem;
+ right: 0;
+ background: var(--pink);
+}
+
+.welcome-orbit-blue {
+ bottom: 3rem;
+ left: 0;
+ background: var(--blue);
+ animation-name: clay-float-delayed;
+}
+
+.public-card {
+ position: relative;
+ width: min(42rem, 100%);
+ padding: clamp(2rem, 6vw, 4rem);
+ overflow: hidden;
+ border: 1px solid rgb(255 255 255 / 82%);
+ border-radius: clamp(2rem, 5vw, 3rem);
+ background: rgb(255 255 255 / 70%);
+ box-shadow:
+ 30px 30px 60px #cdc6d9,
+ -30px -30px 60px #ffffff,
+ inset 10px 10px 20px rgb(139 92 246 / 5%),
+ inset -10px -10px 20px rgb(255 255 255 / 80%);
+ backdrop-filter: blur(1.25rem);
+}
+
+.public-card h1 {
+ font-size: clamp(2.65rem, 7vw, 4.5rem);
+}
+
+.public-card-copy {
+ max-width: 34rem;
+ margin: 1.25rem 0 0;
+ color: var(--muted);
+ font-size: 1.05rem;
+}
+
+.clay-icon {
+ display: inline-grid;
+ width: 3.5rem;
+ height: 3.5rem;
+ place-items: center;
+ flex: 0 0 auto;
+ margin-bottom: 1.5rem;
+ border-radius: 1.25rem;
+ box-shadow:
+ 10px 10px 20px rgb(139 92 246 / 20%),
+ -7px -7px 14px rgb(255 255 255 / 60%),
+ inset 4px 4px 8px rgb(255 255 255 / 35%),
+ inset -4px -4px 8px rgb(0 0 0 / 9%);
+ color: #ffffff;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 1.35rem;
+ font-weight: 900;
+}
+
+.clay-icon-violet {
+ background: linear-gradient(145deg, #a78bfa, #7c3aed);
+}
+
+.clay-icon-pink {
+ background: linear-gradient(145deg, #f472b6, #db2777);
+}
+
+.clay-icon-blue {
+ background: linear-gradient(145deg, #38bdf8, #0284c7);
+}
+
+.message-card {
+ text-align: center;
+}
+
+.message-card .public-card-copy {
+ margin-inline: auto;
+}
+
+.message-hint,
+.identity-pill {
+ margin: 1.75rem 0 0;
+ padding: 1rem 1.25rem;
+ border-radius: 1.25rem;
+ background: #efebf5;
+ box-shadow: var(--clay-pressed-shadow);
+ color: var(--muted);
+ overflow-wrap: anywhere;
+}
+
+.identity-pill {
+ color: var(--foreground);
+ font-weight: 800;
+ text-align: center;
+}
+
+.collection-page,
+.account-page,
+.users-page {
+ width: 100%;
+}
+
+.collection-heading {
+ align-items: center;
+}
+
+.pull-panel {
+ display: flex;
+ align-items: center;
gap: 1rem;
+ padding: 0.75rem;
+ border: 1px solid rgb(255 255 255 / 82%);
+ border-radius: 2rem;
+ background: rgb(255 255 255 / 68%);
+ box-shadow: var(--clay-card-shadow);
+ backdrop-filter: blur(1.25rem);
+}
+
+.pull-count {
+ display: grid;
+ min-width: 8rem;
+ padding: 0.4rem 0.75rem;
+ text-align: center;
+}
+
+.pull-count strong {
+ color: var(--violet);
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 2rem;
+ font-weight: 900;
+ line-height: 1;
+}
+
+.pull-count span {
+ color: var(--muted);
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+}
+
+.pull-button:disabled {
+ background: #d8d1e2;
+ box-shadow: var(--clay-pressed-shadow);
+ color: var(--muted);
+ cursor: not-allowed;
+}
+
+.collection-notice {
+ display: flex;
+ align-items: center;
+ gap: 1.25rem;
+ margin-bottom: 2.5rem;
+ padding: 1.25rem 1.5rem;
+ border-radius: 2rem;
+ background: rgb(255 255 255 / 70%);
+ box-shadow: var(--clay-card-shadow);
+}
+
+.collection-notice .clay-icon {
+ margin: 0;
+}
+
+.collection-notice strong {
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 1.1rem;
+ font-weight: 900;
+}
+
+.collection-notice p {
+ margin: 0;
+ color: var(--muted);
+}
+
+.collection-empty {
+ display: grid;
+ min-height: 20rem;
+ place-items: center;
+ align-content: center;
+ padding: 2rem;
+ border-radius: 2.5rem;
+ background: #efebf5;
+ box-shadow: var(--clay-pressed-shadow);
+ text-align: center;
+}
+
+.collection-empty .clay-icon {
+ margin: 0 0 1.25rem;
+}
+
+.collection-empty h2 {
+ margin: 0;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: clamp(1.5rem, 4vw, 2rem);
+ font-weight: 900;
+}
+
+.collection-empty p {
+ margin: 0.35rem 0 0;
+ color: var(--muted);
+}
+
+.collection-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
+ gap: clamp(1.5rem, 3vw, 2.5rem);
+ margin: 0;
padding: 0;
list-style: none;
}
+.collection-card {
+ min-width: 0;
+}
+
.collection-card a {
position: relative;
display: grid;
- gap: 0.5rem;
+ height: 100%;
+ padding: 0.75rem 0.75rem 1.25rem;
+ overflow: hidden;
+ border: 1px solid rgb(255 255 255 / 85%);
+ border-radius: 2rem;
+ background: rgb(255 255 255 / 72%);
+ box-shadow: var(--clay-card-shadow);
+ text-decoration: none;
+ backdrop-filter: blur(1.25rem);
+ transition:
+ box-shadow 500ms ease,
+ transform 500ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
-.collection-card img {
+.collection-card a:hover {
+ box-shadow: var(--clay-card-shadow-hover);
+ transform: translateY(-0.5rem) scale(1.015);
+}
+
+.collection-card a:active {
+ box-shadow: var(--clay-pressed-shadow);
+ transform: scale(0.97);
+}
+
+.collection-image {
+ display: block;
+ aspect-ratio: 5 / 7;
+ overflow: hidden;
+ border-radius: 1.5rem;
+ background: #e9e2f2;
+ box-shadow:
+ inset 7px 7px 14px rgb(174 164 190 / 20%),
+ inset -7px -7px 14px rgb(255 255 255 / 85%),
+ 4px 4px 10px rgb(139 92 246 / 8%),
+ -3px -3px 8px rgb(255 255 255 / 70%);
+}
+
+.collection-image img {
+ display: block;
width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 500ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.collection-card a:hover img {
+ transform: scale(1.035);
+}
+
+.collection-card-details {
+ display: grid;
+ gap: 0.1rem;
+ padding: 1rem 0.6rem 0;
+}
+
+.collection-card-name {
+ overflow-wrap: anywhere;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 1.1rem;
+ font-weight: 900;
+ line-height: 1.25;
+}
+
+.collection-card-details > span {
+ color: var(--violet);
+ font-size: 0.75rem;
+ font-weight: 800;
}
.collection-quantity {
position: absolute;
- right: 0.5rem;
- bottom: 2rem;
- padding: 0.2rem 0.5rem;
+ top: 1.35rem;
+ right: 1.35rem;
+ display: grid;
+ min-width: 2.75rem;
+ min-height: 2.75rem;
+ place-items: center;
+ padding: 0.35rem 0.6rem;
+ border: 2px solid rgb(255 255 255 / 70%);
+ border-radius: 50%;
+ background: linear-gradient(145deg, #f472b6, #db2777);
+ box-shadow:
+ 8px 8px 18px rgb(190 36 105 / 30%),
+ -5px -5px 12px rgb(255 255 255 / 50%),
+ inset 3px 3px 7px rgb(255 255 255 / 35%),
+ inset -3px -3px 7px rgb(0 0 0 / 9%);
+ color: #ffffff;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 0.85rem;
+ font-weight: 900;
+}
+
+.account-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr);
+ gap: clamp(1.5rem, 3vw, 2.5rem);
+}
+
+.settings-card {
+ display: grid;
+ align-content: start;
+ gap: 1.5rem;
+ padding: clamp(1.75rem, 4vw, 2.75rem);
+ 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);
+}
+
+.settings-card .clay-icon {
+ margin: 0;
+}
+
+.settings-label {
+ margin: 0 0 0.4rem;
+ color: var(--violet);
+ font-size: 0.75rem;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.settings-card h2 {
+ margin: 0 0 0.5rem;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 1.5rem;
+ font-weight: 900;
+ line-height: 1.2;
+}
+
+.account-details {
+ display: grid;
+ gap: 0.75rem;
+ margin: 1.5rem 0 0;
+}
+
+.account-details div {
+ display: grid;
+ gap: 0.15rem;
+ padding: 1rem 1.25rem;
+ border-radius: 1.25rem;
+ background: #efebf5;
+ box-shadow: var(--clay-pressed-shadow);
+}
+
+.account-details dt {
+ color: var(--muted);
+ font-size: 0.7rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.account-details dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 1.05rem;
+ font-weight: 800;
+}
+
+.settings-card .clay-action {
+ width: fit-content;
+}
+
+.heading-copy {
+ max-width: 24rem;
+ margin: 0;
+ color: var(--muted);
+ font-size: 1.05rem;
+}
+
+.user-table-shell {
+ overflow-x: auto;
+ padding: 1rem;
+ 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);
+}
+
+.user-table {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0 0.75rem;
+ text-align: left;
+}
+
+.user-table th {
+ padding: 0 1rem 0.25rem;
+ color: var(--muted);
+ font-size: 0.7rem;
+ font-weight: 800;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.user-table td {
+ padding: 1rem;
+ background: #efebf5;
+ box-shadow:
+ inset 5px 5px 11px rgb(185 177 198 / 40%),
+ inset -5px -5px 11px rgb(255 255 255 / 85%);
+ overflow-wrap: anywhere;
+}
+
+.user-table td:first-child {
+ border-radius: 1.25rem 0 0 1.25rem;
+}
+
+.user-table td:last-child {
+ border-radius: 0 1.25rem 1.25rem 0;
+}
+
+.role-badge {
+ display: inline-flex;
+ min-height: 2rem;
+ align-items: center;
+ padding: 0.3rem 0.75rem;
border-radius: 999px;
- color: white;
- background: rgb(0 0 0 / 75%);
+ background: rgb(124 58 237 / 10%);
+ color: var(--violet);
+ font-size: 0.72rem;
+ font-weight: 900;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.user-action {
+ text-align: right;
+}
+
+.table-action {
+ min-height: 2.75rem;
+ padding: 0.5rem 1rem;
+ font-size: 0.8rem;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+@media(max-width: 62rem) {
+ .welcome-hero,
+ .account-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .welcome-copy {
+ text-align: center;
+ justify-self: center;
+ }
+
+ .hero-copy {
+ margin-inline: auto;
+ }
+
+ .welcome-preview {
+ min-height: 32rem;
+ }
+
+ .collection-heading {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .pull-panel {
+ width: fit-content;
+ }
+}
+
+@media(max-width: 42rem) {
+ .public-main {
+ padding-top: 10rem;
+ }
+
+ .public-header {
+ align-items: center;
+ flex-direction: row;
+ }
+
+ .public-header-note {
+ font-size: 0.65rem;
+ }
+
+ .welcome-hero {
+ gap: 1rem;
+ }
+
+ .welcome-copy h1 {
+ font-size: clamp(3.25rem, 17vw, 5rem);
+ }
+
+ .welcome-preview {
+ min-height: 29rem;
+ }
+
+ .public-card {
+ padding: 2rem 1.5rem;
+ border-radius: 2rem;
+ }
+
+ .pull-panel {
+ width: 100%;
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .pull-panel form,
+ .pull-button {
+ width: 100%;
+ }
+
+ .collection-grid {
+ grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
+ gap: 1rem;
+ }
+
+ .collection-card a {
+ border-radius: 1.5rem;
+ }
+
+ .collection-image {
+ border-radius: 1.15rem;
+ }
+
+ .settings-card {
+ border-radius: 2rem;
+ }
+
+ .settings-card .clay-action,
+ .settings-card form,
+ .settings-card form .clay-action {
+ width: 100%;
+ }
+
+ .user-table-shell {
+ padding: 0;
+ overflow: visible;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+ }
+
+ .user-table,
+ .user-table tbody,
+ .user-table tr,
+ .user-table td {
+ display: block;
+ width: 100%;
+ }
+
+ .user-table thead {
+ display: none;
+ }
+
+ .user-table tr {
+ margin-bottom: 1.25rem;
+ padding: 1.25rem;
+ border: 1px solid rgb(255 255 255 / 82%);
+ border-radius: 2rem;
+ background: rgb(255 255 255 / 70%);
+ box-shadow: var(--clay-card-shadow);
+ }
+
+ .user-table td {
+ display: grid;
+ grid-template-columns: 6rem minmax(0, 1fr);
+ gap: 0.75rem;
+ padding: 0.6rem 0;
+ background: transparent;
+ box-shadow: none;
+ }
+
+ .user-table td::before {
+ color: var(--muted);
+ content: attr(data-label);
+ font-size: 0.68rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+
+ .user-table td:first-child,
+ .user-table td:last-child {
+ border-radius: 0;
+ }
+
+ .user-table .user-action {
+ display: block;
+ padding-top: 1rem;
+ text-align: left;
+ }
+
+ .user-action form,
+ .table-action {
+ width: 100%;
+ }
}
diff --git a/templates/account.html b/templates/account.html
index 6a81447..e5ffbc8 100644
--- a/templates/account.html
+++ b/templates/account.html
@@ -1,13 +1,41 @@
{% extends "layout.html" %}
{% block content %}
-<section class="page-shell form-page">
- <h1>Account</h1>
- <dl><dt>Email</dt><dd>{{ email }}</dd>
- <dt>Username</dt><dd>{{ username }}</dd></dl>
- <p><a href="{{ username_url }}">Change username</a></p>
- <form method="post" action="{{ logout_url }}">
- <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
- <button>Sign out</button>
- </form>
+<section class="account-page" aria-labelledby="AccountHeading">
+ <header class="page-heading">
+ <div>
+ <p class="eyebrow">Your profile</p>
+ <h1 id="AccountHeading">Account</h1>
+ </div>
+ </header>
+ <div class="account-grid">
+ <article class="settings-card">
+ <div class="clay-icon clay-icon-violet" aria-hidden="true">@</div>
+ <div>
+ <p class="settings-label">Collector profile</p>
+ <dl class="account-details">
+ <div><dt>Username</dt><dd>{{ username }}</dd></div>
+ <div><dt>Email</dt><dd>{{ email }}</dd></div>
+ </dl>
+ </div>
+ <a class="clay-action clay-action-secondary"
+ href="{{ username_url }}">Change username</a>
+ </article>
+ <article class="settings-card settings-card-compact">
+ <div class="clay-icon clay-icon-pink" aria-hidden="true">↪</div>
+ <div>
+ <p class="settings-label">Session</p>
+ <h2>Finished collecting for now?</h2>
+ <p class="muted-copy">
+ Sign out safely on this device. Your collection stays put.
+ </p>
+ </div>
+ <form method="post" action="{{ logout_url }}">
+ <input type="hidden" name="csrf_token"
+ value="{{ csrf_token }}">
+ <button class="clay-action clay-action-secondary"
+ type="submit">Sign out</button>
+ </form>
+ </article>
+ </div>
</section>
{% endblock %}
diff --git a/templates/authentication.html b/templates/authentication.html
index bddafcb..4f5094c 100644
--- a/templates/authentication.html
+++ b/templates/authentication.html
@@ -1,9 +1,21 @@
{% extends "public_layout.html" %}
{% block content %}
-<h1>Sign in or register</h1>
-<form method="post" action="{{ action_url }}">
- <label>Email <input type="email" name="email" required></label>
- <input type="hidden" name="form_nonce" value="{{ form_nonce }}">
- <button>Send sign-in link</button>
-</form>
+<section class="public-card auth-card" aria-labelledby="AuthHeading">
+ <div class="clay-icon clay-icon-violet" aria-hidden="true">✦</div>
+ <p class="eyebrow">Welcome back</p>
+ <h1 id="AuthHeading">Sign in or register</h1>
+ <p class="public-card-copy">
+ No password needed. We will email you a secure sign-in link.
+ </p>
+ <form class="standalone-form" method="post" action="{{ action_url }}">
+ <label class="form-field" for="AuthenticationEmail">
+ <span>Email address</span>
+ <input id="AuthenticationEmail" type="email" name="email"
+ autocomplete="email" placeholder="you@example.com"
+ required autofocus>
+ </label>
+ <input type="hidden" name="form_nonce" value="{{ form_nonce }}">
+ <button class="form-submit" type="submit">Send sign-in link</button>
+ </form>
+</section>
{% endblock %}
diff --git a/templates/authentication_confirm.html b/templates/authentication_confirm.html
index a6dd214..e9560d6 100644
--- a/templates/authentication_confirm.html
+++ b/templates/authentication_confirm.html
@@ -1,9 +1,14 @@
{% extends "public_layout.html" %}
{% block content %}
-<h1>Confirm sign in</h1>
-<p>Continue as {{ email }}?</p>
-<form method="post" action="{{ action_url }}">
+<section class="public-card auth-card" aria-labelledby="ConfirmHeading">
+ <div class="clay-icon clay-icon-pink" aria-hidden="true">✓</div>
+ <p class="eyebrow">Almost there</p>
+ <h1 id="ConfirmHeading">Confirm sign in</h1>
+ <p class="public-card-copy">Continue with this email address?</p>
+ <p class="identity-pill">{{ email }}</p>
+ <form class="standalone-form" method="post" action="{{ action_url }}">
<input type="hidden" name="form_nonce" value="{{ form_nonce }}">
- <button>Continue</button>
-</form>
+ <button class="form-submit" type="submit">Continue</button>
+ </form>
+</section>
{% endblock %}
diff --git a/templates/authentication_sent.html b/templates/authentication_sent.html
index fee6e10..adb37c8 100644
--- a/templates/authentication_sent.html
+++ b/templates/authentication_sent.html
@@ -1,5 +1,12 @@
{% extends "public_layout.html" %}
{% block content %}
-<h1>Check your email</h1>
-<p>If the address can receive mail, a sign-in link is on its way.</p>
+<section class="public-card message-card" aria-labelledby="SentHeading">
+ <div class="clay-icon clay-icon-blue" aria-hidden="true">✉</div>
+ <p class="eyebrow">Link sent</p>
+ <h1 id="SentHeading">Check your email</h1>
+ <p class="public-card-copy">
+ If the address can receive mail, a sign-in link is on its way.
+ </p>
+ <p class="message-hint">You can close this page after it arrives.</p>
+</section>
{% endblock %}
diff --git a/templates/collection.html b/templates/collection.html
index 6ea6440..4721b21 100644
--- a/templates/collection.html
+++ b/templates/collection.html
@@ -1,20 +1,62 @@
{% extends "layout.html" %}
{% block content %}
-<section class="page-shell">
- <h1>Your collection</h1>
- <p>Available pulls: {{ available_pulls }}</p>
- <form method="post" action="{{ pull_url }}">
- <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
- <button {% if pull_disabled %}disabled{% endif %}>Pull a card</button>
- </form>
- {% if pool_empty %}<p>The card pool is currently empty.</p>{% endif %}
+<section class="collection-page" aria-labelledby="CollectionHeading">
+ <header class="page-heading collection-heading">
+ <div>
+ <p class="eyebrow">Your daily discoveries</p>
+ <h1 id="CollectionHeading">Your collection</h1>
+ </div>
+ <div class="pull-panel">
+ <div class="pull-count">
+ <strong>{{ available_pulls }}</strong>
+ <span>available pulls</span>
+ </div>
+ <form method="post" action="{{ pull_url }}">
+ <input type="hidden" name="csrf_token"
+ value="{{ csrf_token }}">
+ <button class="pull-button" type="submit"
+ {% if pull_disabled %}disabled{% endif %}>
+ Pull a card
+ </button>
+ </form>
+ </div>
+ </header>
+ {% if pool_empty %}
+ <div class="collection-notice" role="status">
+ <span class="clay-icon clay-icon-pink" aria-hidden="true">!</span>
+ <div>
+ <strong>The card pool is empty</strong>
+ <p>Check back after a creator adds some cards.</p>
+ </div>
+ </div>
+ {% endif %}
+ {% if length(entries) == 0 %}
+ <div class="collection-empty">
+ <div class="clay-icon clay-icon-blue" aria-hidden="true">✦</div>
+ <h2>Your collection is ready to grow</h2>
+ <p>Cards you pull will appear here.</p>
+ </div>
+ {% endif %}
<ul class="collection-grid">
{% for entry in entries %}
- <li class="collection-card"><a href="{{ entry.url }}">
- <img src="{{ entry.thumbnail_url }}" alt="">
- <span>{{ entry.name }}</span>
- <strong class="collection-quantity">{{ entry.quantity }}</strong>
- </a></li>
+ <li class="collection-card">
+ <a href="{{ entry.url }}">
+ <span class="collection-image">
+ <img src="{{ entry.thumbnail_url }}"
+ alt="{{ entry.name }}">
+ </span>
+ <span class="collection-card-details">
+ <strong class="collection-card-name">
+ {{ entry.name }}
+ </strong>
+ <span>View card</span>
+ </span>
+ <strong class="collection-quantity"
+ aria-label="Quantity {{ entry.quantity }}">
+ ×{{ entry.quantity }}
+ </strong>
+ </a>
+ </li>
{% endfor %}
</ul>
</section>
diff --git a/templates/error.html b/templates/error.html
index 6d03cea..63c54e4 100644
--- a/templates/error.html
+++ b/templates/error.html
@@ -1,2 +1,9 @@
{% extends "public_layout.html" %}
-{% block content %}<h1>{{ heading }}</h1><p>{{ message }}</p>{% endblock %}
+{% block content %}
+<section class="public-card message-card" aria-labelledby="ErrorHeading">
+ <div class="clay-icon clay-icon-pink" aria-hidden="true">!</div>
+ <p class="eyebrow">Something went wrong</p>
+ <h1 id="ErrorHeading">{{ heading }}</h1>
+ <p class="public-card-copy">{{ message }}</p>
+</section>
+{% endblock %}
diff --git a/templates/layout.html b/templates/layout.html
index b34b2e4..4dde445 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -20,6 +20,12 @@
<a class="nav-link" href="{{ url_for("collection") }}">
Collection
</a>
+ {% if navigation.show_cards %}
+ <a class="nav-link" href="{{ navigation.cards_url }}">Cards</a>
+ {% endif %}
+ {% if navigation.show_users %}
+ <a class="nav-link" href="{{ navigation.users_url }}">Users</a>
+ {% endif %}
<a class="nav-link" href="{{ url_for("account") }}">Account</a>
</nav>
</header>
diff --git a/templates/onboarding_username.html b/templates/onboarding_username.html
index f0c1bca..38d7415 100644
--- a/templates/onboarding_username.html
+++ b/templates/onboarding_username.html
@@ -1,9 +1,22 @@
{% extends "public_layout.html" %}
{% block content %}
-<h1>{{ heading }}</h1>
-<form method="post" action="{{ action_url }}">
- <input name="username" required maxlength="32" value="{{ username }}">
- <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
- <button>{{ submit_label }}</button>
-</form>
+<section class="public-card auth-card" aria-labelledby="UsernameHeading">
+ <div class="clay-icon clay-icon-blue" aria-hidden="true">@</div>
+ <p class="eyebrow">Your collector identity</p>
+ <h1 id="UsernameHeading">{{ heading }}</h1>
+ <p class="public-card-copy">
+ Choose the name other collectors will recognize.
+ </p>
+ <form class="standalone-form" method="post" action="{{ action_url }}">
+ <label class="form-field" for="Username">
+ <span>Username</span>
+ <input id="Username" name="username" required maxlength="32"
+ autocomplete="username" value="{{ username }}"
+ placeholder="Your display name" autofocus>
+ <em>Up to 32 characters. Unicode is welcome.</em>
+ </label>
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+ <button class="form-submit" type="submit">{{ submit_label }}</button>
+ </form>
+</section>
{% endblock %}
diff --git a/templates/public_layout.html b/templates/public_layout.html
index ed55a44..72fcaf8 100644
--- a/templates/public_layout.html
+++ b/templates/public_layout.html
@@ -7,7 +7,16 @@
<link rel="stylesheet" href="{{ url_for("static", "css/styles.css") }}">
</head>
<body>
- <main class="page-shell form-page">
+ <div class="ambient-light" aria-hidden="true">
+ <div class="ambient-blob ambient-blob-violet"></div>
+ <div class="ambient-blob ambient-blob-pink"></div>
+ <div class="ambient-blob ambient-blob-blue"></div>
+ </div>
+ <header class="site-header public-header">
+ <span class="site-title">Card Collection</span>
+ <span class="public-header-note">One card every day</span>
+ </header>
+ <main class="public-main">
{% block content %}{% endblock %}
</main>
</body>
diff --git a/templates/user_admin.html b/templates/user_admin.html
index bf473d0..81b615c 100644
--- a/templates/user_admin.html
+++ b/templates/user_admin.html
@@ -1,20 +1,48 @@
{% extends "layout.html" %}
{% block content %}
-<section class="page-shell">
- <h1>Users</h1>
- <table><thead><tr><th>Username</th><th>Email</th><th>Role</th><th></th>
- </tr></thead><tbody>
- {% for user in users %}
- <tr><td>{{ user.username }}</td><td>{{ user.email }}</td>
- <td>{{ user.role }}</td><td>
- {% if user.can_promote %}
- <form method="post" action="{{ user.promote_url }}">
- <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
- <button>Promote</button>
- </form>
- {% endif %}
- </td></tr>
- {% endfor %}
- </tbody></table>
+<section class="users-page" aria-labelledby="UsersHeading">
+ <header class="page-heading">
+ <div>
+ <p class="eyebrow">Administration</p>
+ <h1 id="UsersHeading">Users</h1>
+ </div>
+ <p class="heading-copy">Manage who can create cards.</p>
+ </header>
+ <div class="user-table-shell">
+ <table class="user-table">
+ <thead>
+ <tr>
+ <th>Username</th>
+ <th>Email</th>
+ <th>Role</th>
+ <th><span class="visually-hidden">Actions</span></th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for user in users %}
+ <tr>
+ <td data-label="Username">
+ <strong>{{ user.username }}</strong>
+ </td>
+ <td data-label="Email">{{ user.email }}</td>
+ <td data-label="Role">
+ <span class="role-badge">{{ user.role }}</span>
+ </td>
+ <td class="user-action">
+ {% if user.can_promote %}
+ <form method="post" action="{{ user.promote_url }}">
+ <input type="hidden" name="csrf_token"
+ value="{{ csrf_token }}">
+ <button class="table-action" type="submit">
+ Promote
+ </button>
+ </form>
+ {% endif %}
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
</section>
{% endblock %}
diff --git a/templates/welcome.html b/templates/welcome.html
index 5aece9b..285da89 100644
--- a/templates/welcome.html
+++ b/templates/welcome.html
@@ -1,7 +1,24 @@
{% extends "public_layout.html" %}
{% block content %}
-<h1>Card Collection</h1>
-<p>Build your collection one daily pull at a time.</p>
-<img src="{{ example_url }}" alt="Example card">
-<p><a href="{{ authentication_url }}">Sign in or register</a></p>
+<section class="welcome-hero" aria-labelledby="WelcomeHeading">
+ <div class="welcome-copy">
+ <p class="eyebrow">A tiny daily ritual</p>
+ <h1 id="WelcomeHeading">Build a collection that is yours.</h1>
+ <p class="hero-copy">
+ Pull one card every day, discover new favorites, and watch your
+ personal collection take shape.
+ </p>
+ <a class="clay-action" href="{{ authentication_url }}">
+ Start collecting
+ </a>
+ </div>
+ <div class="welcome-preview" aria-label="Example collectible card">
+ <div class="welcome-orbit welcome-orbit-pink"></div>
+ <div class="welcome-orbit welcome-orbit-blue"></div>
+ <div class="welcome-card">
+ <img src="{{ example_url }}" alt="Example collectible card">
+ </div>
+ <p>There is always another card to discover.</p>
+ </div>
+</section>
{% endblock %}
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
index 67f5632..fe41775 100644
--- a/tests/app_integration_test.cpp
+++ b/tests/app_integration_test.cpp
@@ -278,6 +278,18 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
ASSERT_NE(onboarded, nullptr);
ASSERT_EQ(onboarded->status, 303) << onboarded->body;
+ auto administrator_collection = client.Get(
+ "/collection/collection", session_headers);
+ ASSERT_NE(administrator_collection, nullptr);
+ ASSERT_EQ(administrator_collection->status, 200)
+ << administrator_collection->body;
+ EXPECT_NE(
+ administrator_collection->body.find("/collection/admin/cards"),
+ std::string::npos);
+ EXPECT_NE(
+ administrator_collection->body.find("/collection/admin/users"),
+ std::string::npos);
+
auto card = client.Get("/collection/cards/test-1", session_headers);
ASSERT_NE(card, nullptr);
EXPECT_EQ(card->status, 200) << card->body;
@@ -434,6 +446,16 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
player_onboarding_fields);
ASSERT_NE(player_onboarded, nullptr);
ASSERT_EQ(player_onboarded->status, 303) << player_onboarded->body;
+ auto player_collection = client.Get(
+ "/collection/collection", player_headers);
+ ASSERT_NE(player_collection, nullptr);
+ ASSERT_EQ(player_collection->status, 200) << player_collection->body;
+ EXPECT_EQ(
+ player_collection->body.find("/collection/admin/cards"),
+ std::string::npos);
+ EXPECT_EQ(
+ player_collection->body.find("/collection/admin/users"),
+ std::string::npos);
auto player_card_new = client.Get(
"/collection/cards/new", player_headers);
ASSERT_NE(player_card_new, nullptr);