mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(portals): make facet chips keyboard-operable and reject zero years
- The chips were plain spans with a click handler, so the whole feature was mouse-only. Actionable chips are <button> now (focusable, Enter and Space activate); a year chip that cannot be discovered by stays an informational span rather than becoming a disabled button. The button chrome is neutralized so they render identically to the chips beside them, with a visible focus ring. - `0000-00-00`, the placeholder providers ship for "no date", read as a four-digit year: the chip offered it, and the request then dropped the filter because 0 is falsy, so the page answered with unfiltered popular titles. `isTmdbYearFacet()` now gates both the chip and the route params, so a deep link cannot reach a state the chips refuse to offer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
065437b378
commit
8a7a7ba8b0
11 files changed
+164
-60
No files matched your search
@@ -102,6 +102,17 @@ describe('createDiscoverFacetNavigation', () => {
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses the zero-date placeholder providers ship for "no date"', () => {
|
||||
const discover = create(xtreamMovie);
|
||||
|
||||
// '0000-00-00' reads as a four-digit year but filters by nothing
|
||||
expect(discover.canOpenYear(603, '0000-00-00')).toBe(false);
|
||||
expect(discover.canOpenYear(603, '0000')).toBe(false);
|
||||
|
||||
discover.openYear(603, '0000-00-00');
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not navigate while the host cannot resolve its playlist', () => {
|
||||
const discover = create(() => null);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
TmdbCountryFacet,
|
||||
TmdbGenreFacet,
|
||||
TmdbMediaType,
|
||||
isTmdbYearFacet,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { discoverLink } from './discover-link.util';
|
||||
|
||||
@@ -31,11 +32,13 @@ export interface DiscoverFacetNavigation {
|
||||
* Year stated by a provider date field, whatever shape it arrives in —
|
||||
* `1976`, `1999-03-31` and `31-03-1999` all resolve. Reads the first
|
||||
* four-digit run rather than a fixed slice, which the day-first form
|
||||
* would otherwise turn into `NaN`.
|
||||
* would otherwise turn into `NaN`, and rejects the `0000-00-00`
|
||||
* placeholder rather than offering a chip that filters by nothing.
|
||||
*/
|
||||
function facetYear(releaseDate: string | null | undefined): number | null {
|
||||
const match = releaseDate?.match(/\d{4}/);
|
||||
return match ? Number(match[0]) : null;
|
||||
const year = match ? Number(match[0]) : null;
|
||||
return year !== null && isTmdbYearFacet(year) ? year : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+31
-20
@@ -16,30 +16,39 @@
|
||||
}}</span>
|
||||
}
|
||||
@if (serial.info.releasedate) {
|
||||
<span
|
||||
class="details__tag"
|
||||
[class.details__tag--clickable]="
|
||||
discover.canOpenYear(
|
||||
serial.info.tmdb_id,
|
||||
serial.info.releasedate
|
||||
)
|
||||
"
|
||||
(click)="
|
||||
discover.openYear(
|
||||
serial.info.tmdb_id,
|
||||
serial.info.releasedate
|
||||
)
|
||||
"
|
||||
>{{ serial.info.releasedate }}</span
|
||||
>
|
||||
@if (
|
||||
discover.canOpenYear(
|
||||
serial.info.tmdb_id,
|
||||
serial.info.releasedate
|
||||
)
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="
|
||||
discover.openYear(
|
||||
serial.info.tmdb_id,
|
||||
serial.info.releasedate
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ serial.info.releasedate }}
|
||||
</button>
|
||||
} @else {
|
||||
<span class="details__tag">{{
|
||||
serial.info.releasedate
|
||||
}}</span>
|
||||
}
|
||||
}
|
||||
@if (serial.info.tmdb_genres?.length) {
|
||||
@for (genre of serial.info.tmdb_genres; track genre.id) {
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="discover.openGenre(genre)"
|
||||
>{{ genre.name }}</span
|
||||
>
|
||||
{{ genre.name }}
|
||||
</button>
|
||||
}
|
||||
} @else if (serial.info.genre) {
|
||||
<span class="details__tag">{{ serial.info.genre }}</span>
|
||||
@@ -59,11 +68,13 @@
|
||||
country of serial.info.tmdb_countries;
|
||||
track country.code
|
||||
) {
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="discover.openCountry(country)"
|
||||
>{{ country.name }}</span
|
||||
>
|
||||
{{ country.name }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -17,22 +17,31 @@
|
||||
}}</span>
|
||||
}
|
||||
@if (info.releaseDate) {
|
||||
<span
|
||||
class="details__tag"
|
||||
[class.details__tag--clickable]="
|
||||
discover.canOpenYear(info.tmdb_id, info.releaseDate)
|
||||
"
|
||||
(click)="discover.openYear(info.tmdb_id, info.releaseDate)"
|
||||
>{{ info.releaseDate | slice: 0 : 4 }}</span
|
||||
>
|
||||
@if (discover.canOpenYear(info.tmdb_id, info.releaseDate)) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="
|
||||
discover.openYear(info.tmdb_id, info.releaseDate)
|
||||
"
|
||||
>
|
||||
{{ info.releaseDate | slice: 0 : 4 }}
|
||||
</button>
|
||||
} @else {
|
||||
<span class="details__tag">{{
|
||||
info.releaseDate | slice: 0 : 4
|
||||
}}</span>
|
||||
}
|
||||
}
|
||||
@if (info.tmdb_genres?.length) {
|
||||
@for (genre of info.tmdb_genres; track genre.id) {
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="discover.openGenre(genre)"
|
||||
>{{ genre.name }}</span
|
||||
>
|
||||
{{ genre.name }}
|
||||
</button>
|
||||
}
|
||||
} @else if (info.genre) {
|
||||
<span class="details__tag">{{ info.genre }}</span>
|
||||
@@ -49,11 +58,13 @@
|
||||
}
|
||||
@if (info.tmdb_countries?.length) {
|
||||
@for (country of info.tmdb_countries; track country.code) {
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="discover.openCountry(country)"
|
||||
>{{ country.name }}</span
|
||||
>
|
||||
{{ country.name }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -295,22 +295,31 @@
|
||||
>
|
||||
<ng-template appDetailTags>
|
||||
@if (info.releasedate) {
|
||||
<span
|
||||
class="details__tag"
|
||||
[class.details__tag--clickable]="
|
||||
discover.canOpenYear(info.tmdb_id, info.releasedate)
|
||||
"
|
||||
(click)="discover.openYear(info.tmdb_id, info.releasedate)"
|
||||
>{{ info.releasedate | slice: 0 : 4 }}</span
|
||||
>
|
||||
@if (discover.canOpenYear(info.tmdb_id, info.releasedate)) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="
|
||||
discover.openYear(info.tmdb_id, info.releasedate)
|
||||
"
|
||||
>
|
||||
{{ info.releasedate | slice: 0 : 4 }}
|
||||
</button>
|
||||
} @else {
|
||||
<span class="details__tag">{{
|
||||
info.releasedate | slice: 0 : 4
|
||||
}}</span>
|
||||
}
|
||||
}
|
||||
@if (info.tmdb_genres?.length) {
|
||||
@for (genre of info.tmdb_genres; track genre.id) {
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="discover.openGenre(genre)"
|
||||
>{{ genre.name }}</span
|
||||
>
|
||||
{{ genre.name }}
|
||||
</button>
|
||||
}
|
||||
} @else if (info.genre) {
|
||||
<span class="details__tag">{{ info.genre }}</span>
|
||||
@@ -325,11 +334,13 @@
|
||||
}
|
||||
@if (info.tmdb_countries?.length) {
|
||||
@for (country of info.tmdb_countries; track country.code) {
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="discover.openCountry(country)"
|
||||
>{{ country.name }}</span
|
||||
>
|
||||
{{ country.name }}
|
||||
</button>
|
||||
}
|
||||
} @else if (info.country) {
|
||||
<span class="details__tag">{{ info.country }}</span>
|
||||
|
||||
@@ -119,6 +119,18 @@ export interface TmdbCountryFacet {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a number is a year the Discover page can actually filter by.
|
||||
*
|
||||
* Providers ship `0000-00-00` as their "no date" placeholder, which reads
|
||||
* as a four-digit year and would otherwise produce a `0000` chip that
|
||||
* filters by nothing. Shared by the chip side and the route-param side so
|
||||
* a deep link cannot smuggle in what a chip refuses to offer.
|
||||
*/
|
||||
export function isTmdbYearFacet(year: number): boolean {
|
||||
return Number.isInteger(year) && year >= 1000 && year <= 9999;
|
||||
}
|
||||
|
||||
/**
|
||||
* One cached TMDB lookup. Two kinds of rows share the table, discriminated
|
||||
* by the `lookupKey` prefix:
|
||||
|
||||
@@ -320,8 +320,15 @@
|
||||
border-color: rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
// Rendered as a <button> so the facet is keyboard-operable; the
|
||||
// UA's own font and appearance have to be neutralized to keep it
|
||||
// visually identical to the informational chips beside it.
|
||||
&--clickable {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
@@ -330,6 +337,11 @@
|
||||
background: var(--tag-border);
|
||||
border-color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--text-primary, #fff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,8 +83,15 @@
|
||||
border-color: rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
// Rendered as a <button> so the facet is keyboard-operable; the UA's
|
||||
// own font and appearance have to be neutralized to keep it visually
|
||||
// identical to the informational chips beside it.
|
||||
&--clickable {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
@@ -93,6 +100,11 @@
|
||||
background: var(--tag-border, rgba(255, 255, 255, 0.2));
|
||||
border-color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--text-primary, #fff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,22 +12,27 @@
|
||||
<!-- Tags: Year, Genre, Duration, Rating -->
|
||||
<ng-template appDetailTags>
|
||||
@if (meta.year) {
|
||||
<span
|
||||
class="details__tag"
|
||||
[class.details__tag--clickable]="
|
||||
discover.canOpenYear(meta.tmdbId, meta.year)
|
||||
"
|
||||
(click)="discover.openYear(meta.tmdbId, meta.year)"
|
||||
>{{ meta.year }}</span
|
||||
>
|
||||
@if (discover.canOpenYear(meta.tmdbId, meta.year)) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="discover.openYear(meta.tmdbId, meta.year)"
|
||||
>
|
||||
{{ meta.year }}
|
||||
</button>
|
||||
} @else {
|
||||
<span class="details__tag">{{ meta.year }}</span>
|
||||
}
|
||||
}
|
||||
@if (meta.tmdbGenres?.length) {
|
||||
@for (genre of meta.tmdbGenres; track genre.id) {
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="discover.openGenre(genre)"
|
||||
>{{ genre.name }}</span
|
||||
>
|
||||
{{ genre.name }}
|
||||
</button>
|
||||
}
|
||||
} @else if (meta.genre) {
|
||||
<span class="details__tag">{{ meta.genre }}</span>
|
||||
@@ -42,11 +47,13 @@
|
||||
}
|
||||
@if (meta.tmdbCountries?.length) {
|
||||
@for (country of meta.tmdbCountries; track country.code) {
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
class="details__tag details__tag--clickable"
|
||||
(click)="discover.openCountry(country)"
|
||||
>{{ country.name }}</span
|
||||
>
|
||||
{{ country.name }}
|
||||
</button>
|
||||
}
|
||||
} @else if (meta.country) {
|
||||
<span class="details__tag">{{ meta.country }}</span>
|
||||
|
||||
@@ -38,6 +38,15 @@ describe('parseDiscoverParams', () => {
|
||||
expect(parseDiscoverParams({ year: '1990' }).year).toBe(1990);
|
||||
});
|
||||
|
||||
it('rejects a zero year a deep link could smuggle in', () => {
|
||||
// The chips refuse '0000'; the route must refuse it too, or the
|
||||
// request drops the filter and returns unfiltered popular titles
|
||||
expect(parseDiscoverParams({ year: '0000' }).year).toBeNull();
|
||||
expect(hasDiscoverFacet(parseDiscoverParams({ year: '0000' }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a genre label without a valid genre id', () => {
|
||||
const facets = parseDiscoverParams({
|
||||
genre: 'drama',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TmdbMediaType } from '@iptvnator/shared/interfaces';
|
||||
import { TmdbMediaType, isTmdbYearFacet } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Facets of a portal Discover page, parsed from route query params.
|
||||
@@ -30,8 +30,13 @@ export function parseDiscoverParams(params: RawParams): DiscoverRouteParams {
|
||||
const type: TmdbMediaType =
|
||||
rawString(params, 'type') === 'tv' ? 'tv' : 'movie';
|
||||
|
||||
// `0000` parses as four digits but filters by nothing — a deep link
|
||||
// must not reach a state the chips themselves refuse to offer
|
||||
const rawYear = rawString(params, 'year');
|
||||
const year = rawYear && /^\d{4}$/.test(rawYear) ? Number(rawYear) : null;
|
||||
const parsedYear =
|
||||
rawYear && /^\d{4}$/.test(rawYear) ? Number(rawYear) : null;
|
||||
const year =
|
||||
parsedYear !== null && isTmdbYearFacet(parsedYear) ? parsedYear : null;
|
||||
|
||||
const rawGenre = rawString(params, 'genre');
|
||||
const parsedGenre = rawGenre && /^\d+$/.test(rawGenre) ? Number(rawGenre) : null;
|
||||
|
||||
Reference in new issue
Block a user