Add a Card Search tab and fix horizontal-card (Battle/Plane) preview orientation

Card Search browses the whole downloaded card universe (not just owned
cards), reusing the Library tab's filter/group/sort/list/tiles UI; results
show ownership and offer quick "own" and "add to deck" actions.

Battles, Planes, and Phenomena store their Scryfall image portrait-shaped
with the content itself rotated, so hover/click previews now detect this
(a new `horizontal` flag threaded through ingest and every card query, with
graceful fallback for a reference.db built before the column existed) and
rotate the image back into place. Single-faced and non-DFC cases are
verified fixed; a double-faced Battle's front face still needs another pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:55:17 -05:00
parent 3c625a9830
commit 2b361c4a85
19 changed files with 1076 additions and 102 deletions

View File

@@ -1481,10 +1481,17 @@ tbody tr:hover {
behind the dialog it's in. */
z-index: 450;
border-radius: 14px;
overflow: hidden;
box-shadow: var(--shadow-lg);
border: 1px solid rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.hover-card-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-modal-backdrop {
position: fixed;
@@ -1527,6 +1534,29 @@ tbody tr:hover {
.preview-modal-img.active {
display: block;
}
/* Horizontal cards (Battles/Planes/Phenomena, see `CardRef.horizontal`): the
stored image is portrait-shaped with the content itself rotated. The
wrapper's width/height (landscape) and the inner img's width/height
(the same box transposed, pre-rotation) are computed in JS —
`useRotatedPreviewBox` — and passed in as inline styles; this just
positions/rotates the img to fill the wrapper exactly. */
.preview-modal-img-rotated {
position: relative;
overflow: hidden;
/* Override `.preview-modal-img`'s portrait-oriented max-width/max-height —
they'd otherwise clamp this wrapper's JS-computed (correctly landscape)
inline width/height down, while the un-clamped rotated img inside stays
at its full computed size and overflows, getting cut off by the
`overflow: hidden` above instead of shrinking to fit. */
max-width: none;
max-height: none;
}
.preview-modal-rotate-img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(90deg);
}
/* Wide enough to show a double-faced card's front and back side by side —
no flip needed. */
@media (min-width: 860px) {
@@ -1538,6 +1568,14 @@ tbody tr:hover {
display: block;
max-width: min(430px, 43vw);
}
/* A horizontal front face (e.g. a transformed Battle) shown side-by-side
with its back face: keep using the JS-computed inline width/height
(see `.preview-modal-img-rotated` above) rather than the narrower DFC
column width, which would clamp the wrapper and crop the rotated image
the same way the base max-width/max-height would. */
.preview-modal-dfc .preview-modal-img-rotated {
max-width: none;
}
.preview-modal-dfc .preview-modal-flip {
display: none;
}
@@ -1707,6 +1745,16 @@ tbody tr:hover {
font-size: 13px;
color: var(--muted);
}
.sort-dir-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 30px;
height: 30px;
padding: 0;
font-size: 15px;
}
.view-toggle {
display: inline-flex;
background: var(--bg-input);