Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every screen size the Decks tab opens to a deck list page (DeckList), and picking a deck opens its builder full-width with an "All decks" back button. The list shows each deck as an art tile (or a compact row, toggleable) with its colors, size against its format, owned %, cost and legality, and can be searched, filtered by format and sorted (recently opened, name, format, size, cost, owned %). Rename/duplicate/export/delete live in each deck's menu and beside the builder's title; New deck now asks for a format too, defaulting to the last one used. A deck's cover art is the commander, else its priciest nonland, unless a card is chosen with "Use as cover" in the card preview. The cover is stored in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a document carrying one is version 3, so the sync server must be updated before clients publish covers. The list's stats come from one all_deck_contents query, summarised with the builder's own analysis code, now in deckAnalysis.ts. Closes #9 Closes #11 Closes #12
This commit is contained in:
513
src/styles.css
513
src/styles.css
@@ -1224,20 +1224,6 @@ h4 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Row-level action icons (e.g. rename/duplicate/delete a deck): hidden until
|
||||
* the row is hovered/focused, so the collapsed list stays uncluttered. */
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
li:hover .row-actions,
|
||||
li:focus-within .row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Dialogs: confirm (delete) / prompt (rename, duplicate-as)
|
||||
============================================================ */
|
||||
@@ -1312,7 +1298,8 @@ li:focus-within .row-actions {
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dialog-field input {
|
||||
.dialog-field input,
|
||||
.dialog-field select {
|
||||
width: 100%;
|
||||
}
|
||||
.dialog-error {
|
||||
@@ -1359,26 +1346,6 @@ li:focus-within .row-actions {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
/* Deck list: name+format on one row, actions on their own row below, so
|
||||
* long names don't push the action icons off the edge of the sidebar. */
|
||||
.result-list.deck-list li {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
.deck-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-list-row .card-name {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.deck-list-actions-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Suggestions list: a thumbnail + a compact name/badges header + wrapped
|
||||
* synergy reason chips per row, with stacked actions on the right. Denser
|
||||
* than the default .result-list row (smaller padding/gaps, chips instead of
|
||||
@@ -2613,25 +2580,412 @@ tbody tr:hover {
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Deck metadata (format + commander)
|
||||
Deck list (the Decks tab's front page)
|
||||
============================================================ */
|
||||
|
||||
/* Two-pane deck screen: deck list + selected-deck detail. Stacks below the
|
||||
tablet breakpoint (see the responsive section) so the list sits above the
|
||||
detail on narrow/portrait viewports. */
|
||||
.deck-layout {
|
||||
.deck-gallery-toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.deck-sidebar {
|
||||
width: 240px;
|
||||
.deck-gallery-toolbar h2 {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.deck-gallery-search {
|
||||
width: 260px;
|
||||
}
|
||||
.deck-gallery-controls,
|
||||
.deck-gallery-create {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.deck-gallery-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
text-align: center;
|
||||
padding: var(--space-8) var(--space-5);
|
||||
}
|
||||
.deck-gallery-empty h3 {
|
||||
margin: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.deck-gallery-empty .hint {
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
.deck-gallery-empty-icon svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--accent-quiet);
|
||||
}
|
||||
|
||||
/* Tiles: an art banner with the deck's name on it, stats below. */
|
||||
.deck-gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.deck-tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--panel-highlight);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.deck-tile:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent-glow);
|
||||
box-shadow: var(--panel-highlight), var(--shadow-md);
|
||||
}
|
||||
.deck-tile:focus-visible,
|
||||
.deck-row:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* The hover lift makes each tile its own stacking context, so the one whose
|
||||
menu is open has to sit above its neighbours or they'd cover the menu. */
|
||||
.deck-tile.menu-open,
|
||||
.deck-row.menu-open {
|
||||
z-index: 5;
|
||||
}
|
||||
.deck-tile-cover {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
.deck-tile-art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.deck-tile-art img,
|
||||
.deck-row-art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.deck-tile-art img {
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.deck-tile:hover .deck-tile-art img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.deck-tile-art,
|
||||
.deck-row-art {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: repeating-linear-gradient(-45deg, var(--bg-input) 0 10px, var(--bg-hover) 10px 20px);
|
||||
}
|
||||
.deck-art-empty {
|
||||
font-size: 44px;
|
||||
font-weight: 800;
|
||||
color: var(--faint);
|
||||
}
|
||||
/* Darkens the top (for the badge + menu) and, more, the bottom (for the
|
||||
name), so both stay legible over any art. */
|
||||
.deck-tile-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(12, 10, 8, 0.45) 0%,
|
||||
rgba(12, 10, 8, 0) 28%,
|
||||
rgba(12, 10, 8, 0) 45%,
|
||||
rgba(12, 10, 8, 0.92) 100%
|
||||
);
|
||||
}
|
||||
.deck-tile-heading {
|
||||
position: absolute;
|
||||
left: var(--space-3);
|
||||
right: var(--space-3);
|
||||
bottom: var(--space-2);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.deck-tile-name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.deck-tile-heading .pips {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.deck-tile-cover .deck-legality {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
}
|
||||
.deck-tile-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-3) var(--space-3);
|
||||
}
|
||||
.deck-tile-sub {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.deck-tile-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--fs-caption);
|
||||
min-height: 22px;
|
||||
}
|
||||
.deck-tile-meta .deck-chip,
|
||||
.deck-row-stats .deck-chip {
|
||||
margin-right: 0;
|
||||
}
|
||||
.deck-tile-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
}
|
||||
.deck-tile-foot span:last-child,
|
||||
.deck-row-cost {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.deck-size {
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-size-target {
|
||||
color: var(--faint);
|
||||
font-weight: 500;
|
||||
}
|
||||
.deck-size.done {
|
||||
color: var(--success);
|
||||
}
|
||||
.deck-size.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.deck-owned-bar {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
.deck-owned-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--success);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.deck-legality {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-tile .deck-legality {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(12, 10, 8, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.deck-legality.legal {
|
||||
color: var(--success);
|
||||
}
|
||||
.deck-legality.illegal {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* The ⋯ menu: over the tile's top-right corner, or at a row's end. */
|
||||
.deck-menu {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.deck-detail-pane {
|
||||
.deck-tile > .deck-menu {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
}
|
||||
.deck-menu-list {
|
||||
left: auto;
|
||||
right: 0;
|
||||
min-width: 170px;
|
||||
}
|
||||
.deck-menu-list > button.danger-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
/* Where there's a hover, the tile's menu waits for it; on touch it's always
|
||||
showing, since there's nothing to reveal it. */
|
||||
@media (hover: hover) {
|
||||
.deck-tile > .deck-menu {
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.deck-tile:hover > .deck-menu,
|
||||
.deck-tile:focus-within > .deck-menu,
|
||||
.deck-tile.menu-open > .deck-menu {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Rows: the compact alternative, more decks per screen. */
|
||||
.deck-gallery-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.deck-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-2);
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--panel-highlight);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.deck-row:hover {
|
||||
border-color: var(--accent-glow);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.deck-row-art {
|
||||
width: 72px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.deck-row-art .deck-art-empty {
|
||||
font-size: 22px;
|
||||
}
|
||||
.deck-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.deck-row-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-row-name .card-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.deck-row-name .pips {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.deck-row-sub {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.deck-row-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-shrink: 0;
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
.deck-row-owned {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.deck-row-owned .deck-owned-bar {
|
||||
width: 56px;
|
||||
}
|
||||
.deck-row-owned > span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
/* Fixed widths, so each stat lines up in a column down the list. */
|
||||
.deck-row-stats .deck-size {
|
||||
min-width: 4.5em;
|
||||
text-align: right;
|
||||
}
|
||||
.deck-row-owned > span:last-child {
|
||||
min-width: 4ch;
|
||||
}
|
||||
.deck-row-cost {
|
||||
min-width: 4.5em;
|
||||
text-align: right;
|
||||
}
|
||||
.deck-row .deck-legality {
|
||||
min-width: 1em;
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
/* The builder's way back to the deck list. */
|
||||
.deck-builder-back {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.deck-builder-back:hover {
|
||||
color: var(--text-strong);
|
||||
background: none;
|
||||
}
|
||||
.deck-builder-title {
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-builder-title h2 {
|
||||
margin-right: var(--space-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Deck metadata (format + commander)
|
||||
============================================================ */
|
||||
|
||||
/* Surface from `.panel`; layout only here. */
|
||||
.deck-meta {
|
||||
@@ -2809,27 +3163,9 @@ tbody tr:hover {
|
||||
Responsive groundwork
|
||||
Desktop (the primary target) is unchanged above; these breakpoints keep
|
||||
the layout usable as the window narrows and lay the foundation for a
|
||||
future mobile port. Breakpoints: 1000 (tablet) and 720 (compact).
|
||||
future mobile port. Breakpoint: 720 (compact).
|
||||
============================================================ */
|
||||
|
||||
/* Tablet: the deck two-pane stacks (list above detail). */
|
||||
@media (max-width: 1000px) {
|
||||
.deck-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.deck-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Back button into the deck list — only meaningful once the list and the
|
||||
deck-building pane become separate screens below, so it's hidden outside
|
||||
that breakpoint (and on the tablet stacked layout, where both panes are
|
||||
still visible together). */
|
||||
.deck-back-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Compact: tighten page gutters and collapse the nav rail to an icon strip
|
||||
the left rail's job moves to .bottom-nav below instead, the customary
|
||||
placement on Android). */
|
||||
@@ -2871,6 +3207,41 @@ tbody tr:hover {
|
||||
.deck-stats-curve {
|
||||
min-width: 0;
|
||||
}
|
||||
/* Deck list toolbar: title and create buttons on top, then search, then
|
||||
the filter/sort/view controls sharing a line. */
|
||||
.deck-gallery-toolbar .spacer {
|
||||
display: none;
|
||||
}
|
||||
.deck-gallery-toolbar h2 {
|
||||
flex: 1;
|
||||
}
|
||||
.deck-gallery-create {
|
||||
order: 1;
|
||||
}
|
||||
.deck-gallery-search {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
}
|
||||
.deck-gallery-controls {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
.deck-gallery-controls select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Rows put their stats on a second line, under the name. */
|
||||
.deck-row {
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--space-1);
|
||||
}
|
||||
.deck-row-stats {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
padding-left: calc(72px + var(--space-3));
|
||||
}
|
||||
/* `.deck-meta` puts the Format selector and the commander search box side
|
||||
by side as flex-wrap row items — with `.commander-box`'s min-width now
|
||||
0, it no longer wraps onto its own line, it just gets squeezed into
|
||||
@@ -2880,20 +3251,6 @@ tbody tr:hover {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
/* Deck list and deck-building pane become separate screens (only one
|
||||
visible at a time) instead of stacking above one another — with many
|
||||
decks, scrolling past the whole list to reach the builder doesn't
|
||||
scale. `.deck-layout`'s show-list/show-detail class is toggled by
|
||||
DeckView in response to picking a deck / the back button. */
|
||||
.deck-back-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
.deck-layout.show-list .deck-detail-pane {
|
||||
display: none;
|
||||
}
|
||||
.deck-layout.show-detail .deck-sidebar {
|
||||
display: none;
|
||||
}
|
||||
/* The Suggested Cards dialog: `.suggestion-actions`' two nowrap buttons
|
||||
are flex-shrink:0, so they claimed their full fixed width no matter how
|
||||
little room was left, crushing `.suggestion-main` down to almost
|
||||
|
||||
Reference in New Issue
Block a user