mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(portals): posters-only cover wall for movie and series grids (#1604)
* 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>
This commit is contained in:
1 parent
40092f6efc
commit
ef3f98d026
49 files changed
+1297
-106
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: feature
|
||||
area: portals
|
||||
highlight: Posters-only cover wall
|
||||
---
|
||||
|
||||
Movie and series grids can now drop the title row: turn off "Show titles under covers" in Settings > General and the catalog, favorites and recent grids become a wall of covers that shows more rows per screen. The title slides in on the cover when you point at it or focus it with the keyboard, and stays on for items whose cover is missing. Live channels and search results keep their labels.
|
||||
@@ -531,6 +531,8 @@ The live EPG panel is a horizontal **timeline ribbon** under the player (`app-ep
|
||||
|
||||
**Collapsible live channel rail** (M3U player, Xtream/Stalker live layouts, unified favorites/recent live tab): collapse state is owned by `LiveLayoutSidebarStateService` (`@iptvnator/portal/shared/util`) and kept per surface (`m3u` / `portal` / `collection`, localStorage `live-sidebar-state:<surface>`); the pre-split shared key `live-sidebar-state` is forgotten on startup and never read (issue #1458: one stored `collapsed` hid every channel list in the app behind a 32px chevron and survived restart, "Remove all playlists" and re-import). The workspace header renders a `view_sidebar` toggle on every route that renders its own rail (`resolveRouteLiveSidebarSurface`) so the control exists in both states, and a collapsed rail with nothing playing shows `app-channel-list-hidden-state` (title + hint + "Show channels list" button) instead of "select a channel". Contract: "Collapsible Live Sidebar" in `docs/architecture/iptvnator-ui-guidelines.md`.
|
||||
|
||||
**Cover grids** (Xtream/Stalker VOD + series catalogs, favorites/recent, dashboard rails): sized by `Settings.coverSize` (small/medium/large → `--cover-grid-min-width`/`--cover-rail-width`/`--cover-gap`, written to `<html data-cover-size>` in `app.component.ts`, tokens in `apps/web/src/_cover-size.scss`). `Settings.showCoverTitles` (Settings → General, default on; only an explicit `false` opts out, coerced with the other default-on flags in `libs/services/src/lib/settings-opt-out.util.ts`) turns VOD/series grids into a posters-only wall: the title row is dropped and a `.cover-title-overlay` caption slides in on hover/`:focus-visible`, pinned open when the cover is missing or failed. `CoverTitlesService` (`libs/portal/shared/ui`) is the single resolver — opt-out AND a `(any-hover: hover)` pointer, so touch-only devices keep titles. Live channel grids, search results (search pages pass `[allowPostersOnly]="false"` to `app-content-card`; `app-grid-list` and `app-unified-grid-tab` keep titles while their `searchTerm` is non-blank), "recently added" rails and dashboard rails always keep their labels. Catalog and collection cards are keyboard buttons (`role="button"`, Enter/Space, focus ring). Contract: "Cover Grids" in `docs/architecture/iptvnator-ui-guidelines.md`.
|
||||
|
||||
**Radio Channel Layout** (when `channel.radio === 'true'`):
|
||||
|
||||
```
|
||||
|
||||
@@ -883,6 +883,48 @@ type XtreamRawEpgListing = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
test('@xtream posters-only wall — hides VOD titles behind a hover caption after Save', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addXtreamPortal(page);
|
||||
await page.locator('.context-panel .category-item').first().click();
|
||||
|
||||
const grid = page.locator('app-grid-list');
|
||||
const firstCard = grid.locator('mat-card').first();
|
||||
await expect(firstCard).toBeVisible({ timeout: 10_000 });
|
||||
await expect(firstCard.locator('.title')).toBeVisible();
|
||||
await expect(grid).not.toHaveClass(/grid-list--posters-only/);
|
||||
|
||||
const catalogUrl = page.url();
|
||||
await page.goto('/workspace/settings/general');
|
||||
const toggle = page.locator('[data-test-id="cover-titles-toggle"]');
|
||||
await expect(toggle.locator('input')).toBeChecked();
|
||||
await toggle.click();
|
||||
const saveButton = page.locator('[data-test-id="save-settings"]');
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
|
||||
await page.goto(catalogUrl);
|
||||
await page.locator('.context-panel .category-item').first().click();
|
||||
await expect(firstCard).toBeVisible({ timeout: 10_000 });
|
||||
await expect(grid).toHaveClass(/grid-list--posters-only/);
|
||||
await expect(firstCard.locator('.title')).toHaveCount(0);
|
||||
|
||||
// The caption is a hover/focus reveal: hidden at rest, shown when the
|
||||
// pointer or keyboard focus lands on the card.
|
||||
const caption = firstCard.locator('.cover-title-overlay');
|
||||
await expect(caption).toHaveCSS('opacity', '0');
|
||||
await firstCard.hover();
|
||||
await expect(caption).toHaveCSS('opacity', '1');
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(caption).toHaveCSS('opacity', '0');
|
||||
await firstCard.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(firstCard).toBeFocused();
|
||||
await expect(caption).toHaveCSS('opacity', '1');
|
||||
});
|
||||
|
||||
async function getEpgFixtureStream(
|
||||
request: APIRequestContext
|
||||
): Promise<XtreamLiveStream> {
|
||||
|
||||
@@ -114,6 +114,7 @@ export function createSettingsForm(
|
||||
embeddedMpvAutoReconnect: true,
|
||||
portalConnectivityGuard: true,
|
||||
coverSize: 'medium' as CoverSize,
|
||||
showCoverTitles: true,
|
||||
...(supportsEpg
|
||||
? {
|
||||
preferUploadedEpgOverXtream: false,
|
||||
@@ -204,6 +205,7 @@ export function createSettingsFromFormValue(
|
||||
embeddedMpvAutoReconnect: value.embeddedMpvAutoReconnect ?? true,
|
||||
portalConnectivityGuard: value.portalConnectivityGuard !== false,
|
||||
coverSize: value.coverSize ?? 'medium',
|
||||
showCoverTitles: value.showCoverTitles !== false,
|
||||
epgUrl,
|
||||
preferUploadedEpgOverXtream:
|
||||
value.preferUploadedEpgOverXtream ??
|
||||
|
||||
@@ -108,6 +108,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="cover-titles-label">
|
||||
{{ 'SETTINGS.COVER_TITLES' | translate }}
|
||||
</h4>
|
||||
<p id="cover-titles-description">
|
||||
{{ 'SETTINGS.COVER_TITLES_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showCoverTitles"
|
||||
aria-labelledby="cover-titles-label"
|
||||
aria-describedby="cover-titles-description"
|
||||
data-test-id="cover-titles-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
|
||||
|
||||
@@ -81,6 +81,29 @@ describe('SettingsComponent form', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('stages the posters-only cover wall until Save', async () => {
|
||||
const checkbox = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-test-id="cover-titles-toggle"] input'
|
||||
) as HTMLInputElement;
|
||||
expect(checkbox).not.toBeNull();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
checkbox.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.settingsForm.get('showCoverTitles')?.value).toBe(
|
||||
false
|
||||
);
|
||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
||||
|
||||
await component.form.save(() => undefined);
|
||||
|
||||
expect(settingsStore.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ showCoverTitles: false }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the portal pause setting when the desktop bridge is unavailable', () => {
|
||||
fixture.destroy();
|
||||
window.electron = undefined;
|
||||
|
||||
@@ -115,6 +115,7 @@ export const DEFAULT_SETTINGS = {
|
||||
embeddedMpvAutoReconnect: true,
|
||||
portalConnectivityGuard: true,
|
||||
coverSize: 'medium',
|
||||
showCoverTitles: true,
|
||||
dashboardRails: DEFAULT_DASHBOARD_RAILS,
|
||||
preferUploadedEpgOverXtream: false,
|
||||
epgViewMode: 'timeline',
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "صغير",
|
||||
"COVER_SIZE_MEDIUM": "متوسط",
|
||||
"COVER_SIZE_LARGE": "كبير",
|
||||
"COVER_TITLES": "عرض العناوين أسفل الأغلفة",
|
||||
"COVER_TITLES_DESCRIPTION": "عطّل هذا الخيار للحصول على جدار ملصقات فقط في شبكات الأفلام والمسلسلات: يظهر العنوان على الغلاف عند تمرير المؤشر فوقه أو التركيز عليه. تحتفظ القنوات المباشرة ونتائج البحث وأشرطة لوحة التحكم بعناوينها.",
|
||||
"EPG_VIEW_MODE": "عرض الدليل",
|
||||
"EPG_OFFSET_TITLE": "إزاحة وقت دليل البرامج",
|
||||
"EPG_OFFSET_HINT": "اضبط أوقات البرامج المعروضة دون تحديث الدليل. استخدم الدقائق من -720 إلى 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "صغير",
|
||||
"COVER_SIZE_MEDIUM": "متوسط",
|
||||
"COVER_SIZE_LARGE": "كبير",
|
||||
"COVER_TITLES": "وري العناوين تحت الأغلفة",
|
||||
"COVER_TITLES_DESCRIPTION": "طفي هاد الخيار باش تولي شبكات الأفلام والسلاسل حيط غير بالأغلفة: العنوان كيبان فوق الغلاف ملي تقرب بالماوس عليه ولا تفوكيسي عليه بالكيبورد. القنوات المباشرة، نتايج البحث وقضبان لوحة التحكم كيبقاو عناوينهم ظاهرين.",
|
||||
"EPG_VIEW_MODE": "عرض الدليل",
|
||||
"EPG_OFFSET_TITLE": "إزاحة وقت دليل البرامج",
|
||||
"EPG_OFFSET_HINT": "اضبط أوقات البرامج المعروضة دون تحديث الدليل. استخدم الدقائق من -720 إلى 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Малы",
|
||||
"COVER_SIZE_MEDIUM": "Сярэдні",
|
||||
"COVER_SIZE_LARGE": "Вялікі",
|
||||
"COVER_TITLES": "Паказаць назвы пад вокладкамі",
|
||||
"COVER_TITLES_DESCRIPTION": "Выключыце, каб атрымаць сцяну толькі з постараў у сетках фільмаў і серыялаў: назва з'яўляецца на вокладцы пры навядзенні курсора або пераводзе фокусу на яе. Жывыя каналы, вынікі пошуку і стужкі панэлі заўсёды паказваюць свае назвы.",
|
||||
"EPG_VIEW_MODE": "Выгляд праграмы перадач",
|
||||
"EPG_OFFSET_TITLE": "Зрушэнне часу праграмы",
|
||||
"EPG_OFFSET_HINT": "Змяняйце час паказу праграм без абнаўлення даведніка. Выкарыстоўвайце хвіліны ад -720 да 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Klein",
|
||||
"COVER_SIZE_MEDIUM": "Mittel",
|
||||
"COVER_SIZE_LARGE": "Groß",
|
||||
"COVER_TITLES": "Titel unter Covern anzeigen",
|
||||
"COVER_TITLES_DESCRIPTION": "Deaktivieren Sie diese Option für eine reine Poster-Wand in Film- und Serienrastern: Der Titel erscheint auf dem Cover, wenn Sie mit der Maus darauf zeigen oder es per Tastatur fokussieren. Live-Kanäle, Suchergebnisse und Dashboard-Reihen behalten ihre Beschriftungen.",
|
||||
"EPG_VIEW_MODE": "Programmführer-Ansicht",
|
||||
"EPG_OFFSET_TITLE": "EPG-Zeitverschiebung",
|
||||
"EPG_OFFSET_HINT": "Angezeigte Sendezeiten verschieben, ohne den Programmführer zu aktualisieren. Minuten von -720 bis 720 verwenden.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Μικρό",
|
||||
"COVER_SIZE_MEDIUM": "Μεσαίο",
|
||||
"COVER_SIZE_LARGE": "Μεγάλο",
|
||||
"COVER_TITLES": "Εμφάνιση τίτλων κάτω από τα εξώφυλλα",
|
||||
"COVER_TITLES_DESCRIPTION": "Απενεργοποιήστε το για έναν τοίχο μόνο με αφίσες στα πλέγματα ταινιών και σειρών: ο τίτλος εμφανίζεται πάνω στο εξώφυλλο όταν περάσετε το ποντίκι από πάνω του ή το εστιάσετε. Τα ζωντανά κανάλια, τα αποτελέσματα αναζήτησης και οι σειρές του πίνακα διατηρούν τις ετικέτες τους.",
|
||||
"EPG_VIEW_MODE": "Προβολή οδηγού",
|
||||
"EPG_OFFSET_TITLE": "Μετατόπιση ώρας EPG",
|
||||
"EPG_OFFSET_HINT": "Μετατοπίστε τις εμφανιζόμενες ώρες προγραμμάτων χωρίς ανανέωση του οδηγού. Χρησιμοποιήστε λεπτά από -720 έως 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Small",
|
||||
"COVER_SIZE_MEDIUM": "Medium",
|
||||
"COVER_SIZE_LARGE": "Large",
|
||||
"COVER_TITLES": "Show titles under covers",
|
||||
"COVER_TITLES_DESCRIPTION": "Turn off for a posters-only wall in movie and series grids: the title appears on the cover when you point at it or focus it. Live channels, search results and dashboard rails keep their labels.",
|
||||
"EPG_VIEW_MODE": "Guide view",
|
||||
"EPG_OFFSET_TITLE": "EPG time offset",
|
||||
"EPG_OFFSET_HINT": "Shift displayed programme times without refreshing the guide. Use minutes, from -720 to 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Pequeño",
|
||||
"COVER_SIZE_MEDIUM": "Mediano",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"COVER_TITLES": "Mostrar títulos debajo de las portadas",
|
||||
"COVER_TITLES_DESCRIPTION": "Desactívalo para lograr un muro de solo pósteres en las cuadrículas de películas y series: el título aparece sobre la portada al pasar el cursor por encima o al enfocarla. Los canales en vivo, los resultados de búsqueda y los carriles del panel mantienen sus etiquetas.",
|
||||
"EPG_VIEW_MODE": "Vista de la guía",
|
||||
"EPG_OFFSET_TITLE": "Desfase horario de la EPG",
|
||||
"EPG_OFFSET_HINT": "Desplaza las horas mostradas de los programas sin actualizar la guía. Usa minutos, de -720 a 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Petite",
|
||||
"COVER_SIZE_MEDIUM": "Moyenne",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"COVER_TITLES": "Afficher les titres sous les affiches",
|
||||
"COVER_TITLES_DESCRIPTION": "Désactivez cette option pour un mur d'affiches uniquement dans les grilles de films et de séries : le titre apparaît sur l'affiche lorsque vous la survolez ou lui donnez le focus. Les chaînes en direct, les résultats de recherche et les rangées du tableau de bord conservent leurs libellés.",
|
||||
"EPG_VIEW_MODE": "Vue du guide",
|
||||
"EPG_OFFSET_TITLE": "Décalage horaire de l’EPG",
|
||||
"EPG_OFFSET_HINT": "Décalez les horaires affichés des programmes sans actualiser le guide. Utilisez des minutes de -720 à 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Kicsi",
|
||||
"COVER_SIZE_MEDIUM": "Közepes",
|
||||
"COVER_SIZE_LARGE": "Nagy",
|
||||
"COVER_TITLES": "Borítók alatti címek megjelenítése",
|
||||
"COVER_TITLES_DESCRIPTION": "Kapcsolja ki, ha csak borítókból álló falat szeretne a film- és sorozatrácsokban: a cím a borítón jelenik meg, amikor rámutat az egérrel vagy arra fókuszál. Az élő csatornák, a keresési találatok és az irányítópulti sávok megtartják a feliratukat.",
|
||||
"EPG_VIEW_MODE": "Műsorújság nézete",
|
||||
"EPG_OFFSET_TITLE": "EPG-időeltolás",
|
||||
"EPG_OFFSET_HINT": "A műsorok megjelenített idejének eltolása a műsorújság frissítése nélkül. Percben adja meg, -720 és 720 között.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Piccola",
|
||||
"COVER_SIZE_MEDIUM": "Media",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"COVER_TITLES": "Mostra i titoli sotto le copertine",
|
||||
"COVER_TITLES_DESCRIPTION": "Disattiva per un muro di sole copertine nelle griglie di film e serie: il titolo appare sulla copertina quando ci passi sopra o la selezioni con la tastiera. I canali in diretta, i risultati di ricerca e le fasce della dashboard mantengono comunque le loro etichette.",
|
||||
"EPG_VIEW_MODE": "Vista guida",
|
||||
"EPG_OFFSET_TITLE": "Scostamento orario EPG",
|
||||
"EPG_OFFSET_HINT": "Sposta gli orari dei programmi visualizzati senza aggiornare la guida. Usa minuti da -720 a 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "小",
|
||||
"COVER_SIZE_MEDIUM": "中",
|
||||
"COVER_SIZE_LARGE": "大",
|
||||
"COVER_TITLES": "カバーの下にタイトルを表示",
|
||||
"COVER_TITLES_DESCRIPTION": "オフにすると、映画・シリーズのグリッドがポスターのみの表示になります。マウスを合わせるか、フォーカスするとカバーにタイトルが表示されます。ライブチャンネル、検索結果、ダッシュボードのレールでは常にラベルが表示されます。",
|
||||
"EPG_VIEW_MODE": "番組表の表示",
|
||||
"EPG_OFFSET_TITLE": "EPG 時刻の補正",
|
||||
"EPG_OFFSET_HINT": "番組表を更新せずに表示時刻をずらします。-720 から 720 までの分単位で指定してください。",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "작게",
|
||||
"COVER_SIZE_MEDIUM": "중간",
|
||||
"COVER_SIZE_LARGE": "크게",
|
||||
"COVER_TITLES": "커버 아래에 제목 표시",
|
||||
"COVER_TITLES_DESCRIPTION": "끄면 영화 및 시리즈 그리드가 포스터만 표시되는 화면이 됩니다. 마우스로 가리키거나 키보드로 포커스하면 커버 위에 제목이 나타납니다. 라이브 채널, 검색 결과, 대시보드 레일은 항상 레이블을 유지합니다.",
|
||||
"EPG_VIEW_MODE": "가이드 보기",
|
||||
"EPG_OFFSET_TITLE": "EPG 시간 오프셋",
|
||||
"EPG_OFFSET_HINT": "가이드를 새로 고치지 않고 표시되는 프로그램 시간을 조정합니다. -720에서 720 사이의 분 단위로 입력하세요.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Klein",
|
||||
"COVER_SIZE_MEDIUM": "Gemiddeld",
|
||||
"COVER_SIZE_LARGE": "Groot",
|
||||
"COVER_TITLES": "Titels onder hoezen tonen",
|
||||
"COVER_TITLES_DESCRIPTION": "Schakel dit uit voor een muur met alleen posters in rasters met films en reeksen: de titel verschijnt op de hoes wanneer u de muis erover beweegt of hem focust. Livekanalen, zoekresultaten en dashboardrails behouden hun labels.",
|
||||
"EPG_VIEW_MODE": "Gidsweergave",
|
||||
"EPG_OFFSET_TITLE": "EPG-tijdverschuiving",
|
||||
"EPG_OFFSET_HINT": "Verschuif weergegeven programmatijden zonder de gids te vernieuwen. Gebruik minuten van -720 tot 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Mały",
|
||||
"COVER_SIZE_MEDIUM": "Średni",
|
||||
"COVER_SIZE_LARGE": "Duży",
|
||||
"COVER_TITLES": "Pokaż tytuły pod okładkami",
|
||||
"COVER_TITLES_DESCRIPTION": "Wyłącz, aby uzyskać ścianę samych plakatów w siatkach filmów i seriali: tytuł pojawia się na okładce po najechaniu na nią kursorem lub przejściu do niej klawiaturą. Kanały na żywo, wyniki wyszukiwania i sekcje pulpitu nadal pokazują tytuły.",
|
||||
"EPG_VIEW_MODE": "Widok przewodnika",
|
||||
"EPG_OFFSET_TITLE": "Przesunięcie czasu EPG",
|
||||
"EPG_OFFSET_HINT": "Przesuń wyświetlane godziny programów bez odświeżania przewodnika. Użyj minut od -720 do 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Pequeno",
|
||||
"COVER_SIZE_MEDIUM": "Médio",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"COVER_TITLES": "Mostrar títulos sob as capas",
|
||||
"COVER_TITLES_DESCRIPTION": "Desative para um mural apenas de pôsteres nas grades de filmes e séries: o título aparece sobre a capa ao passar o cursor ou focar nela. Canais ao vivo, resultados de pesquisa e os trilhos do dashboard mantêm os seus rótulos.",
|
||||
"EPG_VIEW_MODE": "Visualização do guia",
|
||||
"EPG_OFFSET_TITLE": "Deslocamento de horário do EPG",
|
||||
"EPG_OFFSET_HINT": "Desloque os horários dos programas exibidos sem atualizar o guia. Use minutos de -720 a 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Маленький",
|
||||
"COVER_SIZE_MEDIUM": "Средний",
|
||||
"COVER_SIZE_LARGE": "Большой",
|
||||
"COVER_TITLES": "Показывать названия под обложками",
|
||||
"COVER_TITLES_DESCRIPTION": "Отключите, чтобы в сетках фильмов и сериалов отображались только обложки: название появляется на обложке при наведении курсора или получении фокуса. Live-каналы, результаты поиска и ленты панели по-прежнему показывают подписи.",
|
||||
"EPG_VIEW_MODE": "Вид программы передач",
|
||||
"EPG_OFFSET_TITLE": "Смещение времени телепрограммы",
|
||||
"EPG_OFFSET_HINT": "Смещайте время отображения программ без обновления телепрограммы. Укажите минуты от -720 до 720.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "Küçük",
|
||||
"COVER_SIZE_MEDIUM": "Orta",
|
||||
"COVER_SIZE_LARGE": "Büyük",
|
||||
"COVER_TITLES": "Kapakların altında başlıkları göster",
|
||||
"COVER_TITLES_DESCRIPTION": "Film ve dizi ızgaralarında yalnızca poster gösteren bir görünüm için kapatın: üzerine geldiğinizde veya odaklandığınızda başlık kapağın üzerinde görünür. Canlı kanallar, arama sonuçları ve pano bölümleri etiketlerini korur.",
|
||||
"EPG_VIEW_MODE": "Rehber görünümü",
|
||||
"EPG_OFFSET_TITLE": "EPG zaman kayması",
|
||||
"EPG_OFFSET_HINT": "Rehberi yenilemeden görüntülenen program saatlerini kaydırın. -720 ile 720 arasında dakika kullanın.",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "小",
|
||||
"COVER_SIZE_MEDIUM": "中",
|
||||
"COVER_SIZE_LARGE": "大",
|
||||
"COVER_TITLES": "在封面下方显示标题",
|
||||
"COVER_TITLES_DESCRIPTION": "关闭后,电影和剧集网格将呈现纯海报墙:将指针悬停在封面上或将其聚焦时才会显示标题。直播频道、搜索结果和仪表盘栏目将始终显示标签。",
|
||||
"EPG_VIEW_MODE": "节目指南视图",
|
||||
"EPG_OFFSET_TITLE": "EPG 时间偏移",
|
||||
"EPG_OFFSET_HINT": "无需刷新节目指南即可调整显示的节目时间。请输入 -720 到 720 之间的分钟数。",
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
"COVER_SIZE_SMALL": "小",
|
||||
"COVER_SIZE_MEDIUM": "中",
|
||||
"COVER_SIZE_LARGE": "大",
|
||||
"COVER_TITLES": "在封面下方顯示標題",
|
||||
"COVER_TITLES_DESCRIPTION": "停用後,電影和影集網格會變成純海報牆:滑鼠指向或聚焦封面時才會顯示標題。直播頻道、搜尋結果和儀表板欄位仍會保留標籤。",
|
||||
"EPG_VIEW_MODE": "節目表檢視方式",
|
||||
"EPG_OFFSET_TITLE": "EPG 時間偏移",
|
||||
"EPG_OFFSET_HINT": "無需重新整理節目表即可調整顯示的節目時間。請輸入 -720 到 720 之間的分鐘數。",
|
||||
|
||||
@@ -234,6 +234,62 @@ minimum dimensions and flexible columns over fixed row widths.
|
||||
- Do not render placeholder and real logo at the same time
|
||||
- Keep logos contained with `object-fit: contain`
|
||||
|
||||
## Cover Grids
|
||||
|
||||
Movie and series covers render in three surfaces: the catalog grid
|
||||
(`app-grid-list`, `libs/portal/shared/ui/.../grid-list/`), the favorites /
|
||||
recent card (`app-content-card`, same lib) and the dashboard rails. All of
|
||||
them size from the `--cover-grid-min-width` / `--cover-rail-width` /
|
||||
`--cover-gap` tokens that `Settings.coverSize` writes onto `<html>` as
|
||||
`data-cover-size` (`apps/web/src/_cover-size.scss`).
|
||||
|
||||
### Posters-only wall
|
||||
|
||||
`Settings.showCoverTitles` (Settings > General, default on, only an explicit
|
||||
`false` opts out — coerced like `webPlayerSharedControls`) removes the title
|
||||
row under VOD and series covers so the grid shows more rows per screen.
|
||||
|
||||
- **Resolution.** `CoverTitlesService.postersOnly` (`libs/portal/shared/ui`)
|
||||
is the single source: the opt-out AND a hover-capable pointer
|
||||
(`(any-hover: hover)` media query, tracked live). On touch-only devices the
|
||||
preference is ignored and titles stay under the covers, because a tap
|
||||
already opens the item and there is no gesture left to peek at a hidden
|
||||
name.
|
||||
- **Scope.** Catalog grids (Xtream/Stalker VOD and series), unified
|
||||
favorites/recent grids and the portal favorites tab. Exempt, regardless of
|
||||
the setting: live channel grids (`type` `live`/`itv`/`radio` or the
|
||||
`logo` variant — logos are too often missing to identify a channel),
|
||||
search results and "recently added" rails (they answer by name; hosts
|
||||
pass `[allowPostersOnly]="false"` to `app-content-card`; `app-grid-list`
|
||||
and `app-unified-grid-tab` drop the wall themselves while their
|
||||
`searchTerm` input is non-blank, i.e. an in-section search is filtering
|
||||
the list), and
|
||||
the dashboard rails (their meta rows do not fit an overlay).
|
||||
- **Reveal.** The title is a `.cover-title-overlay` inside the poster
|
||||
wrapper: bottom gradient scrim, two clamped lines, 150 ms ease-out
|
||||
opacity, shown on `:hover` and `:focus-visible` of the card, none under
|
||||
`prefers-reduced-motion`. It is `aria-hidden`; the card itself carries the
|
||||
accessible name.
|
||||
- **Pinned caption.** When the item has no cover to identify it — no
|
||||
poster URL, or the image failed and the default poster / placeholder is
|
||||
showing — the overlay is pinned open (`--pinned`). Both components track
|
||||
failed URLs so the fallback branch re-renders instead of swapping `src`
|
||||
in place.
|
||||
- **Layout hints.** The grid's `contain-intrinsic-size` drops from 270 px to
|
||||
222 px (bare 2/3 poster) under `.grid-list--posters-only`, and the skeleton
|
||||
hides its text lines so loading matches the cards it precedes.
|
||||
- **Keyboard.** Both cards expose a `role="button"`, `tabindex="0"` surface
|
||||
labelled by the title, activated by Enter and Space (Space prevents the
|
||||
page scroll) and carrying a `:focus-visible` ring (`card-focus-ring`
|
||||
mixin in `libs/ui/styles/_content-grid.scss`). On `app-content-card` that
|
||||
surface is the inner `.content-card__activation` element, and the Remove
|
||||
control (labelled by `removeTooltip`) is a SIBLING positioned over the
|
||||
poster corner — an interactive control nested inside a `role="button"`
|
||||
is an invalid accessibility structure. Its ring is drawn on the OUTER
|
||||
`.content-card` via `:has(> .content-card__activation:focus-visible)`,
|
||||
because the card's `overflow: hidden` would clip an outline on the inner
|
||||
surface on every edge. Poster `alt` is the title, not a literal.
|
||||
|
||||
## EPG Views
|
||||
|
||||
The shared timeline and list still contain local dark surfaces, blue selection
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './lib/components/category-view/category-view.component';
|
||||
export * from './lib/directives/infinite-scroll.directive';
|
||||
export * from './lib/components/content-card/content-card.component';
|
||||
export * from './lib/cover-titles/cover-titles.service';
|
||||
export * from './lib/components/content-rail-shell/content-rail-shell.component';
|
||||
export * from './lib/components/favorites-layout/favorites-layout.component';
|
||||
export * from './lib/components/global-favorites-list/global-favorites-list.component';
|
||||
|
||||
@@ -1,52 +1,84 @@
|
||||
<div class="content-card" (click)="onCardClick()">
|
||||
<div class="thumbnail-wrapper">
|
||||
@if (showRemoveButton()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="remove-button"
|
||||
[matTooltip]="removeTooltip()"
|
||||
(click)="onRemoveClick($event)"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<div class="content-card">
|
||||
<!-- The activation surface is its own element so the Remove control
|
||||
below stays a sibling: an interactive control nested inside a
|
||||
role="button" is an invalid accessibility structure. -->
|
||||
<div
|
||||
class="content-card__activation"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="title()"
|
||||
(click)="onCardClick()"
|
||||
(keydown.enter)="onCardKey($event)"
|
||||
(keydown.space)="onCardKey($event)"
|
||||
>
|
||||
<div class="thumbnail-wrapper">
|
||||
@if (posterUrl() && !artworkFailed()) {
|
||||
<img
|
||||
[src]="posterUrl()"
|
||||
[alt]="title()"
|
||||
(error)="onImageError()"
|
||||
class="poster"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
} @else if (showPlaceholder()) {
|
||||
<div class="poster-placeholder">
|
||||
<mat-icon>{{ getPlaceholderIcon() }}</mat-icon>
|
||||
</div>
|
||||
} @else {
|
||||
<img
|
||||
src="./assets/images/default-poster.png"
|
||||
[alt]="title()"
|
||||
class="poster"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (posterUrl()) {
|
||||
<img
|
||||
[src]="posterUrl()"
|
||||
[alt]="title()"
|
||||
(error)="onImageError($event)"
|
||||
class="poster"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
} @else if (showPlaceholder()) {
|
||||
<div class="poster-placeholder">
|
||||
<mat-icon>{{ getPlaceholderIcon() }}</mat-icon>
|
||||
@if (postersOnly()) {
|
||||
<!-- Hover/focus caption for the posters-only wall; pinned open
|
||||
when the cover cannot identify the item on its own. -->
|
||||
<div
|
||||
class="cover-title-overlay"
|
||||
[class.cover-title-overlay--pinned]="artworkMissing()"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="cover-title-overlay__text">{{ title() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (type() && showTypeBadge()) {
|
||||
<span class="type-badge" [class]="type()">
|
||||
{{ type() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!postersOnly()) {
|
||||
<div class="card-info">
|
||||
<h3>{{ title() }}</h3>
|
||||
@if (date()) {
|
||||
<p class="date-info">
|
||||
{{
|
||||
date()
|
||||
| date: 'd MMM, HH:mm' : undefined : currentLocale()
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<img
|
||||
src="./assets/images/default-poster.png"
|
||||
[alt]="title()"
|
||||
class="poster"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (type() && showTypeBadge()) {
|
||||
<span class="type-badge" [class]="type()">
|
||||
{{ type() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card-info">
|
||||
<h3>{{ title() }}</h3>
|
||||
@if (date()) {
|
||||
<p class="date-info">
|
||||
{{ date() | date: 'd MMM, HH:mm' : undefined : currentLocale() }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@if (showRemoveButton()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
[matTooltip]="removeTooltip()"
|
||||
[attr.aria-label]="removeTooltip()"
|
||||
(click)="onRemoveClick($event)"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -2,12 +2,30 @@
|
||||
|
||||
.content-card {
|
||||
@include grid.content-card;
|
||||
position: relative;
|
||||
|
||||
// Keyboard/pointer activation surface; the Remove button is a sibling
|
||||
// absolutely positioned over the poster's corner. The focus ring is
|
||||
// drawn on this outer card: `overflow: hidden` here would clip an
|
||||
// outline on the inner surface on every edge.
|
||||
.content-card__activation {
|
||||
border-radius: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:has(> .content-card__activation:focus-visible) {
|
||||
@include grid.focus-ring-declarations;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
@include grid.remove-button;
|
||||
}
|
||||
|
||||
.thumbnail-wrapper {
|
||||
@include grid.thumbnail-wrapper;
|
||||
|
||||
.remove-button {
|
||||
@include grid.remove-button;
|
||||
.cover-title-overlay {
|
||||
@include grid.cover-title-overlay;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
@@ -19,3 +37,8 @@
|
||||
@include grid.card-info;
|
||||
}
|
||||
}
|
||||
|
||||
.content-card:hover .cover-title-overlay,
|
||||
.content-card__activation:focus-visible .cover-title-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { ContentCardComponent } from './content-card.component';
|
||||
|
||||
describe('ContentCardComponent', () => {
|
||||
let fixture: ComponentFixture<ContentCardComponent>;
|
||||
let showCoverTitles: ReturnType<typeof signal<boolean>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
showCoverTitles = signal(false);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContentCardComponent],
|
||||
providers: [
|
||||
{ provide: SettingsStore, useValue: { showCoverTitles } },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
onLangChange: of(),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ContentCardComponent);
|
||||
fixture.componentRef.setInput('title', 'Blade Runner');
|
||||
fixture.componentRef.setInput('posterUrl', 'blade-runner.jpg');
|
||||
fixture.componentRef.setInput('type', 'movie');
|
||||
});
|
||||
|
||||
const overlay = () =>
|
||||
fixture.debugElement.query(By.css('.cover-title-overlay'));
|
||||
const info = () => fixture.debugElement.query(By.css('.card-info'));
|
||||
|
||||
it('joins the posters-only wall by default: overlay instead of the info row', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.classList).toContain(
|
||||
'content-card--posters-only'
|
||||
);
|
||||
expect(info()).toBeNull();
|
||||
expect(overlay().nativeElement.textContent.trim()).toBe(
|
||||
'Blade Runner'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the info row when the host opts the card out (search results)', () => {
|
||||
fixture.componentRef.setInput('allowPostersOnly', false);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(info().nativeElement.textContent).toContain('Blade Runner');
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['live', 'radio'])(
|
||||
'keeps the info row for %s cards whose logos rarely identify them',
|
||||
(type) => {
|
||||
fixture.componentRef.setInput('type', type);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(info()).not.toBeNull();
|
||||
expect(overlay()).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('pins the overlay open for a missing or broken cover', () => {
|
||||
fixture.componentRef.setInput('posterUrl', undefined);
|
||||
fixture.componentRef.setInput('showPlaceholder', false);
|
||||
fixture.detectChanges();
|
||||
expect(overlay().nativeElement.classList).toContain(
|
||||
'cover-title-overlay--pinned'
|
||||
);
|
||||
|
||||
fixture.componentRef.setInput('posterUrl', 'broken.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(overlay().nativeElement.classList).not.toContain(
|
||||
'cover-title-overlay--pinned'
|
||||
);
|
||||
|
||||
fixture.debugElement
|
||||
.query(By.css('.poster'))
|
||||
.nativeElement.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement
|
||||
.query(By.css('.poster'))
|
||||
.nativeElement.getAttribute('src')
|
||||
).toContain('default-poster.png');
|
||||
expect(overlay().nativeElement.classList).toContain(
|
||||
'cover-title-overlay--pinned'
|
||||
);
|
||||
});
|
||||
|
||||
it('gives a new poster URL a fresh chance after a failed one', () => {
|
||||
fixture.componentRef.setInput('posterUrl', 'broken.jpg');
|
||||
fixture.detectChanges();
|
||||
fixture.debugElement
|
||||
.query(By.css('.poster'))
|
||||
.nativeElement.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentRef.setInput('posterUrl', 'fixed.jpg');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement
|
||||
.query(By.css('.poster'))
|
||||
.nativeElement.getAttribute('src')
|
||||
).toBe('fixed.jpg');
|
||||
});
|
||||
|
||||
it('renders the info row while titles are enabled', () => {
|
||||
showCoverTitles.set(true);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.classList).not.toContain(
|
||||
'content-card--posters-only'
|
||||
);
|
||||
expect(info().nativeElement.textContent).toContain('Blade Runner');
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
|
||||
it('is a keyboard-activatable button named after the item', () => {
|
||||
fixture.detectChanges();
|
||||
const clicked = jest.fn();
|
||||
fixture.componentInstance.cardClick.subscribe(clicked);
|
||||
const card = fixture.debugElement.query(
|
||||
By.css('.content-card__activation')
|
||||
);
|
||||
|
||||
expect(card.nativeElement.getAttribute('role')).toBe('button');
|
||||
expect(card.nativeElement.getAttribute('tabindex')).toBe('0');
|
||||
expect(card.nativeElement.getAttribute('aria-label')).toBe(
|
||||
'Blade Runner'
|
||||
);
|
||||
|
||||
const cardElement = card.nativeElement as HTMLElement;
|
||||
cardElement.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
||||
);
|
||||
const space = new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
cardElement.dispatchEvent(space);
|
||||
|
||||
expect(clicked).toHaveBeenCalledTimes(2);
|
||||
expect(space.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the Remove control outside the card button so its keys never open the item', () => {
|
||||
fixture.componentRef.setInput('showRemoveButton', true);
|
||||
fixture.componentRef.setInput('removeTooltip', 'Remove from favorites');
|
||||
fixture.detectChanges();
|
||||
const clicked = jest.fn();
|
||||
const removed = jest.fn();
|
||||
fixture.componentInstance.cardClick.subscribe(clicked);
|
||||
fixture.componentInstance.remove.subscribe(removed);
|
||||
const removeButton = fixture.debugElement.query(
|
||||
By.css('.remove-button')
|
||||
).nativeElement as HTMLElement;
|
||||
|
||||
// No interactive control nested inside a role="button".
|
||||
expect(removeButton.closest('[role="button"]')).toBeNull();
|
||||
expect(removeButton.getAttribute('aria-label')).toBe(
|
||||
'Remove from favorites'
|
||||
);
|
||||
|
||||
removeButton.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
||||
);
|
||||
const space = new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
removeButton.dispatchEvent(space);
|
||||
removeButton.click();
|
||||
|
||||
// The button's own activation removes the item; the card must
|
||||
// neither open it nor swallow the Space the button relies on.
|
||||
expect(clicked).not.toHaveBeenCalled();
|
||||
expect(space.defaultPrevented).toBe(false);
|
||||
expect(removed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
@@ -14,6 +15,10 @@ import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import { CoverTitlesService } from '../../cover-titles/cover-titles.service';
|
||||
|
||||
/** Channel-like cards always keep their label: logos rarely identify them. */
|
||||
const LABELLED_CONTENT_TYPES: ReadonlySet<string> = new Set(['live', 'radio']);
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-card',
|
||||
@@ -21,10 +26,12 @@ import { startWith } from 'rxjs';
|
||||
imports: [DatePipe, MatIcon, MatIconButton, MatTooltip],
|
||||
templateUrl: './content-card.component.html',
|
||||
styleUrl: './content-card.component.scss',
|
||||
host: { '[class.content-card--posters-only]': 'postersOnly()' },
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ContentCardComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly coverTitles = inject(CoverTitlesService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
@@ -54,11 +61,33 @@ export class ContentCardComponent {
|
||||
/** Whether to render the type badge (live/movie/series) on the poster */
|
||||
readonly showTypeBadge = input<boolean>(true);
|
||||
|
||||
/**
|
||||
* Whether this card may join the posters-only wall
|
||||
* (`Settings.showCoverTitles === false`). Hosts whose grids answer by
|
||||
* name — search results, "recently added" rails — pass false so their
|
||||
* labels stay put regardless of the preference.
|
||||
*/
|
||||
readonly allowPostersOnly = input<boolean>(true);
|
||||
|
||||
/** Emitted when the card is clicked */
|
||||
readonly cardClick = output<void>();
|
||||
|
||||
/** Emitted when the remove button is clicked */
|
||||
readonly remove = output<void>();
|
||||
/** A failed poster URL falls back per card; a new URL gets a fresh try. */
|
||||
protected readonly artworkFailed = linkedSignal({
|
||||
source: this.posterUrl,
|
||||
computation: () => false,
|
||||
});
|
||||
protected readonly artworkMissing = computed(
|
||||
() => !this.posterUrl() || this.artworkFailed()
|
||||
);
|
||||
protected readonly postersOnly = computed(
|
||||
() =>
|
||||
this.allowPostersOnly() &&
|
||||
this.coverTitles.postersOnly() &&
|
||||
!LABELLED_CONTENT_TYPES.has(this.type() ?? '')
|
||||
);
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
@@ -84,13 +113,22 @@ export class ContentCardComponent {
|
||||
this.cardClick.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter/Space activate the card like a click; Space also prevents the
|
||||
* page scroll. The Remove control is a sibling of the activation
|
||||
* surface, never a descendant, so its keys cannot reach this handler.
|
||||
*/
|
||||
onCardKey(event: Event): void {
|
||||
event.preventDefault();
|
||||
this.cardClick.emit();
|
||||
}
|
||||
|
||||
onRemoveClick(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.remove.emit();
|
||||
}
|
||||
|
||||
onImageError(event: Event): void {
|
||||
(event.target as HTMLImageElement).src =
|
||||
'./assets/images/default-poster.png';
|
||||
onImageError(): void {
|
||||
this.artworkFailed.set(true);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@use '../../styles/skeleton';
|
||||
@use '../../styles/content-grid' as grid;
|
||||
|
||||
// ─── Card base ───────────────────────────────────────────────────────────────
|
||||
mat-card {
|
||||
@@ -15,6 +16,10 @@ mat-card:hover {
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
mat-card {
|
||||
@include grid.card-focus-ring;
|
||||
}
|
||||
|
||||
// ─── Grid layout ─────────────────────────────────────────────────────────────
|
||||
.grid-list__grid {
|
||||
display: grid;
|
||||
@@ -90,6 +95,15 @@ mat-card:hover {
|
||||
}
|
||||
}
|
||||
|
||||
.cover-title-overlay {
|
||||
@include grid.cover-title-overlay;
|
||||
}
|
||||
|
||||
&:hover .cover-title-overlay,
|
||||
&:focus-visible .cover-title-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 8px;
|
||||
padding: 0 2px;
|
||||
@@ -211,6 +225,19 @@ mat-card:hover {
|
||||
contain-intrinsic-size: auto 270px;
|
||||
}
|
||||
|
||||
// ─── Posters-only wall ───────────────────────────────────────────────────────
|
||||
// No title row: the hint shrinks to the bare 2/3 poster, and the skeleton
|
||||
// drops its text lines so the loading state matches the cards it precedes.
|
||||
:host.grid-list--posters-only {
|
||||
.grid-list__grid mat-card {
|
||||
contain-intrinsic-size: auto 222px;
|
||||
}
|
||||
|
||||
.grid-skeleton-title {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-list__tail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -311,3 +311,218 @@ describe('GridListComponent with strip country prefix enabled', () => {
|
||||
expect(renderedTitle()).toBe('US | Some Movie');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GridListComponent posters-only wall', () => {
|
||||
let fixture: ComponentFixture<GridListComponent>;
|
||||
let showCoverTitles: ReturnType<typeof signal<boolean>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
showCoverTitles = signal(false);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [GridListComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { showCoverTitles },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(GridListComponent, {
|
||||
remove: { imports: [TranslatePipe] },
|
||||
add: {
|
||||
imports: [
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(GridListComponent);
|
||||
});
|
||||
|
||||
const overlay = () =>
|
||||
fixture.debugElement.query(By.css('.cover-title-overlay'));
|
||||
|
||||
it('replaces the title row with a hover overlay on VOD covers', () => {
|
||||
fixture.componentRef.setInput('items', [
|
||||
{ title: 'Blade Runner', poster_url: 'blade-runner.jpg' },
|
||||
]);
|
||||
fixture.componentRef.setInput('type', 'vod');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.classList).toContain(
|
||||
'grid-list--posters-only'
|
||||
);
|
||||
expect(fixture.debugElement.query(By.css('.title'))).toBeNull();
|
||||
expect(overlay().nativeElement.textContent.trim()).toBe(
|
||||
'Blade Runner'
|
||||
);
|
||||
expect(overlay().nativeElement.classList).not.toContain(
|
||||
'cover-title-overlay--pinned'
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the overlay open when the item has no cover to identify it', () => {
|
||||
fixture.componentRef.setInput('items', [{ title: 'No Poster Film' }]);
|
||||
fixture.componentRef.setInput('type', 'vod');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(overlay().nativeElement.classList).toContain(
|
||||
'cover-title-overlay--pinned'
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the overlay once the cover fails to load', () => {
|
||||
fixture.componentRef.setInput('items', [
|
||||
{ title: 'Broken Poster Film', poster_url: 'broken.jpg' },
|
||||
]);
|
||||
fixture.componentRef.setInput('type', 'vod');
|
||||
fixture.detectChanges();
|
||||
expect(overlay().nativeElement.classList).not.toContain(
|
||||
'cover-title-overlay--pinned'
|
||||
);
|
||||
|
||||
fixture.debugElement
|
||||
.query(By.css('.stream-icon'))
|
||||
.nativeElement.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
|
||||
// The template re-renders the default poster, and the overlay
|
||||
// stays open because that poster identifies nothing.
|
||||
expect(
|
||||
fixture.debugElement
|
||||
.query(By.css('.stream-icon'))
|
||||
.nativeElement.getAttribute('src')
|
||||
).toContain('default-poster.png');
|
||||
expect(overlay().nativeElement.classList).toContain(
|
||||
'cover-title-overlay--pinned'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the title row on live channel grids regardless of the setting', () => {
|
||||
fixture.componentRef.setInput('items', [
|
||||
{ name: 'CNN', stream_icon: 'cnn.png' },
|
||||
]);
|
||||
fixture.componentRef.setInput('type', 'live');
|
||||
fixture.componentRef.setInput('variant', 'logo');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.classList).not.toContain(
|
||||
'grid-list--posters-only'
|
||||
);
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.title')).nativeElement
|
||||
.textContent
|
||||
).toContain('CNN');
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the title row while an in-section search filters the grid', () => {
|
||||
fixture.componentRef.setInput('items', [
|
||||
{ title: 'Blade Runner', poster_url: 'blade-runner.jpg' },
|
||||
]);
|
||||
fixture.componentRef.setInput('type', 'vod');
|
||||
fixture.componentRef.setInput('searchTerm', 'blade');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.classList).not.toContain(
|
||||
'grid-list--posters-only'
|
||||
);
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.title')).nativeElement
|
||||
.textContent
|
||||
).toContain('Blade Runner');
|
||||
expect(overlay()).toBeNull();
|
||||
|
||||
fixture.componentRef.setInput('searchTerm', ' ');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('.title'))).toBeNull();
|
||||
expect(overlay()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('restores the title row as soon as the setting is switched back on', () => {
|
||||
fixture.componentRef.setInput('items', [
|
||||
{ title: 'Blade Runner', poster_url: 'blade-runner.jpg' },
|
||||
]);
|
||||
fixture.componentRef.setInput('type', 'vod');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.debugElement.query(By.css('.title'))).toBeNull();
|
||||
|
||||
showCoverTitles.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.title')).nativeElement
|
||||
.textContent
|
||||
).toContain('Blade Runner');
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GridListComponent keyboard access', () => {
|
||||
let fixture: ComponentFixture<GridListComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [GridListComponent],
|
||||
providers: [{ provide: SettingsStore, useValue: {} }],
|
||||
})
|
||||
.overrideComponent(GridListComponent, {
|
||||
remove: { imports: [TranslatePipe] },
|
||||
add: {
|
||||
imports: [
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(GridListComponent);
|
||||
fixture.componentRef.setInput('items', [
|
||||
{ title: 'Blade Runner', poster_url: 'blade-runner.jpg' },
|
||||
]);
|
||||
fixture.componentRef.setInput('type', 'vod');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('exposes each card as a focusable button named after the item', () => {
|
||||
const card = fixture.debugElement.query(By.css('mat-card'))
|
||||
.nativeElement as HTMLElement;
|
||||
|
||||
expect(card.getAttribute('role')).toBe('button');
|
||||
expect(card.getAttribute('tabindex')).toBe('0');
|
||||
expect(card.getAttribute('aria-label')).toBe('Blade Runner');
|
||||
expect(
|
||||
fixture.debugElement
|
||||
.query(By.css('.stream-icon'))
|
||||
.nativeElement.getAttribute('alt')
|
||||
).toBe('Blade Runner');
|
||||
});
|
||||
|
||||
it('activates a card with Enter and Space without scrolling the grid', () => {
|
||||
const clicked = jest.fn();
|
||||
fixture.componentInstance.itemClicked.subscribe(clicked);
|
||||
const card = fixture.debugElement.query(By.css('mat-card'));
|
||||
|
||||
card.triggerEventHandler('keydown.enter', new KeyboardEvent('keydown'));
|
||||
const space = new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
cancelable: true,
|
||||
});
|
||||
card.triggerEventHandler('keydown.space', space);
|
||||
|
||||
expect(clicked).toHaveBeenCalledTimes(2);
|
||||
expect(space.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ProgressCapsuleComponent,
|
||||
WatchedBadgeComponent,
|
||||
} from '@iptvnator/ui/components';
|
||||
import { CoverTitlesService } from '../../cover-titles/cover-titles.service';
|
||||
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
|
||||
|
||||
interface GridListItem {
|
||||
@@ -107,19 +108,26 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
|
||||
} @else {
|
||||
@for (item of items(); track $index) {
|
||||
@let i = $any(item);
|
||||
@let title = channelTitle(i);
|
||||
<mat-card
|
||||
[class.grid-card--logo]="variant() === 'logo'"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="title"
|
||||
(click)="itemClicked.emit(item)"
|
||||
(keydown.enter)="itemClicked.emit(item)"
|
||||
(keydown.space)="onSpaceKey($event, item)"
|
||||
>
|
||||
@let poster = resolvePoster(i);
|
||||
@let artworkMissing = !poster || hasArtworkFailed(poster);
|
||||
<div class="card-thumbnail-container">
|
||||
@if (poster && !hasArtworkFailed(poster)) {
|
||||
@if (!artworkMissing) {
|
||||
<img
|
||||
class="stream-icon"
|
||||
[src]="poster"
|
||||
(error)="onImageError($event, poster)"
|
||||
loading="lazy"
|
||||
alt="logo"
|
||||
[alt]="title"
|
||||
/>
|
||||
} @else if (
|
||||
shouldRenderArtworkPlaceholder(poster)
|
||||
@@ -137,9 +145,25 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
|
||||
class="stream-icon"
|
||||
src="./assets/images/default-poster.png"
|
||||
loading="lazy"
|
||||
alt="logo"
|
||||
[alt]="title"
|
||||
/>
|
||||
}
|
||||
@if (postersOnly()) {
|
||||
<!-- Hover/focus caption for the posters-only
|
||||
wall; pinned open when the cover cannot
|
||||
identify the item on its own. -->
|
||||
<div
|
||||
class="cover-title-overlay"
|
||||
[class.cover-title-overlay--pinned]="
|
||||
artworkMissing
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="cover-title-overlay__text">{{
|
||||
title
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
@if (i.progress && i.progress > 0) {
|
||||
<app-progress-capsule [progress]="i.progress" />
|
||||
}
|
||||
@@ -182,11 +206,11 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
|
||||
<mat-icon>star</mat-icon>{{ rating }}
|
||||
</div>
|
||||
}
|
||||
<mat-card-actions>
|
||||
<div class="title">
|
||||
{{ channelTitle(i) }}
|
||||
</div>
|
||||
</mat-card-actions>
|
||||
@if (!postersOnly()) {
|
||||
<mat-card-actions>
|
||||
<div class="title">{{ title }}</div>
|
||||
</mat-card-actions>
|
||||
}
|
||||
</mat-card>
|
||||
} @empty {
|
||||
<div class="grid-empty-state">
|
||||
@@ -250,11 +274,13 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
|
||||
ProgressCapsuleComponent,
|
||||
WatchedBadgeComponent,
|
||||
],
|
||||
host: { '[class.grid-list--posters-only]': 'postersOnly()' },
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GridListComponent {
|
||||
private readonly failedArtworkUrls = signal<ReadonlySet<string>>(new Set());
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly coverTitles = inject(CoverTitlesService);
|
||||
|
||||
readonly items = input<GridListItem[]>([]);
|
||||
readonly isLoading = input<boolean>(false);
|
||||
@@ -282,6 +308,20 @@ export class GridListComponent {
|
||||
private readonly isLiveGrid = computed(() =>
|
||||
['live', 'itv', 'radio'].includes(this.type())
|
||||
);
|
||||
/**
|
||||
* Posters-only wall (`Settings.showCoverTitles === false`) applies to
|
||||
* VOD/series covers only: channel logos are too often missing or
|
||||
* generic to identify a channel without its name. A grid filtered by
|
||||
* an in-section search keeps its titles too — those results are
|
||||
* identified by the name the user just typed.
|
||||
*/
|
||||
protected readonly postersOnly = computed(
|
||||
() =>
|
||||
this.coverTitles.postersOnly() &&
|
||||
!this.isLiveGrid() &&
|
||||
this.variant() !== 'logo' &&
|
||||
!this.hasActiveSearch()
|
||||
);
|
||||
protected readonly catchupDays = getXtreamCatchupDays;
|
||||
/** Catch-up badge is live-grid only; VOD/series rows never carry it. */
|
||||
protected showCatchupBadge(item: GridListItem): boolean {
|
||||
@@ -329,23 +369,28 @@ export class GridListComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Space activates like a click but must not scroll the grid. */
|
||||
protected onSpaceKey(event: Event, item: GridListItem): void {
|
||||
event.preventDefault();
|
||||
this.itemClicked.emit(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed poster is remembered per URL so the template re-renders the
|
||||
* fallback branch (placeholder or default poster) — and the posters-only
|
||||
* overlay can pin the title, since a default poster identifies nothing.
|
||||
*/
|
||||
protected onImageError(event: Event, poster: string): void {
|
||||
if (this.usesArtworkPlaceholder()) {
|
||||
this.failedArtworkUrls.update((failedUrls) => {
|
||||
const nextFailedUrls = new Set(failedUrls);
|
||||
nextFailedUrls.add(poster);
|
||||
this.failedArtworkUrls.update((failedUrls) => {
|
||||
const nextFailedUrls = new Set(failedUrls);
|
||||
nextFailedUrls.add(poster);
|
||||
|
||||
return nextFailedUrls;
|
||||
});
|
||||
(event.target as HTMLImageElement | null)?.style.setProperty(
|
||||
'display',
|
||||
'none'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
(event.target as HTMLImageElement).src =
|
||||
'./assets/images/default-poster.png';
|
||||
return nextFailedUrls;
|
||||
});
|
||||
(event.target as HTMLImageElement | null)?.style.setProperty(
|
||||
'display',
|
||||
'none'
|
||||
);
|
||||
}
|
||||
|
||||
private usesArtworkPlaceholder(): boolean {
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
<div class="grid-container">
|
||||
@for (item of filteredItems(); track trackByUid($index, item)) {
|
||||
<app-content-card
|
||||
[allowPostersOnly]="!hasActiveSearch()"
|
||||
[posterUrl]="item.posterUrl ?? ''"
|
||||
[title]="item.name"
|
||||
[type]="item.contentType"
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { of } from 'rxjs';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
|
||||
|
||||
const ITEMS: UnifiedCollectionItem[] = [
|
||||
{
|
||||
uid: 'x:1:movie:1',
|
||||
name: 'Blade Runner',
|
||||
contentType: 'movie',
|
||||
posterUrl: 'blade-runner.jpg',
|
||||
} as UnifiedCollectionItem,
|
||||
{
|
||||
uid: 'x:1:movie:2',
|
||||
name: 'Alien',
|
||||
contentType: 'movie',
|
||||
posterUrl: 'alien.jpg',
|
||||
} as UnifiedCollectionItem,
|
||||
];
|
||||
|
||||
describe('UnifiedGridTabComponent posters-only wall', () => {
|
||||
let fixture: ComponentFixture<UnifiedGridTabComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UnifiedGridTabComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { showCoverTitles: signal(false) },
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
onLangChange: of(),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedGridTabComponent, {
|
||||
remove: { imports: [TranslatePipe] },
|
||||
add: {
|
||||
imports: [
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UnifiedGridTabComponent);
|
||||
fixture.componentRef.setInput('items', ITEMS);
|
||||
});
|
||||
|
||||
const cardInfos = () => fixture.debugElement.queryAll(By.css('.card-info'));
|
||||
const overlays = () =>
|
||||
fixture.debugElement.queryAll(By.css('.cover-title-overlay'));
|
||||
|
||||
it('hides titles behind the overlay while nothing is being searched', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(cardInfos()).toHaveLength(0);
|
||||
expect(overlays()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps titles visible while a search term filters the collection', () => {
|
||||
fixture.componentRef.setInput('searchTerm', 'blade');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(cardInfos()).toHaveLength(1);
|
||||
expect(cardInfos()[0].nativeElement.textContent).toContain(
|
||||
'Blade Runner'
|
||||
);
|
||||
expect(overlays()).toHaveLength(0);
|
||||
|
||||
fixture.componentRef.setInput('searchTerm', ' ');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(cardInfos()).toHaveLength(0);
|
||||
expect(overlays()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+12
-1
@@ -29,8 +29,19 @@ export class UnifiedGridTabComponent {
|
||||
readonly removeItem = output<UnifiedCollectionItem>();
|
||||
readonly itemSelected = output<UnifiedCollectionItem>();
|
||||
|
||||
private readonly normalizedSearchTerm = computed(() =>
|
||||
this.searchTerm().trim().toLowerCase()
|
||||
);
|
||||
/**
|
||||
* Search results are identified by the name the user typed, so a
|
||||
* filtered grid keeps its titles even under the posters-only wall.
|
||||
*/
|
||||
readonly hasActiveSearch = computed(
|
||||
() => this.normalizedSearchTerm().length > 0
|
||||
);
|
||||
|
||||
readonly filteredItems = computed(() => {
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
const term = this.normalizedSearchTerm();
|
||||
const all = this.items();
|
||||
return term
|
||||
? all.filter((i) => i.name.toLowerCase().includes(term))
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Injector, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { CoverTitlesService } from './cover-titles.service';
|
||||
|
||||
type ChangeListener = (event: { matches: boolean }) => void;
|
||||
|
||||
describe('CoverTitlesService', () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
let showCoverTitles: ReturnType<typeof signal<boolean | undefined>>;
|
||||
let hoverMatches: boolean;
|
||||
let listeners: ChangeListener[];
|
||||
let removeListener: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
showCoverTitles = signal<boolean | undefined>(true);
|
||||
hoverMatches = true;
|
||||
listeners = [];
|
||||
removeListener = jest.fn();
|
||||
window.matchMedia = jest.fn((query: string) => ({
|
||||
matches: query === '(any-hover: hover)' ? hoverMatches : false,
|
||||
addEventListener: (_type: string, listener: ChangeListener) => {
|
||||
listeners.push(listener);
|
||||
},
|
||||
removeEventListener: removeListener,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
CoverTitlesService,
|
||||
{ provide: SettingsStore, useValue: { showCoverTitles } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
it('keeps titles while the setting is on or absent', () => {
|
||||
const service = TestBed.inject(CoverTitlesService);
|
||||
expect(service.postersOnly()).toBe(false);
|
||||
|
||||
showCoverTitles.set(undefined);
|
||||
expect(service.postersOnly()).toBe(false);
|
||||
});
|
||||
|
||||
it('switches to the posters-only wall only on an explicit opt-out', () => {
|
||||
const service = TestBed.inject(CoverTitlesService);
|
||||
|
||||
showCoverTitles.set(false);
|
||||
|
||||
expect(service.postersOnly()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores the opt-out on touch-only devices where nothing can hover', () => {
|
||||
hoverMatches = false;
|
||||
const service = TestBed.inject(CoverTitlesService);
|
||||
showCoverTitles.set(false);
|
||||
|
||||
expect(service.postersOnly()).toBe(false);
|
||||
});
|
||||
|
||||
it('reads any-hover, not hover, so a touch-first device with a mouse keeps the wall', () => {
|
||||
TestBed.inject(CoverTitlesService);
|
||||
|
||||
expect(window.matchMedia).toHaveBeenCalledWith('(any-hover: hover)');
|
||||
});
|
||||
|
||||
it('follows the hover capability when a pointer is attached or removed', () => {
|
||||
hoverMatches = false;
|
||||
const service = TestBed.inject(CoverTitlesService);
|
||||
showCoverTitles.set(false);
|
||||
expect(service.postersOnly()).toBe(false);
|
||||
|
||||
listeners.forEach((listener) => listener({ matches: true }));
|
||||
expect(service.postersOnly()).toBe(true);
|
||||
|
||||
listeners.forEach((listener) => listener({ matches: false }));
|
||||
expect(service.postersOnly()).toBe(false);
|
||||
});
|
||||
|
||||
it('unsubscribes from the media query when its injector is destroyed', () => {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{ provide: CoverTitlesService },
|
||||
{ provide: SettingsStore, useValue: { showCoverTitles } },
|
||||
],
|
||||
parent: TestBed.inject(Injector),
|
||||
});
|
||||
injector.get(CoverTitlesService);
|
||||
expect(listeners).toHaveLength(1);
|
||||
|
||||
(injector as unknown as { destroy(): void }).destroy();
|
||||
|
||||
expect(removeListener).toHaveBeenCalledWith('change', listeners[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
Signal,
|
||||
} from '@angular/core';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
|
||||
const HOVER_CAPABLE_QUERY = '(any-hover: hover)';
|
||||
|
||||
/**
|
||||
* Resolves whether cover grids should render as a posters-only wall.
|
||||
*
|
||||
* The wall hides the title row under every cover and reveals the title as a
|
||||
* hover/focus overlay instead. That overlay needs a pointer that can hover,
|
||||
* so on touch-only devices the preference is ignored and the titles stay
|
||||
* under the covers — a tap already opens the item, leaving no gesture to
|
||||
* peek at a hidden name. `any-hover` rather than `hover`: the latter
|
||||
* describes only the PRIMARY pointer, so a touch-first tablet with a mouse
|
||||
* or hover-capable stylus attached would wrongly lose the wall.
|
||||
*
|
||||
* Consumers still apply their own exemptions (live grids, search results)
|
||||
* on top of this signal; it only answers "does the user want the wall AND
|
||||
* can this device show it".
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CoverTitlesService {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly hoverCapable = createHoverCapabilitySignal(
|
||||
inject(DestroyRef)
|
||||
);
|
||||
|
||||
/** True when covers render without a title row. */
|
||||
readonly postersOnly = computed(
|
||||
() =>
|
||||
this.settingsStore.showCoverTitles?.() === false &&
|
||||
this.hoverCapable()
|
||||
);
|
||||
}
|
||||
|
||||
function createHoverCapabilitySignal(destroyRef: DestroyRef): Signal<boolean> {
|
||||
// No `matchMedia` (SSR, some test hosts) means we cannot prove a hover
|
||||
// pointer exists; default to true so desktop keeps the feature in those
|
||||
// environments and the touch fallback only triggers on real evidence.
|
||||
if (typeof window === 'undefined' || !('matchMedia' in window)) {
|
||||
return signal(true).asReadonly();
|
||||
}
|
||||
|
||||
const query = window.matchMedia(HOVER_CAPABLE_QUERY);
|
||||
const hoverCapable = signal(query.matches);
|
||||
const onChange = (event: MediaQueryListEvent): void => {
|
||||
hoverCapable.set(event.matches);
|
||||
};
|
||||
|
||||
query.addEventListener('change', onChange);
|
||||
destroyRef.onDestroy(() => query.removeEventListener('change', onChange));
|
||||
|
||||
return hoverCapable.asReadonly();
|
||||
}
|
||||
@@ -56,6 +56,7 @@
|
||||
<div class="results-grid">
|
||||
@for (item of searchResults(); track item.id) {
|
||||
<app-content-card
|
||||
[allowPostersOnly]="false"
|
||||
[title]="item.o_name || item.name || ''"
|
||||
[posterUrl]="item.screenshot_uri"
|
||||
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
>
|
||||
@for (item of recentlyAddedVod(); track item.xtream_id) {
|
||||
<app-content-card
|
||||
[allowPostersOnly]="false"
|
||||
[title]="item.title || item.name || ''"
|
||||
[posterUrl]="item.poster_url || item.stream_icon"
|
||||
[type]="'movie'"
|
||||
@@ -52,6 +53,7 @@
|
||||
>
|
||||
@for (item of recentlyAddedSeries(); track item.xtream_id) {
|
||||
<app-content-card
|
||||
[allowPostersOnly]="false"
|
||||
[title]="item.title || item.name || ''"
|
||||
[posterUrl]="item.poster_url || item.cover"
|
||||
[type]="'series'"
|
||||
@@ -72,6 +74,7 @@
|
||||
>
|
||||
@for (item of recentlyAddedLive(); track item.xtream_id) {
|
||||
<app-content-card
|
||||
[allowPostersOnly]="false"
|
||||
[title]="item.title || item.name || ''"
|
||||
[posterUrl]="item.poster_url || item.stream_icon"
|
||||
[type]="'live'"
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
<div class="results-grid">
|
||||
@for (item of group.items; track item.id) {
|
||||
<app-content-card
|
||||
[allowPostersOnly]="false"
|
||||
[posterUrl]="
|
||||
item.poster_url ?? undefined
|
||||
"
|
||||
@@ -138,6 +139,7 @@
|
||||
<div class="results-grid">
|
||||
@for (item of xtreamStore.searchResults(); track item.id) {
|
||||
<app-content-card
|
||||
[allowPostersOnly]="false"
|
||||
[posterUrl]="item.poster_url ?? undefined"
|
||||
[title]="item.title"
|
||||
[type]="getDisplayType(item)"
|
||||
@@ -163,6 +165,7 @@
|
||||
<div class="results-grid">
|
||||
@for (item of visibleInPortalResults(); track item.id) {
|
||||
<app-content-card
|
||||
[allowPostersOnly]="false"
|
||||
[posterUrl]="item.poster_url ?? undefined"
|
||||
[title]="item.title"
|
||||
[type]="item.type"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
coerceProvidedDefaultOnSettings,
|
||||
resolveDefaultOnSettings,
|
||||
} from './settings-opt-out.util';
|
||||
|
||||
describe('default-on settings coercion', () => {
|
||||
it('resolves every default-on flag, treating only explicit false as an opt-out', () => {
|
||||
expect(
|
||||
resolveDefaultOnSettings({
|
||||
webPlayerSharedControls: false,
|
||||
portalConnectivityGuard: 'false' as unknown as boolean,
|
||||
})
|
||||
).toEqual({
|
||||
webPlayerSharedControls: false,
|
||||
portalConnectivityGuard: true,
|
||||
embeddedMpvAutoReconnect: true,
|
||||
showCoverTitles: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces only the flags an update actually carries', () => {
|
||||
expect(
|
||||
coerceProvidedDefaultOnSettings({
|
||||
showCoverTitles: false,
|
||||
embeddedMpvAutoReconnect: undefined,
|
||||
language: undefined,
|
||||
})
|
||||
).toEqual({ showCoverTitles: false });
|
||||
expect(coerceProvidedDefaultOnSettings({})).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Settings } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Boolean settings that default ON and are stored as an explicit opt-out.
|
||||
*
|
||||
* Absent in settings stored before the default flip means "never chose" —
|
||||
* those users get the default; only an explicit `false` opts out, so junk
|
||||
* such as the string `'false'` falls back to the default too.
|
||||
*/
|
||||
export const DEFAULT_ON_SETTINGS = [
|
||||
'webPlayerSharedControls',
|
||||
'portalConnectivityGuard',
|
||||
'embeddedMpvAutoReconnect',
|
||||
'showCoverTitles',
|
||||
] as const satisfies readonly (keyof Settings)[];
|
||||
|
||||
export type DefaultOnSetting = (typeof DEFAULT_ON_SETTINGS)[number];
|
||||
|
||||
/** Every default-on flag resolved: `true` unless stored as exactly `false`. */
|
||||
export function resolveDefaultOnSettings(
|
||||
stored: Partial<Settings>
|
||||
): Record<DefaultOnSetting, boolean> {
|
||||
// `reduce` rather than `Object.fromEntries`: the web build lib target
|
||||
// predates ES2019.
|
||||
return DEFAULT_ON_SETTINGS.reduce(
|
||||
(resolved, key) => {
|
||||
resolved[key] = stored[key] !== false;
|
||||
return resolved;
|
||||
},
|
||||
{} as Record<DefaultOnSetting, boolean>
|
||||
);
|
||||
}
|
||||
|
||||
/** Only the default-on flags the caller supplied, coerced the same way. */
|
||||
export function coerceProvidedDefaultOnSettings(
|
||||
settings: Partial<Settings>
|
||||
): Partial<Record<DefaultOnSetting, boolean>> {
|
||||
return DEFAULT_ON_SETTINGS.reduce(
|
||||
(coerced, key) => {
|
||||
if (settings[key] !== undefined) {
|
||||
coerced[key] = settings[key] !== false;
|
||||
}
|
||||
return coerced;
|
||||
},
|
||||
{} as Partial<Record<DefaultOnSetting, boolean>>
|
||||
);
|
||||
}
|
||||
@@ -270,6 +270,36 @@ describe('SettingsStore dashboard rail settings', () => {
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults cover titles to shown when the stored field is missing', async () => {
|
||||
storedSettings = {};
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
expect(store.getSettings().showCoverTitles).toBe(true);
|
||||
});
|
||||
|
||||
it('restores a persisted posters-only opt-out and persists it back', async () => {
|
||||
storedSettings = { showCoverTitles: false };
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
expect(store.getSettings().showCoverTitles).toBe(false);
|
||||
|
||||
await store.updateSettings({ showCoverTitles: true });
|
||||
expect(store.showCoverTitles?.()).toBe(true);
|
||||
expect(storage.set).toHaveBeenLastCalledWith(
|
||||
STORE_KEY.Settings,
|
||||
expect.objectContaining({ showCoverTitles: true })
|
||||
);
|
||||
|
||||
await store.updateSettings({
|
||||
showCoverTitles: 'false' as unknown as boolean,
|
||||
});
|
||||
// Junk never opts out: only an explicit boolean false does.
|
||||
expect(store.showCoverTitles?.()).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults embedded MPV auto-reconnect to true when the stored field is missing', async () => {
|
||||
storedSettings = {};
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
EpgSourceSettingsService,
|
||||
epgSourceUrlsChanged,
|
||||
} from './epg-source-settings.service';
|
||||
import {
|
||||
coerceProvidedDefaultOnSettings,
|
||||
resolveDefaultOnSettings,
|
||||
} from './settings-opt-out.util';
|
||||
import { computed, inject } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
@@ -64,6 +68,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
embeddedMpvAutoReconnect: true,
|
||||
portalConnectivityGuard: true,
|
||||
coverSize: 'medium',
|
||||
showCoverTitles: true,
|
||||
epgViewMode: 'timeline',
|
||||
epgOffsetMinutes: 0,
|
||||
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
@@ -169,19 +174,7 @@ export const SettingsStore = signalStore(
|
||||
epgOffsetMinutes: normalizeEpgOffsetMinutes(
|
||||
storedSettings.epgOffsetMinutes
|
||||
),
|
||||
|
||||
// Absent in settings stored before the default
|
||||
// flip means "never chose" — those users get the
|
||||
// new default; only an explicit false opts out.
|
||||
webPlayerSharedControls:
|
||||
storedSettings.webPlayerSharedControls !==
|
||||
false,
|
||||
portalConnectivityGuard:
|
||||
storedSettings.portalConnectivityGuard !==
|
||||
false,
|
||||
embeddedMpvAutoReconnect:
|
||||
storedSettings.embeddedMpvAutoReconnect !==
|
||||
false,
|
||||
...resolveDefaultOnSettings(storedSettings),
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
storedSettings.dashboardRails
|
||||
),
|
||||
@@ -222,24 +215,7 @@ export const SettingsStore = signalStore(
|
||||
const previousEpgUrls = store.epgUrl();
|
||||
patchState(store, {
|
||||
...settings,
|
||||
...(settings.webPlayerSharedControls !== undefined
|
||||
? {
|
||||
webPlayerSharedControls:
|
||||
settings.webPlayerSharedControls !== false,
|
||||
}
|
||||
: {}),
|
||||
...(settings.portalConnectivityGuard !== undefined
|
||||
? {
|
||||
portalConnectivityGuard:
|
||||
settings.portalConnectivityGuard !== false,
|
||||
}
|
||||
: {}),
|
||||
...(settings.embeddedMpvAutoReconnect !== undefined
|
||||
? {
|
||||
embeddedMpvAutoReconnect:
|
||||
settings.embeddedMpvAutoReconnect !== false,
|
||||
}
|
||||
: {}),
|
||||
...coerceProvidedDefaultOnSettings(settings),
|
||||
...(settings.dashboardRails !== undefined
|
||||
? {
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
@@ -344,6 +320,7 @@ export const SettingsStore = signalStore(
|
||||
store.embeddedMpvAutoReconnect?.() !== false,
|
||||
coverSize:
|
||||
store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
|
||||
showCoverTitles: store.showCoverTitles?.() !== false,
|
||||
epgViewMode:
|
||||
store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode,
|
||||
epgOffsetMinutes: normalizeEpgOffsetMinutes(
|
||||
|
||||
@@ -241,6 +241,15 @@ export interface Settings {
|
||||
portalConnectivityGuard?: boolean;
|
||||
/** Cover/poster sizing preset applied across grids and rails */
|
||||
coverSize?: CoverSize;
|
||||
/**
|
||||
* Titles under movie/series covers. Default on; only an explicit false
|
||||
* switches VOD, series, favorites and recent grids to a posters-only
|
||||
* wall whose title appears as an overlay on hover/focus. Live channel
|
||||
* grids, search results and the dashboard rails always keep their
|
||||
* labels: logos are often missing, a search answers by name, and rails
|
||||
* carry meta rows the overlay cannot hold.
|
||||
*/
|
||||
showCoverTitles?: boolean;
|
||||
/** Live EPG panel layout: horizontal timeline (default) or vertical list */
|
||||
epgViewMode?: EpgViewMode;
|
||||
/**
|
||||
|
||||
@@ -106,6 +106,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user