mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
One app-source-row (regular for the Sources page, compact for the playlist switcher) with an app-source-tile, built on pure helpers: - resolvePlaylistSourceKind: one provider rule for icons, filters and counts (the list filter and the panel counts used to disagree). - sourceDisplayName / sourceDisplayUrl: a name or URL never shows a password, token or MAC. URL imports that stored "get.php?username=…&password=…" as the title now show the host. - stripUrlCredentials (shared/logging): drops what the log redactor would mask, plus short user/pass keys. - formatSourceDate / sourceMetaParts / sourceStatusTag: one meta line order and Intl dates and durations in the UI language. - SourceStatusService + SourceHealthHistoryService: one status per source (desktop health check, browser Xtream portal status) and the last conclusive result kept across restarts, so "not responding for 3 days" survives a relaunch. No URL or credential is stored. Also fixes playlistNameHasCredentials answering differently on repeated calls (global regex lastIndex), and adds the UI-kit button, menu and segmented-control mixins (libs/ui/styles/_app-controls.scss); menus opt in with class="app-menu", styled by the components that use them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
280 lines
8.4 KiB
SCSS
280 lines
8.4 KiB
SCSS
// Buttons, menus and the segmented control of the IPTVnator UI kit, over
|
|
// Angular Material: Material keeps the behaviour (focus, keyboard, overlay,
|
|
// ARIA), these mixins set the look through `mat.*-overrides()`.
|
|
//
|
|
// Button variants are roles, not looks:
|
|
// - `secondary-button` (`mat-flat-button`): every ordinary action and every
|
|
// dropdown trigger (label + 12px chevron, opening a `mat-menu`);
|
|
// - `ghost-button` (`mat-button`) and `ghost-icon-button`
|
|
// (`mat-icon-button`): row and header actions.
|
|
// No outline, no ripple: hover changes the fill only, and a disabled button
|
|
// is the same button at 40% opacity. Focus keeps the app's ring
|
|
// (`styles.scss` rings every `.mat-mdc-button-base`).
|
|
@use 'sass:map';
|
|
@use '@angular/material' as mat;
|
|
|
|
$radius: 10px;
|
|
$radius-sm: 8px;
|
|
|
|
$_sizes: (
|
|
md: (
|
|
height: 40px,
|
|
radius: $radius,
|
|
padding: 16px,
|
|
icon-padding: 14px,
|
|
text: 13.5px,
|
|
icon: 16px,
|
|
gap: 8px,
|
|
),
|
|
sm: (
|
|
height: 32px,
|
|
radius: $radius-sm,
|
|
padding: 12px,
|
|
icon-padding: 10px,
|
|
text: 12.5px,
|
|
icon: 14px,
|
|
gap: 6px,
|
|
),
|
|
);
|
|
|
|
@function _size($size, $key) {
|
|
@return map.get($_sizes, $size, $key);
|
|
}
|
|
|
|
@mixin _icon-size($size) {
|
|
.mat-icon {
|
|
width: $size;
|
|
height: $size;
|
|
font-size: $size;
|
|
line-height: $size;
|
|
}
|
|
}
|
|
|
|
@mixin _disabled {
|
|
&[disabled],
|
|
&.mat-mdc-button-disabled {
|
|
opacity: 0.4;
|
|
}
|
|
}
|
|
|
|
/// `$size`: `md` (40px) or `sm` (32px).
|
|
@mixin secondary-button($size: md) {
|
|
$fill: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
|
|
@include mat.button-overrides(
|
|
(
|
|
filled-container-color: $fill,
|
|
filled-disabled-container-color: $fill,
|
|
filled-label-text-color: var(--app-heading-color),
|
|
filled-disabled-label-text-color: var(--app-heading-color),
|
|
filled-container-height: _size($size, height),
|
|
filled-container-shape: _size($size, radius),
|
|
filled-horizontal-padding: _size($size, padding),
|
|
filled-label-text-size: _size($size, text),
|
|
filled-label-text-weight: 600,
|
|
filled-label-text-tracking: 0,
|
|
filled-icon-spacing: _size($size, gap),
|
|
filled-state-layer-color: var(--app-on-surface),
|
|
filled-hover-state-layer-opacity: 0.06,
|
|
filled-focus-state-layer-opacity: 0.06,
|
|
filled-pressed-state-layer-opacity: 0.1,
|
|
filled-ripple-color: transparent,
|
|
)
|
|
);
|
|
@include _icon-size(_size($size, icon));
|
|
@include _disabled;
|
|
}
|
|
|
|
/// A text action: `--app-body-color`, the heading colour on hover.
|
|
@mixin ghost-button($size: sm) {
|
|
@include mat.button-overrides(
|
|
(
|
|
text-label-text-color: var(--app-body-color),
|
|
text-disabled-label-text-color: var(--app-body-color),
|
|
text-container-height: _size($size, height),
|
|
text-container-shape: _size($size, radius),
|
|
text-horizontal-padding: _size($size, padding),
|
|
text-with-icon-horizontal-padding: _size($size, icon-padding),
|
|
text-label-text-size: _size($size, text),
|
|
text-label-text-weight: 600,
|
|
text-label-text-tracking: 0,
|
|
text-icon-spacing: 6px,
|
|
text-state-layer-color: var(--app-on-surface),
|
|
text-hover-state-layer-opacity: 0.08,
|
|
text-focus-state-layer-opacity: 0.08,
|
|
text-pressed-state-layer-opacity: 0.12,
|
|
text-ripple-color: transparent,
|
|
)
|
|
);
|
|
@include _icon-size(14px);
|
|
@include _disabled;
|
|
|
|
&:not([disabled]):hover {
|
|
@include mat.button-overrides(
|
|
(
|
|
text-label-text-color: var(--app-heading-color),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A square icon action with an 8px radius, not Material's circle.
|
|
@mixin ghost-icon-button($size: 32px, $icon-size: 16px) {
|
|
@include mat.icon-button-overrides(
|
|
(
|
|
state-layer-size: $size,
|
|
container-shape: $radius-sm,
|
|
icon-size: $icon-size,
|
|
icon-color: var(--app-body-color),
|
|
disabled-icon-color: var(--app-body-color),
|
|
state-layer-color: var(--app-on-surface),
|
|
hover-state-layer-opacity: 0.08,
|
|
focus-state-layer-opacity: 0.08,
|
|
pressed-state-layer-opacity: 0.12,
|
|
ripple-color: transparent,
|
|
touch-target-display: none,
|
|
)
|
|
);
|
|
border-radius: $radius-sm;
|
|
@include _icon-size($icon-size);
|
|
@include _disabled;
|
|
|
|
&:not([disabled]):hover {
|
|
@include mat.icon-button-overrides(
|
|
(
|
|
icon-color: var(--app-heading-color),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The trailing 12px chevron of a dropdown trigger.
|
|
@mixin trigger-chevron {
|
|
width: 12px;
|
|
height: 12px;
|
|
font-size: 12px;
|
|
line-height: 12px;
|
|
margin-inline-start: 2px;
|
|
color: var(--app-body-color);
|
|
}
|
|
|
|
/// A `mat-menu` panel; apply to the class given to `<mat-menu class>`.
|
|
/// Items: 36px, 13px, 16px icons in the body colour; destructive items
|
|
/// carry `app-menu-item--danger` and sit after a divider; a keyboard hint
|
|
/// is a trailing `kbd.app-menu-item__kbd`.
|
|
@mixin menu-panel {
|
|
@include mat.menu-overrides(
|
|
(
|
|
container-color: var(--app-widget-bg),
|
|
container-shape: $radius,
|
|
item-label-text-color: var(--app-heading-color),
|
|
item-label-text-size: 13px,
|
|
item-label-text-weight: 400,
|
|
item-icon-color: var(--app-body-color),
|
|
item-icon-size: 16px,
|
|
item-hover-state-layer-color: color-mix(
|
|
in srgb,
|
|
var(--app-on-surface) 8%,
|
|
transparent
|
|
),
|
|
item-focus-state-layer-color: color-mix(
|
|
in srgb,
|
|
var(--app-on-surface) 10%,
|
|
transparent
|
|
),
|
|
item-leading-spacing: 10px,
|
|
item-trailing-spacing: 10px,
|
|
item-with-icon-leading-spacing: 10px,
|
|
item-with-icon-trailing-spacing: 10px,
|
|
item-spacing: 10px,
|
|
divider-color: var(--app-separator),
|
|
divider-top-spacing: 4px,
|
|
divider-bottom-spacing: 4px,
|
|
)
|
|
);
|
|
min-width: 240px;
|
|
border: 1px solid var(--app-line-strong);
|
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
|
|
|
.mat-mdc-menu-content {
|
|
padding: 6px;
|
|
}
|
|
|
|
.mat-mdc-menu-item {
|
|
min-height: 36px;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.mat-mdc-menu-item[aria-checked='true'] {
|
|
background: var(--app-list-selected-bg);
|
|
color: var(--app-list-selected-fg);
|
|
|
|
.mat-icon {
|
|
color: inherit;
|
|
}
|
|
}
|
|
|
|
.app-menu-item--danger,
|
|
.app-menu-item--danger .mat-icon {
|
|
color: var(--app-danger-text);
|
|
}
|
|
|
|
.app-menu-item__kbd {
|
|
margin-inline-start: auto;
|
|
padding-inline-start: 16px;
|
|
font-family: var(--app-font-mono);
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--app-muted-color);
|
|
}
|
|
}
|
|
|
|
/// `mat-button-toggle-group` as a segmented control: a recessed track, no
|
|
/// dividers, the chosen segment filled.
|
|
@mixin segmented-control($height: 30px) {
|
|
@include mat.button-toggle-overrides(
|
|
(
|
|
height: $height,
|
|
shape: 10px,
|
|
divider-color: transparent,
|
|
background-color: transparent,
|
|
text-color: var(--app-muted-color),
|
|
selected-state-background-color: color-mix(
|
|
in srgb,
|
|
var(--app-on-surface) 10%,
|
|
transparent
|
|
),
|
|
selected-state-text-color: var(--app-heading-color),
|
|
state-layer-color: var(--app-on-surface),
|
|
hover-state-layer-opacity: 0.06,
|
|
focus-state-layer-opacity: 0.06,
|
|
label-text-size: 12.5px,
|
|
label-text-weight: 500,
|
|
label-text-tracking: 0,
|
|
)
|
|
);
|
|
display: inline-flex;
|
|
gap: 1px;
|
|
padding: 2px;
|
|
border: 1px solid var(--app-line-strong);
|
|
background: var(--app-content-bg);
|
|
|
|
.mat-button-toggle {
|
|
border-radius: 8px;
|
|
border: 0;
|
|
}
|
|
|
|
// The group already draws its frame; Material adds a left border per
|
|
// toggle as the divider.
|
|
.mat-button-toggle + .mat-button-toggle {
|
|
border-inline-start: 0;
|
|
}
|
|
|
|
.mat-button-toggle-label-content {
|
|
padding: 0 10px;
|
|
}
|
|
|
|
.mat-pseudo-checkbox {
|
|
display: none;
|
|
}
|
|
}
|