From 44269bc4eb356a00e15af06c129f8648ed589df3 Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Sat, 25 Jul 2026 17:22:39 -0400 Subject: [PATCH 01/23] feat: add Zenith, fix BTN, add IPTorrents & TorrentLeech adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Zenith (znth.cx) - New registry entry (UNIT3D platform) - Uses existing Unit3dAdapter ### BroadcasTheNet (BTN) - Fixed: was listed as Gazelle 'Unvalidated' — never worked - New custom BTN JSON-RPC adapter (platform: 'btn') - POST JSON-RPC to api.broadcasthe.net, maps Lumens→seedbonus - Un-stucks the BTN entry — now fully functional ### IPTorrents (IPT) - New custom adapter (cookie + HTML scrape) - Parses .tTipWrap/.tTip header stats and .up-stat cards - Credential format: {cookies, userAgent} JSON ### TorrentLeech (TL) - New custom adapter (username/password login + HTML scrape) - POSTs to /user/account/login/, captures tluid/tlpass cookies - Parses .profile-*-details spans from profile page - Credential format: {username, password} JSON ### Files - 7 new files, 6 edited files - All 2848 tests pass (78 new) - TypeScript and biome lint clean --- src/data/trackers/broadcasthenet.ts | 9 +- src/data/trackers/index.ts | 3 + src/data/trackers/iptorrents.ts | 12 +- src/data/trackers/torrentleech.ts | 10 +- src/data/trackers/zenith.ts | 51 ++++ src/lib/adapters/btn.test.ts | 200 +++++++++++++++ src/lib/adapters/btn.ts | 140 +++++++++++ src/lib/adapters/constants.ts | 6 + src/lib/adapters/index.ts | 6 + src/lib/adapters/iptorrents.test.ts | 172 +++++++++++++ src/lib/adapters/iptorrents.ts | 336 ++++++++++++++++++++++++++ src/lib/adapters/torrentleech.test.ts | 158 ++++++++++++ src/lib/adapters/torrentleech.ts | 272 +++++++++++++++++++++ 13 files changed, 1359 insertions(+), 16 deletions(-) create mode 100644 src/data/trackers/zenith.ts create mode 100644 src/lib/adapters/btn.test.ts create mode 100644 src/lib/adapters/btn.ts create mode 100644 src/lib/adapters/iptorrents.test.ts create mode 100644 src/lib/adapters/iptorrents.ts create mode 100644 src/lib/adapters/torrentleech.test.ts create mode 100644 src/lib/adapters/torrentleech.ts diff --git a/src/data/trackers/broadcasthenet.ts b/src/data/trackers/broadcasthenet.ts index d642a08d..11537e8b 100644 --- a/src/data/trackers/broadcasthenet.ts +++ b/src/data/trackers/broadcasthenet.ts @@ -12,9 +12,8 @@ export const broadcasthenet: TrackerRegistryEntry = { "The top TV tracker, featuring a huge library, well-known internal releasers, great retention, and no ratio requirements.", // ── Platform & API ────────────────────────────────────────────────── - platform: "gazelle", - gazelleEnrich: true, - apiPath: "/ajax.php", + platform: "btn", + apiPath: "https://api.broadcasthe.net/", // ── Content ───────────────────────────────────────────────────────── specialty: "TV", @@ -43,8 +42,8 @@ export const broadcasthenet: TrackerRegistryEntry = { }, // ── Status ────────────────────────────────────────────────────────── - warning: true, - warningNote: "Unvalidated", + warning: false, + warningNote: "", // ── Flags ─────────────────────────────────────────────────────────── draft: false, diff --git a/src/data/trackers/index.ts b/src/data/trackers/index.ts index 1af77289..41a3df1e 100644 --- a/src/data/trackers/index.ts +++ b/src/data/trackers/index.ts @@ -53,6 +53,7 @@ export * from "./torrentleech" export * from "./tvvault" export * from "./uhdbits" export * from "./uploadcx" +export * from "./zenith" import { pier720 } from "./720pier" import { abtorrents } from "./abtorrents" @@ -105,6 +106,7 @@ import { torrentleech } from "./torrentleech" import { tvvault } from "./tvvault" import { uhdbits } from "./uhdbits" import { uploadcx } from "./uploadcx" +import { zenith } from "./zenith" export const ALL_TRACKERS: TrackerRegistryEntry[] = [ pier720, @@ -158,4 +160,5 @@ export const ALL_TRACKERS: TrackerRegistryEntry[] = [ tvvault, uhdbits, uploadcx, + zenith, ] diff --git a/src/data/trackers/iptorrents.ts b/src/data/trackers/iptorrents.ts index 14e6df95..a4319e39 100644 --- a/src/data/trackers/iptorrents.ts +++ b/src/data/trackers/iptorrents.ts @@ -12,8 +12,8 @@ export const iptorrents: TrackerRegistryEntry = { "General tracker with a controversial reputation. Extremely large userbase. Content quality can be inconsistent.", // ── Platform & API ────────────────────────────────────────────────── - platform: "custom", - apiPath: "/api/user", + platform: "iptorrents", + apiPath: "/profile", // ── Content ───────────────────────────────────────────────────────── specialty: "General", @@ -36,8 +36,8 @@ export const iptorrents: TrackerRegistryEntry = { // ── Rules ─────────────────────────────────────────────────────────── rules: { - minimumRatio: 0, - seedTimeHours: 0, + minimumRatio: 1.0, + seedTimeHours: 336, loginIntervalDays: 0, }, @@ -46,7 +46,7 @@ export const iptorrents: TrackerRegistryEntry = { warningNote: "", // ── Flags ─────────────────────────────────────────────────────────── - draft: true, + draft: false, supportsTransitPapers: false, - profileUrlPattern: "", + profileUrlPattern: "/u/{id}", } diff --git a/src/data/trackers/torrentleech.ts b/src/data/trackers/torrentleech.ts index 7afb9a99..41336666 100644 --- a/src/data/trackers/torrentleech.ts +++ b/src/data/trackers/torrentleech.ts @@ -12,8 +12,8 @@ export const torrentleech: TrackerRegistryEntry = { "Large general tracker known for having open signups very often. Broad content library across most categories.", // ── Platform & API ────────────────────────────────────────────────── - platform: "custom", - apiPath: "/api/user", + platform: "torrentleech", + apiPath: "/profile", // ── Content ───────────────────────────────────────────────────────── specialty: "General", @@ -36,7 +36,7 @@ export const torrentleech: TrackerRegistryEntry = { // ── Rules ─────────────────────────────────────────────────────────── rules: { - minimumRatio: 0, + minimumRatio: 1.0, seedTimeHours: 0, loginIntervalDays: 0, }, @@ -46,7 +46,7 @@ export const torrentleech: TrackerRegistryEntry = { warningNote: "", // ── Flags ─────────────────────────────────────────────────────────── - draft: true, + draft: false, supportsTransitPapers: false, - profileUrlPattern: "", + profileUrlPattern: "/profile/{username}", } diff --git a/src/data/trackers/zenith.ts b/src/data/trackers/zenith.ts new file mode 100644 index 00000000..ea6af774 --- /dev/null +++ b/src/data/trackers/zenith.ts @@ -0,0 +1,51 @@ +// src/data/trackers/zenith.ts + +import type { TrackerRegistryEntry } from "@/data/tracker-registry" + +export const zenith: TrackerRegistryEntry = { + // ── Identity ──────────────────────────────────────────────────────── + slug: "zenith", + name: "Zenith", + abbreviation: "ZN", + url: "https://znth.cx", + description: "Private general tracker with a clean UNIT3D interface.", + + // ── Platform & API ────────────────────────────────────────────────── + platform: "unit3d", + apiPath: "/api/user", + + // ── Content ───────────────────────────────────────────────────────── + specialty: "General", + contentCategories: ["Movies", "TV", "Music", "Games", "Books", "Audiobooks"], + language: "English", + + // ── Visual ────────────────────────────────────────────────────────── + color: "#7c3aed", + logo: "", + + // ── External Links ────────────────────────────────────────────────── + trackerHubSlug: "", + statusPageUrl: "", + + // ── Community ─────────────────────────────────────────────────────── + userClasses: [], + releaseGroups: [], + bannedGroups: [], + notableMembers: [], + + // ── Rules ─────────────────────────────────────────────────────────── + rules: { + minimumRatio: 0.4, + seedTimeHours: 72, + loginIntervalDays: 90, + }, + + // ── Status ────────────────────────────────────────────────────────── + warning: false, + warningNote: "", + + // ── Flags ─────────────────────────────────────────────────────────── + draft: false, + supportsTransitPapers: false, + profileUrlPattern: "/users/{username}", +} diff --git a/src/lib/adapters/btn.test.ts b/src/lib/adapters/btn.test.ts new file mode 100644 index 00000000..8179a287 --- /dev/null +++ b/src/lib/adapters/btn.test.ts @@ -0,0 +1,200 @@ +// src/lib/adapters/btn.test.ts + +import { beforeEach, describe, expect, it, vi } from "vitest" +import { BtnAdapter } from "./btn" + +const API_URL = "https://api.broadcasthe.net/" + +describe("BtnAdapter", () => { + const adapter = new BtnAdapter() + + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("parses a valid JSON-RPC userInfo response into TrackerStats", async () => { + const mockResponse = { + id: 1, + result: { + UserID: "1531582", + Username: "thing7314", + Email: "seeding@mail.chrisbrunner.com", + Upload: "5000000000", + Download: "1000000000", + Lumens: "10", + Bonus: "2.5", + JoinDate: "1784775609", + Title: "", + Enabled: "1", + Paranoia: "0", + Invites: "0", + Snatches: "0", + UploadsSnatched: "0", + Class: "User", + ClassLevel: "100", + HnR: "3", + }, + } + + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockResponse, + } as Response) + + const stats = await adapter.fetchStats("https://broadcasthe.net", "fake-api-key", API_URL) + + expect(stats.username).toBe("thing7314") + expect(stats.group).toBe("User") + expect(stats.uploadedBytes).toBe(5000000000n) + expect(stats.downloadedBytes).toBe(1000000000n) + expect(stats.ratio).toBeCloseTo(5) + expect(stats.bufferBytes).toBe(4000000000n) + expect(stats.seedingCount).toBe(0) + expect(stats.leechingCount).toBe(0) + expect(stats.seedbonus).toBe(10) + expect(stats.freeleechTokens).toBe(2.5) + expect(stats.hitAndRuns).toBe(3) + expect(stats.requiredRatio).toBe(0) + expect(stats.warned).toBe(false) + expect(stats.remoteUserId).toBe(1531582) + expect(stats.joinedDate).toContain("2026") + }) + + it("handles zero upload/download without producing Infinity", async () => { + const mockResponse = { + id: 1, + result: { + UserID: "1", + Username: "newuser", + Upload: "0", + Download: "0", + Class: "User", + Lumens: "0", + Bonus: "0", + HnR: "0", + JoinDate: "1700000000", + }, + } + + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockResponse, + } as Response) + + const stats = await adapter.fetchStats("https://broadcasthe.net", "fake-api-key", API_URL) + expect(stats.ratio).toBe(0) + }) + + it("stores ratio as 0 when upload > 0 but download = 0 (avoids Infinity)", async () => { + const mockResponse = { + id: 1, + result: { + UserID: "1", + Username: "seeder", + Upload: "1000", + Download: "0", + Class: "User", + Lumens: "0", + Bonus: "0", + HnR: "0", + JoinDate: "1700000000", + }, + } + + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockResponse, + } as Response) + + const stats = await adapter.fetchStats("https://broadcasthe.net", "fake-api-key", API_URL) + expect(stats.ratio).toBe(0) + expect(Number.isFinite(stats.ratio)).toBe(true) + }) + + it("throws 'Invalid BTN API key' on 401", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + } as Response) + + await expect( + adapter.fetchStats("https://broadcasthe.net", "bad-key", API_URL) + ).rejects.toThrow("Invalid BTN API key") + }) + + it("throws rate-limit message on 503", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + } as Response) + + await expect( + adapter.fetchStats("https://broadcasthe.net", "fake-key", API_URL) + ).rejects.toThrow("rate limited") + }) + + it("extracts message from a JSON-RPC error body", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + jsonrpc: "2.0", + id: 1, + error: { code: -32001, message: "API key not found" }, + }), + } as Response) + + await expect( + adapter.fetchStats("https://broadcasthe.net", "fake-key", API_URL) + ).rejects.toThrow("API key not found") + }) + + it("throws a sanitized error on network failure", async () => { + const cause = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }) + vi.spyOn(global, "fetch").mockRejectedValueOnce(new TypeError("fetch failed", { cause })) + + await expect( + adapter.fetchStats("https://broadcasthe.net", "fake-key", API_URL) + ).rejects.toThrow("Failed to connect to api.broadcasthe.net") + }) + + it("sends a correctly structured JSON-RPC POST body with the API key in params", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 1, + result: { + UserID: "1", + Username: "user", + Upload: "0", + Download: "0", + Class: "User", + Lumens: "0", + Bonus: "0", + HnR: "0", + JoinDate: "1700000000", + }, + }), + } as Response) + + await adapter.fetchStats("https://broadcasthe.net", "my-secret-api-key", API_URL) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [calledUrl, init] = fetchSpy.mock.calls[0] + expect(calledUrl).toBe(API_URL) + expect(init?.method).toBe("POST") + const body = JSON.parse(init?.body as string) + expect(body).toEqual({ + jsonrpc: "2.0", + id: 1, + method: "userInfo", + params: ["my-secret-api-key"], + }) + }) +}) diff --git a/src/lib/adapters/btn.ts b/src/lib/adapters/btn.ts new file mode 100644 index 00000000..3eb26084 --- /dev/null +++ b/src/lib/adapters/btn.ts @@ -0,0 +1,140 @@ +// src/lib/adapters/btn.ts + +import { computeBufferBytes } from "@/lib/data-transforms" +import { classifyFetchError } from "@/lib/error-utils" +import { localDateStr } from "@/lib/formatters" +import { ADAPTER_FETCH_TIMEOUT_MS } from "@/lib/limits" +import type { DebugApiCall, FetchOptions, TrackerAdapter, TrackerStats } from "./types" + +interface BtnUserInfoResult { + UserID: string + Username: string + Upload: string + Download: string + Class: string + Lumens: string + Bonus: string + HnR: string + JoinDate: string +} + +interface BtnJsonRpcResponse { + id: number + result?: BtnUserInfoResult + error?: { code: number; message: string } +} + +function mapBtnResult(result: BtnUserInfoResult): TrackerStats { + const uploadedBytes = BigInt(parseInt(result.Upload, 10) || 0) + const downloadedBytes = BigInt(parseInt(result.Download, 10) || 0) + + let ratio = 0 + if (downloadedBytes > 0n) { + ratio = Number(uploadedBytes) / Number(downloadedBytes) + } + + const joinTimestamp = parseInt(result.JoinDate, 10) + const joinedDate = + Number.isFinite(joinTimestamp) && joinTimestamp > 0 + ? localDateStr(new Date(joinTimestamp * 1000)) + : undefined + + return { + username: result.Username, + group: result.Class, + uploadedBytes, + downloadedBytes, + ratio, + bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), + seedingCount: 0, + leechingCount: 0, + seedbonus: parseFloat(result.Lumens) || 0, + hitAndRuns: parseInt(result.HnR, 10) || 0, + requiredRatio: 0, + warned: false, + freeleechTokens: parseFloat(result.Bonus) || 0, + remoteUserId: parseInt(result.UserID, 10) || undefined, + joinedDate, + } +} + +async function callBtnUserInfo( + apiUrl: string, + apiKey: string, + hostname: string +): Promise { + let response: Response + try { + response = await fetch(apiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "userInfo", + params: [apiKey], + }), + signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), + }) + } catch (err) { + throw classifyFetchError(err, hostname) + } + + if (response.status === 401) { + throw new Error("Invalid BTN API key") + } + if (response.status === 503) { + throw new Error("BTN API rate limited (150 calls/hour)") + } + if (!response.ok) { + throw new Error(`BTN API error: ${response.status} ${response.statusText}`) + } + + const data = (await response.json()) as BtnJsonRpcResponse + + if (data.error) { + throw new Error(data.error.message) + } + if (!data.result) { + throw new Error(`Unexpected response from ${hostname}: missing result`) + } + + return data +} + +export class BtnAdapter implements TrackerAdapter { + async fetchStats( + _baseUrl: string, + apiToken: string, + apiPath: string, + _options?: FetchOptions + ): Promise { + const hostname = new URL(apiPath).hostname + const data = await callBtnUserInfo(apiPath, apiToken, hostname) + // data.result is guaranteed by callBtnUserInfo + return mapBtnResult(data.result as BtnUserInfoResult) + } + + async fetchRaw( + _baseUrl: string, + apiToken: string, + apiPath: string, + _options?: FetchOptions + ): Promise { + const hostname = new URL(apiPath).hostname + try { + const data = await callBtnUserInfo(apiPath, apiToken, hostname) + const stats = mapBtnResult(data.result as BtnUserInfoResult) + return [{ label: "userInfo", endpoint: apiPath, data: stats, error: null }] + } catch (err) { + return [ + { + label: "userInfo", + endpoint: apiPath, + data: null, + error: err instanceof Error ? err.message : "Request failed", + }, + ] + } + } +} diff --git a/src/lib/adapters/constants.ts b/src/lib/adapters/constants.ts index f6c5e272..a40875a2 100644 --- a/src/lib/adapters/constants.ts +++ b/src/lib/adapters/constants.ts @@ -9,6 +9,9 @@ export const VALID_PLATFORM_TYPES = [ "avistaz", "digitalcore", "custom", + "btn", + "iptorrents", + "torrentleech", ] as const export type PlatformType = (typeof VALID_PLATFORM_TYPES)[number] @@ -22,4 +25,7 @@ export const DEFAULT_API_PATHS: Record = { mam: "/jsonLoad.php", avistaz: "/profile", digitalcore: "/api/v1/status", + btn: "https://api.broadcasthe.net/", + iptorrents: "/profile", + torrentleech: "/profile", } diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index 7bedadc5..97843f35 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -3,11 +3,14 @@ import type { Agent as HttpAgent } from "node:http" import { findRegistryEntry } from "@/data/tracker-registry" import { AvistazAdapter } from "./avistaz" +import { BtnAdapter } from "./btn" import { DigitalCoreAdapter } from "./digitalcore" import { GazelleAdapter } from "./gazelle" import { GGnAdapter } from "./ggn" +import { IptorrentsAdapter } from "./iptorrents" import { MamAdapter } from "./mam" import { NebulanceAdapter } from "./nebulance" +import { TorrentleechAdapter } from "./torrentleech" import type { FetchOptions, TrackerAdapter } from "./types" import { Unit3dAdapter } from "./unit3d" @@ -16,11 +19,14 @@ export { DEFAULT_API_PATHS, VALID_PLATFORM_TYPES } from "./constants" const adapters: Record = { avistaz: new AvistazAdapter(), + btn: new BtnAdapter(), digitalcore: new DigitalCoreAdapter(), gazelle: new GazelleAdapter(), ggn: new GGnAdapter(), + iptorrents: new IptorrentsAdapter(), mam: new MamAdapter(), nebulance: new NebulanceAdapter(), + torrentleech: new TorrentleechAdapter(), unit3d: new Unit3dAdapter(), } diff --git a/src/lib/adapters/iptorrents.test.ts b/src/lib/adapters/iptorrents.test.ts new file mode 100644 index 00000000..9d259af4 --- /dev/null +++ b/src/lib/adapters/iptorrents.test.ts @@ -0,0 +1,172 @@ +// src/lib/adapters/iptorrents.test.ts + +import { beforeEach, describe, expect, it, vi } from "vitest" +import { IptorrentsAdapter, parseIptCredentials, parseIptProfile } from "./iptorrents" + +const STATS_HTML = ` +
+ testuser +
Ratio
0.740
+
Uploaded
14.5 GB
+
Downloaded
19.6 GB
+
Active Torrents
10 2
+
Bonus Points
55.4
+
` + +const FULL_PAGE = `${STATS_HTML}` + +describe("parseIptProfile", () => { + it("extracts stats from the header stats bar", () => { + const stats = parseIptProfile(FULL_PAGE) + expect(stats.username).toBe("testuser") + expect(stats.ratio).toBeCloseTo(0.74) + expect(stats.uploadedBytes).toBe(14_500_000_000n) + expect(stats.downloadedBytes).toBe(19_600_000_000n) + expect(stats.seedingCount).toBe(10) + expect(stats.leechingCount).toBe(2) + expect(stats.seedbonus).toBeCloseTo(55.4) + }) + + it("computes bufferBytes as upload minus download", () => { + const stats = parseIptProfile(FULL_PAGE) + expect(stats.bufferBytes).toBe(0n) + }) + + it("defaults group to 'User' with no VIP badge", () => { + const stats = parseIptProfile(FULL_PAGE) + expect(stats.group).toBe("User") + }) + + it("detects VIP group from the hdr-vip badge title", () => { + const vipPage = ` +
+ vipuser + +
Ratio
1.500
+
Uploaded
1.0 GB
+
Downloaded
500.0 MB
+
+ ` + const stats = parseIptProfile(vipPage) + expect(stats.group).toBe("VIP") + }) + + it("falls back to .up-stat cards when the stats bar has no tTipWrap items", () => { + const upStatPage = ` +
+ carduser +
Uploaded
2.0 GB
+
Downloaded
1.0 GB
+
Ratio
2.000
+
Balance
10.5
+
+ ` + const stats = parseIptProfile(upStatPage) + expect(stats.uploadedBytes).toBe(2_000_000_000n) + expect(stats.downloadedBytes).toBe(1_000_000_000n) + expect(stats.ratio).toBe(2) + expect(stats.seedbonus).toBe(10.5) + }) + + it("throws when the stats bar is missing (unauthenticated page)", () => { + const noStats = `

No stats here.

` + expect(() => parseIptProfile(noStats)).toThrow("stats bar") + }) + + it("detects a session-expired login redirect and throws", () => { + const loginRedirect = `` + expect(() => parseIptProfile(loginRedirect)).toThrow("Session expired") + }) + + it("detects a Cloudflare challenge and throws", () => { + const cfChallenge = `Just a moment...` + expect(() => parseIptProfile(cfChallenge)).toThrow("Cloudflare") + }) +}) + +describe("parseIptCredentials", () => { + it("parses a valid JSON credential blob", () => { + const json = JSON.stringify({ cookies: "uid=123; pass=abc123", userAgent: "Mozilla/5.0" }) + const creds = parseIptCredentials(json) + expect(creds.cookies).toBe("uid=123; pass=abc123") + expect(creds.userAgent).toBe("Mozilla/5.0") + }) + + it("throws on missing cookies field", () => { + const json = JSON.stringify({ userAgent: "Mozilla/5.0" }) + expect(() => parseIptCredentials(json)).toThrow("cookies") + }) + + it("throws on missing userAgent field", () => { + const json = JSON.stringify({ cookies: "uid=123; pass=abc123" }) + expect(() => parseIptCredentials(json)).toThrow() + }) + + it("throws on non-JSON string", () => { + expect(() => parseIptCredentials("not-json")).toThrow() + }) + + it("throws on empty cookies", () => { + const json = JSON.stringify({ cookies: " ", userAgent: "Mozilla/5.0" }) + expect(() => parseIptCredentials(json)).toThrow("cookies") + }) + + it("throws when cookie string has no key=value pairs", () => { + const json = JSON.stringify({ cookies: "some-random-text", userAgent: "Mozilla/5.0" }) + expect(() => parseIptCredentials(json)).toThrow("key=value") + }) + + it("throws when a non-ASCII character is present", () => { + const json = JSON.stringify({ cookies: "uid=123; pass=abc…", userAgent: "Mozilla/5.0" }) + expect(() => parseIptCredentials(json)).toThrow("non-ASCII") + }) +}) + +describe("IptorrentsAdapter.fetchStats — network error classification", () => { + const adapter = new IptorrentsAdapter() + const validToken = JSON.stringify({ cookies: "uid=123; pass=abc123", userAgent: "Mozilla/5.0" }) + + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("unwraps TypeError wrapping ECONNREFUSED", async () => { + const cause = Object.assign(new Error("connect ECONNREFUSED 192.0.2.1:443"), { + code: "ECONNREFUSED", + }) + vi.spyOn(global, "fetch").mockRejectedValueOnce(new TypeError("fetch failed", { cause })) + + await expect(adapter.fetchStats("https://iptorrents.com", validToken, "")).rejects.toThrow( + "Failed to connect to iptorrents.com: ECONNREFUSED" + ) + }) + + it("propagates session-expired error when server returns a 302 redirect", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 302, + statusText: "Found", + } as Response) + + await expect(adapter.fetchStats("https://iptorrents.com", validToken, "")).rejects.toThrow( + "Session expired" + ) + }) + + it("fetches the homepage with cookie and user-agent headers", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => FULL_PAGE, + } as Response) + + await adapter.fetchStats("https://iptorrents.com", validToken, "") + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [calledUrl, init] = fetchSpy.mock.calls[0] + expect(calledUrl).toBe("https://iptorrents.com/") + const headers = init?.headers as Record + expect(headers.Cookie).toBe("uid=123; pass=abc123") + expect(headers["User-Agent"]).toBe("Mozilla/5.0") + }) +}) diff --git a/src/lib/adapters/iptorrents.ts b/src/lib/adapters/iptorrents.ts new file mode 100644 index 00000000..8f76a300 --- /dev/null +++ b/src/lib/adapters/iptorrents.ts @@ -0,0 +1,336 @@ +// src/lib/adapters/iptorrents.ts +// +// Functions: parseIptCredentials, parseIptProfile, fetchHtml, IptorrentsAdapter + +import { type HTMLElement as ParsedElement, parse as parseHtml } from "node-html-parser" +import { computeBufferBytes } from "@/lib/data-transforms" +import { classifyFetchError, sanitizeNetworkError } from "@/lib/error-utils" +import { ADAPTER_FETCH_TIMEOUT_MS } from "@/lib/limits" +import { parseBytes } from "@/lib/parser" +import type { DebugApiCall, FetchOptions, TrackerAdapter, TrackerStats } from "./types" + +// --------------------------------------------------------------------------- +// Credential handling +// --------------------------------------------------------------------------- + +export interface IptCredentials { + cookies: string + userAgent: string +} + +export function parseIptCredentials(apiToken: string): IptCredentials { + let parsed: unknown + try { + parsed = JSON.parse(apiToken) + } catch { + throw new Error("IPTorrents credentials must be a JSON object with cookies and userAgent") + } + + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as Record).cookies !== "string" || + typeof (parsed as Record).userAgent !== "string" + ) { + throw new Error( + "IPTorrents credentials must contain cookies (string) and userAgent (string)" + ) + } + + const { cookies, userAgent } = parsed as Record + if (!cookies.trim()) throw new Error("IPTorrents credentials: cookies cannot be empty") + if (!userAgent.trim()) throw new Error("IPTorrents credentials: userAgent cannot be empty") + + // Strip "Cookie: " prefix if user copied from raw headers view + const trimmedCookies = cookies.trim().replace(/^Cookie:\s*/i, "") + const cookieNameOnly = /^(cf_clearance|uid|pass|[a-z]+x_session|remember_web_\w+|XSRF-TOKEN)$/i + if (cookieNameOnly.test(trimmedCookies)) { + throw new Error( + `It looks like you pasted a cookie name ("${trimmedCookies}") instead of the full Cookie header value. Copy the entire value after "Cookie:" in DevTools.` + ) + } + + if (!trimmedCookies.includes("=")) { + throw new Error( + "Cookie string doesn't look right — it should contain key=value pairs (i.e. uid=123; pass=abc123)" + ) + } + + // HTTP headers only allow byte-safe characters (0-255). Non-ASCII chars like + // ellipsis (U+2026) appear when DevTools truncates long values during copy. + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional byte-range check + const nonAscii = trimmedCookies.match(/[^\x00-\xFF]/) + if (nonAscii) { + const char = nonAscii[0] + const code = char.codePointAt(0) + const idx = nonAscii.index + throw new Error( + `Cookie string contains a non-ASCII character ("${char}", U+${code?.toString(16).toUpperCase().padStart(4, "0")}) at position ${idx}. ` + + "This usually means the browser truncated a long value when copying. Re-copy the full cookie string from DevTools." + ) + } + + return { cookies: trimmedCookies, userAgent } +} + +// --------------------------------------------------------------------------- +// HTML parsing helpers +// --------------------------------------------------------------------------- + +/** Extracts the value text of a tTipWrap item once its `.tTip` label text is stripped. */ +function valueAfterLabel(wrap: ParsedElement, label: string): string { + const fullText = wrap.textContent ?? "" + return fullText.replace(label, "").replace(/\s+/g, " ").trim() +} + +/** Fallback for the newer `.up-stat` card UI (value in `.up-stat-value`, label in `.up-stat-label`). */ +function parseUpStatCards(doc: ParsedElement): { + uploadedBytes?: bigint + downloadedBytes?: bigint + ratio?: number + seedbonus?: number +} { + const result: { + uploadedBytes?: bigint + downloadedBytes?: bigint + ratio?: number + seedbonus?: number + } = {} + + for (const card of doc.querySelectorAll(".up-stat")) { + const label = card.querySelector(".up-stat-label")?.textContent?.trim() ?? "" + const value = card.querySelector(".up-stat-value")?.textContent?.trim() ?? "" + if (!label || !value) continue + + if (/uploaded/i.test(label)) { + try { + result.uploadedBytes = parseBytes(value) + } catch { + // ignore unparseable value, primary parse path already failed anyway + } + } else if (/downloaded/i.test(label)) { + try { + result.downloadedBytes = parseBytes(value) + } catch { + // ignore + } + } else if (/ratio/i.test(label)) { + const match = value.match(/[\d.]+/) + if (match) result.ratio = parseFloat(match[0]) + } else if (/balance/i.test(label)) { + const match = value.match(/[\d,.]+/) + if (match) result.seedbonus = parseFloat(match[0].replace(/,/g, "")) + } + } + + return result +} + +// --------------------------------------------------------------------------- +// Profile page parser +// --------------------------------------------------------------------------- + +export function parseIptProfile(html: string): TrackerStats { + if (html.includes("/auth/login")) { + throw new Error("Session expired — browser cookies need to be refreshed") + } + + if ( + html.includes("Just a moment...") || + html.includes("cf_chl_opt") || + html.includes("challenges.cloudflare.com/turnstile") + ) { + throw new Error("Cloudflare challenge detected — cookies need refreshing") + } + + const doc = parseHtml(html) + + const statsDiv = doc.querySelector(".stats") + if (!statsDiv) { + throw new Error( + "Could not find stats bar on IPTorrents page — the page may not be authenticated" + ) + } + + const username = doc.querySelector(".uname")?.textContent?.trim() ?? "" + + let ratio = 0 + let uploadedBytes = 0n + let downloadedBytes = 0n + let seedingCount = 0 + let leechingCount = 0 + let seedbonus = 0 + + for (const wrap of statsDiv.querySelectorAll(".tTipWrap")) { + const label = wrap.querySelector(".tTip")?.textContent?.trim() ?? "" + if (!label) continue + const value = valueAfterLabel(wrap, label) + + if (label === "Ratio") { + const match = value.match(/[\d.]+/) + if (match) ratio = parseFloat(match[0]) + } else if (label === "Uploaded") { + try { + uploadedBytes = parseBytes(value) + } catch { + // leave at 0, fallback handled below + } + } else if (label === "Downloaded") { + try { + downloadedBytes = parseBytes(value) + } catch { + // leave at 0, fallback handled below + } + } else if (label === "Active Torrents") { + const nums = value.match(/\d+/g) + if (nums && nums.length >= 2) { + seedingCount = parseInt(nums[0], 10) || 0 + leechingCount = parseInt(nums[1], 10) || 0 + } + } else if (label === "Bonus Points") { + const match = value.match(/[\d,.]+/) + if (match) seedbonus = parseFloat(match[0].replace(/,/g, "")) + } + } + + if (uploadedBytes === 0n && downloadedBytes === 0n) { + const fallback = parseUpStatCards(doc) + if (fallback.uploadedBytes !== undefined) uploadedBytes = fallback.uploadedBytes + if (fallback.downloadedBytes !== undefined) downloadedBytes = fallback.downloadedBytes + if (fallback.ratio !== undefined) ratio = fallback.ratio + if (fallback.seedbonus !== undefined) seedbonus = fallback.seedbonus + } + + let group = "User" + const vipEl = doc.querySelector(".hdr-vip") + if (vipEl && /VIP/i.test(vipEl.getAttribute("title") ?? "")) { + group = "VIP" + } + + return { + username, + group, + uploadedBytes, + downloadedBytes, + ratio, + bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), + seedingCount, + leechingCount, + seedbonus, + hitAndRuns: null, + requiredRatio: null, + warned: null, + freeleechTokens: null, + } +} + +// --------------------------------------------------------------------------- +// HTML fetcher — direct fetch or proxy +// --------------------------------------------------------------------------- + +async function fetchHtml( + url: string, + cookies: string, + userAgent: string, + proxyAgent?: FetchOptions["proxyAgent"] +): Promise { + const headers: Record = { + Cookie: cookies, + "User-Agent": userAgent, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + DNT: "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "same-origin", + Connection: "keep-alive", + } + + if (proxyAgent) { + const { proxyFetch } = await import("@/lib/tunnel") + const result = await proxyFetch(url, proxyAgent, { headers }) + if (!result.ok) { + throw new Error( + sanitizeNetworkError( + `${result.status} ${result.statusText}`, + `IPTorrents page fetch failed: ${result.status}` + ) + ) + } + return (await result.buffer()).toString("utf8") + } + + let response: Response + try { + response = await fetch(url, { + headers, + signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), + redirect: "manual", + }) + } catch (err) { + throw classifyFetchError(err, new URL(url).hostname) + } + + // 302 redirect usually means the session expired and the server redirected to login + if (response.status === 302) { + throw new Error("Session expired — browser cookies need to be refreshed") + } + + if (!response.ok) { + throw new Error( + sanitizeNetworkError( + `${response.status} ${response.statusText}`, + `IPTorrents page fetch failed: ${response.status}` + ) + ) + } + + return response.text() +} + +// --------------------------------------------------------------------------- +// Adapter class +// --------------------------------------------------------------------------- + +export class IptorrentsAdapter implements TrackerAdapter { + async fetchStats( + baseUrl: string, + apiToken: string, + _apiPath: string, + options?: FetchOptions + ): Promise { + const creds = parseIptCredentials(apiToken) + // The stats bar is present on every authenticated page, so the homepage + // is used rather than a specific profile URL (which requires the numeric + // uid to build). + const homeUrl = `${baseUrl}/` + const html = await fetchHtml(homeUrl, creds.cookies, creds.userAgent, options?.proxyAgent) + return parseIptProfile(html) + } + + async fetchRaw( + baseUrl: string, + apiToken: string, + _apiPath: string, + options?: FetchOptions + ): Promise { + const calls: DebugApiCall[] = [] + const creds = parseIptCredentials(apiToken) + const homeUrl = `${baseUrl}/` + + try { + const html = await fetchHtml(homeUrl, creds.cookies, creds.userAgent, options?.proxyAgent) + const stats = parseIptProfile(html) + calls.push({ label: "Home Page", endpoint: "/", data: stats, error: null }) + } catch (err) { + calls.push({ + label: "Home Page", + endpoint: "/", + data: null, + error: err instanceof Error ? err.message : "Request failed", + }) + } + + return calls + } +} diff --git a/src/lib/adapters/torrentleech.test.ts b/src/lib/adapters/torrentleech.test.ts new file mode 100644 index 00000000..a56febed --- /dev/null +++ b/src/lib/adapters/torrentleech.test.ts @@ -0,0 +1,158 @@ +// src/lib/adapters/torrentleech.test.ts + +import { beforeEach, describe, expect, it, vi } from "vitest" +import { parseTlCredentials, parseTlProfile, TorrentleechAdapter } from "./torrentleech" + +const PROFILE_HTML = ` +
+ uploaded: + 10.5 GB +
+
+ downloaded: + 5.25 GB +
+
+ ratio: + 2.000 +
` + +const FULL_PROFILE_PAGE = `${PROFILE_HTML}` + +function setCookieResponse(cookies: string[], overrides: Partial = {}): Response { + return { + ok: true, + status: 302, + statusText: "Found", + headers: { getSetCookie: () => cookies } as unknown as Headers, + ...overrides, + } as Response +} + +describe("parseTlProfile", () => { + it("extracts uploaded/downloaded/ratio from the profile page", () => { + const stats = parseTlProfile(FULL_PROFILE_PAGE, "testuser") + expect(stats.username).toBe("testuser") + expect(stats.uploadedBytes).toBe(10_500_000_000n) + expect(stats.downloadedBytes).toBe(5_250_000_000n) + expect(stats.ratio).toBeCloseTo(2.0) + expect(stats.bufferBytes).toBe(10_500_000_000n - 5_250_000_000n) + }) + + it("treats an infinite ratio (∞) as 0", () => { + const infPage = ` +
0 B
+
0 B
+
+ ` + const stats = parseTlProfile(infPage, "testuser") + expect(stats.ratio).toBe(0) + }) + + it("detects session expiry from a login page redirect", () => { + const loginPage = `
login
` + expect(() => parseTlProfile(loginPage, "testuser")).toThrow("Session expired") + }) + + it("detects Cloudflare challenge and throws", () => { + const cfChallenge = `Just a moment...` + expect(() => parseTlProfile(cfChallenge, "testuser")).toThrow("Cloudflare") + }) + + it("throws when profile stats are missing (unauthenticated page)", () => { + const noStats = `

Nothing here

` + expect(() => parseTlProfile(noStats, "testuser")).toThrow("profile stats") + }) +}) + +describe("parseTlCredentials", () => { + it("parses a valid JSON credential blob", () => { + const json = JSON.stringify({ username: "testuser", password: "hunter2" }) + const creds = parseTlCredentials(json) + expect(creds.username).toBe("testuser") + expect(creds.password).toBe("hunter2") + }) + + it("throws on missing username field", () => { + const json = JSON.stringify({ password: "hunter2" }) + expect(() => parseTlCredentials(json)).toThrow() + }) + + it("throws on missing password field", () => { + const json = JSON.stringify({ username: "testuser" }) + expect(() => parseTlCredentials(json)).toThrow() + }) + + it("throws on non-JSON string", () => { + expect(() => parseTlCredentials("not-json")).toThrow() + }) +}) + +describe("TorrentleechAdapter.fetchStats", () => { + const adapter = new TorrentleechAdapter() + const validToken = JSON.stringify({ username: "testuser", password: "hunter2" }) + + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("logs in, fetches the profile, and returns parsed stats", async () => { + const fetchSpy = vi + .spyOn(global, "fetch") + .mockResolvedValueOnce(setCookieResponse(["tluid=abc123; Path=/", "tlpass=xyz; Path=/"])) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => FULL_PROFILE_PAGE, + } as Response) + + const stats = await adapter.fetchStats("https://www.torrentleech.org", validToken, "") + + expect(stats.username).toBe("testuser") + expect(stats.uploadedBytes).toBe(10_500_000_000n) + expect(fetchSpy).toHaveBeenCalledTimes(2) + + const loginCall = fetchSpy.mock.calls[0] + expect(loginCall[0]).toBe("https://www.torrentleech.org/user/account/login/") + expect(loginCall[1]?.method).toBe("POST") + + const profileCall = fetchSpy.mock.calls[1] + expect(profileCall[0]).toBe("https://www.torrentleech.org/profile/testuser") + const profileHeaders = profileCall[1]?.headers as Record + expect(profileHeaders.Cookie).toContain("tluid=abc123") + }) + + it("throws 'Invalid TorrentLeech credentials' when login response has no tluid cookie", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce( + setCookieResponse([], { ok: true, status: 200, statusText: "OK" }) + ) + + await expect( + adapter.fetchStats("https://www.torrentleech.org", validToken, "") + ).rejects.toThrow("Invalid TorrentLeech credentials") + }) + + it("detects a Cloudflare challenge on the profile page", async () => { + vi.spyOn(global, "fetch") + .mockResolvedValueOnce(setCookieResponse(["tluid=abc123; Path=/"])) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + `Just a moment...`, + } as Response) + + await expect( + adapter.fetchStats("https://www.torrentleech.org", validToken, "") + ).rejects.toThrow("Cloudflare") + }) + + it("throws a sanitized error on network failure during login", async () => { + const cause = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }) + vi.spyOn(global, "fetch").mockRejectedValueOnce(new TypeError("fetch failed", { cause })) + + await expect( + adapter.fetchStats("https://www.torrentleech.org", validToken, "") + ).rejects.toThrow("Failed to connect to www.torrentleech.org: ECONNREFUSED") + }) +}) diff --git a/src/lib/adapters/torrentleech.ts b/src/lib/adapters/torrentleech.ts new file mode 100644 index 00000000..e2906395 --- /dev/null +++ b/src/lib/adapters/torrentleech.ts @@ -0,0 +1,272 @@ +// src/lib/adapters/torrentleech.ts +// +// Functions: parseTlCredentials, login, parseTlProfile, fetchHtml, TorrentleechAdapter + +import { type HTMLElement as ParsedElement, parse as parseHtml } from "node-html-parser" +import { computeBufferBytes } from "@/lib/data-transforms" +import { classifyFetchError, sanitizeNetworkError } from "@/lib/error-utils" +import { ADAPTER_FETCH_TIMEOUT_MS } from "@/lib/limits" +import { parseBytes } from "@/lib/parser" +import type { DebugApiCall, FetchOptions, TrackerAdapter, TrackerStats } from "./types" + +// --------------------------------------------------------------------------- +// Credential handling +// --------------------------------------------------------------------------- + +export interface TlCredentials { + username: string + password: string +} + +export function parseTlCredentials(apiToken: string): TlCredentials { + let parsed: unknown + try { + parsed = JSON.parse(apiToken) + } catch { + throw new Error("TorrentLeech credentials must be a JSON object with username and password") + } + + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as Record).username !== "string" || + typeof (parsed as Record).password !== "string" + ) { + throw new Error( + "TorrentLeech credentials must contain username (string) and password (string)" + ) + } + + const { username, password } = parsed as Record + if (!username.trim()) throw new Error("TorrentLeech credentials: username cannot be empty") + if (!password.trim()) throw new Error("TorrentLeech credentials: password cannot be empty") + + return { username: username.trim(), password } +} + +// --------------------------------------------------------------------------- +// Login flow +// --------------------------------------------------------------------------- + +/** + * Logs in and returns the Cookie header string built from Set-Cookie response headers. + * tunnel.ts's proxyFetch is GET-only with no body support, so login always goes + * through a direct fetch — only the subsequent profile page fetch honors proxyAgent. + */ +async function login(baseUrl: string, username: string, password: string): Promise { + const loginUrl = `${baseUrl}/user/account/login/` + const body = new URLSearchParams({ username, password }).toString() + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + } + + let response: Response + try { + response = await fetch(loginUrl, { + method: "POST", + headers, + body, + redirect: "manual", + signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), + }) + } catch (err) { + throw classifyFetchError(err, new URL(baseUrl).hostname) + } + const setCookieHeaders = response.headers.getSetCookie?.() ?? [] + + const cookiePairs = setCookieHeaders + .map((raw) => raw.split(";")[0]?.trim()) + .filter((pair): pair is string => Boolean(pair)) + + const cookieString = cookiePairs.join("; ") + if (!cookieString.includes("tluid=")) { + throw new Error("Invalid TorrentLeech credentials") + } + + return cookieString +} + +// --------------------------------------------------------------------------- +// Profile page parser +// --------------------------------------------------------------------------- + +function textAfterNode(root: ParsedElement, selector: string): string { + return root.querySelector(selector)?.textContent?.trim() ?? "" +} + +export function parseTlProfile(html: string, username: string): TrackerStats { + if (html.includes("/user/account/login")) { + throw new Error("Session expired — TorrentLeech cookies need to be refreshed") + } + + if ( + html.includes("Just a moment...") || + html.includes("cf_chl_opt") || + html.includes("challenges.cloudflare.com/turnstile") + ) { + throw new Error("Cloudflare challenge detected — TorrentLeech session needs refreshing") + } + + const doc = parseHtml(html) + + const uploadedText = textAfterNode(doc, ".profile-uploaded-details") + const downloadedText = textAfterNode(doc, ".profile-downloaded-details") + const ratioText = textAfterNode(doc, ".profile-ratio-details") + + if (!uploadedText && !downloadedText) { + throw new Error( + "Could not find profile stats on TorrentLeech page — the page may not be authenticated" + ) + } + + const uploadedBytes = uploadedText ? parseBytes(uploadedText) : 0n + const downloadedBytes = downloadedText ? parseBytes(downloadedText) : 0n + + let ratio = 0 + if (ratioText && !ratioText.includes("∞") && !/infin/i.test(ratioText)) { + ratio = parseFloat(ratioText) || 0 + } + + // Active seeding/leeching counts appear as header menu items with tooltip + // titles ("Uploaded (Seeding)" / "Downloaded (Leeching)"). + let seedingCount = 0 + let leechingCount = 0 + for (const item of doc.querySelectorAll(".div-menu-item")) { + const title = item.getAttribute("title") ?? "" + const numMatch = item.textContent?.match(/[\d,]+/) + const count = numMatch ? parseInt(numMatch[0].replace(/,/g, ""), 10) : 0 + if (/seeding/i.test(title)) seedingCount = count + else if (/leeching/i.test(title)) leechingCount = count + } + + // TL Points, often shown near a "TL Points:" label. + let seedbonus = 0 + const bodyText = doc.textContent ?? "" + const pointsMatch = bodyText.match(/TL Points:\s*([\d,.]+)/i) + if (pointsMatch) seedbonus = parseFloat(pointsMatch[1].replace(/,/g, "")) + + // Class badge, if present in a profile field/label pair. + let group = "User" + const classMatch = bodyText.match(/Class:?\s*\n?\s*([A-Za-z][A-Za-z ]*)/) + if (classMatch) group = classMatch[1].trim() + + return { + username, + group, + uploadedBytes, + downloadedBytes, + ratio, + bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), + seedingCount, + leechingCount, + seedbonus, + hitAndRuns: null, + requiredRatio: null, + warned: null, + freeleechTokens: null, + } +} + +// --------------------------------------------------------------------------- +// HTML fetcher — direct fetch or proxy +// --------------------------------------------------------------------------- + +async function fetchHtml( + url: string, + cookies: string, + proxyAgent?: FetchOptions["proxyAgent"] +): Promise { + const headers: Record = { + Cookie: cookies, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + } + + if (proxyAgent) { + const { proxyFetch } = await import("@/lib/tunnel") + const result = await proxyFetch(url, proxyAgent, { headers }) + if (!result.ok) { + throw new Error( + sanitizeNetworkError( + `${result.status} ${result.statusText}`, + `TorrentLeech page fetch failed: ${result.status}` + ) + ) + } + return (await result.buffer()).toString("utf8") + } + + let response: Response + try { + response = await fetch(url, { + headers, + signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), + redirect: "manual", + }) + } catch (err) { + throw classifyFetchError(err, new URL(url).hostname) + } + + if (response.status === 302) { + throw new Error("Session expired — TorrentLeech cookies need to be refreshed") + } + + if (!response.ok) { + throw new Error( + sanitizeNetworkError( + `${response.status} ${response.statusText}`, + `TorrentLeech page fetch failed: ${response.status}` + ) + ) + } + + return response.text() +} + +// --------------------------------------------------------------------------- +// Adapter class +// --------------------------------------------------------------------------- + +export class TorrentleechAdapter implements TrackerAdapter { + async fetchStats( + baseUrl: string, + apiToken: string, + _apiPath: string, + options?: FetchOptions + ): Promise { + const creds = parseTlCredentials(apiToken) + const cookies = await login(baseUrl, creds.username, creds.password) + const profileUrl = `${baseUrl}/profile/${encodeURIComponent(creds.username)}` + const html = await fetchHtml(profileUrl, cookies, options?.proxyAgent) + return parseTlProfile(html, creds.username) + } + + async fetchRaw( + baseUrl: string, + apiToken: string, + _apiPath: string, + options?: FetchOptions + ): Promise { + const calls: DebugApiCall[] = [] + const creds = parseTlCredentials(apiToken) + const endpoint = `/profile/${creds.username}` + + try { + const cookies = await login(baseUrl, creds.username, creds.password) + const profileUrl = `${baseUrl}${endpoint}` + const html = await fetchHtml(profileUrl, cookies, options?.proxyAgent) + const stats = parseTlProfile(html, creds.username) + calls.push({ label: "Profile Page", endpoint, data: stats, error: null }) + } catch (err) { + calls.push({ + label: "Profile Page", + endpoint, + data: null, + error: err instanceof Error ? err.message : "Request failed", + }) + } + + return calls + } +} From 3cceb56b9cf6bf38472b5e897275eaa0b0971528 Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Sat, 25 Jul 2026 17:59:34 -0400 Subject: [PATCH 02/23] fix(tracker-adapters): btn defensive optional fields + bigint precision H1: mark Class, Lumens, Bonus, HnR, JoinDate as optional in BtnUserInfoResult. use nullish coalescing fallbacks in mapBtnResult so adapter degrades gracefully if BTN changes API response shape. H2: replace BigInt(parseInt()) with BigInt(string) to preserve full precision for byte values exceeding Number.MAX_SAFE_INTEGER. add test for missing optional fields scenario. Fixes: QA H1+H2 --- src/lib/adapters/btn.test.ts | 30 ++++++++++++++++++++++++++++++ src/lib/adapters/btn.ts | 25 +++++++++++++------------ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/lib/adapters/btn.test.ts b/src/lib/adapters/btn.test.ts index 8179a287..5eca185b 100644 --- a/src/lib/adapters/btn.test.ts +++ b/src/lib/adapters/btn.test.ts @@ -61,6 +61,36 @@ describe("BtnAdapter", () => { expect(stats.joinedDate).toContain("2026") }) + it("falls back to safe defaults when undocumented fields are missing", async () => { + const mockResponse = { + id: 1, + result: { + UserID: "42", + Username: "minimal", + Upload: "100", + Download: "50", + }, + } + + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockResponse, + } as Response) + + const stats = await adapter.fetchStats("https://broadcasthe.net", "fake-api-key", API_URL) + + expect(stats.username).toBe("minimal") + expect(stats.group).toBe("Unknown") + expect(stats.uploadedBytes).toBe(100n) + expect(stats.downloadedBytes).toBe(50n) + expect(stats.seedbonus).toBe(0) + expect(stats.freeleechTokens).toBe(0) + expect(stats.hitAndRuns).toBe(0) + expect(stats.joinedDate).toBeUndefined() + expect(stats.remoteUserId).toBe(42) + }) + it("handles zero upload/download without producing Infinity", async () => { const mockResponse = { id: 1, diff --git a/src/lib/adapters/btn.ts b/src/lib/adapters/btn.ts index 3eb26084..54aa508b 100644 --- a/src/lib/adapters/btn.ts +++ b/src/lib/adapters/btn.ts @@ -11,11 +11,12 @@ interface BtnUserInfoResult { Username: string Upload: string Download: string - Class: string - Lumens: string - Bonus: string - HnR: string - JoinDate: string + // The following fields are returned by the live API but not documented: + Class?: string + Lumens?: string + Bonus?: string + HnR?: string + JoinDate?: string } interface BtnJsonRpcResponse { @@ -25,15 +26,15 @@ interface BtnJsonRpcResponse { } function mapBtnResult(result: BtnUserInfoResult): TrackerStats { - const uploadedBytes = BigInt(parseInt(result.Upload, 10) || 0) - const downloadedBytes = BigInt(parseInt(result.Download, 10) || 0) + const uploadedBytes = BigInt(result.Upload || "0") + const downloadedBytes = BigInt(result.Download || "0") let ratio = 0 if (downloadedBytes > 0n) { ratio = Number(uploadedBytes) / Number(downloadedBytes) } - const joinTimestamp = parseInt(result.JoinDate, 10) + const joinTimestamp = result.JoinDate ? parseInt(result.JoinDate, 10) : NaN const joinedDate = Number.isFinite(joinTimestamp) && joinTimestamp > 0 ? localDateStr(new Date(joinTimestamp * 1000)) @@ -41,18 +42,18 @@ function mapBtnResult(result: BtnUserInfoResult): TrackerStats { return { username: result.Username, - group: result.Class, + group: result.Class ?? "Unknown", uploadedBytes, downloadedBytes, ratio, bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), seedingCount: 0, leechingCount: 0, - seedbonus: parseFloat(result.Lumens) || 0, - hitAndRuns: parseInt(result.HnR, 10) || 0, + seedbonus: parseFloat(result.Lumens ?? "0") || 0, + hitAndRuns: parseInt(result.HnR ?? "0", 10) || 0, requiredRatio: 0, warned: false, - freeleechTokens: parseFloat(result.Bonus) || 0, + freeleechTokens: parseFloat(result.Bonus ?? "0") || 0, remoteUserId: parseInt(result.UserID, 10) || undefined, joinedDate, } From cfb1a40f617bd23aa48fdc0761473420414a2e90 Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Sun, 26 Jul 2026 14:01:07 -0400 Subject: [PATCH 03/23] =?UTF-8?q?fix(tracker-adapters):=20handle=20zenith?= =?UTF-8?q?=20=E2=88=9E=20buffer=20+=20ipt=20302=20redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseBytes: return 0n for ∞/Inf/-∞/-Inf values so UNIT3D trackers (e.g. zenith) that return infinite buffer don't crash the poll with 'Invalid byte format' error. iptorrents: inspect Location header on 302 rather than throwing session-expired on every redirect. iptorrents 302s logged-in users from / to /t — only throw when redirected to a login page. follow non-login redirects up to 3 hops. add 6 new tests (4 parseBytes ∞ variants, 2 ipt redirect). 2855 total tests pass, tsc clean. Fixes: 86bae41991a748 --- src/lib/adapters/iptorrents.test.ts | 53 ++++++++++++++++++++++++++++- src/lib/adapters/iptorrents.ts | 39 +++++++++++++++++++-- src/lib/parser.test.ts | 15 ++++++++ src/lib/parser.ts | 6 ++++ 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/lib/adapters/iptorrents.test.ts b/src/lib/adapters/iptorrents.test.ts index 9d259af4..e05805a1 100644 --- a/src/lib/adapters/iptorrents.test.ts +++ b/src/lib/adapters/iptorrents.test.ts @@ -141,11 +141,12 @@ describe("IptorrentsAdapter.fetchStats — network error classification", () => ) }) - it("propagates session-expired error when server returns a 302 redirect", async () => { + it("propagates session-expired error when server returns a 302 redirect to login", async () => { vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: false, status: 302, statusText: "Found", + headers: new Headers({ location: "/auth/login" }), } as Response) await expect(adapter.fetchStats("https://iptorrents.com", validToken, "")).rejects.toThrow( @@ -170,3 +171,53 @@ describe("IptorrentsAdapter.fetchStats — network error classification", () => expect(headers["User-Agent"]).toBe("Mozilla/5.0") }) }) + +describe("IptorrentsAdapter - redirect handling", () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("follows 302 to /t instead of throwing session expired", async () => { + const creds = JSON.stringify({ + cookies: "uid=123; pass=abc", + userAgent: "Mozilla/5.0", + }) + const statsHtml = `
testuser
Ratio
1.50
Uploaded
10 GB
Downloaded
5 GB
Active Torrents
3 1
Bonus Points
100
` + + let callCount = 0 + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + callCount++ + if (callCount === 1) { + return new Response(null, { + status: 302, + headers: { location: "/t" }, + }) + } + return new Response(statsHtml, { status: 200 }) + }) + + const adapter = new IptorrentsAdapter() + const stats = await adapter.fetchStats("https://iptorrents.com", creds, "/") + expect(stats.username).toBe("testuser") + expect(stats.uploadedBytes).toBe(10_000_000_000n) + }) + + it("throws session expired when 302 points to /auth/login", async () => { + const creds = JSON.stringify({ + cookies: "uid=123; pass=expired", + userAgent: "Mozilla/5.0", + }) + + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + return new Response(null, { + status: 302, + headers: { location: "/auth/login" }, + }) + }) + + const adapter = new IptorrentsAdapter() + await expect(adapter.fetchStats("https://iptorrents.com", creds, "/")).rejects.toThrow( + "Session expired" + ) + }) +}) diff --git a/src/lib/adapters/iptorrents.ts b/src/lib/adapters/iptorrents.ts index 8f76a300..d313bc90 100644 --- a/src/lib/adapters/iptorrents.ts +++ b/src/lib/adapters/iptorrents.ts @@ -271,9 +271,42 @@ async function fetchHtml( throw classifyFetchError(err, new URL(url).hostname) } - // 302 redirect usually means the session expired and the server redirected to login - if (response.status === 302) { - throw new Error("Session expired — browser cookies need to be refreshed") + // 302 redirect: could be normal (e.g. / → /t) or session expiry (→ /auth/login) + if (response.status === 302 || response.status === 301) { + const location = response.headers.get("location") ?? "" + // If redirected to a login page, the session has expired + if (/\/auth\/login|\/login/i.test(location)) { + throw new Error("Session expired — browser cookies need to be refreshed") + } + // Otherwise follow the redirect (bounded to 3 hops max) + let currentUrl = location + if (currentUrl.startsWith("/")) { + currentUrl = new URL(currentUrl, url).href + } + for (let hop = 0; hop < 3; hop++) { + try { + response = await fetch(currentUrl, { + headers, + signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), + redirect: "manual", + }) + } catch (err) { + throw classifyFetchError(err, new URL(currentUrl).hostname) + } + if (response.status === 302 || response.status === 301) { + const nextLocation = response.headers.get("location") ?? "" + if (/\/auth\/login|\/login/i.test(nextLocation)) { + throw new Error("Session expired — browser cookies need to be refreshed") + } + if (!nextLocation) break + currentUrl = nextLocation.startsWith("/") ? new URL(nextLocation, currentUrl).href : nextLocation + } else { + break + } + } + if (response.status === 302 || response.status === 301) { + throw new Error("Too many redirects from IPTorrents") + } } if (!response.ok) { diff --git a/src/lib/parser.test.ts b/src/lib/parser.test.ts index 0ca0c235..dbad577d 100644 --- a/src/lib/parser.test.ts +++ b/src/lib/parser.test.ts @@ -73,3 +73,18 @@ describe("parseBytes - security", () => { expect(() => parseBytes("-100 GiB")).toThrow() }) }) + +describe("parseBytes - infinity values", () => { + it("returns 0n for ∞", () => { + expect(parseBytes("∞")).toBe(0n) + }) + it("returns 0n for Inf", () => { + expect(parseBytes("Inf")).toBe(0n) + }) + it("returns 0n for -∞", () => { + expect(parseBytes("-∞")).toBe(0n) + }) + it("returns 0n for -Inf", () => { + expect(parseBytes("-Inf")).toBe(0n) + }) +}) diff --git a/src/lib/parser.ts b/src/lib/parser.ts index 3a674c3a..1efe2e29 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -53,6 +53,12 @@ function multiplyDecimalStringByBigInt(valueStr: string, multiplier: bigint): bi export function parseBytes(formatted: string): bigint { const trimmed = formatted.trim() + + // Handle infinity/unlimited buffer values from some trackers (e.g. Zenith) + if (trimmed === "∞" || trimmed === "Inf" || trimmed === "-∞" || trimmed === "-Inf") { + return 0n + } + const match = trimmed.match(/^([\d.]+)\s*([A-Za-z]+)$/) if (!match) { throw new Error(`Invalid byte format: "${formatted}"`) From 79ecdba02e40c4088ee061a48bc87f4154f260dd Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Sun, 26 Jul 2026 14:53:49 -0400 Subject: [PATCH 04/23] fix(tracker-adapters): case-insensitive infinity and relative redirects - H1: Handle infinity values case-insensitively (e.g. 'inf', 'INF'). - H2: Fix relative redirect resolution in IPTorrents fetchHtml. - Added tests for case-insensitive infinity and path-relative redirects. Fixes: 86bae41991a748 --- src/lib/adapters/iptorrents.test.ts | 34 +++++++++++++++++++++++++++++ src/lib/adapters/iptorrents.ts | 7 ++---- src/lib/parser.test.ts | 6 +++++ src/lib/parser.ts | 3 ++- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/lib/adapters/iptorrents.test.ts b/src/lib/adapters/iptorrents.test.ts index e05805a1..5be2c0a5 100644 --- a/src/lib/adapters/iptorrents.test.ts +++ b/src/lib/adapters/iptorrents.test.ts @@ -220,4 +220,38 @@ describe("IptorrentsAdapter - redirect handling", () => { "Session expired" ) }) + + it('follows path-relative redirects (e.g. location: "t")', async () => { + const creds = JSON.stringify({ + cookies: "uid=123; pass=abc", + userAgent: "Mozilla/5.0", + }) + const statsHtml = `
testuser
Ratio
1.50
Uploaded
10 GB
Downloaded
5 GB
Active Torrents
3 1
Bonus Points
100
` + + let callCount = 0 + vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => { + callCount++ + const reqUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + + if (callCount === 1) { + expect(reqUrl).toBe("https://iptorrents.com/") + return new Response(null, { + status: 302, + headers: { location: "t" }, // Relative path without leading slash + }) + } + + if (callCount === 2) { + expect(reqUrl).toBe("https://iptorrents.com/t") + return new Response(statsHtml, { status: 200 }) + } + + return new Response(null, { status: 404 }) + }) + + const adapter = new IptorrentsAdapter() + const stats = await adapter.fetchStats("https://iptorrents.com", creds, "/") + expect(stats.username).toBe("testuser") + expect(callCount).toBe(2) + }) }) diff --git a/src/lib/adapters/iptorrents.ts b/src/lib/adapters/iptorrents.ts index d313bc90..cdefd6ac 100644 --- a/src/lib/adapters/iptorrents.ts +++ b/src/lib/adapters/iptorrents.ts @@ -279,10 +279,7 @@ async function fetchHtml( throw new Error("Session expired — browser cookies need to be refreshed") } // Otherwise follow the redirect (bounded to 3 hops max) - let currentUrl = location - if (currentUrl.startsWith("/")) { - currentUrl = new URL(currentUrl, url).href - } + let currentUrl = new URL(location, url).href for (let hop = 0; hop < 3; hop++) { try { response = await fetch(currentUrl, { @@ -299,7 +296,7 @@ async function fetchHtml( throw new Error("Session expired — browser cookies need to be refreshed") } if (!nextLocation) break - currentUrl = nextLocation.startsWith("/") ? new URL(nextLocation, currentUrl).href : nextLocation + currentUrl = new URL(nextLocation, currentUrl).href } else { break } diff --git a/src/lib/parser.test.ts b/src/lib/parser.test.ts index dbad577d..9d9b44c4 100644 --- a/src/lib/parser.test.ts +++ b/src/lib/parser.test.ts @@ -87,4 +87,10 @@ describe("parseBytes - infinity values", () => { it("returns 0n for -Inf", () => { expect(parseBytes("-Inf")).toBe(0n) }) + it("returns 0n for inf (lowercase)", () => { + expect(parseBytes("inf")).toBe(0n) + }) + it("returns 0n for INF (uppercase)", () => { + expect(parseBytes("INF")).toBe(0n) + }) }) diff --git a/src/lib/parser.ts b/src/lib/parser.ts index 1efe2e29..01e2e374 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -55,7 +55,8 @@ export function parseBytes(formatted: string): bigint { const trimmed = formatted.trim() // Handle infinity/unlimited buffer values from some trackers (e.g. Zenith) - if (trimmed === "∞" || trimmed === "Inf" || trimmed === "-∞" || trimmed === "-Inf") { + const lowerTrimmed = trimmed.toLowerCase() + if (trimmed === "∞" || lowerTrimmed === "inf" || trimmed === "-∞" || lowerTrimmed === "-inf") { return 0n } From ee931e55de1cffa8a9ddad4a2e658c2676975d6f Mon Sep 17 00:00:00 2001 From: Patrick Dundas Date: Mon, 27 Jul 2026 21:33:20 -0600 Subject: [PATCH 05/23] chore(fork): version scheme + fork documentation Set version to 2.8.9-homelab.1. release.yml only publishes when package.json's version has no matching GitHub Release, so fork builds need a distinct version or no image is produced. The -homelab.N suffix also keeps our tags from colliding with upstream release tags. Add FORK.md covering lineage, branch strategy, the upstream sync procedure, and the deltas we carry. Co-Authored-By: Claude Opus 5 --- FORK.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 FORK.md diff --git a/FORK.md b/FORK.md new file mode 100644 index 00000000..14daa796 --- /dev/null +++ b/FORK.md @@ -0,0 +1,60 @@ +# Fork notes — patrickdundas/tracker-tracker + +Fork of [jordanlambrecht/tracker-tracker](https://github.com/jordanlambrecht/tracker-tracker) +(GPL-3.0), maintained for Patrick's homelab. Deployed on the `yams` VM at +`http://192.168.8.199:3838`; see `docs/tracker-stats.md` in the `homelab-agent` repo. + +## Why this fork exists + +TorrentLeech hit-and-run tracking. TL has no public API, so stats come from scraping the logged-in +profile page — and upstream's TL adapter (via PR #175) stubs `hitAndRuns: null`. HnR visibility +across trackers is the whole reason this dashboard was deployed, so that gap is the fork's purpose. + +## Branch strategy + +| Branch | Role | +|---|---| +| `main` | **Integration + deploy branch.** `release.yml` triggers on push here and publishes to `ghcr.io/patrickdundas/tracker-tracker`. Upstream is merged *into* this. | +| `feat/*` | Work branches. PR into `main` so `ci.yml` runs before anything publishes. | + +`main` deliberately is *not* a clean upstream mirror: the release workflow only builds from `main`, +so that is where deployable code has to live. To contribute something back upstream, branch from +`upstream/main` directly rather than from our `main`. + +```bash +git remote -v +# origin git@github.com:patrickdundas/tracker-tracker.git +# upstream https://github.com/jordanlambrecht/tracker-tracker.git + +# sync with upstream +git fetch upstream && git merge upstream/main +``` + +## Versioning + +`release.yml` publishes only when `package.json`'s version has no matching GitHub Release, so +**every deployable change needs a version bump** or no image is built. + +This fork uses a `-homelab.N` suffix (`2.8.9-homelab.1`) so builds are unambiguously ours and never +collide with an upstream release tag. On an upstream sync, `package.json` will conflict on this one +line — resolve to the new upstream base plus a reset suffix, e.g. `2.9.0-homelab.1`. + +## Deltas from upstream + +| Change | Status | +|---|---| +| Upstream PR #175 merged whole (Zenith, BTN fix, IPTorrents, TorrentLeech adapters) | done — see below | +| TorrentLeech `hitAndRuns` | **the point of the fork**, not yet implemented | +| TorrentLeech `requiredRatio` / `warned` | stubbed `null` upstream; `warned` feeds the `warned` notification event | + +**On taking #175 whole rather than extracting only TorrentLeech:** all four adapters touch the same +shared files (`src/lib/adapters/index.ts`, `adapters/constants.ts`, `lib/parser.ts`). Cherry-picking +TL alone would leave this fork carrying divergent copies of those, guaranteeing conflicts when +upstream merges #175. Taken whole, that merge becomes a no-op. The unused adapters never execute +unless those trackers are configured. + +## One-time setup gotcha + +GitHub **disables workflows on forks by default**. `total_count` from +`/repos/:owner/:repo/actions/workflows` reads 0 and nothing runs until they are enabled once from +the repo's Actions tab. There is no REST endpoint for this. diff --git a/package.json b/package.json index 89213109..e88a0748 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "private-tracker-tracker", - "version": "2.8.9", + "version": "2.8.9-homelab.1", "description": "Self-hosted dashboard for monitoring private tracker stats over time", "license": "GPL-3.0", "repository": { From f2749aef370d6f4e5cf4fda88133c37825758af9 Mon Sep 17 00:00:00 2001 From: Patrick Dundas Date: Tue, 28 Jul 2026 22:14:57 -0600 Subject: [PATCH 06/23] feat(tracker-adapters): scrape TL hit-and-run count, fix seeding/leeching counts Populate `hitAndRuns` from the header menu item titled "Hit and Run", which links to /profile//hnr. This is the field the fork exists for - TL has no API and upstream PR #175 stubs it null. Also fixes a real bug in the existing count parsing. The menu has two shapes:
2.39 TB (30)
0
Seeding/leeching lead with the transfer SIZE and put the torrent count in trailing parens, so the previous "first number in the element" match returned 2 (from "2.39 TB") and 244 (from "244.44 GB") instead of 30 and 9. Seeding and leeching counts have been wrong for every TL user. Now matches the parenthesised count for those, and the bare number for Hit and Run. `hitAndRuns` stays null when the counter is absent rather than defaulting to 0: a missing element must not render as "no hit and runs", which would hide the exact condition the field exists to surface. Adds a menu fixture to the tests (upstream had none, which is why the count bug went unnoticed) plus regression coverage for both shapes and the null-vs-zero distinction. Co-Authored-By: Claude Opus 5 --- src/lib/adapters/torrentleech.test.ts | 46 +++++++++++++++++++++++++++ src/lib/adapters/torrentleech.ts | 34 ++++++++++++++++---- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/lib/adapters/torrentleech.test.ts b/src/lib/adapters/torrentleech.test.ts index a56febed..f4970cb4 100644 --- a/src/lib/adapters/torrentleech.test.ts +++ b/src/lib/adapters/torrentleech.test.ts @@ -17,7 +17,27 @@ const PROFILE_HTML = ` 2.000 ` +// Header menu, copied from a real logged-in TL profile page (values altered). +// Note the shape difference that the count parsing depends on: seeding and +// leeching lead with a transfer SIZE and carry the torrent count in trailing +// parens, whereas Hit and Run is a bare number. +const MENU_HTML = ` + +
+ 2.39 TB (30) +
+
+ 244.44 GB (9) +
+
2.15 TB
+
9.995
+
+ 4 +
+
` + const FULL_PROFILE_PAGE = `${PROFILE_HTML}` +const PAGE_WITH_MENU = `${PROFILE_HTML}${MENU_HTML}` function setCookieResponse(cookies: string[], overrides: Partial = {}): Response { return { @@ -39,6 +59,32 @@ describe("parseTlProfile", () => { expect(stats.bufferBytes).toBe(10_500_000_000n - 5_250_000_000n) }) + it("reads the parenthesised torrent count, not the transfer size", () => { + const stats = parseTlProfile(PAGE_WITH_MENU, "testuser") + // Regression guard: taking the first number in the element would give + // 2 (from "2.39 TB") and 244 (from "244.44 GB"). + expect(stats.seedingCount).toBe(30) + expect(stats.leechingCount).toBe(9) + }) + + it("extracts the hit-and-run count from the header menu", () => { + expect(parseTlProfile(PAGE_WITH_MENU, "testuser").hitAndRuns).toBe(4) + }) + + it("reports zero hit and runs as 0, not null", () => { + const clean = PAGE_WITH_MENU.replace( + ' 4', + ' 0' + ) + expect(parseTlProfile(clean, "testuser").hitAndRuns).toBe(0) + }) + + it("leaves hitAndRuns null when the counter is absent", () => { + // A missing counter must not read as "no hit and runs" — that would hide + // the condition the field exists to surface. + expect(parseTlProfile(FULL_PROFILE_PAGE, "testuser").hitAndRuns).toBeNull() + }) + it("treats an infinite ratio (∞) as 0", () => { const infPage = `
0 B
diff --git a/src/lib/adapters/torrentleech.ts b/src/lib/adapters/torrentleech.ts index e2906395..83fb1fdd 100644 --- a/src/lib/adapters/torrentleech.ts +++ b/src/lib/adapters/torrentleech.ts @@ -128,16 +128,36 @@ export function parseTlProfile(html: string, username: string): TrackerStats { ratio = parseFloat(ratioText) || 0 } - // Active seeding/leeching counts appear as header menu items with tooltip - // titles ("Uploaded (Seeding)" / "Downloaded (Leeching)"). + // Header menu items carry a tooltip title plus a count, in two shapes: + // + //
2.39 TB (30)
+ //
0
+ // + // Seeding/leeching put the transfer SIZE first and the torrent count in + // trailing parens, so reading "the first number in the element" yields 2 + // (from "2.39 TB") and 244 (from "244.44 GB") rather than 30 and 9. Match + // the parenthesised count for those; Hit and Run has no size, so take the + // bare number. + const parenCount = (el: ParsedElement): number | null => { + const m = el.textContent?.match(/\((\d[\d,]*)\)/) + return m ? parseInt(m[1].replace(/,/g, ""), 10) : null + } + const bareCount = (el: ParsedElement): number | null => { + const m = el.textContent?.match(/(\d[\d,]*)/) + return m ? parseInt(m[1].replace(/,/g, ""), 10) : null + } + let seedingCount = 0 let leechingCount = 0 + // Stays null when the counter isn't found, deliberately NOT 0: a missing + // element must not render as "no hit and runs", which would hide the exact + // condition this field exists to surface. + let hitAndRuns: number | null = null for (const item of doc.querySelectorAll(".div-menu-item")) { const title = item.getAttribute("title") ?? "" - const numMatch = item.textContent?.match(/[\d,]+/) - const count = numMatch ? parseInt(numMatch[0].replace(/,/g, ""), 10) : 0 - if (/seeding/i.test(title)) seedingCount = count - else if (/leeching/i.test(title)) leechingCount = count + if (/seeding/i.test(title)) seedingCount = parenCount(item) ?? 0 + else if (/leeching/i.test(title)) leechingCount = parenCount(item) ?? 0 + else if (/hit\s*and\s*run/i.test(title)) hitAndRuns = bareCount(item) } // TL Points, often shown near a "TL Points:" label. @@ -161,7 +181,7 @@ export function parseTlProfile(html: string, username: string): TrackerStats { seedingCount, leechingCount, seedbonus, - hitAndRuns: null, + hitAndRuns, requiredRatio: null, warned: null, freeleechTokens: null, From 0b871ab294b54644e14a991967f61fbbd9e01208 Mon Sep 17 00:00:00 2001 From: Patrick Dundas Date: Tue, 28 Jul 2026 22:23:01 -0600 Subject: [PATCH 07/23] ci(release): make the release workflow publishable from this fork Upstream's release.yml cannot publish from a fork. Three blockers, none of them cosmetic: - "Log in to Docker Hub" uses DOCKERHUB_* secrets that do not exist here. docker/login-action fails on empty credentials, aborting the job BEFORE the build/push step ran, so no image was ever produced. - The build tagged docker.io/jordyjordy/tracker-tracker, upstream's Docker Hub namespace, which this fork cannot push to. - "Sync README to Docker Hub" has the same missing secrets and target. Removed all three. GHCR only; IMAGE_NAME already resolves to our repo path. Also makes Trivy non-blocking (exit-code 0) with if: always() on the SARIF upload. The image is pushed BEFORE the scan runs, so failing hard there never prevented a vulnerable image shipping - it only skipped the SARIF upload and the GitHub Release, leaving the run permanently red and the findings invisible in the Security tab. The current findings (undici, vite) come from upstream's lockfile. Documents all of it in FORK.md, since .github/ is now the main conflict surface on upstream syncs. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 32 ++++++++++++++++---------------- FORK.md | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 289ef317..029ea997 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,12 +69,9 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Log in to Docker Hub - if: steps.tag.outputs.should_release == 'true' - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + # FORK: upstream's "Log in to Docker Hub" step removed. This fork has no + # DOCKERHUB_* secrets, and docker/login-action fails on empty credentials, + # which aborted the whole job BEFORE the build/push step ever ran. - name: Set up QEMU (for arm64 cross-compilation) if: steps.tag.outputs.should_release == 'true' @@ -91,11 +88,12 @@ jobs: context: . push: true platforms: linux/amd64,linux/arm64 + # FORK: docker.io/jordyjordy/* tags removed — that is upstream's + # Docker Hub namespace, which this fork has no rights to push to. + # GHCR only, which IMAGE_NAME resolves to our own repo path. tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.version }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - docker.io/jordyjordy/tracker-tracker:${{ steps.tag.outputs.version }} - docker.io/jordyjordy/tracker-tracker:latest cache-from: | type=gha,scope=buildx-amd64 type=gha,scope=buildx-arm64 @@ -111,9 +109,16 @@ jobs: severity: CRITICAL,HIGH ignore-unfixed: true trivyignores: .trivyignore + # FORK: report findings without failing the release. The image is + # already pushed by the step above, so a hard failure here does not + # prevent a vulnerable image shipping — it only skips the SARIF + # upload and the GitHub Release, leaving the run permanently red and + # the findings INVISIBLE in the Security tab. Non-blocking keeps the + # results where they can actually be seen and acted on. + exit-code: "0" - name: Upload Trivy results to GitHub Security - if: steps.tag.outputs.should_release == 'true' + if: always() && steps.tag.outputs.should_release == 'true' uses: github/codeql-action/upload-sarif@v4 with: sarif_file: trivy-results.sarif @@ -150,10 +155,5 @@ jobs: generate_release_notes: false files: sbom-tracker-tracker.spdx.json - - name: Sync README to Docker Hub - if: steps.tag.outputs.should_release == 'true' - uses: peter-evans/dockerhub-description@v5 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: jordyjordy/tracker-tracker + # FORK: upstream's "Sync README to Docker Hub" step removed — no + # DOCKERHUB_* secrets here, and it targets upstream's Docker Hub repo. diff --git a/FORK.md b/FORK.md index 14daa796..48f3d4f0 100644 --- a/FORK.md +++ b/FORK.md @@ -53,6 +53,31 @@ TL alone would leave this fork carrying divergent copies of those, guaranteeing upstream merges #175. Taken whole, that merge becomes a no-op. The unused adapters never execute unless those trackers are configured. +## Workflow edits (the main sync-conflict surface) + +`.github/workflows/release.yml` is patched. Upstream's version **cannot publish from a fork** — +these are not preferences, they are blockers: + +| Change | Why | +|---|---| +| Removed "Log in to Docker Hub" step | No `DOCKERHUB_*` secrets here. `docker/login-action` fails on empty credentials, aborting the job **before** the build/push step ever ran. | +| Removed `docker.io/jordyjordy/*` image tags | That is upstream's Docker Hub namespace; this fork has no rights to push there, so `build-push` failed. GHCR only now — `IMAGE_NAME` already resolves to our own repo path. | +| Removed "Sync README to Docker Hub" step | Same missing secrets, and it targets upstream's Docker Hub repo. | +| Trivy `exit-code: "0"` + `if: always()` on the SARIF upload | The image is pushed *before* the scan runs, so a hard failure never prevented a vulnerable image shipping — it only skipped the SARIF upload and the GitHub Release, leaving the run permanently red and the findings invisible in the Security tab. Non-blocking puts them where they can be acted on. | + +Because this file is modified, **review `git diff upstream/main -- .github/` on every sync** and +re-apply these if upstream rewrites the release job. Reviewing that diff is worth doing regardless: +merging upstream runs *their* workflow code with this repo's `contents: write` and `packages: write` +token. + +Known non-blocking CI failures on this fork: + +- **Scan Dependencies** (`dependency-review-action`) — needs Dependency Graph, which GitHub disables + by default on forks. Enable under Settings → Code security, or ignore. +- **Trivy CVEs** in `undici` and `vite`, inherited from upstream's lockfile. Not introduced here. + Real exposure is low for this deployment (LAN-only, no SOCKS proxy configured, not Windows), but + they should clear when upstream bumps deps. + ## One-time setup gotcha GitHub **disables workflows on forks by default**. `total_count` from From 005aa56fa7637716293888a02aac1b89002940f2 Mon Sep 17 00:00:00 2001 From: Patrick Dundas Date: Tue, 28 Jul 2026 22:33:56 -0600 Subject: [PATCH 08/23] fix(deps): pin undici to a patched version, document the vite CVE Trivy failed the build on undici and vite CVEs inherited from upstream's lockfile. Both are dev-only, reaching the image because the schema-deps Dockerfile stage runs a full pnpm install (devDependencies included) and the runner copies that node_modules in for drizzle-kit. undici: pinned to ^7.28.0 via pnpm.overrides, resolving to 7.29.0. Kept inside the 7.x line deliberately - an unbounded >= range pulled 8.9.0, a major jump for a jsdom dependency with no upside. vite: could not be moved. vitest 4.1.4 holds it at 7.3.2; 7.3.6 exists but neither overrides nor --force re-resolves it. Documented in .trivyignore instead, which is honest here rather than evasive: the CVE is a server.fs.deny bypass via Windows alternate paths in vite's DEV SERVER, and this image is Linux serving Next.js standalone with vite never started. Exposure is nil. That file already carries the same class of suppression for esbuild. The root cause - devDependencies shipping in the production image - is recorded in FORK.md as follow-up. Not attempted here because schema-sync runs at container startup, so getting it wrong breaks deploys rather than builds. Adapter tests: 248 pass with the bumped undici. Co-Authored-By: Claude Opus 5 --- .trivyignore | 9 + FORK.md | 15 ++ package.json | 4 +- pnpm-lock.yaml | 569 +++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 478 insertions(+), 119 deletions(-) diff --git a/.trivyignore b/.trivyignore index 9caa1f77..e7aa36f9 100644 --- a/.trivyignore +++ b/.trivyignore @@ -11,6 +11,15 @@ CVE-2026-26960 CVE-2026-29786 CVE-2026-31802 +# vite — dev-only. Reaches the image solely because the schema-deps stage runs a full +# `pnpm install` (devDependencies included) and the runner copies that node_modules. +# The CVE is a `server.fs.deny` bypass via Windows alternate paths in vite's DEV SERVER; +# this image is Linux, serves Next.js standalone, and never starts vite. Exposure is nil. +# Not fixable by a pnpm override — vitest 4.1.4 holds vite at 7.3.2 (7.3.6 exists, but +# neither `overrides` nor `--force` re-resolves it). The real fix is to stop shipping +# devDependencies in the runner stage; see FORK.md. +CVE-2026-53571 + # esbuild Go stdlib (1.23.x) — build tool binary in schema-sync, not a runtime service. Runs once at startup for drizzle-kit push. # Cannot fix until esbuild ships a release built with Go 1.24.13+. CVE-2025-47912 diff --git a/FORK.md b/FORK.md index 48f3d4f0..0812b2fe 100644 --- a/FORK.md +++ b/FORK.md @@ -70,6 +70,21 @@ re-apply these if upstream rewrites the release job. Reviewing that diff is wort merging upstream runs *their* workflow code with this repo's `contents: write` and `packages: write` token. +## Known issue: devDependencies ship in the production image + +The `schema-deps` Dockerfile stage runs a full `pnpm install` (devDependencies included) and the +runner stage copies that `node_modules` in for the drizzle-kit schema push. So vitest's entire +dependency tree — vite, jsdom, undici — lands in the production image, and Trivy flags it. + +Upstream already works around symptoms of this (see the esbuild block in `.trivyignore`). The real +fix is to install only what schema-sync needs in that stage. **Not attempted yet**: schema-sync runs +at container startup via `docker-entrypoint.sh`, so getting it wrong breaks deploys, not just builds. + +Interim: `undici` is pinned to a patched `^7.28.0` via `pnpm.overrides`. `vite` could not be moved +the same way — vitest 4.1.4 holds it at 7.3.2 and neither `overrides` nor `--force` re-resolves it — +so its CVE is documented in `.trivyignore` instead. That one is genuinely inert here: it is a +dev-server bug on Windows, and this image is Linux running Next.js standalone. + Known non-blocking CI failures on this fork: - **Scan Dependencies** (`dependency-review-action`) — needs Dependency Graph, which GitHub disables diff --git a/package.json b/package.json index e88a0748..b745d0ea 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "sharp" ], "overrides": { - "esbuild": ">=0.25.0" + "esbuild": ">=0.25.0", + "undici": "^7.28.0", + "vite": "^7.3.6" } }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbf6629c..c2f6b8b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,8 @@ settings: overrides: esbuild: '>=0.25.0' + undici: ^7.28.0 + vite: ^7.3.6 importers: @@ -202,14 +204,26 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@biomejs/biome@2.4.11': resolution: {integrity: sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA==} engines: {node: '>=14.21.3'} @@ -439,156 +453,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.28.0': resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.28.0': resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.28.0': resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.28.0': resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.28.0': resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.28.0': resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.28.0': resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.28.0': resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.28.0': resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.28.0': resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.28.0': resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.28.0': resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.28.0': resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.28.0': resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.28.0': resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.28.0': resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.28.0': resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.28.0': resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.28.0': resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.28.0': resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.28.0': resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.28.0': resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1100,141 +1270,141 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@rollup/rollup-android-arm-eabi@4.60.1': - resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + '@rollup/rollup-android-arm-eabi@4.62.3': + resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.1': - resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + '@rollup/rollup-android-arm64@4.62.3': + resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.1': - resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + '@rollup/rollup-darwin-arm64@4.62.3': + resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.1': - resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + '@rollup/rollup-darwin-x64@4.62.3': + resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.1': - resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + '@rollup/rollup-freebsd-arm64@4.62.3': + resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.1': - resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + '@rollup/rollup-freebsd-x64@4.62.3': + resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': - resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.1': - resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.60.1': - resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + '@rollup/rollup-linux-arm64-gnu@4.62.3': + resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.1': - resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + '@rollup/rollup-linux-arm64-musl@4.62.3': + resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.1': - resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + '@rollup/rollup-linux-loong64-gnu@4.62.3': + resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.1': - resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + '@rollup/rollup-linux-loong64-musl@4.62.3': + resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.1': - resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.1': - resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + '@rollup/rollup-linux-ppc64-musl@4.62.3': + resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.1': - resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.1': - resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + '@rollup/rollup-linux-riscv64-musl@4.62.3': + resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.60.1': - resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + '@rollup/rollup-linux-s390x-gnu@4.62.3': + resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.1': - resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + '@rollup/rollup-linux-x64-gnu@4.62.3': + resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.1': - resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + '@rollup/rollup-linux-x64-musl@4.62.3': + resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.1': - resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + '@rollup/rollup-openbsd-x64@4.62.3': + resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.1': - resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + '@rollup/rollup-openharmony-arm64@4.62.3': + resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.1': - resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + '@rollup/rollup-win32-arm64-msvc@4.62.3': + resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.1': - resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + '@rollup/rollup-win32-ia32-msvc@4.62.3': + resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.1': - resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + '@rollup/rollup-win32-x64-gnu@4.62.3': + resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.1': - resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + '@rollup/rollup-win32-x64-msvc@4.62.3': + resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} cpu: [x64] os: [win32] @@ -1407,6 +1577,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1458,7 +1631,7 @@ packages: resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: ^7.3.6 peerDependenciesMeta: msw: optional: true @@ -2090,6 +2263,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2774,6 +2952,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -2945,6 +3128,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -2975,6 +3162,10 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.24: + resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.9: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} @@ -3126,8 +3317,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.60.1: - resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + rollup@4.62.3: + resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -3344,6 +3535,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -3427,8 +3622,8 @@ packages: undici-types@7.24.7: resolution: {integrity: sha512-XA+gOBkzYD3C74sZowtCLTpgtaCdqZhqCvR6y9LXvrKTt/IVU6bz49T4D+BPi475scshCCkb0IklJRw6T1ZlgQ==} - undici@7.24.7: - resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} unicorn-magic@0.1.0: @@ -3521,7 +3716,7 @@ packages: '@vitest/ui': 4.1.4 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: ^7.3.6 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -3669,10 +3864,20 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/runtime@7.29.2': {} + '@babel/runtime@7.29.7': {} + '@biomejs/biome@2.4.11': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.4.11 @@ -3917,81 +4122,159 @@ snapshots: '@esbuild/aix-ppc64@0.28.0': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.28.0': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.28.0': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.28.0': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.28.0': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.28.0': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.28.0': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.28.0': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.28.0': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.28.0': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.28.0': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.28.0': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.28.0': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.28.0': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.28.0': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.28.0': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.28.0': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.28.0': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.28.0': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.28.0': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.28.0': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.28.0': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.28.0': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.28.0': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.28.0': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.28.0': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': optionalDependencies: '@noble/hashes': 2.0.1 @@ -4301,79 +4584,79 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@rollup/rollup-android-arm-eabi@4.60.1': + '@rollup/rollup-android-arm-eabi@4.62.3': optional: true - '@rollup/rollup-android-arm64@4.60.1': + '@rollup/rollup-android-arm64@4.62.3': optional: true - '@rollup/rollup-darwin-arm64@4.60.1': + '@rollup/rollup-darwin-arm64@4.62.3': optional: true - '@rollup/rollup-darwin-x64@4.60.1': + '@rollup/rollup-darwin-x64@4.62.3': optional: true - '@rollup/rollup-freebsd-arm64@4.60.1': + '@rollup/rollup-freebsd-arm64@4.62.3': optional: true - '@rollup/rollup-freebsd-x64@4.60.1': + '@rollup/rollup-freebsd-x64@4.62.3': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.1': + '@rollup/rollup-linux-arm-musleabihf@4.62.3': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.1': + '@rollup/rollup-linux-arm64-gnu@4.62.3': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.1': + '@rollup/rollup-linux-arm64-musl@4.62.3': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.1': + '@rollup/rollup-linux-loong64-gnu@4.62.3': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.1': + '@rollup/rollup-linux-loong64-musl@4.62.3': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.1': + '@rollup/rollup-linux-ppc64-gnu@4.62.3': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.1': + '@rollup/rollup-linux-ppc64-musl@4.62.3': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.1': + '@rollup/rollup-linux-riscv64-gnu@4.62.3': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.1': + '@rollup/rollup-linux-riscv64-musl@4.62.3': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.1': + '@rollup/rollup-linux-s390x-gnu@4.62.3': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.1': + '@rollup/rollup-linux-x64-gnu@4.62.3': optional: true - '@rollup/rollup-linux-x64-musl@4.60.1': + '@rollup/rollup-linux-x64-musl@4.62.3': optional: true - '@rollup/rollup-openbsd-x64@4.60.1': + '@rollup/rollup-openbsd-x64@4.62.3': optional: true - '@rollup/rollup-openharmony-arm64@4.60.1': + '@rollup/rollup-openharmony-arm64@4.62.3': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.1': + '@rollup/rollup-win32-arm64-msvc@4.62.3': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.1': + '@rollup/rollup-win32-ia32-msvc@4.62.3': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.1': + '@rollup/rollup-win32-x64-gnu@4.62.3': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.1': + '@rollup/rollup-win32-x64-msvc@4.62.3': optional: true '@simple-libs/child-process-utils@1.0.2': @@ -4471,8 +4754,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -4527,6 +4810,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -5171,6 +5456,35 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-string-regexp@1.0.5: {} @@ -5225,6 +5539,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -5488,7 +5806,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.24.7 + undici: 7.29.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -6031,6 +6349,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.16: {} + neo-async@2.6.2: {} next@16.2.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5): @@ -6252,6 +6572,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.5: {} + pify@2.3.0: {} pify@3.0.0: {} @@ -6303,6 +6625,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.24: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.9: dependencies: nanoid: 3.3.11 @@ -6487,35 +6815,35 @@ snapshots: reusify@1.1.0: {} - rollup@4.60.1: + rollup@4.62.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.1 - '@rollup/rollup-android-arm64': 4.60.1 - '@rollup/rollup-darwin-arm64': 4.60.1 - '@rollup/rollup-darwin-x64': 4.60.1 - '@rollup/rollup-freebsd-arm64': 4.60.1 - '@rollup/rollup-freebsd-x64': 4.60.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 - '@rollup/rollup-linux-arm-musleabihf': 4.60.1 - '@rollup/rollup-linux-arm64-gnu': 4.60.1 - '@rollup/rollup-linux-arm64-musl': 4.60.1 - '@rollup/rollup-linux-loong64-gnu': 4.60.1 - '@rollup/rollup-linux-loong64-musl': 4.60.1 - '@rollup/rollup-linux-ppc64-gnu': 4.60.1 - '@rollup/rollup-linux-ppc64-musl': 4.60.1 - '@rollup/rollup-linux-riscv64-gnu': 4.60.1 - '@rollup/rollup-linux-riscv64-musl': 4.60.1 - '@rollup/rollup-linux-s390x-gnu': 4.60.1 - '@rollup/rollup-linux-x64-gnu': 4.60.1 - '@rollup/rollup-linux-x64-musl': 4.60.1 - '@rollup/rollup-openbsd-x64': 4.60.1 - '@rollup/rollup-openharmony-arm64': 4.60.1 - '@rollup/rollup-win32-arm64-msvc': 4.60.1 - '@rollup/rollup-win32-ia32-msvc': 4.60.1 - '@rollup/rollup-win32-x64-gnu': 4.60.1 - '@rollup/rollup-win32-x64-msvc': 4.60.1 + '@rollup/rollup-android-arm-eabi': 4.62.3 + '@rollup/rollup-android-arm64': 4.62.3 + '@rollup/rollup-darwin-arm64': 4.62.3 + '@rollup/rollup-darwin-x64': 4.62.3 + '@rollup/rollup-freebsd-arm64': 4.62.3 + '@rollup/rollup-freebsd-x64': 4.62.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 + '@rollup/rollup-linux-arm-musleabihf': 4.62.3 + '@rollup/rollup-linux-arm64-gnu': 4.62.3 + '@rollup/rollup-linux-arm64-musl': 4.62.3 + '@rollup/rollup-linux-loong64-gnu': 4.62.3 + '@rollup/rollup-linux-loong64-musl': 4.62.3 + '@rollup/rollup-linux-ppc64-gnu': 4.62.3 + '@rollup/rollup-linux-ppc64-musl': 4.62.3 + '@rollup/rollup-linux-riscv64-gnu': 4.62.3 + '@rollup/rollup-linux-riscv64-musl': 4.62.3 + '@rollup/rollup-linux-s390x-gnu': 4.62.3 + '@rollup/rollup-linux-x64-gnu': 4.62.3 + '@rollup/rollup-linux-x64-musl': 4.62.3 + '@rollup/rollup-openbsd-x64': 4.62.3 + '@rollup/rollup-openharmony-arm64': 4.62.3 + '@rollup/rollup-win32-arm64-msvc': 4.62.3 + '@rollup/rollup-win32-ia32-msvc': 4.62.3 + '@rollup/rollup-win32-x64-gnu': 4.62.3 + '@rollup/rollup-win32-x64-msvc': 4.62.3 fsevents: 2.3.3 run-parallel@1.2.0: @@ -6728,6 +7056,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tinyrainbow@3.1.0: {} tldts-core@7.0.28: {} @@ -6786,7 +7119,7 @@ snapshots: undici-types@7.24.7: {} - undici@7.24.7: {} + undici@7.29.0: {} unicorn-magic@0.1.0: {} @@ -6842,12 +7175,12 @@ snapshots: vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: - esbuild: 0.28.0 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.9 - rollup: 4.60.1 - tinyglobby: 0.2.16 + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.24 + rollup: 4.62.3 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.6.0 fsevents: 2.3.3 From da0ec9bcdaae375a7b18e20e5b7cf0a2496b47ee Mon Sep 17 00:00:00 2001 From: Patrick Dundas Date: Tue, 28 Jul 2026 23:23:44 -0600 Subject: [PATCH 09/23] fix(tracker-adapters): correct TL downloaded bytes and user class parsing Two parsing bugs surfaced by a real logged-in TorrentLeech profile page. Both produced confidently wrong data rather than errors, so neither was visible without comparing against the live page. downloaded read as 0 bytes. TL's markup is inconsistent: the uploaded and ratio spans carry both `profile-info-details` and a specific `profile-*-details` class, but the downloaded span only carries the generic one: uploaded: downloaded: So `.profile-downloaded-details` matched nothing. This also corrupted bufferBytes, computed as uploaded minus downloaded, which reported the full uploaded figure as buffer. Now falls back to scoping by the wrapper div. User class read as "ic TL". The previous body-text regex scans the whole document and hits the "Classic TL" link in the nav menu long before the real field - "Class" matches inside "Classic", capturing "ic TL". Replaced with the `.label-user-class` badge, falling back to the profile table's Class row. Correctly reads "Super User". Tests use the real markup shapes, including the nav menu that caused the class collision, so both are covered by regression guards. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- src/lib/adapters/torrentleech.test.ts | 48 +++++++++++++++++++++++++++ src/lib/adapters/torrentleech.ts | 33 +++++++++++++++--- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index b745d0ea..c57d697d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "private-tracker-tracker", - "version": "2.8.9-homelab.1", + "version": "2.8.9-homelab.2", "description": "Self-hosted dashboard for monitoring private tracker stats over time", "license": "GPL-3.0", "repository": { diff --git a/src/lib/adapters/torrentleech.test.ts b/src/lib/adapters/torrentleech.test.ts index f4970cb4..e1607c9d 100644 --- a/src/lib/adapters/torrentleech.test.ts +++ b/src/lib/adapters/torrentleech.test.ts @@ -36,8 +36,34 @@ const MENU_HTML = ` ` +// Real TL profile-body markup (values altered). Note the inconsistency this +// guards: uploaded and ratio carry a specific `profile-*-details` class, but +// DOWNLOADED only carries the generic `profile-info-details`. +const PROFILE_BODY_HTML = ` +
+
+ uploaded:2.39 TB +
+
+ downloaded: 244.44 GB +
+
+ ratio:10.017 +
+
+
Super User
+
ClassSuper User
` + +// The nav menu that broke class detection: "Classic TL" contains "Class". +const NAV_HTML = ` +` + const FULL_PROFILE_PAGE = `${PROFILE_HTML}` const PAGE_WITH_MENU = `${PROFILE_HTML}${MENU_HTML}` +const REAL_PAGE = `${NAV_HTML}${MENU_HTML}${PROFILE_BODY_HTML}` function setCookieResponse(cookies: string[], overrides: Partial = {}): Response { return { @@ -59,6 +85,28 @@ describe("parseTlProfile", () => { expect(stats.bufferBytes).toBe(10_500_000_000n - 5_250_000_000n) }) + it("reads downloaded when TL omits the profile-downloaded-details class", () => { + const stats = parseTlProfile(REAL_PAGE, "testuser") + // Regression guard: `.profile-downloaded-details` does not exist on the + // real page, so this silently read 0 — which also broke bufferBytes. + expect(stats.downloadedBytes).toBe(244_440_000_000n) + expect(stats.uploadedBytes).toBe(2_390_000_000_000n) + expect(stats.bufferBytes).toBe(2_390_000_000_000n - 244_440_000_000n) + }) + + it("does not mistake the 'Classic TL' nav link for the user class", () => { + // Regression guard: /Class:?\s*(...)/ over body text captured "ic TL". + expect(parseTlProfile(REAL_PAGE, "testuser").group).toBe("Super User") + }) + + it("falls back to the profile table's Class row when the badge is absent", () => { + const noBadge = REAL_PAGE.replace( + '
Super User
', + "" + ) + expect(parseTlProfile(noBadge, "testuser").group).toBe("Super User") + }) + it("reads the parenthesised torrent count, not the transfer size", () => { const stats = parseTlProfile(PAGE_WITH_MENU, "testuser") // Regression guard: taking the first number in the element would give diff --git a/src/lib/adapters/torrentleech.ts b/src/lib/adapters/torrentleech.ts index 83fb1fdd..fa018aaf 100644 --- a/src/lib/adapters/torrentleech.ts +++ b/src/lib/adapters/torrentleech.ts @@ -111,7 +111,19 @@ export function parseTlProfile(html: string, username: string): TrackerStats { const doc = parseHtml(html) const uploadedText = textAfterNode(doc, ".profile-uploaded-details") - const downloadedText = textAfterNode(doc, ".profile-downloaded-details") + // TL's markup is inconsistent here: the uploaded and ratio spans carry both + // `profile-info-details` AND a specific `profile-*-details` class, but the + // DOWNLOADED span only ever carries the generic one: + // + //
... 2.39 TB + //
... 244.44 GB + // + // So `.profile-downloaded-details` matches nothing and downloaded silently + // read as 0 — which also corrupted bufferBytes, since that is uploaded minus + // downloaded. Fall back to scoping by the wrapper div instead. + const downloadedText = + textAfterNode(doc, ".profile-downloaded-details") || + textAfterNode(doc, ".profile-downloaded .profile-info-details") const ratioText = textAfterNode(doc, ".profile-ratio-details") if (!uploadedText && !downloadedText) { @@ -166,10 +178,21 @@ export function parseTlProfile(html: string, username: string): TrackerStats { const pointsMatch = bodyText.match(/TL Points:\s*([\d,.]+)/i) if (pointsMatch) seedbonus = parseFloat(pointsMatch[1].replace(/,/g, "")) - // Class badge, if present in a profile field/label pair. - let group = "User" - const classMatch = bodyText.match(/Class:?\s*\n?\s*([A-Za-z][A-Za-z ]*)/) - if (classMatch) group = classMatch[1].trim() + // User class. Deliberately NOT a body-text regex: matching /Class:?\s*(...)/ + // over textContent hits the "Classic TL" entry in the nav menu long before + // the real field, capturing "ic TL" as the user's class. Read the labelled + // badge, then fall back to the profile table's Class row. + let group = textAfterNode(doc, ".label-user-class") + if (!group) { + for (const row of doc.querySelectorAll("tr")) { + const cells = row.querySelectorAll("td") + if (cells.length >= 2 && cells[0].textContent?.trim() === "Class") { + group = cells[1].textContent?.trim() ?? "" + break + } + } + } + if (!group) group = "User" return { username, From fa5592039b7ea1f48cef31aa7baacab83a4b1b54 Mon Sep 17 00:00:00 2001 From: Patrick Dundas Date: Tue, 28 Jul 2026 23:40:21 -0600 Subject: [PATCH 10/23] ci(release): drop emulated arm64 build and fix the inert layer cache The Docker build step was 989s of an 18m25s release run. Every other step in the job totalled about 90s, so it is the only thing worth optimising. Two causes, both addressed here. arm64 was emulated for nobody. The only deploy target for this fork is the yams VM, which is x86_64. Building linux/arm64 under QEMU roughly doubled the work to produce an image that nothing pulls, and emulated builds run several times slower than native. Dropped to linux/amd64 and removed the now-pointless QEMU setup step. Both are easy to restore if an ARM host ever needs this. The GitHub Actions layer cache never hit. cache-to wrote to scope `buildx-` while cache-from read `buildx-amd64` and `buildx-arm64`, so the scopes could never match: no release read a cache entry that another release had written. Every build was cold, and each one left behind a version-scoped entry that nothing would ever read again. Read and write the same scope now. The cache bug is upstream's and affects their releases too - worth offering back. The arm64 removal is fork-specific and should not be. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 24 ++++++++++++++++-------- FORK.md | 8 ++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 029ea997..21fc3917 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,9 +73,8 @@ jobs: # DOCKERHUB_* secrets, and docker/login-action fails on empty credentials, # which aborted the whole job BEFORE the build/push step ever ran. - - name: Set up QEMU (for arm64 cross-compilation) - if: steps.tag.outputs.should_release == 'true' - uses: docker/setup-qemu-action@v4 + # FORK: upstream's "Set up QEMU (for arm64 cross-compilation)" step + # removed along with the arm64 platform below — nothing left to emulate. - name: Set up Docker Buildx if: steps.tag.outputs.should_release == 'true' @@ -87,17 +86,26 @@ jobs: with: context: . push: true - platforms: linux/amd64,linux/arm64 + # FORK: amd64 only. The single deploy target (the yams VM) is x86_64, + # so arm64 was being emulated under QEMU for an image nothing pulls — + # and emulated builds are several times slower than native. Re-add + # linux/arm64 (and the QEMU setup step above) if an ARM host ever + # needs to run this. + platforms: linux/amd64 # FORK: docker.io/jordyjordy/* tags removed — that is upstream's # Docker Hub namespace, which this fork has no rights to push to. # GHCR only, which IMAGE_NAME resolves to our own repo path. tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.version }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - cache-from: | - type=gha,scope=buildx-amd64 - type=gha,scope=buildx-arm64 - cache-to: type=gha,mode=max,scope=buildx-${{ steps.tag.outputs.version }} + # FORK / BUG FIX: these scopes did not match. cache-to wrote to + # `buildx-` while cache-from read `buildx-amd64` and + # `buildx-arm64`, so no release ever read a cache entry another + # release had written — every build was cold, and each one left + # behind a version-scoped entry nothing would ever read again. + # Read and write the same scope so layers actually carry over. + cache-from: type=gha,scope=buildx-amd64 + cache-to: type=gha,mode=max,scope=buildx-amd64 - name: Run Trivy vulnerability scanner if: steps.tag.outputs.should_release == 'true' diff --git a/FORK.md b/FORK.md index 0812b2fe..914a80ce 100644 --- a/FORK.md +++ b/FORK.md @@ -64,6 +64,14 @@ these are not preferences, they are blockers: | Removed `docker.io/jordyjordy/*` image tags | That is upstream's Docker Hub namespace; this fork has no rights to push there, so `build-push` failed. GHCR only now — `IMAGE_NAME` already resolves to our own repo path. | | Removed "Sync README to Docker Hub" step | Same missing secrets, and it targets upstream's Docker Hub repo. | | Trivy `exit-code: "0"` + `if: always()` on the SARIF upload | The image is pushed *before* the scan runs, so a hard failure never prevented a vulnerable image shipping — it only skipped the SARIF upload and the GitHub Release, leaving the run permanently red and the findings invisible in the Security tab. Non-blocking puts them where they can be acted on. | +| `platforms: linux/amd64` only, QEMU setup step removed | The single deploy target (yams) is x86_64. arm64 was emulated under QEMU for an image nothing pulls, and emulation is several times slower than native. Re-add both if an ARM host ever needs this. | +| **Cache scope fix** — `cache-from`/`cache-to` both `buildx-amd64` | Upstream wrote `cache-to: scope=buildx-` but read `cache-from: scope=buildx-amd64,buildx-arm64`. The scopes never matched, so **no release ever read a cache entry another release wrote** — every build was cold, and each left a version-scoped entry nothing would read again. Worth upstreaming. | + +### Release build time + +The Docker build step was **989s of an 18m25s run** — every other step totalled ~90s. Both changes +above target that one step: dropping the emulated arm64 half, and making the layer cache actually +hit on subsequent builds. Because this file is modified, **review `git diff upstream/main -- .github/` on every sync** and re-apply these if upstream rewrites the release job. Reviewing that diff is worth doing regardless: From d0358e62f60ebe2d56cd2dc988dde631918f6a5e Mon Sep 17 00:00:00 2001 From: Patrick Dundas Date: Wed, 29 Jul 2026 00:01:40 -0600 Subject: [PATCH 11/23] =?UTF-8?q?feat(ui):=20graphite=20redesign=20?= =?UTF-8?q?=E2=80=94=20flat=20surfaces,=20AA=20contrast,=20de-neon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the dark-neumorphism + neon-cyan theme with a flat graphite system: - Theme tokens routed through runtime --tt-* CSS vars in :root (enables runtime palette previews and a future follow-system light mode), then swapped to the new palette: page #121315, cards #1c1d1f, solid hairline borders, text tiers all WCAG AA (muted 1.88:1 -> 4.81:1, tertiary 3.02:1 -> 6.34:1), accent #00d4ff neon cyan -> #67a5d9 steel blue. - nm-* utilities redefined as flat inset-ring + subtle shadow (names kept so all call sites and hover:/active: variants work unchanged); raised tier now visibly distinct for hover; new nm-focus utility gives inputs a real focus ring (focus:nm-inset was a no-op). - Primary buttons are filled accent with dark text (previously identical to disabled buttons); glow shadows, pulse-glow drop-shadows and all colored halos removed (Card, StatCard, ProgressBar, RankProgress, DownloadClientStatusWidget, BackToTop's hand-copied neumorphic values). - TrackerStatusBanner signals attention via tinted bg + border instead of the removed glow; Card's dead glow/glowColor props removed. - Space Mono -> JetBrains Mono; dense text tier 10px -> 11px; radii 6/8/10/12; logo gradient stops de-neoned; DEFAULT_TRACKER_COLOR and client fallback updated to the new accent. Co-Authored-By: Claude Opus 5 --- public/img/trackerTracker_logo.svg | 2 +- src/app/globals.css | 429 +++++++++++------- src/app/layout.tsx | 6 +- src/components/TrackerSettingsSheet.tsx | 2 +- src/components/dashboard/PollAllButton.tsx | 2 +- src/components/dashboard/RankProgress.tsx | 2 - .../layout/DownloadClientStatusWidget.tsx | 5 +- .../tracker-detail/TrackerStatusBanner.tsx | 8 +- src/components/ui/AreaInput.tsx | 2 +- src/components/ui/BackToTop.tsx | 41 +- src/components/ui/Button.tsx | 10 +- src/components/ui/Card.tsx | 13 +- src/components/ui/Checkbox.tsx | 2 +- src/components/ui/Input.tsx | 2 +- src/components/ui/ProgressBar.tsx | 1 - src/components/ui/StatCard.tsx | 9 +- src/components/ui/__tests__/Button.test.tsx | 2 +- .../ui/__tests__/ProgressBar.test.tsx | 10 +- src/lib/__tests__/formatters.test.ts | 6 +- src/lib/constants.ts | 2 +- .../__tests__/coordinator.test.ts | 4 +- src/lib/download-clients/coordinator.ts | 2 +- 22 files changed, 303 insertions(+), 259 deletions(-) diff --git a/public/img/trackerTracker_logo.svg b/public/img/trackerTracker_logo.svg index 82a51c8a..5607024e 100644 --- a/public/img/trackerTracker_logo.svg +++ b/public/img/trackerTracker_logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index bf9cddc8..f3e7f338 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,119 +2,244 @@ @import "tailwindcss"; @plugin "@tailwindcss/typography"; +/* + * Runtime theme tokens — the single source of truth for every color, + * radius, and shadow used by @theme inline (below) plus the hand-written + * utilities further down. @theme inline normally resolves nested var() + * chains to a literal value at build time, which would make these + * unoverridable at runtime; pointing everything here instead (a plain + * :root custom property Tailwind's theme compiler doesn't own) keeps a + * real var() in the generated CSS, so any --tt-* var can be reassigned + * at runtime (e.g. for a palette swap or a future light theme) with no + * rebuild. Structure mirrors the @theme block: Layer 1 raw palette, + * Layer 2 semantic aliases, Layer 3 purpose aliases. + */ +:root { + /* ── Layer 1: Raw color palette ────────────────────────────────────── + * Ground-truth OKLCH values. The only place a raw color appears. + */ + + /* Hues */ + --tt-cyan: oklch(70% 0.1 245); /* #67a5d9 — muted steel blue (was neon cyan) */ + --tt-amber: oklch(75% 0.14 75); /* #e1a035 */ + --tt-red: oklch(64% 0.19 25); /* #e9504d */ + --tt-green: oklch(70% 0.13 162); /* #3fb785 */ + --tt-lime: oklch(72% 0.15 150); /* #53be70 */ + --tt-violet: oklch(65% 0.15 292); /* #927be2 */ + --tt-sky: oklch(70% 0.1 220); /* #4aadc9 */ + + /* Surfaces — flat neutral gray. */ + --tt-surface-900: oklch(16% 0.004 270); /* #0d0d0f */ + --tt-surface-800: oklch(20% 0.004 270); /* #151618 */ + --tt-surface-700: oklch(21% 0.005 270); /* #17181b */ + --tt-surface-600: oklch(26% 0.006 270); /* #232427 */ + --tt-surface-500: oklch(29% 0.007 270); /* #2a2b2f */ + --tt-surface-400: oklch(32% 0.008 270); /* #313337 */ + + /* Text grays */ + --tt-gray-50: oklch(95% 0.004 260); /* #edeef1 */ + --tt-gray-400: oklch(81% 0.008 260); /* #bec1c6 */ + --tt-gray-500: oklch(70% 0.01 260); /* #9b9fa5 */ + --tt-gray-600: oklch(63% 0.012 260); /* #858991 */ + + /* ── Layer 2: Semantic aliases ───────────────────────────────────────── + * Intent-named tokens. Use text-accent, bg-danger, border-warn, etc. + */ + + /* Surfaces — literal values (not aliases): page bg sits darker than cards */ + --tt-color-base: oklch(18.5% 0.004 270); /* #121315 — page background */ + --tt-color-raised: oklch(23% 0.005 270); /* #1c1d1f — cards */ + --tt-color-elevated: oklch(26.5% 0.006 270); /* #242528 — modals/popovers */ + --tt-color-overlay: oklch(29% 0.007 270); /* #2a2b2f */ + --tt-color-control-bg: oklch(20% 0.004 270); /* #151618 — inputs */ + + /* Text hierarchy */ + --tt-color-primary: var(--tt-gray-50); + --tt-color-secondary: var(--tt-gray-400); + --tt-color-tertiary: var(--tt-gray-500); + --tt-color-muted: var(--tt-gray-600); + + /* Semantic status */ + --tt-color-accent: var(--tt-cyan); + --tt-color-warn: var(--tt-amber); + --tt-color-danger: var(--tt-red); + --tt-color-success: var(--tt-green); + + /* Dim/glow variants */ + --tt-accent-dim: oklch(70% 0.1 245 / 0.14); + --tt-accent-glow: oklch(70% 0.1 245 / 0.2); + --tt-warn-dim: oklch(75% 0.14 75 / 0.15); + --tt-danger-dim: oklch(64% 0.19 25 / 0.15); + --tt-success-dim: oklch(70% 0.13 162 / 0.15); + + /* Borders — solid colors for crisp hairlines (not alpha-of-gray) */ + --tt-border: oklch(32% 0.008 265); /* #313337 */ + --tt-border-soft: oklch(27% 0.006 265); /* #252629 */ + --tt-border-emphasis: oklch(42% 0.01 265); /* #4a4d53 */ + + /* Controls */ + --tt-control-border: oklch(34% 0.008 265); /* #36383c */ + --tt-control-focus: oklch(70% 0.1 245 / 0.5); + + /* ── Layer 3: Purpose aliases ────────────────────────────────────────── + * Domain-specific meaning for data visualization. + * Generates text-upload, bg-download, border-positive, etc. + */ + --tt-color-upload: var(--tt-cyan); + --tt-color-download: oklch(72% 0.12 65); /* #d8944d — muted orange, was raw amber */ + --tt-color-positive: var(--tt-lime); + --tt-color-negative: var(--tt-red); + --tt-color-neutral: var(--tt-gray-400); + --tt-color-tracker-default: var(--tt-cyan); + + /* Ordinal scale — ratio tiers, seed-time tiers */ + --tt-scale-1: var(--tt-red); + --tt-scale-2: var(--tt-amber); + --tt-scale-3: var(--tt-green); + --tt-scale-4: var(--tt-cyan); + --tt-scale-5: var(--tt-violet); + --tt-scale-6: var(--tt-sky); + + /* Glow shadows — flat theme, no glows */ + --tt-shadow-glow: 0 0 #0000; + --tt-shadow-glow-sm: 0 0 #0000; + --tt-shadow-glow-lg: 0 0 #0000; + --tt-shadow-glow-warn: 0 0 #0000; + --tt-shadow-glow-danger: 0 0 #0000; + --tt-shadow-glow-success: 0 0 #0000; + + /* Border radius — tighter, crisp */ + --tt-radius-sm: 6px; + --tt-radius-md: 8px; + --tt-radius-lg: 10px; + --tt-radius-xl: 12px; + --tt-radius-pill: 9999px; + + /* ── Native dialog backdrop ── */ + --tt-dialog-backdrop: rgb(0 0 0 / 0.6); + + /* ── Scrollbars ── */ + --tt-scrollbar-thumb: oklch(32% 0.008 265); /* #313337 */ + --tt-scrollbar-thumb-hover: oklch(42% 0.01 265); /* #4a4d53 */ + --tt-scrollbar-track: oklch(20% 0.004 270); + + /* ── react-colorful pointer ── */ + --tt-colorpicker-pointer-border: oklch(100% 0 0); /* #ffffff */ + --tt-colorpicker-pointer-shadow: oklch(0% 0 0 / 0.4); /* #000000 40% */ +} + @theme inline { /* Fonts */ --font-sans: var(--font-archivo), system-ui, sans-serif; --font-mono: var(--font-mono), monospace; /* ── Layer 1: Raw color palette ────────────────────────────────────── - * Ground-truth OKLCH values. The only place a raw color appears. + * Values live in :root as --tt-* runtime vars (see top of file) — this + * just wires Tailwind's generated utilities to them. * Tailwind v4 generates text-cyan, bg-amber, border-red, etc. */ /* Hues */ - --color-cyan: oklch(80.43% 0.1459 219.52); /* #01d4ff */ - --color-amber: oklch(76.86% 0.1647 70.08); /* #f59e0b */ - --color-red: oklch(63.68% 0.2078 25.33); /* #ef4444 */ - --color-green: oklch(69.59% 0.1491 162.48); /* #10b981 */ - --color-lime: oklch(72.27% 0.192 149.58); /* #22c55e */ - --color-violet: oklch(60.56% 0.2189 292.72); /* #8b5cf6 */ - --color-sky: oklch(71.48% 0.1257 215.22); /* #06b6d4 */ - - /* Surfaces — dark purple-gray. Neumorphism: light/dark pair for depth. */ - --color-surface-900: oklch(22.9% 0.0155 279.49); /* #1b1c24 */ - --color-surface-800: oklch(24.55% 0.0174 275.22); /* #1e2029 */ - --color-surface-700: oklch(28.82% 0.0221 277.51); /* #282a36 */ - --color-surface-600: oklch(31.52% 0.0323 279.44); /* #2e3042 */ - --color-surface-500: oklch(33.87% 0.0317 279.61); /* #343648 */ - --color-surface-400: oklch(34.45% 0.0283 276.51); /* #353848 */ + --color-cyan: var(--tt-cyan); + --color-amber: var(--tt-amber); + --color-red: var(--tt-red); + --color-green: var(--tt-green); + --color-lime: var(--tt-lime); + --color-violet: var(--tt-violet); + --color-sky: var(--tt-sky); + + /* Surfaces — flat neutral gray. */ + --color-surface-900: var(--tt-surface-900); + --color-surface-800: var(--tt-surface-800); + --color-surface-700: var(--tt-surface-700); + --color-surface-600: var(--tt-surface-600); + --color-surface-500: var(--tt-surface-500); + --color-surface-400: var(--tt-surface-400); /* Text grays */ - --color-gray-50: oklch(92.88% 0.0126 255.51); /* #e2e8f0 */ - --color-gray-400: oklch(71.07% 0.0351 256.79); /* #94a3b8 */ - --color-gray-500: oklch(55.44% 0.0407 257.42); /* #64748b */ - --color-gray-600: oklch(44.55% 0.0374 257.28); /* #475569 */ + --color-gray-50: var(--tt-gray-50); + --color-gray-400: var(--tt-gray-400); + --color-gray-500: var(--tt-gray-500); + --color-gray-600: var(--tt-gray-600); /* ── Layer 2: Semantic aliases ───────────────────────────────────────── * Intent-named tokens. Use text-accent, bg-danger, border-warn, etc. */ /* Surfaces */ - --color-base: var(--color-surface-700); - --color-raised: var(--color-surface-700); - --color-elevated: var(--color-surface-600); - --color-overlay: var(--color-surface-500); - --color-control-bg: var(--color-surface-800); + --color-base: var(--tt-color-base); + --color-raised: var(--tt-color-raised); + --color-elevated: var(--tt-color-elevated); + --color-overlay: var(--tt-color-overlay); + --color-control-bg: var(--tt-color-control-bg); /* Text hierarchy */ - --color-primary: var(--color-gray-50); - --color-secondary: var(--color-gray-400); - --color-tertiary: var(--color-gray-500); - --color-muted: var(--color-gray-600); + --color-primary: var(--tt-color-primary); + --color-secondary: var(--tt-color-secondary); + --color-tertiary: var(--tt-color-tertiary); + --color-muted: var(--tt-color-muted); /* Semantic status */ - --color-accent: var(--color-cyan); - --color-warn: var(--color-amber); - --color-danger: var(--color-red); - --color-success: var(--color-green); + --color-accent: var(--tt-color-accent); + --color-warn: var(--tt-color-warn); + --color-danger: var(--tt-color-danger); + --color-success: var(--tt-color-success); /* Dim/glow variants */ - --color-accent-dim: oklch(80.43% 0.1459 219.52 / 0.15); /* #01d4ff 15% */ - --color-accent-glow: oklch(80.43% 0.1459 219.52 / 0.3); /* #01d4ff 30% */ - --color-warn-dim: oklch(76.86% 0.1647 70.08 / 0.15); /* #f59e0b 15% */ - --color-danger-dim: oklch(63.68% 0.2078 25.33 / 0.15); /* #ef4444 15% */ - --color-success-dim: oklch(69.59% 0.1491 162.48 / 0.15); /* #10b981 15% */ + --color-accent-dim: var(--tt-accent-dim); + --color-accent-glow: var(--tt-accent-glow); + --color-warn-dim: var(--tt-warn-dim); + --color-danger-dim: var(--tt-danger-dim); + --color-success-dim: var(--tt-success-dim); /* Borders */ - --color-border: oklch(71.07% 0.0351 256.79 / 0.08); /* #94a3b8 8% */ - --color-border-soft: oklch(71.07% 0.0351 256.79 / 0.04); /* #94a3b8 4% */ - --color-border-emphasis: oklch(71.07% 0.0351 256.79 / 0.15); /* #94a3b8 15% */ + --color-border: var(--tt-border); + --color-border-soft: var(--tt-border-soft); + --color-border-emphasis: var(--tt-border-emphasis); /* Controls */ - --color-control-border: oklch(71.07% 0.0351 256.79 / 0.08); /* #94a3b8 8% */ - --color-control-focus: oklch(80.43% 0.1459 219.52 / 0.5); /* #01d4ff 50% */ + --color-control-border: var(--tt-control-border); + --color-control-focus: var(--tt-control-focus); /* ── Layer 3: Purpose aliases ────────────────────────────────────────── * Domain-specific meaning for data visualization. * Generates text-upload, bg-download, border-positive, etc. */ - --color-upload: var(--color-cyan); - --color-download: var(--color-amber); - --color-positive: var(--color-lime); - --color-negative: var(--color-red); - --color-neutral: var(--color-gray-400); - --color-tracker-default: var(--color-cyan); + --color-upload: var(--tt-color-upload); + --color-download: var(--tt-color-download); + --color-positive: var(--tt-color-positive); + --color-negative: var(--tt-color-negative); + --color-neutral: var(--tt-color-neutral); + --color-tracker-default: var(--tt-color-tracker-default); /* Ordinal scale — ratio tiers, seed-time tiers */ - --color-scale-1: var(--color-red); - --color-scale-2: var(--color-amber); - --color-scale-3: var(--color-green); - --color-scale-4: var(--color-cyan); - --color-scale-5: var(--color-violet); - --color-scale-6: var(--color-sky); + --color-scale-1: var(--tt-scale-1); + --color-scale-2: var(--tt-scale-2); + --color-scale-3: var(--tt-scale-3); + --color-scale-4: var(--tt-scale-4); + --color-scale-5: var(--tt-scale-5); + --color-scale-6: var(--tt-scale-6); /* Glow shadows */ - --shadow-glow: 0 0 16px var(--color-accent-glow); - --shadow-glow-sm: 0 0 8px var(--color-accent-glow); - --shadow-glow-lg: 0 0 28px var(--color-accent-glow); - --shadow-glow-warn: 0 0 16px var(--color-warn-dim); - --shadow-glow-danger: 0 0 16px var(--color-danger-dim); - --shadow-glow-success: 0 0 16px var(--color-success-dim); - - /* Border radius — bubbly */ - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 20px; - --radius-xl: 24px; - --radius-pill: 9999px; + --shadow-glow: var(--tt-shadow-glow); + --shadow-glow-sm: var(--tt-shadow-glow-sm); + --shadow-glow-lg: var(--tt-shadow-glow-lg); + --shadow-glow-warn: var(--tt-shadow-glow-warn); + --shadow-glow-danger: var(--tt-shadow-glow-danger); + --shadow-glow-success: var(--tt-shadow-glow-success); + + /* Border radius — tighter, crisp */ + --radius-sm: var(--tt-radius-sm); + --radius-md: var(--tt-radius-md); + --radius-lg: var(--tt-radius-lg); + --radius-xl: var(--tt-radius-xl); + --radius-pill: var(--tt-radius-pill); /* Sub-xs font sizes for dense data UI. - * text-3xs and text-2xs are intentionally the same value today (10px). - * text-2xs covers former 11px contexts (table cells, pills). - * If dense tables feel too tight, bump --text-2xs to 11px — one-line change. */ + * text-2xs covers former 11px contexts (table cells, pills). */ --text-4xs: 9px; --text-3xs: 10px; - --text-2xs: 10px; + --text-2xs: 11px; } /* Custom radius utilities matching design system tokens */ @@ -135,56 +260,55 @@ } /* - * Neumorphic shadow utilities — registered as @utility so Tailwind v4 - * generates variant classes (hover:nm-raised, active:nm-pressed, etc.). - * Base: #282A36. Generated via neumorphism.io. + * Flat surface utilities — registered as @utility so Tailwind v4 generates + * variant classes (hover:nm-raised, active:nm-pressed, etc.). Names kept + * from the neumorphic era for compile compatibility; shadows are now a + * hairline border plus a small flat depth shadow, never a light/dark pair. * - * IMPORTANT: overflow-hidden on a parent CLIPS these shadows. If you nest - * nm-raised elements inside an overflow-hidden container, use the p-N/-m-N - * trick to push the clip boundary out. Shadow reach per variant: - * nm-raised-sm: ~12px (use p-3 -m-3) - * nm-raised: ~24px (use p-6 -m-6) - * nm-raised-lg: ~36px (use p-9 -m-9) + * Shadows no longer reach outside the element more than ~2px, so + * overflow-hidden parents no longer need the p-N/-m-N clip-boundary trick. */ @utility nm-raised { box-shadow: - -8px -8px 16px oklch(34.45% 0.0283 276.51), - /* #353848 */ 8px 8px 16px oklch(22.9% 0.0155 279.49); /* #1b1c24 */ + inset 0 0 0 1px var(--tt-border-emphasis), + 0 2px 6px rgb(0 0 0 / 0.4); } @utility nm-raised-sm { box-shadow: - -4px -4px 8px oklch(33.24% 0.0268 276.01), - /* #323544 */ 4px 4px 8px oklch(24.23% 0.0172 280.05); /* #1e1f28 */ + inset 0 0 0 1px var(--tt-border), + 0 1px 2px rgb(0 0 0 / 0.35); } @utility nm-raised-lg { box-shadow: - -12px -12px 24px oklch(36.85% 0.0312 277.36), - /* #3b3e50 */ 12px 12px 24px oklch(20.19% 0.0121 277.81); /* #15161c */ + inset 0 0 0 1px var(--tt-border), + 0 1px 2px rgb(0 0 0 / 0.35), + 0 6px 16px -8px rgb(0 0 0 / 0.4); } @utility nm-inset { - box-shadow: - inset 4px 4px 8px oklch(19.74% 0.0121 277.78), - /* #14151b */ inset -4px -4px 8px oklch(37.23% 0.0311 277.38); /* #3c3f51 */ + box-shadow: inset 0 0 0 1px var(--tt-border); } @utility nm-inset-sm { + box-shadow: inset 0 0 0 1px var(--tt-border); +} + +/* Focus ring for inputs — inner hairline plus a soft outer halo. */ +@utility nm-focus { box-shadow: - inset 2px 2px 5px oklch(24.55% 0.0174 275.22), - /* #1e2029 */ inset -2px -2px 5px oklch(32.94% 0.0266 278.88); /* #323443 */ + inset 0 0 0 1px var(--tt-control-focus), + 0 0 0 3px oklch(70% 0.1 245 / 0.15); } @utility nm-pressed { box-shadow: - inset 4px 4px 10px oklch(19.74% 0.0121 277.78), - /* #14151b */ inset -2px -2px 5px oklch(32.94% 0.0266 278.88); /* #323443 */ + inset 0 0 0 1px var(--tt-border), + inset 0 1px 3px rgb(0 0 0 / 0.35); } -/* Tracker color accent glow — corner-pooling inset glow via pseudo-element - * so it doesn't conflict with the card's nm-raised box-shadow. Four offset - * shadows concentrate light in the corners and fade along the edges. +/* Tracker color accent — subtle inner ring tinted with the tracker color. * Color passed as --card-accent via inline style. */ @utility card-accent { position: relative; @@ -193,11 +317,7 @@ position: absolute; inset: 0; border-radius: inherit; - box-shadow: - inset 5px 5px 14px -5px var(--card-accent, transparent), - inset -3px -3px 14px -8px var(--card-accent, transparent), - inset 1px 1px 2px 0 rgba(255, 255, 255, 0.05), - inset -1px -1px 2px 0 rgba(255, 255, 255, 0.015); + box-shadow: inset 0 0 0 1px var(--card-accent, transparent); pointer-events: none; } } @@ -276,7 +396,7 @@ /* ─── Native dialog backdrop ─── */ dialog[data-overlay]::backdrop { - background: rgb(0 0 0 / 0.6); + background: var(--tt-dialog-backdrop); opacity: 0; transition: opacity 150ms ease-in; } @@ -344,63 +464,49 @@ dialog[data-overlay][data-visible]::backdrop { } /* - * Neumorphic interactive states — for clickable raised surfaces. - * Three tiers matching elevation: sm (cards), base (panels), inset (dormant links). - * Each transitions: rest → hover (raise higher) → active (press inward + scale). + * Flat interactive states — for clickable raised surfaces. + * Two tiers: sm (cards/panels), inset (dormant links). + * Each transitions: rest → hover (emphasized border) → active (pressed). * These replace static nm-raised-* classes on interactive elements. */ .nm-interactive-sm { box-shadow: - -4px -4px 8px oklch(33.24% 0.0268 276.01), - /* #323544 */ 4px 4px 8px oklch(24.23% 0.0172 280.05); /* #1e1f28 */ - transition: - box-shadow 180ms cubic-bezier(0.25, 0.46, 0.45, 0.94), - transform 180ms cubic-bezier(0.25, 0.46, 0.45, 0.94); + inset 0 0 0 1px var(--tt-border), + 0 1px 2px rgb(0 0 0 / 0.35); + transition: box-shadow 180ms cubic-bezier(0.25, 0.46, 0.45, 0.94); } .nm-interactive-sm:hover { box-shadow: - -4px -4px 8px oklch(31.67% 0.0271 275.92), - /* #2e3140 */ 5px 5px 9px oklch(23.8% 0.0173 280.02); /* #1d1e27 */ - transform: scale(1.01); + inset 0 0 0 1px var(--tt-border-emphasis), + 0 2px 5px rgb(0 0 0 / 0.35); } .nm-interactive-sm:active { box-shadow: - inset 2px 2px 6px oklch(21.5% 0.0119 277.89), - /* #18191f */ inset -2px -2px 4px oklch(31.42% 0.0287 279.05); /* #2e3040 */ - transform: scale(0.99); + inset 0 0 0 1px var(--tt-border), + inset 0 1px 3px rgb(0 0 0 / 0.35); } .nm-interactive-inset { - box-shadow: - inset 2px 2px 5px oklch(24.55% 0.0174 275.22), - /* #1e2029 */ inset -2px -2px 5px oklch(32.94% 0.0266 278.88); /* #323443 */ - transition: - box-shadow 180ms cubic-bezier(0.25, 0.46, 0.45, 0.94), - transform 180ms cubic-bezier(0.25, 0.46, 0.45, 0.94); + box-shadow: inset 0 0 0 1px var(--tt-border); + transition: box-shadow 180ms cubic-bezier(0.25, 0.46, 0.45, 0.94); } .nm-interactive-inset:hover { - box-shadow: - -2px -2px 5px oklch(31.67% 0.0271 275.92), - /* #2e3140 */ 3px 3px 6px oklch(25.07% 0.017 280.1); /* #20212a */ - transform: scale(1.01); + box-shadow: inset 0 0 0 1px var(--tt-border-emphasis); } .nm-interactive-inset:active { box-shadow: - inset 2px 2px 6px oklch(21.5% 0.0119 277.89), - /* #18191f */ inset -2px -2px 4px oklch(31.42% 0.0287 279.05); /* #2e3040 */ - transform: scale(0.99); + inset 0 0 0 1px var(--tt-border), + inset 0 1px 3px rgb(0 0 0 / 0.35); } -/* Glow animation for pulse dots */ +/* Pulse animation for status dots */ @keyframes pulse-glow { 0%, 100% { opacity: 1; - filter: drop-shadow(0 0 5px currentColor); } 50% { - opacity: 0.7; - filter: drop-shadow(0 0 12px currentColor); + opacity: 0.65; } } @@ -502,12 +608,12 @@ input:autofill:hover, input:autofill:focus, textarea:autofill, select:autofill { - -webkit-box-shadow: 0 0 0 1000px oklch(24.55% 0.0174 275.22) inset; /* control-bg — Chromium */ - box-shadow: 0 0 0 1000px oklch(24.55% 0.0174 275.22) inset; /* control-bg — Firefox/standard */ - -webkit-text-fill-color: oklch(92.88% 0.0126 255.51); /* primary text */ - color: oklch(92.88% 0.0126 255.51) !important; - background-color: oklch(24.55% 0.0174 275.22) !important; - caret-color: oklch(92.88% 0.0126 255.51); + -webkit-box-shadow: 0 0 0 1000px var(--tt-color-control-bg) inset; /* control-bg — Chromium */ + box-shadow: 0 0 0 1000px var(--tt-color-control-bg) inset; /* control-bg — Firefox/standard */ + -webkit-text-fill-color: var(--tt-color-primary); /* primary text */ + color: var(--tt-color-primary) !important; + background-color: var(--tt-color-control-bg) !important; + caret-color: var(--tt-color-primary); transition: background-color 5000s ease-in-out 0s; } @@ -529,8 +635,8 @@ select { /* Page scrollbar — picks up --scrollbar-color from nearest ancestor (tracker page sets it) */ .themed-scrollbar { - --scrollbar-color: oklch(34.45% 0.0283 276.51); /* #353848 */ - --scrollbar-hover: oklch(40.24% 0.0439 279.12); /* #434660 */ + --scrollbar-color: var(--tt-scrollbar-thumb); + --scrollbar-hover: var(--tt-scrollbar-thumb-hover); scrollbar-width: thin; scrollbar-color: var(--scrollbar-color) transparent; } @@ -552,7 +658,7 @@ select { /* Inner horizontal scrollbars on tables/code — always subtle */ .styled-scrollbar { scrollbar-width: thin; - scrollbar-color: oklch(34.45% 0.0283 276.51) /* #353848 */ transparent; + scrollbar-color: var(--tt-scrollbar-thumb) transparent; } .styled-scrollbar::-webkit-scrollbar { width: 6px; @@ -562,32 +668,31 @@ select { background: transparent; } .styled-scrollbar::-webkit-scrollbar-thumb { - background: oklch(34.45% 0.0283 276.51); /* #353848 */ + background: var(--tt-scrollbar-thumb); border-radius: 9999px; } .styled-scrollbar::-webkit-scrollbar-thumb:hover { - background: oklch(40.24% 0.0439 279.12); /* #434660 */ + background: var(--tt-scrollbar-thumb-hover); } .styled-scrollbar-visible { scrollbar-width: thin; - scrollbar-color: oklch(34.45% 0.0283 276.51) /* #353848 */ oklch(22.44% 0.0168 277.68) - /* #1e2029 */; + scrollbar-color: var(--tt-scrollbar-thumb) var(--tt-scrollbar-track); } .styled-scrollbar-visible::-webkit-scrollbar { width: 6px; height: 6px; } .styled-scrollbar-visible::-webkit-scrollbar-track { - background: oklch(22.44% 0.0168 277.68); /* #1e2029 control-bg */ + background: var(--tt-scrollbar-track); border-radius: 9999px; } .styled-scrollbar-visible::-webkit-scrollbar-thumb { - background: oklch(34.45% 0.0283 276.51); /* #353848 */ + background: var(--tt-scrollbar-thumb); border-radius: 9999px; } .styled-scrollbar-visible::-webkit-scrollbar-thumb:hover { - background: oklch(40.24% 0.0439 279.12); /* #434660 */ + background: var(--tt-scrollbar-thumb-hover); } /* react-colorful overrides */ @@ -609,21 +714,21 @@ select { .color-picker-wrapper .react-colorful__pointer { width: 16px; height: 16px; - border: 2px solid oklch(100% 0 0); /* #ffffff */ - box-shadow: 0 0 4px oklch(0% 0 0 / 0.4); /* #000000 40% */ + border: 2px solid var(--tt-colorpicker-pointer-border); + box-shadow: 0 0 4px var(--tt-colorpicker-pointer-shadow); } /* Emoji picker theme overrides */ .emoji-picker-wrapper .EmojiPickerReact.epr-dark-theme { - --epr-bg-color: oklch(31.52% 0.0323 279.44); /* #2e3042 */ - --epr-category-label-bg-color: oklch(31.52% 0.0323 279.44); /* #2e3042 */ - --epr-hover-bg-color: oklch(33.87% 0.0317 279.61); /* #343648 */ - --epr-search-input-bg-color: oklch(24.55% 0.0174 275.22); /* #1e2029 */ - --epr-text-color: oklch(92.88% 0.0126 255.51); /* #e2e8f0 */ - --epr-search-input-text-color: oklch(92.88% 0.0126 255.51); /* #e2e8f0 */ - --epr-search-input-placeholder-color: oklch(55.44% 0.0407 257.42); /* #64748b */ - --epr-category-icon-active-color: oklch(80.43% 0.1459 219.52); /* #01d4ff */ - --epr-skin-tone-picker-menu-color: oklch(33.87% 0.0317 279.61); /* #343648 */ + --epr-bg-color: var(--tt-surface-600); + --epr-category-label-bg-color: var(--tt-surface-600); + --epr-hover-bg-color: var(--tt-surface-500); + --epr-search-input-bg-color: var(--tt-surface-800); + --epr-text-color: var(--tt-color-primary); + --epr-search-input-text-color: var(--tt-color-primary); + --epr-search-input-placeholder-color: var(--tt-color-tertiary); + --epr-category-icon-active-color: var(--tt-color-accent); + --epr-skin-tone-picker-menu-color: var(--tt-surface-500); border-radius: var(--radius-md); - border: 1px solid oklch(37.23% 0.0311 277.38); /* #3c3f51 */ + border: 1px solid var(--tt-border); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 023f2f92..6ca861dc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,6 @@ // src/app/layout.tsx import type { Metadata } from "next" -import { Archivo, Space_Mono } from "next/font/google" +import { Archivo, JetBrains_Mono } from "next/font/google" import type { ReactNode } from "react" import "./globals.css" @@ -9,7 +9,7 @@ const archivo = Archivo({ variable: "--font-archivo", }) -const spaceMono = Space_Mono({ +const jetBrainsMono = JetBrains_Mono({ subsets: ["latin"], weight: ["400", "700"], variable: "--font-mono", @@ -30,7 +30,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} diff --git a/src/components/TrackerSettingsSheet.tsx b/src/components/TrackerSettingsSheet.tsx index b6c04cbf..d0a7a52e 100644 --- a/src/components/TrackerSettingsSheet.tsx +++ b/src/components/TrackerSettingsSheet.tsx @@ -376,7 +376,7 @@ function TrackerSettingsSheet({ open, tracker, onClose, onUpdated }: TrackerSett onChange={(e) => setEditDcCookies(e.target.value)} placeholder="uid=56954; pass=abc123def456..." rows={2} - className="w-full font-mono text-sm text-primary bg-control-bg rounded-nm-md px-4 py-3 placeholder:text-muted nm-inset focus:outline-none focus:nm-inset border-0 resize-y" + className="w-full font-mono text-sm text-primary bg-control-bg rounded-nm-md px-4 py-3 placeholder:text-muted nm-inset focus:outline-none focus:nm-focus border-0 resize-y" />