diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index f578f2bb..22575c67 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -22,6 +22,7 @@ import { type PathBinding as DomainBinding, type ExistingPolicy, type ExistingTarget, + fileKindOf, findOtherVersions, type NewBinding, type OrganizePlan, @@ -32,6 +33,7 @@ import { type QualityFacts, resolveTargetDirectory, type SourceFile, + sidecarStem, } from "@umt/domain"; import { organizationStatusFromFiles, organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping"; import { withAttemptToken } from "@umt/tasks"; @@ -192,6 +194,48 @@ async function walk( } } +/** + * 选中的是单个视频文件时,把同目录里主干名(剥掉 .chs/.eng 等语言标签后)相同的字幕 / 外挂音轨补进源文件列表; + * 配对规则与 planOrganize 的附属文件一致(同目录、主干唯一匹配),这里只负责把它们带进来。 + * 目录源已由 walk 递归带上附属文件;已在列表里的不重复加。 + */ +async function addSidecarSiblings( + sources: string[], + out: string[], + limit: number, + guard?: ((p: string) => Promise) | undefined, +): Promise { + const have = new Set(out); + const byDir = new Map>(); + for (const src of sources) { + if (fileKindOf(src) !== "video" || !have.has(src)) continue; + const dir = dirname(src); + const stems = byDir.get(dir) ?? new Set(); + stems.add( + basename(src) + .replace(/\.[^.]+$/, "") + .toLowerCase(), + ); + byDir.set(dir, stems); + } + for (const [dir, stems] of byDir) { + const names = (await readdir(dir).catch(() => [] as string[])).sort(); + for (const n of names) { + if (out.length >= limit) return; + if (n.startsWith(".")) continue; + const kind = fileKindOf(n); + if (kind !== "subtitle" && kind !== "audio") continue; + const p = join(dir, n); + if (have.has(p) || !stems.has(sidecarStem(n).stem)) continue; + const st = await statSafe(p); + if (st.kind !== "ok" || !st.facts.isFile) continue; + if (guard) await guard(p); + out.push(p); + have.add(p); + } + } +} + function libraryKinds(kind: MediaEntityRow["kind"]): string[] { if (kind === "movie") return ["movie", "mixed"]; if (kind === "variety") return ["variety", "series", "mixed"]; @@ -302,6 +346,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise { await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "=", grant.id).execute(); }); + it("organize:选中的视频带上同目录同主干的字幕 / 外挂音轨,无关字幕不带", async () => { + const dir = join(h.dl, "Some.Show.S01.1080p.WEB-DL"); + await writeFile(join(dir, "Some.Show.S01E02.1080p.WEB-DL-GRP.chs.ass"), "sub-ep2"); + await writeFile(join(dir, "Some.Show.S01E03.1080p.WEB-DL-GRP.mka"), "audio-ep3"); + await writeFile(join(dir, "unrelated.srt"), "noise"); + // 整理向导把目录展开成逐个视频后提交 + const items = ["Some.Show.S01E02.1080p.WEB-DL-GRP.mkv", "Some.Show.S01E03.1080p.WEB-DL-GRP.mkv"].map((n) => ({ + root: h.dlRoot, + path: `Some.Show.S01.1080p.WEB-DL/${n}`, + mediaEntityId: show, + season: 1, + })); + const dry = await h.api("POST", "/files/organize", { + idempotencyKey: key(), + items, + action: "copy", + dryRun: true, + minSizeBytes: 0, + }); + expect(dry.status).toBe(200); + const g = dry.body.data.groups[0]; + expect(g.error).toBeNull(); + const byKind = (k: string) => + g.files.filter((f: { kind: string }) => f.kind === k).map((f: { source: string; target: string }) => f); + expect(byKind("subtitle")).toHaveLength(1); + expect(byKind("subtitle")[0].source).toBe(join(dir, "Some.Show.S01E02.1080p.WEB-DL-GRP.chs.ass")); + expect(byKind("subtitle")[0].target).toMatch(/S01E02.*\.chi\.zh-cn\.ass$/); + expect(byKind("audio")).toHaveLength(1); + expect(byKind("audio")[0].target).toMatch(/S01E03.*\.mka$/); + expect(JSON.stringify(g)).not.toContain("unrelated.srt"); + }); + it("organize:路径逃逸同样拒绝", async () => { const r = await h.api("POST", "/files/organize", { idempotencyKey: key(), diff --git a/apps/web/app/routes/console/organize.tsx b/apps/web/app/routes/console/organize.tsx index 8f6c45d1..0c8f39c2 100644 --- a/apps/web/app/routes/console/organize.tsx +++ b/apps/web/app/routes/console/organize.tsx @@ -4,6 +4,7 @@ import { EventType } from "@umt/contracts"; import { FileStack, FolderInput, X } from "lucide-react"; import { useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; +import { useFileRecords } from "~/api/files.ts"; import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecords } from "~/api/records.ts"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; @@ -89,6 +90,12 @@ export default function OrganizePage() { const [qInput, setQInput] = useState(""); const [q, setQ] = useState(""); const entityId = params.get("mediaEntityId") ?? undefined; + // 整理向导执行多组后跳来:?ids=<记录 id,…> 只看这几条(执行中每 3 秒刷新) + const ids = (params.get("ids") ?? "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + const byIds = useFileRecords({ ids }, ids.length > 0); const navigate = useNavigate(); const qc = useQueryClient(); useEffect(() => { @@ -101,9 +108,13 @@ export default function OrganizePage() { mediaEntityId: entityId, q: q || undefined, }); - useSse([EventType.organizationChanged], () => void qc.invalidateQueries({ queryKey: ["records"] })); - const rows = records.data?.pages.flatMap((p) => p.items); - const filtered = !!(status || trigger || q || entityId); + useSse([EventType.organizationChanged], () => { + void qc.invalidateQueries({ queryKey: ["records"] }); + if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] }); + }); + const pinned = ids.length > 0; + const rows = pinned ? byIds.data?.items : records.data?.pages.flatMap((p) => p.items); + const filtered = !!(status || trigger || q || entityId || pinned); return ( <> @@ -148,16 +159,22 @@ export default function OrganizePage() { ) : null} + {pinned ? ( + + ) : null} r.id} - loading={records.isPending} - error={records.error} - onRetry={() => void records.refetch()} + loading={pinned ? byIds.isPending : records.isPending} + error={pinned ? byIds.error : records.error} + onRetry={() => void (pinned ? byIds.refetch() : records.refetch())} onRowClick={(r) => navigate(`/console/organize/${r.id}`)} - hasMore={records.hasNextPage} + hasMore={!pinned && records.hasNextPage} onLoadMore={() => void records.fetchNextPage()} loadingMore={records.isFetchingNextPage} empty={{ diff --git a/apps/web/scripts/smoke.mjs b/apps/web/scripts/smoke.mjs index b539505b..d0147128 100644 --- a/apps/web/scripts/smoke.mjs +++ b/apps/web/scripts/smoke.mjs @@ -28,6 +28,7 @@ const STATIC_ROUTES = [ "/console/library?filter=unidentified", "/console/library?view=identities", "/console/organize", + "/console/organize?ids=00000000-0000-4000-8000-000000000000", "/console/files", // 文件管理 v2(FE-4):筛选/排序组合与演示数据(?mock=1 只在开发构建生效,随后 ?mock=0 关掉,免得影响后面的页) "/console/files?f=video&sort=size&order=desc&hidden=1", diff --git a/deno.lock b/deno.lock index e37bc505..21107b17 100644 --- a/deno.lock +++ b/deno.lock @@ -45,6 +45,7 @@ "npm:react-dom@^19.2.4": "19.3.0_react@19.3.0", "npm:react-router@7.12.0": "7.12.0_react@19.3.0_react-dom@19.3.0", "npm:react@^19.2.4": "19.3.0", + "npm:recharts@^3.10.1": "3.10.1_react@19.3.0_react-dom@19.3.0_react-is@19.3.0_@types+react@19.3.0_redux@5.0.1", "npm:shadcn@^3.8.5": "3.8.5_@types+node@24.19.0_typescript@7.0.2", "npm:sonner@^2.0.7": "2.0.8_@types+react@19.3.0_react@19.3.0_react-dom@19.3.0", "npm:tailwind-merge@^3.5.0": "3.7.0", @@ -2110,6 +2111,23 @@ "typescript" ] }, + "@reduxjs/toolkit@2.12.0_react@19.3.0_react-redux@9.3.0_@types+react@19.3.0": { + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "dependencies": [ + "@standard-schema/spec", + "@standard-schema/utils", + "immer", + "react", + "react-redux", + "redux", + "redux-thunk", + "reselect" + ], + "optionalPeers": [ + "react", + "react-redux" + ] + }, "@remix-run/node-fetch-server@0.9.0": { "integrity": "sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA==" }, @@ -2292,6 +2310,12 @@ "@stablelib/base64@1.0.1": { "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" }, + "@standard-schema/spec@1.1.0": { + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "@standard-schema/utils@0.3.0": { + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, "@tailwindcss/node@4.3.3": { "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dependencies": [ @@ -2414,6 +2438,42 @@ "assertion-error" ] }, + "@types/d3-array@3.2.2": { + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "@types/d3-color@3.1.3": { + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "@types/d3-ease@3.0.2": { + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "@types/d3-interpolate@3.0.4": { + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": [ + "@types/d3-color" + ] + }, + "@types/d3-path@3.1.1": { + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "@types/d3-scale@4.0.9": { + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": [ + "@types/d3-time" + ] + }, + "@types/d3-shape@3.2.0": { + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "dependencies": [ + "@types/d3-path" + ] + }, + "@types/d3-time@3.0.4": { + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "@types/d3-timer@3.0.2": { + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "@types/deep-eql@4.0.2": { "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==" }, @@ -2461,6 +2521,9 @@ "@types/statuses@2.0.6": { "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==" }, + "@types/use-sync-external-store@0.0.6": { + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, "@types/validate-npm-package-name@4.0.2": { "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==" }, @@ -2956,6 +3019,61 @@ "csstype@3.2.3": { "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "d3-array@3.2.4": { + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": [ + "internmap" + ] + }, + "d3-color@3.1.0": { + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-ease@3.0.1": { + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-format@3.1.2": { + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==" + }, + "d3-interpolate@3.0.1": { + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": [ + "d3-color" + ] + }, + "d3-path@3.1.0": { + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" + }, + "d3-scale@4.0.2": { + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": [ + "d3-array", + "d3-format", + "d3-interpolate", + "d3-time", + "d3-time-format" + ] + }, + "d3-shape@3.2.0": { + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": [ + "d3-path" + ] + }, + "d3-time-format@4.1.0": { + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": [ + "d3-time" + ] + }, + "d3-time@3.1.0": { + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": [ + "d3-array" + ] + }, + "d3-timer@3.0.1": { + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, "data-uri-to-buffer@4.0.1": { "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" }, @@ -2977,6 +3095,9 @@ "ms" ] }, + "decimal.js-light@2.5.1": { + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "dedent@1.7.2": { "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==" }, @@ -3137,6 +3258,9 @@ "es-errors" ] }, + "es-toolkit@1.52.0": { + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==" + }, "esbuild@0.28.2": { "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "optionalDependencies": [ @@ -3189,6 +3313,9 @@ "etag@1.8.1": { "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, + "eventemitter3@5.0.4": { + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" + }, "eventsource-parser@3.1.1": { "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==" }, @@ -3612,6 +3739,9 @@ "ignore@7.0.8": { "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==" }, + "immer@11.1.18": { + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==" + }, "import-fresh@3.3.1": { "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dependencies": [ @@ -3622,6 +3752,9 @@ "inherits@2.0.4": { "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "internmap@2.0.3": { + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" + }, "ip-address@10.7.2": { "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==" }, @@ -4596,6 +4729,23 @@ "scheduler" ] }, + "react-is@19.3.0": { + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==" + }, + "react-redux@9.3.0_@types+react@19.3.0_react@19.3.0_redux@5.0.1": { + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "dependencies": [ + "@types/react", + "@types/use-sync-external-store", + "react", + "redux", + "use-sync-external-store" + ], + "optionalPeers": [ + "@types/react", + "redux" + ] + }, "react-refresh@0.14.2": { "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==" }, @@ -4672,12 +4822,43 @@ "tslib" ] }, + "recharts@3.10.1_react@19.3.0_react-dom@19.3.0_react-is@19.3.0_@types+react@19.3.0_redux@5.0.1": { + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "dependencies": [ + "@reduxjs/toolkit", + "clsx", + "decimal.js-light", + "es-toolkit", + "eventemitter3", + "immer", + "react", + "react-dom", + "react-is", + "react-redux", + "reselect", + "tiny-invariant", + "use-sync-external-store", + "victory-vendor" + ] + }, + "redux-thunk@3.1.0_redux@5.0.1": { + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "dependencies": [ + "redux" + ] + }, + "redux@5.0.1": { + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, "require-directory@2.1.1": { "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" }, "require-from-string@2.0.2": { "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, + "reselect@5.2.0": { + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==" + }, "resolve-from@4.0.0": { "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" }, @@ -5195,6 +5376,12 @@ "@types/react" ] }, + "use-sync-external-store@1.7.0_react@19.3.0": { + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "dependencies": [ + "react" + ] + }, "util-deprecate@1.0.2": { "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, @@ -5213,6 +5400,25 @@ "vary@1.1.2": { "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, + "victory-vendor@37.3.6": { + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "dependencies": [ + "@types/d3-array", + "@types/d3-ease", + "@types/d3-interpolate", + "@types/d3-scale", + "@types/d3-shape", + "@types/d3-time", + "@types/d3-timer", + "d3-array", + "d3-ease", + "d3-interpolate", + "d3-scale", + "d3-shape", + "d3-time", + "d3-timer" + ] + }, "vite-node@3.2.4_@types+node@24.19.0_yaml@2.9.1": { "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dependencies": [ @@ -5446,6 +5652,7 @@ "npm:react-dom@^19.2.4", "npm:react-router@7.12.0", "npm:react@^19.2.4", + "npm:recharts@^3.10.1", "npm:shadcn@^3.8.5", "npm:sonner@^2.0.7", "npm:tailwind-merge@^3.5.0",