Files
iptvnator/libs/ui/styles/_portal-sidebar.scss
4grayandClaude Fable 5.1 0a2373f192 feat(portals): fold live TV panels in nested levels with a category dropdown (#1556)
## Summary

Live TV panels now fold from the outside in, in three nested levels, instead of one toggle that hid the categories rail and the channel list together:

1. **Categories + channels + player** (browse, unchanged).
2. **Channels + player** — a new `chevron_left` in the categories rail header hides only that rail. The channels header then turns its title into a **category dropdown** that opens the same shell panel as a popover (search, sort, counts, selection are one implementation), plus a `chevron_right` that brings the rail back.
3. **Player only** — the channels header chevron, as before. The floating restore handle and `Cmd/Ctrl+B` return to the level the user collapsed from, not always to level 1.

Every level is restored as stored, per surface (`live-sidebar-state:<surface>`, from #1555): a hidden rail is discoverable through the workspace header toggle and the hidden-list empty state that #1555 added, so this PR no longer needs its original "player-only never restores" rule. The level `Cmd/Ctrl+B` comes back to is seeded from the restored level and kept for the session.

## Design notes

- Nested levels rather than two independent booleans: "channels hidden, categories visible" makes no sense since a category click has to bring the channels back anyway. The model follows the outside-in collapse of three-pane apps (Mail, Slack, Plex).
- The categories rail folds at level 2 **only while a category is selected**: the live root ("All Items" grid) has no channels header to host the way back, so folding there would strand the user. Level 3 folds it regardless, because the floating restore handle lives in the content area.
- `LIVE_CATEGORIES_POPOVER` (`@iptvnator/portal/shared/util`) is the DI bridge: the workspace shell provides `WorkspaceLiveCategoriesPopoverService` (CDK overlay hosting `WorkspaceContextPanelComponent` in `presentation="popover"`), the Xtream and Stalker live layouts inject it optionally and keep their plain heading without a provider.
- M3U and the unified live tab have no categories rail and treat level 2 like level 1; their code is untouched.

## Merged with #1555 (per-surface rail state)

#1555 landed while this PR was open and reworked the same service: state per surface (`m3u` / `portal` / `collection`), a workspace header toggle, the hidden-list empty state, and the legacy shared key forgotten on startup. This PR keeps that model and layers the three levels onto the `portal` surface (`areCategoriesHiddenFor`, `hideCategories` / `showCategories` / `collapse` / `expand` per surface; `toggle(surface)` returns to the level the surface collapsed from). "Show playing channel" uses `expand('portal')` so it keeps a deliberately hidden categories rail folded, and the category sort preference moved to `PortalCategorySortStateService` so the popover copy of the context panel and the retained rail agree.

## Also fixed along the way

- The channels header showed "Channels" instead of the category name: provider category ids are strings, the selection is numeric. Compared via `String()` now.
- A collapsed context panel left a 22px padding strip beside the channels rail.
- The panel toggle labels said "Hide channels list" while also hiding categories; labels and tooltips are honest now (8 new i18n keys, all 18 locales).



Docs: `docs/architecture/iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar" rewritten), `docs/architecture/workspace-shell.md`. Release note: `.changes/portals-live-panel-collapse-levels.md`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 14:42:59 +02:00

135 lines
3.9 KiB
SCSS

// ─── Shared sidebar + sidebar-header styles ───────────────────────────────────
// Plain CSS rules (not a mixin) used for M3U/legacy sidebar layouts.
//
// Choose the relative @use path from each consuming stylesheet. Current portal
// live layouts use the example below; other consumers may need a different
// depth. No alias is needed because this partial exports no callable members.
// @use '../../../../../../ui/styles/portal-sidebar';
.sidebar {
// Width is controlled by the resizable directive
// Default fallback if directive not applied
width: 400px;
min-width: 250px;
max-width: 600px;
border-right: 1px solid var(--app-separator);
display: flex;
flex-direction: column;
flex-shrink: 0; // Prevent sidebar from shrinking
.sidebar-header {
padding: 8px 12px;
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-separator);
app-region: drag;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
h2 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
flex: 1;
}
button {
app-region: no-drag;
}
.switcher {
flex: 1;
min-width: 0;
}
.manage-categories-btn {
margin-left: auto;
}
.category-heading {
display: flex;
align-items: baseline;
gap: 8px;
flex: 1;
min-width: 0;
}
.category-title {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.category-count {
flex-shrink: 0;
font-size: 0.72rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
background: var(--mat-sys-surface-container);
padding: 2px 8px;
border-radius: 999px;
}
// Shown while the shell's categories rail is folded: the way back to
// the rail, in the same spot the rail's own chevron folded it from.
.sidebar-show-categories {
flex-shrink: 0;
margin-left: -6px;
}
// The category title as a dropdown trigger (categories rail folded).
// Same type as the static heading so folding the rail does not move
// the title; the caret is the only added ink.
.category-dropdown {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
height: 32px;
padding: 0 4px 0 8px;
margin-left: -8px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
text-align: left;
app-region: no-drag;
.category-title {
flex: 0 1 auto;
min-width: 0;
}
&__caret {
flex-shrink: 0;
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
&:hover,
&[aria-expanded='true'] {
background: var(--app-hover-overlay, rgba(255, 255, 255, 0.08));
border-color: var(--app-separator);
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: -2px;
}
}
}
}