feat(ui): shared cinematic detail pieces (chip, light primary, icon button, more menu, cast row, trailer dialog)

Adds the building blocks of the redesigned VOD details and dashboard hero:
a pill meta chip, the light primary button with a two-line label, a 44px
ghost icon button with tooltip, the right-aligned "…" dropdown menu that
flips upward and hosts the alternative-sources panel, hero credits plus a
Cast & crew row, a horizontal rail with prev/next arrows, a Similar rail
and the trailer modal. Shared detail utilities (season suffix, remaining
time, duration, short country names) move to portal/shared/util, with the
new CTA and rating theme tokens and the i18n keys for all locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-02 07:58:06 +02:00
1 parent b6357da6af
commit 3fd65c935c
63 files changed
+3025 -92

No files matched your search

@@ -5,10 +5,14 @@ import {
} from '@iptvnator/shared/interfaces';
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
export interface DashboardRemainingLabel {
readonly key: string;
readonly params: Record<string, number>;
}
import {
formatRemainingLabel,
playbackProgressPercent,
type RemainingTimeLabel,
} from '@iptvnator/portal/shared/util';
export type DashboardRemainingLabel = RemainingTimeLabel;
export { formatRemainingLabel, playbackProgressPercent };
export function buildPlaybackPositionReloadKey(
items: readonly Pick<
@@ -29,66 +33,6 @@ export function isContinueWatchingRecentItem(
return item.type === 'movie' || item.type === 'series';
}
export function playbackProgressPercent(
position: { positionSeconds: number; durationSeconds?: number } | null
): number | null {
if (
!position ||
position.durationSeconds == null ||
position.durationSeconds <= 0
) {
return null;
}
const ratio = position.positionSeconds / position.durationSeconds;
if (!Number.isFinite(ratio)) {
return null;
}
// Integer percent keeps "92% watched" out of "92.4% watched" territory,
// and matches the resolution of a 3px-tall progress bar on a 280px card.
return Math.max(0, Math.min(100, Math.floor(ratio * 100)));
}
export function formatRemainingLabel(
position: { positionSeconds: number; durationSeconds?: number } | null
): DashboardRemainingLabel | null {
if (
!position ||
position.durationSeconds == null ||
position.durationSeconds <= 0
) {
return null;
}
const remaining = Math.max(
0,
Math.round(position.durationSeconds - position.positionSeconds)
);
if (remaining < 60) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
params: { seconds: remaining },
};
}
const totalMinutes = Math.round(remaining / 60);
if (totalMinutes < 60) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
params: { minutes: totalMinutes },
};
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
params: { hours },
};
}
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
params: { hours, minutes },
};
}
/**
* "S1·E5" for an item whose progress is tracked per episode. Keyed on the
* WATCH kind: a Stalker embedded-VOD / lazy `is_series` show routes as a