mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 09:26:14 -08:00
* feat(portals): posters-only cover wall for movie and series grids Add `Settings.showCoverTitles` (Settings > General, default on). Turning it off drops the title row under VOD/series covers in catalog, favorites and recent grids and reveals the title as a bottom-gradient overlay on hover and keyboard focus, pinned open for items whose cover is missing or failed. `CoverTitlesService` is the single resolver: the opt-out AND a hover-capable pointer, so touch-only devices keep their titles. Live channel grids, search results, "recently added" rails and dashboard rails always keep labels. Catalog and collection cards become keyboard buttons (role, tabindex, aria-label, Enter/Space, focus ring) and poster alt text is the title. The default-on boolean coercion moves into `settings-opt-out.util.ts` because the settings store reached the max-lines limit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): keep nested Remove key presses from activating the card Enter/Space on the content card's nested Remove button bubbled into the card's own key handlers: Enter opened the item before removing it and Space opened it while cancelling the removal. Only keys pressed on the card element itself now activate it. Regression spec added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): keep cover titles while an in-section search filters the grid The posters-only wall exempts search results because they are identified by the name the user typed; the category grid's own in-section filter is the same case, so `app-grid-list` now keeps the title row while its `searchTerm` is non-blank. Contract docs updated, regression spec added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): keep cover titles while the collection tab search is active The unified favorites/recent tab filters by its own search term, so its matches are identified by name like every other search result. The tab now opts its cards out of the posters-only wall while the term is non-blank. Contract docs updated, regression spec added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): move the card Remove control out of the button surface An interactive control nested inside a role="button" is an invalid accessibility structure. The content card's activation surface is now its own inner element and the Remove button a sibling positioned over the poster corner, labelled by its tooltip text. Spec asserts the control is never a descendant of the button. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): draw the collection card focus ring where it is not clipped The card's overflow: hidden clipped an outline drawn on the inner activation surface on every edge, so keyboard users saw no focus indication. The ring now sits on the outer card via :has(> .content-card__activation:focus-visible). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): detect any hover-capable pointer for the posters-only wall `hover` describes only the primary pointer, so a touch-first tablet with a mouse or hover-capable stylus attached lost the wall. The resolver now reads `(any-hover: hover)`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
190 lines
4.4 KiB
SCSS
190 lines
4.4 KiB
SCSS
// Shared content grid and card styles
|
|
//
|
|
// Choose a relative @use path from each consuming stylesheet. A current
|
|
// portal-shared component uses its local forwarding module as shown below;
|
|
// direct consumers of this partial may need a different depth.
|
|
// @use '../../styles/content-grid' as grid;
|
|
// @include grid.content-grid;
|
|
// @include grid.content-card;
|
|
|
|
@mixin content-grid(
|
|
$min-width: var(--cover-grid-min-width, 148px),
|
|
$gap: var(--cover-gap, 16px)
|
|
) {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(#{$min-width}, 1fr));
|
|
gap: $gap;
|
|
}
|
|
|
|
@mixin content-card {
|
|
cursor: pointer;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
|
|
&:hover {
|
|
transform: translateY(-3px);
|
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
|
}
|
|
}
|
|
|
|
@mixin thumbnail-wrapper {
|
|
position: relative;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
|
|
.poster {
|
|
width: 100%;
|
|
aspect-ratio: 2/3;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
.poster-placeholder {
|
|
width: 100%;
|
|
aspect-ratio: 2/3;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(128, 128, 128, 0.1);
|
|
|
|
mat-icon {
|
|
font-size: 36px;
|
|
width: 36px;
|
|
height: 36px;
|
|
opacity: 0.25;
|
|
}
|
|
}
|
|
}
|
|
|
|
@mixin type-badge {
|
|
position: absolute;
|
|
top: 6px;
|
|
left: 6px;
|
|
padding: 3px 6px;
|
|
border-radius: 4px;
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
background: rgba(0, 0, 0, 0.85);
|
|
color: #fff;
|
|
|
|
&.live {
|
|
color: #ef5350;
|
|
}
|
|
&.radio {
|
|
color: #ffa726;
|
|
}
|
|
&.movie {
|
|
color: #42a5f5;
|
|
}
|
|
&.series {
|
|
color: #66bb6a;
|
|
}
|
|
}
|
|
|
|
@mixin remove-button {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 4px;
|
|
z-index: 1;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
width: 28px;
|
|
height: 28px;
|
|
padding: 0;
|
|
|
|
&:hover {
|
|
background: rgba(0, 0, 0, 0.8);
|
|
}
|
|
|
|
mat-icon {
|
|
color: white;
|
|
font-size: 18px;
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
}
|
|
|
|
// Hover/focus caption for the posters-only wall (Settings > General >
|
|
// "Show titles under covers" off). Placed inside the poster wrapper, which
|
|
// must be `position: relative` with `overflow: hidden`; the consumer reveals
|
|
// it on `:hover`/`:focus-visible` and pins it with `--pinned` whenever the
|
|
// cover cannot identify the item (default poster, placeholder).
|
|
@mixin cover-title-overlay {
|
|
position: absolute;
|
|
inset: auto 0 0 0;
|
|
box-sizing: border-box;
|
|
padding: 28px 8px 8px;
|
|
color: #fff;
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
line-height: 1.3;
|
|
text-align: center;
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
|
background: linear-gradient(
|
|
180deg,
|
|
rgba(0, 0, 0, 0) 0%,
|
|
rgba(0, 0, 0, 0.72) 50%,
|
|
rgba(0, 0, 0, 0.88) 100%
|
|
);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
// Entrance under 300ms, ease-out: the caption answers a hover, it is
|
|
// not a state change worth choreographing.
|
|
transition: opacity 150ms ease-out;
|
|
|
|
// The clamp lives on a child: `overflow: hidden` clips at the padding
|
|
// edge, so a clamped element with its own padding paints the third
|
|
// line into that padding.
|
|
.cover-title-overlay__text {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
word-break: break-word;
|
|
}
|
|
|
|
&.cover-title-overlay--pinned {
|
|
opacity: 1;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
transition: none;
|
|
}
|
|
}
|
|
|
|
// Draw the ring on the element that owns `overflow: hidden` (or on one of
|
|
// its ancestors): an outline on a clipped descendant is cut on every edge.
|
|
@mixin focus-ring-declarations {
|
|
outline: 2px solid var(--app-selection-color, #2f7bff);
|
|
outline-offset: 3px;
|
|
}
|
|
|
|
@mixin card-focus-ring {
|
|
&:focus-visible {
|
|
@include focus-ring-declarations;
|
|
}
|
|
}
|
|
|
|
@mixin card-info {
|
|
padding: 8px 10px;
|
|
|
|
h3 {
|
|
margin: 0;
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
line-height: 1.3;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.date-info {
|
|
margin: 4px 0 0;
|
|
font-size: 0.7rem;
|
|
opacity: 0.6;
|
|
}
|
|
}
|