From f3c576d18296b0735d6dfacfd0ce3372c0d81e53 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:44:08 +0200 Subject: [PATCH 1/3] fix(tools): follow weight variables in both readings of a keyframe (#1850) * fix(tools): follow weight variables in both readings of a keyframe A keyframe that sets a font only while it runs makes scanWeights scan the file twice: once with the keyframe over the rule that runs it, once with the rule after it. The wrapper merged only the findings and the deferred weights of the second pass, so a weight read through a variable (`font-weight: $w`, `var(--w)`, `font: $w ...`) that meets JetBrains Mono only after a non-holding animation was never followed to its definition. Merge the references too; findIndirectWeights already dedupes a reference both passes record. Definitions, call sites, loads and declaration counts do not depend on the reading. Co-Authored-By: Claude Opus 5.5 * test(tools): cover a keyframe run by another module's mixin Since #1795 the second reading also starts when an included mixin from another module runs a keyframe that does not hold its frame. Its references went through the same wrapper and were dropped the same way; cover that path too. Co-Authored-By: Claude Opus 5.5 * fix(tools): report a weight definition once however it is reached findIndirectWeights follows each Sass reference from its own read site, so a variable two rules read was reported twice at its definition. Both readings of a non-holding keyframe now reach it too, with different caps, so a computed weight (`$w: calc(400 + 100)`) got the same warning twice. Deduplicate its findings by what they report, the key findWorkspaceWeights already used for landed findings. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- tools/nx/check-font-weights.mjs | 21 ++++++-- tools/nx/check-font-weights.test.mjs | 78 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/tools/nx/check-font-weights.mjs b/tools/nx/check-font-weights.mjs index 2f26d2b26..50355caa0 100644 --- a/tools/nx/check-font-weights.mjs +++ b/tools/nx/check-font-weights.mjs @@ -757,6 +757,11 @@ function capOnly(term) { return Boolean(term.cap) && !offScale; } +/** What a finding reports (see `describeFinding`): one key, one message. */ +function findingKey({ file, line, name, value, cap, computed }) { + return JSON.stringify([file, line, name, value, cap, computed]); +} + /** A string literal in code, whole: its quote and its text. */ const STRING_LITERAL = /^\s*(['"`])([\s\S]*)\1\s*$/; @@ -940,6 +945,8 @@ export function scanWeights(file, written, modules = {}) { return { ...running, findings: union(running.findings, after.findings), + // A variable a weight reads is followed from either reading. + references: union(running.references, after.references), deferred: union(running.deferred, after.deferred), // A call meets the families of either reading. includeCalls: new Map( @@ -2362,7 +2369,13 @@ export function findIndirectWeights(scans) { ); } } - return findings; + // A definition several references reach (two rules reading one Sass + // variable, or both readings of a keyframe) is reported once. + return [ + ...new Map( + findings.map((finding) => [findingKey(finding), finding]) + ).values(), + ]; } /** @@ -2448,13 +2461,11 @@ export function findWorkspaceWeights(sources) { ...scans.flatMap((scan) => scan.findings), ...findIndirectWeights(scans), ]; - const keyOf = ({ file, line, name, value, cap, computed }) => - JSON.stringify([file, line, name, value, cap, computed]); - const seen = new Set(all.filter((f) => !f.landed).map(keyOf)); + const seen = new Set(all.filter((f) => !f.landed).map(findingKey)); const findings = []; for (const { landed, ...finding } of all) { if (landed) { - const key = keyOf(finding); + const key = findingKey(finding); if (seen.has(key)) continue; seen.add(key); } diff --git a/tools/nx/check-font-weights.test.mjs b/tools/nx/check-font-weights.test.mjs index 79eecf32b..a06503085 100644 --- a/tools/nx/check-font-weights.test.mjs +++ b/tools/nx/check-font-weights.test.mjs @@ -197,6 +197,27 @@ test('follows variables a weight refers to, through chains and files', () => { ); }); +test('reports a definition once however many references reach it', () => { + const mono = "'JetBrains Mono'"; + for (const [source, expected] of [ + ['$w: 650; .a { font-weight: $w; } .b { font-weight: $w; }', '650'], + [ + '$w: calc(400 + 100); .a { font-weight: $w; } .b { font-weight: $w; }', + 'calc(400 + 100)', + ], + [ + `$w: calc(400 + 100); .a { font-family: ${mono}; font-weight: $w; } .b { font-weight: $w; }`, + 'calc(400 + 100)', + ], + ]) { + assert.deepEqual( + offScale('libs/a.scss', source), + [`1 $w: ${expected}`], + source + ); + } +}); + test('checks Angular style bindings and literal DOM writes', () => { const template = [ '

a

', @@ -4939,6 +4960,63 @@ test('reads a keyframe where a rule runs it, over its own', () => { } }); +test('follows a weight variable in either reading of a keyframe that does not hold', () => { + const mono = "'JetBrains Mono'"; + const roboto = '@keyframes k { to { font-family: Roboto; } }'; + const report = (body) => + findOffScaleWeights('libs/s11/g.scss', body).findings.map( + ({ name, value, cap }) => + `${name}: ${value}${cap ? ` cap ${cap}` : ''}` + ); + for (const [source, expected] of [ + // Mono once it has run: the variable meets the cap where it is set. + [ + `$w: 700; ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: $w; }`, + ['$w: 700 cap 500'], + ], + [ + `:root { --w: 700; } ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: var(--w); }`, + ['--w: 700 cap 500'], + ], + [ + `$w: 700; ${roboto} .x { animation: k 1ms; font: $w 12px/1 ${mono}; }`, + ['$w: 700 cap 500'], + ], + // A computed weight is one finding, whichever reading reaches it. + [ + `$w: calc(400 + 100); ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: $w; }`, + ['$w: calc(400 + 100)'], + ], + // Mono only while it runs. + [ + `$w: 700; .x { font-family: Roboto; animation: k 1ms; font-weight: $w; } @keyframes k { to { font-family: ${mono}; } }`, + ['$w: 700 cap 500'], + ], + // One that holds its frame keeps Roboto after it. + [ + `$w: 700; ${roboto} .x { font-family: ${mono}; animation: k 1ms forwards; font-weight: $w; }`, + [], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } + // And where another module's mixin runs it. + for (const [run, expected] of [ + ['k 1ms', ['libs/s11/c.scss:2 $w: 700']], + ['k 1ms forwards', []], + ]) { + const face = `@mixin face { font-family: ${mono}; animation: ${run}; }`; + assert.deepEqual( + workspace({ + 'libs/s11/_type.scss': `${roboto}\n${face}`, + 'libs/s11/c.scss': `@use 'type';\n$w: 700;\n.x { @include type.face; font-weight: $w; }`, + }), + expected, + run + ); + } +}); + test('reads an `@at-root` rule where Sass writes it out', () => { const mono = "'JetBrains Mono'"; const media = '@media (min-width: 1px)'; From 932876fbd953b977679626ef1ce1febba20c69dc Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:02:43 +0200 Subject: [PATCH 2/3] fix(import): no bogus Stalker expiry notice for unlimited accounts (#1851) * docs(website): say the Stalker validated notice needs an expiry date The import shows "Portal validated" only when the portal profile carries expire_date; an account without a fixed expiry is added silently. The guide described the notice as unconditional (Codex review on #1808). Co-Authored-By: Claude Opus 5.5 * fix(import): read the Stalker import expiry through parseStalkerDate The validated-import snackbar multiplied the raw account_info.expire_date by 1000. Portals encode an unlimited account as -1, "0" or a zero date, which announced a 1969/1970 expiry, and a date-string expiry rendered as "Invalid Date". Every other consumer already reads the value through parseStalkerDate; the import now does too, so the guide's "no notice without a fixed expiry" holds (Codex review on #1851). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changes/import-stalker-unlimited-expiry.md | 6 +++ .../blog/stalker-portal-setup-guide.mdx | 5 +- .../stalker-portal-import.component.spec.ts | 50 +++++++++++++++++++ .../stalker-portal-import.component.ts | 12 +++-- 4 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 .changes/import-stalker-unlimited-expiry.md diff --git a/.changes/import-stalker-unlimited-expiry.md b/.changes/import-stalker-unlimited-expiry.md new file mode 100644 index 000000000..3e5fd151f --- /dev/null +++ b/.changes/import-stalker-unlimited-expiry.md @@ -0,0 +1,6 @@ +--- +type: fix +area: import +--- + +Adding a Stalker portal with an unlimited subscription no longer announces a bogus 1970 expiry date, and a portal that reports its expiry as a date shows that date instead of "Invalid Date". diff --git a/apps/website/src/content/blog/stalker-portal-setup-guide.mdx b/apps/website/src/content/blog/stalker-portal-setup-guide.mdx index ed194a65b..5701f61a1 100644 --- a/apps/website/src/content/blog/stalker-portal-setup-guide.mdx +++ b/apps/website/src/content/blog/stalker-portal-setup-guide.mdx @@ -94,8 +94,9 @@ setups, so IPTVnator no longer guesses: that address, and if a provider migrates the portal, IPTVnator re-probes once on the first failure and repairs the stored connection. -On a full portal the profile also carries your subscription facts, and IPTVnator confirms -the import with "Portal validated" and the expiry date. A portal that cannot be reached +On a full portal the profile also carries your subscription facts. When they include an +expiry date, IPTVnator confirms the import with "Portal validated" and that date; an account +without a fixed expiry is added without that notice. A portal that cannot be reached during the import is still added when the address looks like a panel, with a notice that it was added without validation, so a temporarily offline provider does not block you; the next successful request completes the setup. diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts index 8b4e0b34f..fc96814f9 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts @@ -449,6 +449,56 @@ describe('StalkerPortalImportComponent identity handling', () => { ); }); + // Portals encode an unlimited account as -1, "0" or a zero date, and + // the wire value is often a string despite the API's number type. + it.each([-1, '0', '-1', '0000-00-00'])( + 'announces no expiry for the unlimited sentinel %p', + async (expireDate) => { + portalDiscovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + accountInfo: { login: 'demo', expire_date: expireDate }, + }); + component.form.patchValue({ + title: 'Unlimited Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + }); + + await component.addPlaylist(); + + expect(snackBar.open).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + } + ); + + it('announces a date-string expiry as that calendar day', async () => { + const translate = TestBed.inject(TranslateService); + portalDiscovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + accountInfo: { login: 'demo', expire_date: '2030-01-15' }, + }); + component.form.patchValue({ + title: 'Dated Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + }); + + await component.addPlaylist(); + + expect(translate.instant).toHaveBeenCalledWith( + 'HOME.STALKER_PORTAL.VALIDATED_EXPIRES', + { date: new Date(2030, 0, 15).toLocaleDateString() } + ); + }); + it('drops a refusal once the user edits the form or retries', async () => { portalDiscovery.discover.mockResolvedValue({ status: 'auth-rejected', diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 8cd65d5fd..1a19d0403 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -28,6 +28,7 @@ import { PlaylistActions } from '@iptvnator/m3u-state'; import { legacyTransformStalkerPortalUrl, normalizeStalkerPortalInputUrl, + parseStalkerDate, STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, StalkerPortalDiscoveryService, normalizeStalkerPortalIdentity, @@ -462,10 +463,13 @@ export class StalkerPortalImportComponent { }; } - if (stalkerAccountInfo?.expireDate) { - const expireDate = new Date( - stalkerAccountInfo.expireDate * 1000 - ); + // The raw value may be a date string or an "unlimited" + // sentinel (-1, "0"), so read it like every other consumer. + const expiresAt = parseStalkerDate( + stalkerAccountInfo?.expireDate + ); + if (expiresAt) { + const expireDate = new Date(expiresAt * 1000); this.snackBar.open( this.translate.instant( 'HOME.STALKER_PORTAL.VALIDATED_EXPIRES', From 4477775b4d39b64c758491855e961342603d4123 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:04:22 +0200 Subject: [PATCH 3/3] fix(ui): keep the saved sidebar width when a narrower sidebar clamps it (#1852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace context panels and Favorites share one stored `sidebar-width` but clamp it to different limits. Loading wrote the clamped value back, so visiting Settings (max 400) cut a categories panel widened to 520 down to 400; the saved width could only shrink. Load now clamps for rendering only. Legacy alias keys migrate their raw value, an unparseable value falls back to the default without a write, and only a drag that changes the width persists — a click on the handle no longer saves the clamped width either. Co-authored-by: Claude Opus 5.5 --- .changes/ui-sidebar-width-kept.md | 6 + .../src/sidebar-width.e2e.ts | 86 +++++++++ docs/architecture/iptvnator-ui-guidelines.md | 8 + .../lib/resizable/resizable.directive.spec.ts | 181 ++++++++++++++++++ .../src/lib/resizable/resizable.directive.ts | 73 ++++--- 5 files changed, 324 insertions(+), 30 deletions(-) create mode 100644 .changes/ui-sidebar-width-kept.md create mode 100644 apps/electron-backend-e2e/src/sidebar-width.e2e.ts create mode 100644 libs/ui/components/src/lib/resizable/resizable.directive.spec.ts diff --git a/.changes/ui-sidebar-width-kept.md b/.changes/ui-sidebar-width-kept.md new file mode 100644 index 000000000..fbabe190b --- /dev/null +++ b/.changes/ui-sidebar-width-kept.md @@ -0,0 +1,6 @@ +--- +type: fix +area: ui +--- + +A sidebar you widen now keeps its width after you visit a page with a narrower sidebar, such as Settings. Opening that page no longer saves its smaller width over yours. diff --git a/apps/electron-backend-e2e/src/sidebar-width.e2e.ts b/apps/electron-backend-e2e/src/sidebar-width.e2e.ts new file mode 100644 index 000000000..e4895bf7a --- /dev/null +++ b/apps/electron-backend-e2e/src/sidebar-width.e2e.ts @@ -0,0 +1,86 @@ +import { Locator, Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + launchElectronApp, + openSettings, + openWorkspaceSection, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +/** + * The workspace context panels share one stored width but clamp it to their + * own limits. Opening a narrower panel used to save its clamped width, so a + * categories panel widened past Settings' 400px maximum came back at 400. + */ + +const SHARED_WIDTH_KEY = 'sidebar-width'; + +/** The CSS width the directive sets; the panels are content-box. */ +function renderedWidth(panel: Locator): Promise { + return panel.evaluate((el) => parseFloat(getComputedStyle(el).width)); +} + +function storedWidth(page: Page): Promise { + return page.evaluate((key) => localStorage.getItem(key), SHARED_WIDTH_KEY); +} + +async function dragPanelTo( + page: Page, + panel: Locator, + width: number +): Promise { + const handle = panel.locator(':scope > .resize-handle'); + const box = await handle.boundingBox(); + if (!box) throw new Error('The panel has no resize handle'); + const startX = Math.round(box.x + box.width / 2); + const y = Math.round(box.y + box.height / 2); + // The directive starts a drag from offsetWidth (border box included). + const delta = width - (await panel.evaluate((el) => el.offsetWidth)); + + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(startX + delta, y, { steps: 10 }); + await page.mouse.up(); +} + +test.describe('Electron shared sidebar width', () => { + test('@persistence @electron keeps a widened categories panel after Settings clamps it', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + try { + const page = app.mainWindow; + await page.setViewportSize({ width: 1440, height: 900 }); + await addXtreamPortal(page); + await openWorkspaceSection(page, 'Movies'); + await waitForXtreamWorkspaceReady(page); + const categories = page.locator('aside.context-panel--route'); + await expect(categories).toBeVisible(); + const categoriesUrl = page.url(); + + await dragPanelTo(page, categories, 520); + await expect.poll(() => renderedWidth(categories)).toBe(520); + await expect.poll(() => storedWidth(page)).toBe('520'); + + await openSettings(page); + const settings = page.locator('aside.context-panel--settings'); + await expect(settings).toBeVisible(); + await expect.poll(() => renderedWidth(settings)).toBe(400); + expect(await storedWidth(page)).toBe('520'); + + await page.goBack(); + await page.waitForURL(categoriesUrl); + await expect(categories).toBeVisible(); + await expect.poll(() => renderedWidth(categories)).toBe(520); + expect(await storedWidth(page)).toBe('520'); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index a9f26aa52..95f0a4cd8 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -212,6 +212,14 @@ directive-generated `.resize-handle` sets this centrally in `resizable.scss`. The shared live-layout sidebar reserves 8 px at its right edge so the inward half of the 12 px resize handle cannot cover the channel scrollbar. +## Resizable Sidebar Widths + +Workspace context panels and Favorites share one `sidebar-width` key (its +legacy aliases map onto it) but have different limits. `ResizableDirective` +renders the stored width clamped to the host's own limits and never writes +that clamped value back: only a drag that changes the width persists, so a +narrow sidebar such as Settings cannot shrink the width a wider one saved. + ## Keyboard Scrolling and Channel Focus `ChannelScrollFocusDirective` belongs on the actual channel scroll owner, diff --git a/libs/ui/components/src/lib/resizable/resizable.directive.spec.ts b/libs/ui/components/src/lib/resizable/resizable.directive.spec.ts new file mode 100644 index 000000000..12dfdebff --- /dev/null +++ b/libs/ui/components/src/lib/resizable/resizable.directive.spec.ts @@ -0,0 +1,181 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ResizableDirective } from './resizable.directive'; + +@Component({ + imports: [ResizableDirective], + template: ` + + `, +}) +class Host { + minWidth = 200; + maxWidth = 600; + defaultWidth = 300; + storageKey = 'sidebar-width'; + handlePosition: 'right' | 'left' = 'right'; +} + +describe('ResizableDirective', () => { + const fixtures: ComponentFixture[] = []; + + function render(host: Partial = {}): HTMLElement { + const fixture = TestBed.createComponent(Host); + Object.assign(fixture.componentInstance, host); + fixture.detectChanges(); + fixtures.push(fixture); + + const aside: HTMLElement = fixture.nativeElement.querySelector('aside'); + // jsdom has no layout; a drag starts from the rendered width. + Object.defineProperty(aside, 'offsetWidth', { + configurable: true, + get: () => parseInt(aside.style.width, 10) || 0, + }); + return aside; + } + + function drag(aside: HTMLElement, fromX: number, toX: number): void { + const handle = aside.querySelector('.resize-handle') as HTMLElement; + handle.dispatchEvent( + new MouseEvent('mousedown', { clientX: fromX, bubbles: true }) + ); + if (toX !== fromX) { + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: toX, bubbles: true }) + ); + } + document.dispatchEvent( + new MouseEvent('mouseup', { clientX: toX, bubbles: true }) + ); + } + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + fixtures.splice(0).forEach((fixture) => fixture.destroy()); + localStorage.clear(); + }); + + it('clamps a shared width for rendering without writing it back', () => { + localStorage.setItem('sidebar-width', '520'); + + const settings = render({ minWidth: 200, maxWidth: 400 }); + + expect(settings.style.width).toBe('400px'); + expect(localStorage.getItem('sidebar-width')).toBe('520'); + + const categories = render({ minWidth: 240, maxWidth: 560 }); + + expect(categories.style.width).toBe('520px'); + expect(localStorage.getItem('sidebar-width')).toBe('520'); + }); + + it('does not raise a shared width a wider minimum clamps up', () => { + localStorage.setItem('sidebar-width', '210'); + + const favorites = render({ minWidth: 250, maxWidth: 600 }); + + expect(favorites.style.width).toBe('250px'); + expect(localStorage.getItem('sidebar-width')).toBe('210'); + }); + + it('reads an alias key from the shared width without writing it', () => { + localStorage.setItem('sidebar-width', '520'); + + const panel = render({ + maxWidth: 400, + storageKey: 'workspace-settings-panel-width', + }); + + expect(panel.style.width).toBe('400px'); + expect(localStorage.getItem('sidebar-width')).toBe('520'); + expect( + localStorage.getItem('workspace-settings-panel-width') + ).toBeNull(); + }); + + it('migrates the raw legacy width, not the clamped one', () => { + localStorage.setItem('downloads-sidebar-width', '520'); + + const downloads = render({ + maxWidth: 400, + storageKey: 'downloads-sidebar-width', + }); + + expect(downloads.style.width).toBe('400px'); + expect(localStorage.getItem('sidebar-width')).toBe('520'); + expect(localStorage.getItem('downloads-sidebar-width')).toBeNull(); + }); + + it('persists the width a user drags to', () => { + localStorage.setItem('sidebar-width', '300'); + const aside = render({ maxWidth: 560 }); + + drag(aside, 100, 180); + + expect(aside.style.width).toBe('380px'); + expect(localStorage.getItem('sidebar-width')).toBe('380'); + }); + + it('persists a left-handle drag on a key of its own', () => { + localStorage.setItem('live-channels-sidebar-width', '400'); + const aside = render({ + storageKey: 'live-channels-sidebar-width', + handlePosition: 'left', + }); + + drag(aside, 400, 350); + + expect(aside.style.width).toBe('450px'); + expect(localStorage.getItem('live-channels-sidebar-width')).toBe('450'); + expect(localStorage.getItem('sidebar-width')).toBeNull(); + }); + + it('does not persist the clamped width when the handle is only clicked', () => { + localStorage.setItem('sidebar-width', '520'); + const settings = render({ maxWidth: 400 }); + + drag(settings, 100, 100); + + expect(settings.style.width).toBe('400px'); + expect(localStorage.getItem('sidebar-width')).toBe('520'); + }); + + it('falls back to the default without writing an unparseable width', () => { + localStorage.setItem('sidebar-width', 'wide'); + + const aside = render({ defaultWidth: 290 }); + + expect(aside.style.width).toBe('290px'); + expect(localStorage.getItem('sidebar-width')).toBe('wide'); + }); + + it('keeps an unparseable legacy width instead of migrating it', () => { + localStorage.setItem('downloads-sidebar-width', 'wide'); + + const aside = render({ + defaultWidth: 290, + storageKey: 'downloads-sidebar-width', + }); + + expect(aside.style.width).toBe('290px'); + expect(localStorage.getItem('sidebar-width')).toBeNull(); + expect(localStorage.getItem('downloads-sidebar-width')).toBe('wide'); + }); + + it('renders the default without writing when nothing is stored', () => { + const aside = render({ defaultWidth: 290 }); + + expect(aside.style.width).toBe('290px'); + expect(localStorage.getItem('sidebar-width')).toBeNull(); + }); +}); diff --git a/libs/ui/components/src/lib/resizable/resizable.directive.ts b/libs/ui/components/src/lib/resizable/resizable.directive.ts index 13003dc25..d9351c828 100644 --- a/libs/ui/components/src/lib/resizable/resizable.directive.ts +++ b/libs/ui/components/src/lib/resizable/resizable.directive.ts @@ -23,6 +23,11 @@ const SIDEBAR_WIDTH_KEY_ALIASES = new Set([ 'iptvnator_video_sidebar_width', ]); +function parseStoredWidth(value: string): number | null { + const width = parseInt(value, 10); + return Number.isNaN(width) ? null : width; +} + /** * ResizableDirective - Makes an element horizontally resizable by dragging its edge. * @@ -72,6 +77,7 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy { private isResizing = signal(false); private startX = 0; private startWidth = 0; + private widthBeforeResize = 0; private currentWidth = signal(0); // Event listener references for cleanup @@ -164,39 +170,41 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy { this.renderer.appendChild(this.el.nativeElement, handle); } + /** + * Sidebars sharing a key have different limits, so the stored width is + * clamped for rendering only: writing it back would hand every other + * sidebar the narrowest (or widest) limit. Only a user resize persists. + */ private loadPersistedWidth(): void { + const storedWidth = this.readStoredWidth(); + this.setWidthImmediate( + storedWidth === null + ? this.defaultWidth() + : this.clampWidth(storedWidth) + ); + } + + /** The unclamped stored width, migrating a legacy key's raw value. */ + private readStoredWidth(): number | null { const { key, legacyKeys } = this.resolveStorageKey(); - let widthToApply = this.defaultWidth(); - - if (key) { - let stored = localStorage.getItem(key); - let sourceLegacyKey: string | null = null; - - if (!stored) { - for (const legacyKey of legacyKeys) { - const legacyValue = localStorage.getItem(legacyKey); - if (legacyValue) { - stored = legacyValue; - sourceLegacyKey = legacyKey; - break; - } - } - } - - if (stored) { - const width = parseInt(stored, 10); - if (!isNaN(width)) { - widthToApply = this.clampWidth(width); - localStorage.setItem(key, widthToApply.toString()); - if (sourceLegacyKey && sourceLegacyKey !== key) { - localStorage.removeItem(sourceLegacyKey); - } - } - } + const stored = localStorage.getItem(key); + if (stored) { + return parseStoredWidth(stored); } - // Apply width immediately without transition to avoid flash - this.setWidthImmediate(widthToApply); + for (const legacyKey of legacyKeys) { + const legacyValue = localStorage.getItem(legacyKey); + if (!legacyValue) continue; + + const width = parseStoredWidth(legacyValue); + if (width !== null) { + localStorage.setItem(key, legacyValue); + localStorage.removeItem(legacyKey); + } + return width; + } + + return null; } /** Set width immediately without CSS transition */ @@ -246,6 +254,7 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy { this.isResizing.set(true); this.startX = clientX; this.startWidth = this.el.nativeElement.offsetWidth; + this.widthBeforeResize = this.currentWidth(); this.resizeStart.emit(); } @@ -284,7 +293,11 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy { this.isResizing.set(false); this.removeGlobalListeners(); - this.persistWidth(); + // A click on the handle is not a resize: persisting would save the + // width this sidebar clamped instead of the shared one. + if (this.currentWidth() !== this.widthBeforeResize) { + this.persistWidth(); + } this.resizeEnd.emit(this.currentWidth()); }