[
  {
    "path": ".dockerignore",
    "content": "node_modules\ndist\n.git\n*.md\ndocs\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: [Maciejonos]\nbuy_me_a_coffee: maciejonos\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n - Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: Docker\n\non:\n  push:\n    tags: ['v*']\n  workflow_dispatch:\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - uses: docker/metadata-action@v5\n        id: meta\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            type=raw,value=latest\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - uses: docker/build-push-action@v6\n        with:\n          context: .\n          push: true\n          platforms: linux/amd64,linux/arm64\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n\n  agent:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - uses: docker/metadata-action@v5\n        id: meta\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-agent\n          tags: |\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            type=raw,value=latest\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - uses: docker/build-push-action@v6\n        with:\n          context: ./net-agent\n          push: true\n          platforms: linux/amd64,linux/arm64\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n"
  },
  {
    "path": ".github/workflows/docs.yml",
    "content": "name: Docs\n\non:\n  push:\n    branches: [master]\n    paths:\n      - 'docs/**'\n      - '.github/workflows/docs.yml'\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\nconcurrency:\n  group: pages\n  cancel-in-progress: false\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: oven-sh/setup-bun@v2\n\n      - run: bun install --frozen-lockfile\n\n      - run: bun run docs:build\n\n      - uses: actions/configure-pages@v4\n\n      - uses: actions/upload-pages-artifact@v3\n        with:\n          path: docs/.vitepress/dist\n\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/deploy-pages@v4\n        id: deployment\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [master]\n  pull_request:\n    branches: [master]\n\njobs:\n  ci:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: oven-sh/setup-bun@v2\n      - run: bun install --frozen-lockfile\n      - run: bun run lint\n      - run: bun run build\n      - run: bun run test\n        env:\n          CI: true\n\n  agent:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-go@v5\n        with:\n          go-version: '1.22'\n      - run: go build -o /dev/null .\n        working-directory: net-agent\n"
  },
  {
    "path": ".gitignore",
    "content": "# local data\nnode_modules\ndist\n*.local\n*.log\nCLAUDE.md\ndocker-compose.yml\ndata/\n*.env\n\n#enforce package manager and runtime\npackage-lock.json\nyarn.lock\npnpm-lock.yaml\n\n# dbs\n*.db\n*.db-wal\n*.db-shm\n\n# tests\ncoverage/\n\n# docs\ndocs/.vitepress/dist\ndocs/.vitepress/cache\n"
  },
  {
    "path": ".npmrc",
    "content": "engine-strict=true\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n\t\"useTabs\": true,\n\t\"tabWidth\": 2,\n\t\"semi\": false,\n\t\"singleQuote\": true,\n\t\"trailingComma\": \"es5\",\n\t\"printWidth\": 120\n}\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM oven/bun:alpine AS builder\nWORKDIR /app\nCOPY package.json bun.lock ./\nRUN bun install --frozen-lockfile\nCOPY . .\nRUN bun run build\n\nFROM oven/bun:alpine\nWORKDIR /app\nCOPY --from=builder /app/dist ./dist\nCOPY --from=builder /app/src/server ./src/server\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY --from=builder /app/package.json ./\n\nENV NODE_ENV=production\nENV PORT=3000\nENV DATABASE_PATH=/data/qbitwebui.db\nENV SALT_PATH=/data/.salt\n\nEXPOSE 3000\nVOLUME /data\n\nCMD [\"bun\", \"run\", \"src/server/index.ts\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026 Maciej\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n <img width=\"200\" height=\"200\" alt=\"logo\" src=\"https://github.com/user-attachments/assets/431cf92d-d8e6-4be7-a5b6-642ed6ab9898\" />\n\n### A modern web interface for managing multiple qBittorrent instances\n\nBuilt with [React](https://react.dev/), [Hono](https://hono.dev/), and [Bun](https://bun.sh/)\n\n[![GitHub stars](https://img.shields.io/github/stars/Maciejonos/qbitwebui?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/Maciejonos/qbitwebui/stargazers)\n[![GitHub License](https://img.shields.io/github/license/Maciejonos/qbitwebui?style=for-the-badge&labelColor=101418&color=abedd5)](https://github.com/Maciejonos/qbitwebui/blob/master/LICENSE)\n[![GitHub release](https://img.shields.io/github/v/release/Maciejonos/qbitwebui?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/Maciejonos/qbitwebui/releases)\n[![Docker Build](https://img.shields.io/github/actions/workflow/status/Maciejonos/qbitwebui/docker.yml?style=for-the-badge&labelColor=101418&color=4EB329&label=build)](https://github.com/Maciejonos/qbitwebui/actions)\n\n**[Documentation](https://maciejonos.github.io/qbitwebui/)** · **[Docker Examples](https://maciejonos.github.io/qbitwebui/guide/docker)** · **[All Features](https://maciejonos.github.io/qbitwebui/guide/features)**\n\n</div>\n\n<div align=\"center\">\n<img width=\"800\" alt=\"main\" src=\"https://github.com/user-attachments/assets/64ae19ea-9029-442c-97dd-958af04e21d1\" />\n</div>\n\n<details>\n<summary><h3>Mobile UI</h3></summary>\n<div align=\"center\">\n<table>\n  <tr>\n   <td> <img width=\"295\" alt=\"mobile\" src=\"https://github.com/user-attachments/assets/ea14587c-1b12-46c7-afdc-def83b5e3e7c\" /></td>\n   <td> <img width=\"295\" alt=\"mobile-detailed\" src=\"https://github.com/user-attachments/assets/97c1ddf1-8df0-4acd-a6a1-5690badd7aa7\" /></td>\n  </tr>\n</table>\n</div>\n</details>\n\n## Features\n\nSee [features section](https://maciejonos.github.io/qbitwebui/guide/features) for more details.\n\n- **Multi-instance** - Manage multiple qBittorrent instances from one dashboard\n- **Cross seed** - Automatic cross seed directly in qbitwebui. (experimental)\n- **Instance statistics** - Overview of all instances with status, speeds, torrent counts\n- **Prowlarr integration** - Search indexers and send torrents directly to qBittorrent\n- **Real-time monitoring** - Auto-refresh torrent status, speeds, progress\n- **Customizable columns** - Show/hide columns, drag and drop reorder\n- **Torrent management** - Add via magnet/file, set priorities, manage trackers/peers\n- **Organization** - Filter by status, category, tag, or tracker, custom views\n- **Bulk actions** - Multi-select with context menu, keyboard navigation\n- **Themes** - Multiple color themes included\n- **File browser** - Browse and download files from your downloads directory\n- **RSS management** - Define rules, add RSS feeds, manage folders\n- **Network agent** - Speedtest, IP check, DNS diagnostics - [setup instructions](https://maciejonos.github.io/qbitwebui/guide/network-agent)\n\n## Docker\n\nSee [Docker section](https://maciejonos.github.io/qbitwebui/guide/docker) for all setup options.\n\n```yaml\nservices:\n  qbitwebui:\n    image: ghcr.io/maciejonos/qbitwebui:latest\n    ports:\n      - \"3000:3000\"\n    environment:\n      # Generate your own: openssl rand -hex 32\n      - ENCRYPTION_KEY=your-secret-key-here\n      # Uncomment to disable login (single-user mode)\n      # - DISABLE_AUTH=true\n      # Uncomment to disable registration (creates default admin account)\n      # - DISABLE_REGISTRATION=true\n      # Uncomment to allow HTTPS with self-signed certificates\n      # - ALLOW_SELF_SIGNED_CERTS=true\n      # Uncomment to enable file browser\n      # - DOWNLOADS_PATH=/downloads\n    volumes:\n      - ./data:/data\n      # Uncomment to enable file browser (read-only: browse & download only)\n      # - /path/to/your/downloads:/downloads:ro\n      # Or mount read-write to enable delete/move/copy/rename\n      # - /path/to/your/downloads:/downloads\n    restart: unless-stopped\n```\n\n## Development\n\n```bash\nexport ENCRYPTION_KEY=$(openssl rand -hex 32)\n\nbun install\nbun run dev\n```\n\n## Tech Stack\n\nReact 19, TypeScript, Tailwind CSS v4, Vite, TanStack Query, Hono, SQLite, Bun\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=Maciejonos/qbitwebui&type=date&legend=top-left)](https://www.star-history.com/#Maciejonos/qbitwebui&type=date&legend=top-left)\n## Credits\n\nBig thanks to [cross-seed](https://github.com/cross-seed/cross-seed). A huge chunk of Qbitwebui cross seed implementation is basically taken from cross-seed directly, or ported and slightly adjusted. Qbitwebui is of course in no way associated or endorsed by cross-seed.\n\nI highly recommend to check cross-seed out, if you want something very reliable. \n\n## License\n\nMIT\n"
  },
  {
    "path": "__tests__/__mocks__/bun-sqlite.ts",
    "content": "import { vi } from 'vitest'\n\nexport class Database {\n\texec = vi.fn()\n\trun = vi.fn(() => ({ changes: 0, lastInsertRowid: 0 }))\n\tquery = vi.fn(() => ({\n\t\tget: vi.fn(),\n\t\tall: vi.fn(() => []),\n\t}))\n\tclose = vi.fn()\n}\n"
  },
  {
    "path": "__tests__/api/auth.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport { register, login, logout, getMe, changePassword } from '../../src/api/auth'\n\n// Mock fetch globally\nconst mockFetch = vi.fn()\nvi.stubGlobal('fetch', mockFetch)\n\ndescribe('auth API', () => {\n    beforeEach(() => {\n        mockFetch.mockReset()\n    })\n\n    afterEach(() => {\n        vi.clearAllMocks()\n    })\n\n    describe('register', () => {\n        it('sends correct request and returns user on success', async () => {\n            const mockUser = { id: 1, username: 'testuser' }\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve(mockUser),\n            })\n\n            const result = await register('testuser', 'password123')\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/auth/register', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: JSON.stringify({ username: 'testuser', password: 'password123' }),\n            })\n            expect(result).toEqual(mockUser)\n        })\n\n        it('throws error with message from response on failure', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'Username already exists' }),\n            })\n\n            await expect(register('testuser', 'password'))\n                .rejects.toThrow('Username already exists')\n        })\n\n        it('throws default error when no error message in response', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({}),\n            })\n\n            await expect(register('testuser', 'password'))\n                .rejects.toThrow('Registration failed')\n        })\n    })\n\n    describe('login', () => {\n        it('sends correct request and returns user on success', async () => {\n            const mockUser = { id: 1, username: 'testuser' }\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve(mockUser),\n            })\n\n            const result = await login('testuser', 'password123')\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: JSON.stringify({ username: 'testuser', password: 'password123' }),\n            })\n            expect(result).toEqual(mockUser)\n        })\n\n        it('throws error with message on invalid credentials', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'Invalid credentials' }),\n            })\n\n            await expect(login('testuser', 'wrongpassword'))\n                .rejects.toThrow('Invalid credentials')\n        })\n    })\n\n    describe('logout', () => {\n        it('sends POST request to logout endpoint', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true })\n\n            await logout()\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/auth/logout', {\n                method: 'POST',\n                credentials: 'include',\n            })\n        })\n    })\n\n    describe('getMe', () => {\n        it('returns user when authenticated', async () => {\n            const mockUser = { id: 1, username: 'testuser' }\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve(mockUser),\n            })\n\n            const result = await getMe()\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/auth/me', {\n                credentials: 'include',\n            })\n            expect(result).toEqual(mockUser)\n        })\n\n        it('returns null when not authenticated', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: false })\n\n            const result = await getMe()\n\n            expect(result).toBeNull()\n        })\n    })\n\n    describe('changePassword', () => {\n        it('sends correct request on success', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true })\n\n            await changePassword('oldpass', 'newpass')\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/auth/password', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: JSON.stringify({ currentPassword: 'oldpass', newPassword: 'newpass' }),\n            })\n        })\n\n        it('throws error when current password is wrong', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'Current password is incorrect' }),\n            })\n\n            await expect(changePassword('wrongpass', 'newpass'))\n                .rejects.toThrow('Current password is incorrect')\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/api/crossSeed.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport {\n\tgetCrossSeedConfig,\n\tupdateCrossSeedConfig,\n\ttriggerScan,\n\tgetSchedulerStatus,\n\tgetInstanceStatus,\n\tclearCache,\n\tgetCacheStats,\n\tgetSearchHistory,\n\tgetDecisions,\n\tgetIndexers,\n\tstopScan,\n\tgetLogs,\n} from '../../src/api/crossSeed'\n\ndescribe('crossSeed API', () => {\n\tconst mockFetch = vi.fn()\n\tconst originalFetch = global.fetch\n\n\tbeforeEach(() => {\n\t\tglobal.fetch = mockFetch\n\t\tmockFetch.mockReset()\n\t})\n\n\tafterEach(() => {\n\t\tglobal.fetch = originalFetch\n\t})\n\n\tdescribe('getCrossSeedConfig', () => {\n\t\tit('fetches config for instance', async () => {\n\t\t\tconst mockConfig = {\n\t\t\t\tinstance_id: 1,\n\t\t\t\tenabled: true,\n\t\t\t\tinterval_hours: 24,\n\t\t\t\tdry_run: false,\n\t\t\t\tcategory_suffix: '_cross-seed',\n\t\t\t\ttag: 'cross-seed',\n\t\t\t\tskip_recheck: false,\n\t\t\t\tintegration_id: 1,\n\t\t\t\tindexer_ids: [1, 2],\n\t\t\t\tlast_run: null,\n\t\t\t\tnext_run: null,\n\t\t\t}\n\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve(mockConfig),\n\t\t\t})\n\n\t\t\tconst result = await getCrossSeedConfig(1)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/config/1', { credentials: 'include' })\n\t\t\texpect(result).toEqual(mockConfig)\n\t\t})\n\n\t\tit('throws on error response', async () => {\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: false,\n\t\t\t})\n\n\t\t\tawait expect(getCrossSeedConfig(1)).rejects.toThrow('Failed to fetch cross-seed config')\n\t\t})\n\t})\n\n\tdescribe('updateCrossSeedConfig', () => {\n\t\tit('updates config successfully', async () => {\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve({ success: true }),\n\t\t\t})\n\n\t\t\tawait updateCrossSeedConfig(1, { enabled: true, interval_hours: 12, indexer_ids: [1, 2] })\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/config/1', {\n\t\t\t\tmethod: 'PUT',\n\t\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\t\tcredentials: 'include',\n\t\t\t\tbody: JSON.stringify({ enabled: true, interval_hours: 12, indexer_ids: [1, 2] }),\n\t\t\t})\n\t\t})\n\n\t\tit('throws on error with message', async () => {\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: false,\n\t\t\t\tjson: () => Promise.resolve({ error: 'Invalid config' }),\n\t\t\t})\n\n\t\t\tawait expect(updateCrossSeedConfig(1, {})).rejects.toThrow('Invalid config')\n\t\t})\n\t})\n\n\tdescribe('triggerScan', () => {\n\t\tit('triggers scan without force', async () => {\n\t\t\tconst mockResult = {\n\t\t\t\tinstanceId: 1,\n\t\t\t\ttorrentsTotal: 150,\n\t\t\t\ttorrentsScanned: 100,\n\t\t\t\ttorrentsSkipped: 50,\n\t\t\t\tmatchesFound: 5,\n\t\t\t\ttorrentsAdded: 3,\n\t\t\t\terrors: [],\n\t\t\t\tdryRun: false,\n\t\t\t\tstartedAt: 1704067200000,\n\t\t\t\tcompletedAt: 1704067260000,\n\t\t\t}\n\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve(mockResult),\n\t\t\t})\n\n\t\t\tconst result = await triggerScan(1)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/scan/1', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\t\tcredentials: 'include',\n\t\t\t\tbody: JSON.stringify({ force: false }),\n\t\t\t})\n\t\t\texpect(result).toEqual(mockResult)\n\t\t})\n\n\t\tit('triggers force scan', async () => {\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve({}),\n\t\t\t})\n\n\t\t\tawait triggerScan(1, true)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/scan/1', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\t\tcredentials: 'include',\n\t\t\t\tbody: JSON.stringify({ force: true }),\n\t\t\t})\n\t\t})\n\t})\n\n\tdescribe('getSchedulerStatus', () => {\n\t\tit('fetches all scheduler statuses', async () => {\n\t\t\tconst mockStatuses = [\n\t\t\t\t{ instanceId: 1, enabled: true, running: false },\n\t\t\t\t{ instanceId: 2, enabled: false, running: false },\n\t\t\t]\n\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve(mockStatuses),\n\t\t\t})\n\n\t\t\tconst result = await getSchedulerStatus()\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/status', { credentials: 'include' })\n\t\t\texpect(result).toEqual(mockStatuses)\n\t\t})\n\t})\n\n\tdescribe('getIndexers', () => {\n\t\tit('fetches indexers for instance', async () => {\n\t\t\tconst mockIndexers = [{ id: 1, name: 'Indexer A', protocol: 'torrent', supportsSearch: true, categories: [2000] }]\n\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve(mockIndexers),\n\t\t\t})\n\n\t\t\tconst result = await getIndexers(1)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/indexers/1', { credentials: 'include' })\n\t\t\texpect(result).toEqual(mockIndexers)\n\t\t})\n\n\t\tit('throws on error response', async () => {\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: false,\n\t\t\t\tjson: () => Promise.resolve({ error: 'No Prowlarr integration configured' }),\n\t\t\t})\n\n\t\t\tawait expect(getIndexers(1)).rejects.toThrow('No Prowlarr integration configured')\n\t\t})\n\t})\n\n\tdescribe('getInstanceStatus', () => {\n\t\tit('fetches status for specific instance', async () => {\n\t\t\tconst mockStatus = {\n\t\t\t\tinstanceId: 1,\n\t\t\t\tenabled: true,\n\t\t\t\trunning: true,\n\t\t\t\tlastRun: 1704067200,\n\t\t\t\tnextRun: 1704153600,\n\t\t\t}\n\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve(mockStatus),\n\t\t\t})\n\n\t\t\tconst result = await getInstanceStatus(1)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/status/1', { credentials: 'include' })\n\t\t\texpect(result).toEqual(mockStatus)\n\t\t})\n\t})\n\n\tdescribe('clearCache', () => {\n\t\tit('clears cache for instance', async () => {\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve({ cacheCleared: 10, outputCleared: 5 }),\n\t\t\t})\n\n\t\t\tconst result = await clearCache(1)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/cache/1/clear', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\tcredentials: 'include',\n\t\t\t})\n\t\t\texpect(result.cacheCleared).toBe(10)\n\t\t\texpect(result.outputCleared).toBe(5)\n\t\t})\n\t})\n\n\tdescribe('getCacheStats', () => {\n\t\tit('fetches cache statistics', async () => {\n\t\t\tconst mockStats = {\n\t\t\t\tcache: { count: 50, totalSize: 1024000 },\n\t\t\t\toutput: { count: 10, files: ['file1.torrent', 'file2.torrent'] },\n\t\t\t}\n\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve(mockStats),\n\t\t\t})\n\n\t\t\tconst result = await getCacheStats(1)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/cache/1/stats', { credentials: 'include' })\n\t\t\texpect(result).toEqual(mockStats)\n\t\t})\n\t})\n\n\tdescribe('getSearchHistory', () => {\n\t\tit('fetches search history with pagination', async () => {\n\t\t\tconst mockHistory = {\n\t\t\t\tsearchees: [\n\t\t\t\t\t{ id: 1, torrent_name: 'Movie 1', decision_count: 5 },\n\t\t\t\t\t{ id: 2, torrent_name: 'Movie 2', decision_count: 3 },\n\t\t\t\t],\n\t\t\t\ttotal: 100,\n\t\t\t}\n\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve(mockHistory),\n\t\t\t})\n\n\t\t\tconst result = await getSearchHistory(1, 50, 10)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/history/1?limit=50&offset=10', {\n\t\t\t\tcredentials: 'include',\n\t\t\t})\n\t\t\texpect(result.total).toBe(100)\n\t\t})\n\n\t\tit('uses default pagination', async () => {\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve({ searchees: [], total: 0 }),\n\t\t\t})\n\n\t\t\tawait getSearchHistory(1)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/history/1?limit=100&offset=0', {\n\t\t\t\tcredentials: 'include',\n\t\t\t})\n\t\t})\n\t})\n\n\tdescribe('getDecisions', () => {\n\t\tit('fetches decisions for searchee', async () => {\n\t\t\tconst mockDecisions = [\n\t\t\t\t{ id: 1, decision: 'MATCH', candidate_name: 'Match 1' },\n\t\t\t\t{ id: 2, decision: 'SIZE_MISMATCH', candidate_name: 'No Match' },\n\t\t\t]\n\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve(mockDecisions),\n\t\t\t})\n\n\t\t\tconst result = await getDecisions(1, 5)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/history/1/5/decisions', {\n\t\t\t\tcredentials: 'include',\n\t\t\t})\n\t\t\texpect(result).toEqual(mockDecisions)\n\t\t})\n\t})\n\n\tdescribe('stopScan', () => {\n\t\tit('stops scan for instance', async () => {\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve({ stopped: true }),\n\t\t\t})\n\n\t\t\tconst result = await stopScan(1)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/stop/1', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\tcredentials: 'include',\n\t\t\t})\n\t\t\texpect(result.stopped).toBe(true)\n\t\t})\n\n\t\tit('throws on error response', async () => {\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: false,\n\t\t\t\tjson: () => Promise.resolve({ error: 'No scan running' }),\n\t\t\t})\n\n\t\t\tawait expect(stopScan(1)).rejects.toThrow('No scan running')\n\t\t})\n\t})\n\n\tdescribe('getLogs', () => {\n\t\tit('fetches logs with limit', async () => {\n\t\t\tconst mockLogs = [{ timestamp: '2024-01-01T00:00:00.000Z', level: 'INFO', message: 'Test' }]\n\n\t\t\tmockFetch.mockResolvedValueOnce({\n\t\t\t\tok: true,\n\t\t\t\tjson: () => Promise.resolve(mockLogs),\n\t\t\t})\n\n\t\t\tconst result = await getLogs(200)\n\n\t\t\texpect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/logs?limit=200', { credentials: 'include' })\n\t\t\texpect(result).toEqual(mockLogs)\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "__tests__/api/files.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport {\n    listFiles,\n    getDownloadUrl,\n    checkWritable,\n    deleteFiles,\n    moveFiles,\n    copyFiles,\n    renameFile,\n} from '../../src/api/files'\n\nconst mockFetch = vi.fn()\nvi.stubGlobal('fetch', mockFetch)\n\ndescribe('files API', () => {\n    beforeEach(() => {\n        mockFetch.mockReset()\n    })\n\n    describe('listFiles', () => {\n        it('fetches files with encoded path', async () => {\n            const mockFiles = [\n                { name: 'file1.txt', size: 1024, isDirectory: false, modified: 1234567890 },\n                { name: 'folder', size: 0, isDirectory: true, modified: 1234567900 },\n            ]\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve(mockFiles),\n            })\n\n            const result = await listFiles('/downloads/movies')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/files?path=%2Fdownloads%2Fmovies',\n                { credentials: 'include' }\n            )\n            expect(result).toEqual(mockFiles)\n        })\n\n        it('handles special characters in path', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve([]),\n            })\n\n            await listFiles('/downloads/My Movies & Shows')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                expect.stringContaining('My%20Movies%20%26%20Shows'),\n                expect.anything()\n            )\n        })\n\n        it('throws error with message from server', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'Path not found' }),\n            })\n\n            await expect(listFiles('/nonexistent')).rejects.toThrow('Path not found')\n        })\n    })\n\n    describe('getDownloadUrl', () => {\n        it('returns encoded download URL', () => {\n            const url = getDownloadUrl('/downloads/movie.mkv')\n            expect(url).toBe('/api/files/download?path=%2Fdownloads%2Fmovie.mkv')\n        })\n\n        it('handles special characters', () => {\n            const url = getDownloadUrl('/downloads/Movie (2024) [1080p].mkv')\n            expect(url).toContain('Movie%20')\n            expect(url).toContain('%5B1080p%5D')\n        })\n    })\n\n    describe('checkWritable', () => {\n        it('returns true when writable', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve({ writable: true }),\n            })\n\n            const result = await checkWritable()\n\n            expect(result).toBe(true)\n        })\n\n        it('returns false when not writable', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve({ writable: false }),\n            })\n\n            const result = await checkWritable()\n\n            expect(result).toBe(false)\n        })\n\n        it('returns false on request failure', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: false })\n\n            const result = await checkWritable()\n\n            expect(result).toBe(false)\n        })\n    })\n\n    describe('deleteFiles', () => {\n        it('sends delete request with paths', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true })\n\n            await deleteFiles(['/downloads/file1.txt', '/downloads/file2.txt'])\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/files/delete', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: JSON.stringify({ paths: ['/downloads/file1.txt', '/downloads/file2.txt'] }),\n            })\n        })\n\n        it('throws error on failure', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'Permission denied' }),\n            })\n\n            await expect(deleteFiles(['/protected/file'])).rejects.toThrow('Permission denied')\n        })\n    })\n\n    describe('moveFiles', () => {\n        it('sends move request with paths and destination', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true })\n\n            await moveFiles(['/downloads/file.txt'], '/archive')\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/files/move', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: JSON.stringify({ paths: ['/downloads/file.txt'], destination: '/archive' }),\n            })\n        })\n\n        it('throws error on move failure', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'Destination not found' }),\n            })\n\n            await expect(moveFiles(['/file'], '/nonexistent')).rejects.toThrow('Destination not found')\n        })\n    })\n\n    describe('copyFiles', () => {\n        it('sends copy request with paths and destination', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true })\n\n            await copyFiles(['/downloads/file.txt'], '/backup')\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/files/copy', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: JSON.stringify({ paths: ['/downloads/file.txt'], destination: '/backup' }),\n            })\n        })\n    })\n\n    describe('renameFile', () => {\n        it('sends rename request', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true })\n\n            await renameFile('/downloads/old.txt', 'new.txt')\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/files/rename', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: JSON.stringify({ path: '/downloads/old.txt', newName: 'new.txt' }),\n            })\n        })\n\n        it('throws error on rename failure', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'File already exists' }),\n            })\n\n            await expect(renameFile('/file', 'existing')).rejects.toThrow('File already exists')\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/api/instances.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport {\n    getInstances,\n    createInstance,\n    updateInstance,\n    deleteInstance,\n    type CreateInstanceData,\n} from '../../src/api/instances'\n\nconst mockFetch = vi.fn()\nvi.stubGlobal('fetch', mockFetch)\n\ndescribe('instances API', () => {\n    beforeEach(() => {\n        mockFetch.mockReset()\n    })\n\n    describe('getInstances', () => {\n        it('fetches and returns instance list', async () => {\n            const mockInstances = [\n                { id: 1, label: 'Home', url: 'http://localhost:8080', qbt_username: 'admin', skip_auth: false, created_at: 1234567890 },\n                { id: 2, label: 'Server', url: 'http://192.168.1.100:8080', qbt_username: null, skip_auth: true, created_at: 1234567900 },\n            ]\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve(mockInstances),\n            })\n\n            const result = await getInstances()\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/instances', {\n                credentials: 'include',\n            })\n            expect(result).toEqual(mockInstances)\n            expect(result).toHaveLength(2)\n        })\n\n        it('throws error on failure', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: false })\n\n            await expect(getInstances()).rejects.toThrow('Failed to fetch instances')\n        })\n    })\n\n    describe('createInstance', () => {\n        it('creates instance with all fields', async () => {\n            const createData: CreateInstanceData = {\n                label: 'New Instance',\n                url: 'http://localhost:9090',\n                qbt_username: 'admin',\n                qbt_password: 'secret',\n                skip_auth: false,\n            }\n            const mockInstance = {\n                id: 3,\n                ...createData,\n                qbt_password: undefined,\n                created_at: Date.now(),\n            }\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve(mockInstance),\n            })\n\n            const result = await createInstance(createData)\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/instances', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: JSON.stringify(createData),\n            })\n            expect(result.id).toBe(3)\n            expect(result.label).toBe('New Instance')\n        })\n\n        it('creates instance with minimal fields', async () => {\n            const createData: CreateInstanceData = {\n                label: 'Minimal',\n                url: 'http://localhost:8080',\n            }\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve({ id: 1, ...createData, skip_auth: false, created_at: 0 }),\n            })\n\n            await createInstance(createData)\n\n            expect(mockFetch).toHaveBeenCalled()\n        })\n\n        it('throws error with message from server', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'Instance with this label already exists' }),\n            })\n\n            await expect(createInstance({ label: 'Dup', url: 'http://test' }))\n                .rejects.toThrow('Instance with this label already exists')\n        })\n    })\n\n    describe('updateInstance', () => {\n        it('updates instance with partial data', async () => {\n            const updateData = { label: 'Updated Label' }\n            const mockUpdated = {\n                id: 1,\n                label: 'Updated Label',\n                url: 'http://localhost:8080',\n                qbt_username: 'admin',\n                skip_auth: false,\n                created_at: 1234567890,\n            }\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve(mockUpdated),\n            })\n\n            const result = await updateInstance(1, updateData)\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/instances/1', {\n                method: 'PUT',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: JSON.stringify(updateData),\n            })\n            expect(result.label).toBe('Updated Label')\n        })\n\n        it('throws error on update failure', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'Instance not found' }),\n            })\n\n            await expect(updateInstance(999, { label: 'Test' }))\n                .rejects.toThrow('Instance not found')\n        })\n    })\n\n    describe('deleteInstance', () => {\n        it('deletes instance by id', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true })\n\n            await deleteInstance(5)\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/instances/5', {\n                method: 'DELETE',\n                credentials: 'include',\n            })\n        })\n\n        it('throws error on deletion failure', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: false })\n\n            await expect(deleteInstance(999)).rejects.toThrow('Failed to delete instance')\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/api/integrations.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport {\n    getIntegrations,\n    createIntegration,\n    deleteIntegration,\n    testIntegrationConnection,\n    getIndexers,\n    search,\n    grabRelease,\n} from '../../src/api/integrations'\n\nconst mockFetch = vi.fn()\nvi.stubGlobal('fetch', mockFetch)\n\ndescribe('integrations API', () => {\n    beforeEach(() => {\n        mockFetch.mockReset()\n    })\n\n    describe('getIntegrations', () => {\n        it('fetches integrations list', async () => {\n            const mockIntegrations = [\n                { id: 1, type: 'prowlarr', label: 'My Prowlarr', url: 'http://localhost:9696', created_at: 123456 },\n            ]\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve(mockIntegrations),\n            })\n\n            const result = await getIntegrations()\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/integrations', { credentials: 'include' })\n            expect(result).toEqual(mockIntegrations)\n        })\n\n        it('throws on failure', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: false })\n\n            await expect(getIntegrations()).rejects.toThrow('Failed to fetch integrations')\n        })\n    })\n\n    describe('createIntegration', () => {\n        it('creates integration with data', async () => {\n            const createData = {\n                type: 'prowlarr',\n                label: 'My Prowlarr',\n                url: 'http://localhost:9696',\n                api_key: 'secret123',\n            }\n            const mockResult = { id: 1, ...createData, created_at: 123456 }\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve(mockResult),\n            })\n\n            const result = await createIntegration(createData)\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/integrations', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: JSON.stringify(createData),\n            })\n            expect(result.id).toBe(1)\n        })\n\n        it('throws error with message from server', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'Invalid API key' }),\n            })\n\n            await expect(createIntegration({\n                type: 'prowlarr',\n                label: 'Test',\n                url: 'http://test',\n                api_key: 'invalid',\n            })).rejects.toThrow('Invalid API key')\n        })\n    })\n\n    describe('deleteIntegration', () => {\n        it('deletes integration by id', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true })\n\n            await deleteIntegration(5)\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/integrations/5', {\n                method: 'DELETE',\n                credentials: 'include',\n            })\n        })\n\n        it('throws on failure', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: false })\n\n            await expect(deleteIntegration(999)).rejects.toThrow('Failed to delete integration')\n        })\n    })\n\n    describe('testIntegrationConnection', () => {\n        it('returns success with version', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve({ success: true, version: '1.0.0' }),\n            })\n\n            const result = await testIntegrationConnection('http://localhost:9696', 'apikey123')\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/integrations/test', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: JSON.stringify({ url: 'http://localhost:9696', api_key: 'apikey123' }),\n            })\n            expect(result.success).toBe(true)\n            expect(result.version).toBe('1.0.0')\n        })\n\n        it('returns failure with error', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve({ success: false, error: 'Connection refused' }),\n            })\n\n            const result = await testIntegrationConnection('http://invalid', 'apikey')\n\n            expect(result.success).toBe(false)\n            expect(result.error).toBe('Connection refused')\n        })\n    })\n\n    describe('getIndexers', () => {\n        it('fetches indexers for integration', async () => {\n            const mockIndexers = [\n                { id: 1, name: 'Indexer 1', enable: true, protocol: 'torrent' },\n                { id: 2, name: 'Indexer 2', enable: false, protocol: 'usenet' },\n            ]\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve(mockIndexers),\n            })\n\n            const result = await getIndexers(1)\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/integrations/1/indexers', { credentials: 'include' })\n            expect(result).toHaveLength(2)\n        })\n\n        it('throws on failure', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: false })\n\n            await expect(getIndexers(1)).rejects.toThrow('Failed to fetch indexers')\n        })\n    })\n\n    describe('search', () => {\n        it('searches with query only', async () => {\n            const mockResults = [\n                { guid: '123', indexerId: 1, indexer: 'Test', title: 'Result', size: 1000, publishDate: '2024-01-01', categories: [] },\n            ]\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve(mockResults),\n            })\n\n            const result = await search(1, 'test query')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                expect.stringContaining('/api/integrations/1/search?query=test+query'),\n                expect.anything()\n            )\n            expect(result).toEqual(mockResults)\n        })\n\n        it('searches with options', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: true,\n                json: () => Promise.resolve([]),\n            })\n\n            await search(1, 'test', { indexerIds: '1,2', categories: '5000', type: 'movie' })\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                expect.stringContaining('indexerIds=1%2C2'),\n                expect.anything()\n            )\n        })\n\n        it('throws error with message from server', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'Rate limited' }),\n            })\n\n            await expect(search(1, 'test')).rejects.toThrow('Rate limited')\n        })\n    })\n\n    describe('grabRelease', () => {\n        it('grabs release with download URL', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true })\n\n            await grabRelease(1, {\n                guid: 'abc123',\n                indexerId: 1,\n                downloadUrl: 'http://example.com/download',\n            }, 5, { savepath: '/downloads/complete', downloadPath: '/downloads/incomplete' })\n\n            expect(mockFetch).toHaveBeenCalledWith('/api/integrations/1/grab', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                credentials: 'include',\n                body: expect.stringMatching(/\"instanceId\":5.*\"savepath\":\"\\/downloads\\/complete\".*\"downloadPath\":\"\\/downloads\\/incomplete\"|\"instanceId\":5.*\"downloadPath\":\"\\/downloads\\/incomplete\".*\"savepath\":\"\\/downloads\\/complete\"/),\n            })\n        })\n\n        it('grabs release with magnet URL', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true })\n\n            await grabRelease(1, {\n                guid: 'abc123',\n                indexerId: 1,\n                magnetUrl: 'magnet:?xt=urn:btih:abc123',\n            }, 5)\n\n            expect(mockFetch).toHaveBeenCalled()\n        })\n\n        it('throws error on failure', async () => {\n            mockFetch.mockResolvedValueOnce({\n                ok: false,\n                json: () => Promise.resolve({ error: 'Indexer offline' }),\n            })\n\n            await expect(grabRelease(1, { guid: '123', indexerId: 1 }, 5))\n                .rejects.toThrow('Indexer offline')\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/api/qbittorrent.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport {\n    getTorrents,\n    getTransferInfo,\n    getSyncMaindata,\n    stopTorrents,\n    startTorrents,\n    deleteTorrents,\n    getCategories,\n    getTags,\n    createTags,\n    deleteTags,\n    setCategory,\n    addTags,\n    removeTags,\n    getTorrentProperties,\n    getTorrentTrackers,\n    getTorrentFiles,\n    renameTorrent,\n    setTorrentLocation,\n    setTorrentDownloadPath,\n    addTrackers,\n    removeTrackers,\n    getPreferences,\n    getLog,\n    getPeerLog,\n    getSpeedLimitsMode,\n    toggleSpeedLimitsMode,\n    createCategory,\n    editCategory,\n    removeCategories,\n    setFilePriority,\n    getRSSItems,\n    getRSSRules,\n} from '../../src/api/qbittorrent'\n\nconst mockFetch = vi.fn()\nvi.stubGlobal('fetch', mockFetch)\n\ndescribe('qBittorrent API', () => {\n    const instanceId = 1\n\n    beforeEach(() => {\n        mockFetch.mockReset()\n    })\n\n    // Helper to create successful JSON response\n    const jsonResponse = (data: unknown) => ({\n        ok: true,\n        text: () => Promise.resolve(JSON.stringify(data)),\n    })\n\n    describe('getTorrents', () => {\n        it('fetches torrents without filters', async () => {\n            const mockTorrents = [{ hash: 'abc123', name: 'Test Torrent' }]\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockTorrents))\n\n            const result = await getTorrents(instanceId)\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/info',\n                expect.objectContaining({ credentials: 'include' })\n            )\n            expect(result).toEqual(mockTorrents)\n        })\n\n        it('fetches torrents with filter options', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse([]))\n\n            await getTorrents(instanceId, { filter: 'downloading', category: 'movies', tag: 'hd' })\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                expect.stringContaining('filter=downloading'),\n                expect.anything()\n            )\n            expect(mockFetch).toHaveBeenCalledWith(\n                expect.stringContaining('category=movies'),\n                expect.anything()\n            )\n            expect(mockFetch).toHaveBeenCalledWith(\n                expect.stringContaining('tag=hd'),\n                expect.anything()\n            )\n        })\n\n        it('skips filter=all in request', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse([]))\n\n            await getTorrents(instanceId, { filter: 'all' })\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/info',\n                expect.anything()\n            )\n        })\n    })\n\n    describe('getTransferInfo', () => {\n        it('fetches transfer info', async () => {\n            const mockInfo = { dl_info_speed: 1024, up_info_speed: 512 }\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockInfo))\n\n            const result = await getTransferInfo(instanceId)\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/transfer/info',\n                expect.anything()\n            )\n            expect(result).toEqual(mockInfo)\n        })\n    })\n\n    describe('getSyncMaindata', () => {\n        it('fetches sync maindata', async () => {\n            const mockData = { rid: 1, torrents: {} }\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockData))\n\n            const result = await getSyncMaindata(instanceId)\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/sync/maindata?rid=0',\n                expect.anything()\n            )\n            expect(result).toEqual(mockData)\n        })\n    })\n\n    describe('torrent actions', () => {\n        it('stops torrents', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await stopTorrents(instanceId, ['hash1', 'hash2'])\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/stop',\n                expect.objectContaining({\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n                })\n            )\n        })\n\n        it('starts torrents', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await startTorrents(instanceId, ['hash1'])\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/start',\n                expect.anything()\n            )\n        })\n\n        it('deletes torrents without files', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await deleteTorrents(instanceId, ['hash1'], false)\n\n            const call = mockFetch.mock.calls[0]\n            expect(call[0]).toBe('/api/instances/1/qbt/v2/torrents/delete')\n            expect(call[1].body.get('deleteFiles')).toBe('false')\n        })\n\n        it('deletes torrents with files', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await deleteTorrents(instanceId, ['hash1'], true)\n\n            const call = mockFetch.mock.calls[0]\n            expect(call[1].body.get('deleteFiles')).toBe('true')\n        })\n\n\tit('changes torrent save path', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await setTorrentLocation(instanceId, ['hash1', 'hash2'], '/downloads/new-path')\n\n            const call = mockFetch.mock.calls[0]\n            expect(call[0]).toBe('/api/instances/1/qbt/v2/torrents/setLocation')\n            expect(call[1].body.get('hashes')).toBe('hash1|hash2')\n            expect(call[1].body.get('location')).toBe('/downloads/new-path')\n        })\n\n        it('changes torrent download path', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await setTorrentDownloadPath(instanceId, ['hash1'], '/downloads/incomplete')\n\n            const call = mockFetch.mock.calls[0]\n            expect(call[0]).toBe('/api/instances/1/qbt/v2/torrents/setDownloadPath')\n            expect(call[1].body.get('hashes')).toBe('hash1')\n            expect(call[1].body.get('downloadPath')).toBe('/downloads/incomplete')\n        })\n    })\n\n    describe('categories', () => {\n        it('gets categories', async () => {\n            const mockCategories = { movies: { name: 'movies', savePath: '/downloads/movies' } }\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockCategories))\n\n            const result = await getCategories(instanceId)\n\n            expect(result).toEqual(mockCategories)\n        })\n\n        it('creates category with save path', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await createCategory(instanceId, 'movies', '/downloads/movies')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/createCategory',\n                expect.anything()\n            )\n        })\n\n        it('edits category', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await editCategory(instanceId, 'movies', '/new/path')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/editCategory',\n                expect.anything()\n            )\n        })\n\n        it('removes categories', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await removeCategories(instanceId, ['movies', 'tv'])\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/removeCategories',\n                expect.anything()\n            )\n        })\n\n        it('sets category on torrents', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await setCategory(instanceId, ['hash1', 'hash2'], 'movies')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/setCategory',\n                expect.anything()\n            )\n        })\n    })\n\n    describe('tags', () => {\n        it('gets tags', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse(['tag1', 'tag2']))\n\n            const result = await getTags(instanceId)\n\n            expect(result).toEqual(['tag1', 'tag2'])\n        })\n\n        it('creates tags', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await createTags(instanceId, 'newtag')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/createTags',\n                expect.anything()\n            )\n        })\n\n        it('deletes tags', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await deleteTags(instanceId, 'oldtag')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/deleteTags',\n                expect.anything()\n            )\n        })\n\n        it('adds tags to torrents', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await addTags(instanceId, ['hash1'], 'tag1,tag2')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/addTags',\n                expect.anything()\n            )\n        })\n\n        it('removes tags from torrents', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await removeTags(instanceId, ['hash1'], 'tag1')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/removeTags',\n                expect.anything()\n            )\n        })\n    })\n\n    describe('torrent details', () => {\n        it('gets torrent properties', async () => {\n            const mockProps = { save_path: '/downloads', total_size: 1000000 }\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockProps))\n\n            const result = await getTorrentProperties(instanceId, 'abc123')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/properties?hash=abc123',\n                expect.anything()\n            )\n            expect(result).toEqual(mockProps)\n        })\n\n        it('gets torrent trackers', async () => {\n            const mockTrackers = [{ url: 'http://tracker.example.com', status: 2 }]\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockTrackers))\n\n            const result = await getTorrentTrackers(instanceId, 'abc123')\n\n            expect(result).toEqual(mockTrackers)\n        })\n\n        it('gets torrent files', async () => {\n            const mockFiles = [{ name: 'file.mkv', size: 1000000 }]\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockFiles))\n\n            const result = await getTorrentFiles(instanceId, 'abc123')\n\n            expect(result).toEqual(mockFiles)\n        })\n    })\n\n    describe('torrent management', () => {\n        it('renames torrent', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await renameTorrent(instanceId, 'abc123', 'New Name')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/rename',\n                expect.anything()\n            )\n        })\n\n        it('adds trackers', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await addTrackers(instanceId, 'abc123', ['http://tracker1.com', 'http://tracker2.com'])\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/addTrackers',\n                expect.anything()\n            )\n        })\n\n        it('removes trackers', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await removeTrackers(instanceId, 'abc123', ['http://tracker1.com'])\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/removeTrackers',\n                expect.anything()\n            )\n        })\n\n        it('sets file priority', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse({}))\n\n            await setFilePriority(instanceId, 'abc123', [0, 1, 2], 7)\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/torrents/filePrio',\n                expect.anything()\n            )\n        })\n    })\n\n    describe('preferences', () => {\n        it('gets preferences', async () => {\n            const mockPrefs = { save_path: '/downloads', listen_port: 6881 }\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockPrefs))\n\n            const result = await getPreferences(instanceId)\n\n            expect(result).toEqual(mockPrefs)\n        })\n    })\n\n    describe('speed limits', () => {\n        it('gets speed limits mode', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('1') })\n\n            const result = await getSpeedLimitsMode(instanceId)\n\n            expect(result).toBe(1)\n        })\n\n        it('toggles speed limits mode', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true })\n\n            await toggleSpeedLimitsMode(instanceId)\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                '/api/instances/1/qbt/v2/transfer/toggleSpeedLimitsMode',\n                expect.objectContaining({ method: 'POST' })\n            )\n        })\n    })\n\n    describe('logs', () => {\n        it('gets log entries', async () => {\n            const mockLogs = [{ id: 1, message: 'Test log', timestamp: 123456, type: 1 }]\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockLogs))\n\n            const result = await getLog(instanceId)\n\n            expect(result).toEqual(mockLogs)\n        })\n\n        it('gets log with filter options', async () => {\n            mockFetch.mockResolvedValueOnce(jsonResponse([]))\n\n            await getLog(instanceId, { normal: true, warning: true, critical: true, lastKnownId: 10 })\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                expect.stringContaining('normal=true'),\n                expect.anything()\n            )\n            expect(mockFetch).toHaveBeenCalledWith(\n                expect.stringContaining('last_known_id=10'),\n                expect.anything()\n            )\n        })\n\n        it('gets peer log', async () => {\n            const mockPeerLogs = [{ id: 1, ip: '192.168.1.1', timestamp: 123456, blocked: false, reason: '' }]\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockPeerLogs))\n\n            const result = await getPeerLog(instanceId)\n\n            expect(result).toEqual(mockPeerLogs)\n        })\n    })\n\n    describe('RSS', () => {\n        it('gets RSS items', async () => {\n            const mockItems = { 'Feed 1': { url: 'http://feed.com/rss' } }\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockItems))\n\n            const result = await getRSSItems(instanceId)\n\n            expect(result).toEqual(mockItems)\n        })\n\n        it('gets RSS rules', async () => {\n            const mockRules = { 'Rule 1': { enabled: true, mustContain: 'test' } }\n            mockFetch.mockResolvedValueOnce(jsonResponse(mockRules))\n\n            const result = await getRSSRules(instanceId)\n\n            expect(result).toEqual(mockRules)\n        })\n    })\n\n    describe('error handling', () => {\n        it('throws on non-ok response', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve('Error') })\n\n            await expect(getTorrents(instanceId)).rejects.toThrow('API error: 500')\n        })\n\n        it('throws on empty response', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('') })\n\n            await expect(getTorrents(instanceId)).rejects.toThrow('Empty response from API')\n        })\n\n        it('throws on invalid JSON', async () => {\n            mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('not json') })\n\n            await expect(getTorrents(instanceId)).rejects.toThrow('Invalid JSON response')\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/hooks/useInstance.test.tsx",
    "content": "import { describe, it, expect, vi } from 'vitest'\nimport { renderHook } from '@testing-library/react'\nimport React from 'react'\nimport { useInstance } from '../../src/hooks/useInstance'\nimport { InstanceContext } from '../../src/contexts/instanceContext'\nimport type { Instance } from '../../src/api/instances'\n\ndescribe('useInstance', () => {\n    const mockInstance: Instance = {\n        id: 1,\n        label: 'Test Instance',\n        url: 'http://localhost:8080',\n        qbt_username: 'admin',\n        skip_auth: false,\n        created_at: 1234567890,\n    }\n\n    it('returns instance from context', () => {\n        const wrapper = ({ children }: { children: React.ReactNode }) =>\n            React.createElement(\n                InstanceContext.Provider,\n                { value: { instance: mockInstance, setInstance: vi.fn() } },\n                children\n            )\n\n        const { result } = renderHook(() => useInstance(), { wrapper })\n\n        expect(result.current).toEqual(mockInstance)\n        expect(result.current.id).toBe(1)\n        expect(result.current.label).toBe('Test Instance')\n    })\n\n    it('throws error when used outside provider', () => {\n        // Suppress console.error for this test\n        const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })\n\n        expect(() => {\n            renderHook(() => useInstance())\n        }).toThrow('useInstance must be used within InstanceProvider')\n\n        consoleSpy.mockRestore()\n    })\n})\n"
  },
  {
    "path": "__tests__/hooks/usePagination.test.tsx",
    "content": "import { describe, it, expect, vi } from 'vitest'\nimport { renderHook } from '@testing-library/react'\nimport React from 'react'\nimport { usePagination } from '../../src/hooks/usePagination'\nimport { PaginationContext } from '../../src/contexts/paginationContext'\n\ndescribe('usePagination', () => {\n    const mockPaginationContext = {\n        page: 1,\n        perPage: 50,\n        setPage: vi.fn(),\n        setPerPage: vi.fn(),\n    }\n\n    it('returns pagination context values', () => {\n        const wrapper = ({ children }: { children: React.ReactNode }) =>\n            React.createElement(\n                PaginationContext.Provider,\n                { value: mockPaginationContext },\n                children\n            )\n\n        const { result } = renderHook(() => usePagination(), { wrapper })\n\n        expect(result.current.page).toBe(1)\n        expect(result.current.perPage).toBe(50)\n        expect(typeof result.current.setPage).toBe('function')\n        expect(typeof result.current.setPerPage).toBe('function')\n    })\n\n    it('throws error when used outside provider', () => {\n        const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })\n\n        expect(() => {\n            renderHook(() => usePagination())\n        }).toThrow('usePagination must be used within PaginationProvider')\n\n        consoleSpy.mockRestore()\n    })\n})\n"
  },
  {
    "path": "__tests__/reporter.ts",
    "content": "import type { Reporter, Vitest } from \"vitest/node\";\nimport pc from \"picocolors\";\nimport path from \"node:path\";\n\ntype TaskState = \"pass\" | \"fail\" | \"skip\" | \"todo\" | \"pending\" | \"unknown\";\n\ntype AnyTask = {\n    id?: string;\n    type?: \"suite\" | \"test\";\n    name?: string;\n    mode?: \"run\" | \"skip\" | \"only\" | \"todo\";\n    tasks?: AnyTask[];\n    result?: {\n        state?: unknown;\n        duration?: number;\n        errors?: unknown[];\n    };\n};\n\ntype AnyFile = {\n    filepath?: string;\n    name?: string;\n    file?: string;\n    tasks?: AnyTask[];\n};\n\ntype TaskResultPack = {\n    id: string;\n    result?: {\n        state?: unknown;\n        duration?: number;\n        errors?: unknown[];\n    };\n};\n\nfunction normalisePath(p: string) {\n    return p.replaceAll(\"\\\\\", \"/\");\n}\n\nfunction toRelative(p: string) {\n    try {\n        return path.relative(process.cwd(), p);\n    } catch {\n        return p;\n    }\n}\n\nfunction safeBasename(p: string) {\n    try {\n        return path.basename(p);\n    } catch {\n        return p;\n    }\n}\n\nfunction safeDirname(p: string) {\n    try {\n        return path.dirname(p);\n    } catch {\n        return \"\";\n    }\n}\n\nfunction normaliseState(rawState: unknown, mode: unknown): TaskState {\n    if (mode === \"skip\") return \"skip\";\n    if (mode === \"todo\") return \"todo\";\n\n    const s = String(rawState ?? \"\").toLowerCase();\n\n    if (s === \"pass\" || s === \"passed\" || s === \"success\") return \"pass\";\n    if (s === \"fail\" || s === \"failed\") return \"fail\";\n    if (s === \"skip\" || s === \"skipped\") return \"skip\";\n    if (s === \"todo\") return \"todo\";\n\n    if (!s) return \"pending\";\n    return \"unknown\";\n}\n\nfunction iconFor(state: TaskState) {\n    switch (state) {\n        case \"pass\":\n            return pc.green(\"✔\");\n        case \"fail\":\n            return pc.red(\"✖\");\n        case \"skip\":\n            return pc.yellow(\"↷\");\n        case \"todo\":\n            return pc.yellow(\"…\");\n        case \"pending\":\n            return pc.gray(\"·\");\n        default:\n            return pc.gray(\"?\");\n    }\n}\n\nfunction colourName(state: TaskState, text: string) {\n    switch (state) {\n        case \"pass\":\n            return pc.white(text);\n        case \"fail\":\n            return pc.red(text);\n        case \"skip\":\n        case \"todo\":\n            return pc.yellow(text);\n        default:\n            return pc.gray(text);\n    }\n}\n\nfunction formatDuration(ms?: number) {\n    if (!ms || ms <= 0) return \"\";\n    if (ms < 1000) return pc.dim(` ${Math.round(ms)}ms`);\n    return pc.dim(` ${(ms / 1000).toFixed(2)}s`);\n}\n\nexport default class PrettyReporter implements Reporter {\n    private ctx: Vitest | undefined;\n\n    private startMs = 0;\n\n    private indexed = false;\n    private totalTests = 0;\n\n    private pass = 0;\n    private fail = 0;\n    private skip = 0;\n    private todo = 0;\n\n    private completed = new Set<string>();\n    private lastProgressRender = 0;\n\n    onInit(ctx: Vitest) {\n        try {\n            this.ctx = ctx;\n            this.startMs = Date.now();\n\n            process.stdout.write(pc.cyan(pc.bold(\"\\n QBITWEBUI TEST SUITE \\n\")));\n            process.stdout.write(pc.gray(\" Running tests…\\n\\n\"));\n        } catch (e) {\n            console.error(\"Reporter init error:\", e);\n        }\n    }\n\n    onTaskUpdate(packs: TaskResultPack[]) {\n        try {\n            this.ensureIndexedFromState();\n\n            for (const pack of packs ?? []) {\n                if (!pack?.id) continue;\n                if (!pack.result) continue;\n\n                const state = normaliseState(pack.result.state, undefined);\n\n                const terminal =\n                    state === \"pass\" || state === \"fail\" || state === \"skip\" || state === \"todo\";\n\n                if (!terminal) continue;\n                if (this.completed.has(pack.id)) continue;\n\n                this.completed.add(pack.id);\n\n                if (state === \"pass\") this.pass += 1;\n                if (state === \"fail\") this.fail += 1;\n                if (state === \"skip\") this.skip += 1;\n                if (state === \"todo\") this.todo += 1;\n            }\n\n            // Progress bar (throttled)\n            const now = Date.now();\n            if (now - this.lastProgressRender < 1) return;\n            this.lastProgressRender = now;\n\n            this.renderProgressLine();\n        } catch (e) {\n            console.error(\"Reporter update error:\", e);\n        }\n    }\n\n    onTestRunEnd() {\n        try {\n            // Clear the progress line\n            process.stdout.write(\"\\r\\x1b[2K\\n\");\n            this.printReportFromState();\n        } catch (e) {\n            console.error(\"Reporter error:\", e);\n        }\n    }\n\n    private getStateFiles(): AnyFile[] {\n        const ctx = this.ctx as { state?: { getFiles?: () => unknown; files?: unknown } } | undefined;\n        const state = ctx?.state;\n\n        const filesFromGetter = state?.getFiles?.();\n        if (Array.isArray(filesFromGetter)) return filesFromGetter;\n\n        const filesFromProp = state?.files;\n        if (Array.isArray(filesFromProp)) return filesFromProp;\n\n        return [];\n    }\n\n    private ensureIndexedFromState() {\n        if (this.indexed) return;\n\n        const files = this.getStateFiles();\n        if (!files.length) return;\n\n        let total = 0;\n\n        const walk = (t: AnyTask) => {\n            if (!t) return;\n            if (t.type === \"test\") total += 1;\n            if (Array.isArray(t.tasks)) t.tasks.forEach(walk);\n        };\n\n        files.forEach((f) => {\n            if (Array.isArray(f.tasks)) f.tasks.forEach(walk);\n        });\n\n        this.totalTests = total;\n        this.indexed = true;\n    }\n\n    private renderProgressLine() {\n        const total = Math.max(this.totalTests, 1);\n        const done = Math.min(this.completed.size, total);\n        const pct = this.totalTests ? Math.round((done / total) * 100) : 0;\n\n        const width = 28;\n        const filled = Math.round((pct / 100) * width);\n        const bar =\n            pc.green(\"█\".repeat(filled)) + pc.gray(\"░\".repeat(width - filled));\n\n        const elapsed = (Date.now() - this.startMs) / 1000;\n\n        const line = [\n            pc.dim(\" Progress \"),\n            \"[\",\n            bar,\n            \"] \",\n            pc.white(`${pct}%`),\n            pc.dim(`  (${done}/${this.totalTests})`),\n            pc.dim(\"  | \"),\n            pc.green(`✔ ${this.pass}`),\n            pc.dim(\" \"),\n            pc.red(`✖ ${this.fail}`),\n            pc.dim(\" \"),\n            pc.yellow(`↷ ${this.skip}`),\n            this.todo ? pc.dim(\" \") : \"\",\n            this.todo ? pc.yellow(`… ${this.todo}`) : \"\",\n            pc.dim(`  | ${elapsed.toFixed(1)}s`),\n        ].join(\"\");\n\n        process.stdout.write(\"\\r\\x1b[2K\" + line);\n    }\n\n    private printReportFromState() {\n        const files = this.getStateFiles();\n\n        const endMs = Date.now();\n        const duration = ((endMs - this.startMs) / 1000).toFixed(2);\n\n        // Recompute final totals from the actual state (authoritative)\n        const totals = this.computeTotals(files);\n        this.pass = totals.pass;\n        this.fail = totals.fail;\n        this.skip = totals.skip;\n        this.todo = totals.todo;\n        this.totalTests = totals.total;\n\n        process.stdout.write(pc.cyan(pc.bold(\"\\n RESULTS \\n\")));\n\n        // Group by directory\n        const grouped = new Map<string, AnyFile[]>();\n        for (const file of files) {\n            const raw = file.filepath ?? file.file ?? file.name ?? \"\";\n            const rel = normalisePath(toRelative(raw));\n            const dir = normalisePath(safeDirname(rel)) || \".\";\n            const arr = grouped.get(dir) ?? [];\n            arr.push(file);\n            grouped.set(dir, arr);\n        }\n\n        const dirs = [...grouped.keys()].sort((a, b) => a.localeCompare(b));\n\n        for (const dir of dirs) {\n            const niceDir = dir === \".\" ? \"__tests__\" : dir;\n            process.stdout.write(pc.magenta(pc.bold(`\\n 📁 ${niceDir}\\n`)));\n\n            const dirFiles = grouped.get(dir) ?? [];\n            dirFiles.sort((a, b) => {\n                const ap = a.filepath ?? a.file ?? a.name ?? \"\";\n                const bp = b.filepath ?? b.file ?? b.name ?? \"\";\n                return ap.localeCompare(bp);\n            });\n\n            for (const file of dirFiles) {\n                const raw = file.filepath ?? file.file ?? file.name ?? \"\";\n                const rel = normalisePath(toRelative(raw));\n                const fname = safeBasename(rel);\n\n                const stats = this.computeFileTotals(file);\n\n                const badge =\n                    stats.fail > 0\n                        ? pc.red(` ${stats.fail} failed`)\n                        : pc.green(` ${stats.pass} passed`);\n\n                const extrasParts: string[] = [];\n                if (stats.skip > 0) extrasParts.push(pc.yellow(`${stats.skip} skipped`));\n                if (stats.todo > 0) extrasParts.push(pc.yellow(`${stats.todo} todo`));\n\n                const extras = extrasParts.length\n                    ? pc.dim(` (${extrasParts.join(\", \")})`)\n                    : \"\";\n\n                process.stdout.write(\n                    `  ${pc.dim(fname)}${pc.dim(\"  \")}${badge}${extras}${formatDuration(\n                        stats.durationMs,\n                    )}\\n`,\n                );\n\n                if (Array.isArray(file.tasks) && file.tasks.length) {\n                    for (const t of file.tasks) this.printTaskTree(t, 4);\n                } else {\n                    process.stdout.write(pc.dim(\"    (no tasks collected)\\n\"));\n                }\n            }\n        }\n\n        const done = this.pass + this.fail + this.skip + this.todo;\n        const pct = this.totalTests ? Math.round((done / this.totalTests) * 100) : 0;\n\n        process.stdout.write(pc.gray(\"\\n ─────────────────────────────────────────────\\n\"));\n        process.stdout.write(\n            ` Summary: ${pc.green(`✔ ${this.pass}`)}  ${pc.red(\n                `✖ ${this.fail}`,\n            )}  ${pc.yellow(`↷ ${this.skip}`)}${this.todo ? `  ${pc.yellow(`… ${this.todo}`)}` : \"\"\n            }\\n`,\n        );\n        process.stdout.write(\n            ` Progress: ${pct}% (${done}/${this.totalTests})\\n`,\n        );\n        process.stdout.write(` Time: ${duration}s\\n`);\n\n        if (this.fail > 0) {\n            process.stdout.write(\n                pc.red(\n                    `\\n ${this.fail} failing test(s). Check your code.\\n\\n`,\n                ),\n            );\n            process.exitCode = 1;\n        } else {\n            process.stdout.write(\n                pc.green(`\\n All tests passed. \\n\\n`),\n            );\n            process.exitCode = 0;\n        }\n    }\n\n    private computeTotals(files: AnyFile[]) {\n        let pass = 0;\n        let fail = 0;\n        let skip = 0;\n        let todo = 0;\n        let total = 0;\n\n        const walk = (t: AnyTask) => {\n            if (!t) return;\n\n            if (t.type === \"test\") {\n                total += 1;\n                const st = normaliseState(t.result?.state, t.mode);\n                if (st === \"pass\") pass += 1;\n                else if (st === \"fail\") fail += 1;\n                else if (st === \"skip\") skip += 1;\n                else if (st === \"todo\") todo += 1;\n            }\n\n            if (Array.isArray(t.tasks)) t.tasks.forEach(walk);\n        };\n\n        files.forEach((f) => {\n            if (Array.isArray(f.tasks)) f.tasks.forEach(walk);\n        });\n\n        return { pass, fail, skip, todo, total };\n    }\n\n    private computeFileTotals(file: AnyFile) {\n        let pass = 0;\n        let fail = 0;\n        let skip = 0;\n        let todo = 0;\n        let durationMs = 0;\n\n        const walk = (t: AnyTask) => {\n            if (!t) return;\n\n            if (t.type === \"test\") {\n                const st = normaliseState(t.result?.state, t.mode);\n                if (st === \"pass\") pass += 1;\n                else if (st === \"fail\") fail += 1;\n                else if (st === \"skip\") skip += 1;\n                else if (st === \"todo\") todo += 1;\n\n                if (typeof t.result?.duration === \"number\") {\n                    durationMs += t.result.duration;\n                }\n            }\n\n            if (Array.isArray(t.tasks)) t.tasks.forEach(walk);\n        };\n\n        if (Array.isArray(file.tasks)) file.tasks.forEach(walk);\n\n        return { pass, fail, skip, todo, durationMs };\n    }\n\n    private printTaskTree(task: AnyTask, indent: number) {\n        const pad = \" \".repeat(indent);\n\n        if (task.type === \"suite\") {\n            if (task.name && task.name.trim()) {\n                process.stdout.write(`${pad}${pc.blue(pc.bold(task.name))}\\n`);\n            }\n            if (Array.isArray(task.tasks)) {\n                for (const child of task.tasks) this.printTaskTree(child, indent + 2);\n            }\n            return;\n        }\n\n        if (task.type === \"test\") {\n            const state = normaliseState(task.result?.state, task.mode);\n            const icon = iconFor(state);\n            const name = colourName(state, task.name ?? \"(unnamed test)\");\n            const time = formatDuration(task.result?.duration);\n\n            process.stdout.write(`${pad}${icon} ${name}${time}\\n`);\n\n            if (state === \"fail\" && Array.isArray(task.result?.errors)) {\n                for (const err of task.result.errors) {\n                    let msg: string;\n                    if (err instanceof Error) {\n                        msg = err.message.split(\"\\n\")[0];\n                    } else if (typeof err === \"string\") {\n                        msg = err.split(\"\\n\")[0];\n                    } else if (err && typeof err === \"object\") {\n                        try {\n                            msg = JSON.stringify(err).slice(0, 100);\n                        } catch {\n                            msg = \"[object]\";\n                        }\n                    } else {\n                        msg = String(err ?? \"Unknown error\").split(\"\\n\")[0];\n                    }\n\n                    process.stdout.write(`${pad}  ${pc.red(\"└─ \")}${pc.dim(msg)}\\n`);\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "__tests__/server/crossSeedCache.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'\nimport { existsSync, rmSync } from 'fs'\nimport { tmpdir } from 'os'\nimport { join } from 'path'\n\nconst TEST_DATA_PATH = join(tmpdir(), `crossseed-test-${process.pid}`)\nprocess.env.DATA_PATH = TEST_DATA_PATH\n\nimport {\n\tcacheTorrent,\n\tgetCachedTorrent,\n\thasCachedTorrent,\n\tclearCacheForInstance,\n\tclearOutputForInstance,\n\tsaveTorrentToOutput,\n\tgetCacheStats,\n\tgetOutputStats,\n\t_resetCachePaths,\n} from '../../src/server/utils/crossSeedCache'\n\ndescribe('crossSeedCache', () => {\n\tconst TEST_INSTANCE = 99999\n\tconst HASH_1 = 'abc123def456abc123def456abc123def456abc1'\n\tconst HASH_2 = 'def456abc123def456abc123def456abc123def4'\n\n\tbeforeAll(() => {\n\t\t_resetCachePaths()\n\t})\n\n\tafterAll(() => {\n\t\trmSync(TEST_DATA_PATH, { recursive: true, force: true })\n\t})\n\n\tbeforeEach(() => {\n\t\tclearCacheForInstance(TEST_INSTANCE)\n\t\tclearOutputForInstance(TEST_INSTANCE)\n\t})\n\n\tafterEach(() => {\n\t\tclearCacheForInstance(TEST_INSTANCE)\n\t\tclearOutputForInstance(TEST_INSTANCE)\n\t})\n\n\tdescribe('cacheTorrent and getCachedTorrent', () => {\n\t\tit('caches and retrieves torrent data', () => {\n\t\t\tconst torrentData = Buffer.from('test torrent data')\n\n\t\t\tcacheTorrent(TEST_INSTANCE, HASH_1, torrentData)\n\n\t\t\tconst cached = getCachedTorrent(TEST_INSTANCE, HASH_1)\n\t\t\texpect(cached).not.toBeNull()\n\t\t\texpect(cached?.toString()).toBe('test torrent data')\n\t\t})\n\n\t\tit('overwrites existing cache', () => {\n\t\t\tconst data1 = Buffer.from('first')\n\t\t\tconst data2 = Buffer.from('second')\n\n\t\t\tcacheTorrent(TEST_INSTANCE, HASH_1, data1)\n\t\t\tcacheTorrent(TEST_INSTANCE, HASH_1, data2)\n\n\t\t\tconst cached = getCachedTorrent(TEST_INSTANCE, HASH_1)\n\t\t\texpect(cached?.toString()).toBe('second')\n\t\t})\n\n\t\tit('returns null for non-existent cache', () => {\n\t\t\texpect(getCachedTorrent(TEST_INSTANCE, HASH_2)).toBeNull()\n\t\t})\n\t})\n\n\tdescribe('hasCachedTorrent', () => {\n\t\tit('returns true when torrent is cached', () => {\n\t\t\tcacheTorrent(TEST_INSTANCE, HASH_1, Buffer.from('data'))\n\t\t\texpect(hasCachedTorrent(TEST_INSTANCE, HASH_1)).toBe(true)\n\t\t})\n\n\t\tit('returns false when torrent is not cached', () => {\n\t\t\texpect(hasCachedTorrent(TEST_INSTANCE, HASH_2)).toBe(false)\n\t\t})\n\t})\n\n\tdescribe('clearCacheForInstance', () => {\n\t\tit('clears all cache for instance', () => {\n\t\t\tcacheTorrent(TEST_INSTANCE, HASH_1, Buffer.from('data1'))\n\t\t\tcacheTorrent(TEST_INSTANCE, HASH_2, Buffer.from('data2'))\n\n\t\t\tconst cleared = clearCacheForInstance(TEST_INSTANCE)\n\n\t\t\texpect(cleared).toBe(2)\n\t\t\texpect(hasCachedTorrent(TEST_INSTANCE, HASH_1)).toBe(false)\n\t\t\texpect(hasCachedTorrent(TEST_INSTANCE, HASH_2)).toBe(false)\n\t\t})\n\n\t\tit('returns 0 when no cache exists', () => {\n\t\t\texpect(clearCacheForInstance(88888)).toBe(0)\n\t\t})\n\t})\n\n\tdescribe('saveTorrentToOutput', () => {\n\t\tit('saves torrent to output directory', () => {\n\t\t\tconst name = 'test-torrent'\n\t\t\tconst data = Buffer.from('torrent data')\n\n\t\t\tconst path = saveTorrentToOutput(TEST_INSTANCE, name, HASH_1, data)\n\n\t\t\texpect(path).toContain('test-torrent')\n\t\t\texpect(path).toContain('.torrent')\n\t\t\texpect(existsSync(path)).toBe(true)\n\t\t})\n\n\t\tit('sanitizes filename', () => {\n\t\t\tconst name = 'test/torrent:with*bad?chars'\n\t\t\tconst data = Buffer.from('data')\n\n\t\t\tconst path = saveTorrentToOutput(TEST_INSTANCE, name, HASH_2, data)\n\t\t\tconst filename = path.split('/').pop()!\n\n\t\t\texpect(filename).not.toContain(':')\n\t\t\texpect(filename).not.toContain('*')\n\t\t\texpect(filename).not.toContain('?')\n\t\t\texpect(filename).toContain('test_torrent_with_bad_chars')\n\t\t})\n\t})\n\n\tdescribe('clearOutputForInstance', () => {\n\t\tit('clears output directory for instance', () => {\n\t\t\tsaveTorrentToOutput(TEST_INSTANCE, 'torrent1', HASH_1, Buffer.from('data1'))\n\t\t\tsaveTorrentToOutput(TEST_INSTANCE, 'torrent2', HASH_2, Buffer.from('data2'))\n\n\t\t\tconst cleared = clearOutputForInstance(TEST_INSTANCE)\n\n\t\t\texpect(cleared).toBe(2)\n\t\t})\n\t})\n\n\tdescribe('getCacheStats', () => {\n\t\tit('returns correct stats for cached torrents', () => {\n\t\t\tcacheTorrent(TEST_INSTANCE, HASH_1, Buffer.from('12345'))\n\t\t\tcacheTorrent(TEST_INSTANCE, HASH_2, Buffer.from('1234567890'))\n\n\t\t\tconst stats = getCacheStats(TEST_INSTANCE)\n\n\t\t\texpect(stats.count).toBe(2)\n\t\t\texpect(stats.totalSize).toBe(15)\n\t\t})\n\n\t\tit('returns zero stats for empty cache', () => {\n\t\t\tconst stats = getCacheStats(77777)\n\n\t\t\texpect(stats.count).toBe(0)\n\t\t\texpect(stats.totalSize).toBe(0)\n\t\t})\n\t})\n\n\tdescribe('getOutputStats', () => {\n\t\tit('returns correct stats for output files', () => {\n\t\t\tsaveTorrentToOutput(TEST_INSTANCE, 'torrent1', HASH_1, Buffer.from('data'))\n\t\t\tsaveTorrentToOutput(TEST_INSTANCE, 'torrent2', HASH_2, Buffer.from('moredata'))\n\n\t\t\tconst stats = getOutputStats(TEST_INSTANCE)\n\n\t\t\texpect(stats.count).toBe(2)\n\t\t\texpect(stats.files.length).toBe(2)\n\t\t})\n\n\t\tit('returns empty stats for no output', () => {\n\t\t\tconst stats = getOutputStats(66666)\n\n\t\t\texpect(stats.count).toBe(0)\n\t\t\texpect(stats.files).toEqual([])\n\t\t})\n\t})\n\n})\n"
  },
  {
    "path": "__tests__/server/crossSeedMatcher.test.ts",
    "content": "import { describe, it, expect, vi } from 'vitest'\n\nvi.mock('../../src/server/db', () => ({\n\tdb: {\n\t\texec: vi.fn(),\n\t\trun: vi.fn(),\n\t\tquery: vi.fn(() => ({ get: vi.fn(), all: vi.fn(() => []) })),\n\t},\n\tCrossSeedDecisionType: {\n\t\tMATCH: 'MATCH',\n\t\tMATCH_SIZE_ONLY: 'MATCH_SIZE_ONLY',\n\t\tSIZE_MISMATCH: 'SIZE_MISMATCH',\n\t\tFILE_TREE_MISMATCH: 'FILE_TREE_MISMATCH',\n\t\tALREADY_EXISTS: 'ALREADY_EXISTS',\n\t\tDOWNLOAD_FAILED: 'DOWNLOAD_FAILED',\n\t\tNO_DOWNLOAD_LINK: 'NO_DOWNLOAD_LINK',\n\t\tBLOCKED_RELEASE: 'BLOCKED_RELEASE',\n\t},\n\tBlocklistType: {\n\t\tNAME: 'name',\n\t\tNAME_REGEX: 'nameRegex',\n\t\tFOLDER: 'folder',\n\t\tFOLDER_REGEX: 'folderRegex',\n\t\tCATEGORY: 'category',\n\t\tTAG: 'tag',\n\t\tTRACKER: 'tracker',\n\t\tINFOHASH: 'infoHash',\n\t\tSIZE_BELOW: 'sizeBelow',\n\t\tSIZE_ABOVE: 'sizeAbove',\n\t\tLEGACY: 'legacy',\n\t},\n}))\n\nimport {\n\tmatchTorrentsBySizes,\n\tpreFilterCandidate,\n\ttype FileInfo,\n} from '../../src/server/utils/crossSeedMatcher'\nimport { CrossSeedDecisionType } from '../../src/server/db'\n\ndescribe('crossSeedMatcher', () => {\n\tdescribe('matchTorrentsBySizes', () => {\n\t\tdescribe('exact matches', () => {\n\t\t\tit('matches identical single file torrents', () => {\n\t\t\t\tconst source: FileInfo[] = [{ name: 'movie.mkv', size: 1000000 }]\n\t\t\t\tconst candidate: FileInfo[] = [{ name: 'movie.mkv', size: 1000000 }]\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(true)\n\t\t\t\texpect(result.decision).toBe(CrossSeedDecisionType.MATCH)\n\t\t\t})\n\n\t\t\tit('matches multi-file torrents with same files', () => {\n\t\t\t\tconst source: FileInfo[] = [\n\t\t\t\t\t{ name: 'video.mkv', size: 5000000 },\n\t\t\t\t\t{ name: 'subs.srt', size: 50000 },\n\t\t\t\t\t{ name: 'info.nfo', size: 1000 },\n\t\t\t\t]\n\t\t\t\tconst candidate: FileInfo[] = [\n\t\t\t\t\t{ name: 'video.mkv', size: 5000000 },\n\t\t\t\t\t{ name: 'subs.srt', size: 50000 },\n\t\t\t\t\t{ name: 'info.nfo', size: 1000 },\n\t\t\t\t]\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(true)\n\t\t\t\texpect(result.decision).toBe(CrossSeedDecisionType.MATCH)\n\t\t\t})\n\t\t})\n\n\t\tdescribe('flexible matching - different names, same sizes', () => {\n\t\t\tit('matches when file names differ but sizes match', () => {\n\t\t\t\tconst source: FileInfo[] = [{ name: 'Movie.2024.1080p.mkv', size: 5000000 }]\n\t\t\t\tconst candidate: FileInfo[] = [{ name: 'different-name.mkv', size: 5000000 }]\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(true)\n\t\t\t\texpect(result.decision).toBe(CrossSeedDecisionType.MATCH_SIZE_ONLY)\n\t\t\t})\n\n\t\t\tit('matches multi-file with different names but same sizes', () => {\n\t\t\t\tconst source: FileInfo[] = [\n\t\t\t\t\t{ name: 'ep01.mkv', size: 500000 },\n\t\t\t\t\t{ name: 'ep02.mkv', size: 500000 },\n\t\t\t\t\t{ name: 'ep03.mkv', size: 500000 },\n\t\t\t\t]\n\t\t\t\tconst candidate: FileInfo[] = [\n\t\t\t\t\t{ name: 's01e01.mkv', size: 500000 },\n\t\t\t\t\t{ name: 's01e02.mkv', size: 500000 },\n\t\t\t\t\t{ name: 's01e03.mkv', size: 500000 },\n\t\t\t\t]\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(true)\n\t\t\t})\n\t\t})\n\n\t\tdescribe('mismatches', () => {\n\t\t\tit('rejects when candidate file size not found in searchee', () => {\n\t\t\t\tconst source: FileInfo[] = [\n\t\t\t\t\t{ name: 'file1.mkv', size: 1000 },\n\t\t\t\t\t{ name: 'file2.mkv', size: 1000 },\n\t\t\t\t]\n\t\t\t\tconst candidate: FileInfo[] = [{ name: 'file1.mkv', size: 2000 }]\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(false)\n\t\t\t\texpect(result.decision).toBe(CrossSeedDecisionType.SIZE_MISMATCH)\n\t\t\t})\n\n\t\t\tit('allows searchee to have extra files (candidate subset of searchee)', () => {\n\t\t\t\tconst source: FileInfo[] = [\n\t\t\t\t\t{ name: 'file1.mkv', size: 1000 },\n\t\t\t\t\t{ name: 'file2.mkv', size: 2000 },\n\t\t\t\t\t{ name: 'extra.nfo', size: 500 },\n\t\t\t\t]\n\t\t\t\tconst candidate: FileInfo[] = [\n\t\t\t\t\t{ name: 'file1.mkv', size: 1000 },\n\t\t\t\t\t{ name: 'file2.mkv', size: 2000 },\n\t\t\t\t]\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(true)\n\t\t\t\texpect(result.decision).toBe(CrossSeedDecisionType.MATCH)\n\t\t\t})\n\n\t\t\tit('rejects when sizes do not match', () => {\n\t\t\t\tconst source: FileInfo[] = [{ name: 'movie.mkv', size: 1000000 }]\n\t\t\t\tconst candidate: FileInfo[] = [{ name: 'movie.mkv', size: 999999 }]\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(false)\n\t\t\t\texpect(result.decision).toBe(CrossSeedDecisionType.SIZE_MISMATCH)\n\t\t\t})\n\n\t\t\tit('rejects when multi-file sizes partially match', () => {\n\t\t\t\tconst source: FileInfo[] = [\n\t\t\t\t\t{ name: 'ep01.mkv', size: 500000 },\n\t\t\t\t\t{ name: 'ep02.mkv', size: 500000 },\n\t\t\t\t]\n\t\t\t\tconst candidate: FileInfo[] = [\n\t\t\t\t\t{ name: 'ep01.mkv', size: 500000 },\n\t\t\t\t\t{ name: 'ep02.mkv', size: 499999 },\n\t\t\t\t]\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(false)\n\t\t\t\texpect(result.decision).toBe(CrossSeedDecisionType.SIZE_MISMATCH)\n\t\t\t})\n\t\t})\n\n\t\tdescribe('edge cases', () => {\n\t\t\tit('rejects empty file arrays', () => {\n\t\t\t\tconst source: FileInfo[] = []\n\t\t\t\tconst candidate: FileInfo[] = []\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(false)\n\t\t\t})\n\n\t\t\tit('handles files with duplicate sizes correctly', () => {\n\t\t\t\tconst source: FileInfo[] = [\n\t\t\t\t\t{ name: 'ep01.mkv', size: 500000 },\n\t\t\t\t\t{ name: 'ep02.mkv', size: 500000 },\n\t\t\t\t\t{ name: 'ep03.mkv', size: 500000 },\n\t\t\t\t]\n\t\t\t\tconst candidate: FileInfo[] = [\n\t\t\t\t\t{ name: 'different1.mkv', size: 500000 },\n\t\t\t\t\t{ name: 'different2.mkv', size: 500000 },\n\t\t\t\t\t{ name: 'different3.mkv', size: 500000 },\n\t\t\t\t]\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(true)\n\t\t\t})\n\n\t\t\tit('handles very large file sizes', () => {\n\t\t\t\tconst largeSize = 50 * 1024 * 1024 * 1024\n\t\t\t\tconst source: FileInfo[] = [{ name: 'large.mkv', size: largeSize }]\n\t\t\t\tconst candidate: FileInfo[] = [{ name: 'large.mkv', size: largeSize }]\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(true)\n\t\t\t})\n\n\t\t\tit('handles zero-size files', () => {\n\t\t\t\tconst source: FileInfo[] = [\n\t\t\t\t\t{ name: 'empty.txt', size: 0 },\n\t\t\t\t\t{ name: 'video.mkv', size: 1000000 },\n\t\t\t\t]\n\t\t\t\tconst candidate: FileInfo[] = [\n\t\t\t\t\t{ name: 'empty.txt', size: 0 },\n\t\t\t\t\t{ name: 'video.mkv', size: 1000000 },\n\t\t\t\t]\n\n\t\t\t\tconst result = matchTorrentsBySizes(source, candidate)\n\t\t\t\texpect(result.matched).toBe(true)\n\t\t\t})\n\t\t})\n\t})\n\n\tdescribe('preFilterCandidate', () => {\n\t\tdescribe('passing filters', () => {\n\t\t\tit('passes when sizes are within threshold', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie 2024', 1000000, 'Movie 2024', 1000000)\n\t\t\t\texpect(result.pass).toBe(true)\n\t\t\t})\n\n\t\t\tit('passes when sizes are close (within 2%)', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie', 1000000, 'Movie', 1019000)\n\t\t\t\texpect(result.pass).toBe(true)\n\t\t\t})\n\n\t\t\tit('passes with different name formatting', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie.2024.1080p', 1000000, 'Movie 2024 1080p', 1000000)\n\t\t\t\texpect(result.pass).toBe(true)\n\t\t\t})\n\n\t\t\tit('passes when release group is missing on one side', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie.2024.1080p', 1000000, 'Movie.2024.1080p-GROUP', 1000000)\n\t\t\t\texpect(result.pass).toBe(true)\n\t\t\t})\n\n\t\t\tit('passes when source tag is missing on one side', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie.2024.1080p', 1000000, 'Movie.2024.1080p.WEB-DL.NF', 1000000)\n\t\t\t\texpect(result.pass).toBe(true)\n\t\t\t})\n\t\t})\n\n\t\tdescribe('failing filters', () => {\n\t\t\tit('fails when resolution differs', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie.2024.1080p', 1000000, 'Movie.2024.720p', 1000000)\n\t\t\t\texpect(result.pass).toBe(false)\n\t\t\t\texpect(result.reason?.toLowerCase()).toContain('resolution')\n\t\t\t})\n\n\t\t\tit('fails when release group differs', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie.2024.1080p-GROUPA', 1000000, 'Movie.2024.1080p-GROUPB', 1000000)\n\t\t\t\texpect(result.pass).toBe(false)\n\t\t\t\texpect(result.reason?.toLowerCase()).toContain('group')\n\t\t\t})\n\n\t\t\tit('fails when source tag differs', () => {\n\t\t\t\tconst result = preFilterCandidate(\n\t\t\t\t\t'Movie.2024.1080p.AMZN.WEB-DL.x264-GROUP',\n\t\t\t\t\t1000000,\n\t\t\t\t\t'Movie.2024.1080p.NF.WEB-DL.x264-GROUP',\n\t\t\t\t\t1000000\n\t\t\t\t)\n\t\t\t\texpect(result.pass).toBe(false)\n\t\t\t\texpect(result.reason?.toLowerCase()).toContain('source')\n\t\t\t})\n\n\t\t\tit('fails when proper/repack mismatch', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie.2024.1080p.PROPER-GROUP', 1000000, 'Movie.2024.1080p-GROUP', 1000000)\n\t\t\t\texpect(result.pass).toBe(false)\n\t\t\t\texpect(result.reason?.toLowerCase()).toContain('proper')\n\t\t\t})\n\n\t\t\tit('fails when sizes differ too much', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie', 1000000, 'Movie', 2000000)\n\t\t\t\texpect(result.pass).toBe(false)\n\t\t\t\texpect(result.reason?.toLowerCase()).toContain('size')\n\t\t\t})\n\n\t\t\tit('fails when candidate is much smaller', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie', 1000000, 'Movie', 100000)\n\t\t\t\texpect(result.pass).toBe(false)\n\t\t\t})\n\t\t})\n\n\t\tdescribe('edge cases', () => {\n\t\t\tit('handles zero source size with zero candidate', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie', 0, 'Movie', 0)\n\t\t\t\texpect(result.pass).toBe(true)\n\t\t\t})\n\n\t\t\tit('handles zero source size with non-zero candidate without dividing by zero', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie', 0, 'Movie', 1000)\n\t\t\t\texpect(result.pass).toBe(false)\n\t\t\t\texpect(result.reason).toBeDefined()\n\t\t\t\texpect(result.reason).not.toContain('Infinity')\n\t\t\t\texpect(result.reason).toContain('100.0%')\n\t\t\t})\n\n\t\t\tit('handles missing candidate size', () => {\n\t\t\t\tconst result = preFilterCandidate('Movie', 1000000, 'Movie', undefined as unknown as number)\n\t\t\t\texpect(result.pass).toBe(true)\n\t\t\t})\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "__tests__/server/crossSeedScheduler.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'\n\nvi.mock('../../src/server/db', () => ({\n\tdb: {\n\t\tquery: vi.fn(() => ({\n\t\t\tget: vi.fn(),\n\t\t\tall: vi.fn(() => []),\n\t\t})),\n\t\trun: vi.fn(),\n\t},\n}))\n\nvi.mock('../../src/server/utils/crossSeedWorker', () => ({\n\trunCrossSeedScan: vi.fn(),\n}))\n\nvi.mock('../../src/server/utils/logger', () => ({\n\tlog: {\n\t\tinfo: vi.fn(),\n\t\terror: vi.fn(),\n\t\twarn: vi.fn(),\n\t},\n}))\n\nimport {\n\tisInstanceRunning,\n\ttriggerManualScan,\n\tstopScheduler,\n} from '../../src/server/utils/crossSeedScheduler'\nimport { runCrossSeedScan } from '../../src/server/utils/crossSeedWorker'\n\nconst mockRunCrossSeedScan = runCrossSeedScan as Mock\n\ndescribe('crossSeedScheduler', () => {\n\tbeforeEach(() => {\n\t\tvi.clearAllMocks()\n\t\tstopScheduler()\n\t})\n\n\tafterEach(() => {\n\t\tstopScheduler()\n\t})\n\n\tdescribe('isInstanceRunning', () => {\n\t\tit('returns false when no scan is running', () => {\n\t\t\texpect(isInstanceRunning(1)).toBe(false)\n\t\t})\n\n\t\tit('returns true when a scan is in progress', async () => {\n\t\t\tlet resolvePromise: () => void\n\t\t\tconst scanPromise = new Promise<void>((resolve) => {\n\t\t\t\tresolvePromise = resolve\n\t\t\t})\n\n\t\t\tmockRunCrossSeedScan.mockImplementation(() => scanPromise)\n\n\t\t\tconst triggerPromise = triggerManualScan(1, 1, false).catch(() => {})\n\n\t\t\tawait new Promise((r) => setTimeout(r, 10))\n\t\t\texpect(isInstanceRunning(1)).toBe(true)\n\n\t\t\tresolvePromise!()\n\t\t\tawait triggerPromise\n\t\t\texpect(isInstanceRunning(1)).toBe(false)\n\t\t})\n\t})\n\n\tdescribe('triggerManualScan', () => {\n\t\tit('prevents concurrent scans on the same instance', async () => {\n\t\t\tlet resolveFirst: (value: unknown) => void\n\t\t\tconst firstScanPromise = new Promise((resolve) => {\n\t\t\t\tresolveFirst = resolve\n\t\t\t})\n\n\t\t\tmockRunCrossSeedScan.mockImplementationOnce(() => firstScanPromise)\n\n\t\t\tconst firstTrigger = triggerManualScan(1, 1, false)\n\n\t\t\tawait new Promise((r) => setTimeout(r, 10))\n\n\t\t\tawait expect(triggerManualScan(1, 1, false)).rejects.toThrow('Scan already in progress')\n\n\t\t\tresolveFirst!({ instanceId: 1 })\n\t\t\tawait firstTrigger\n\t\t})\n\n\t\tit('allows concurrent scans on different instances', async () => {\n\t\t\tlet resolveFirst: (value: unknown) => void\n\t\t\tlet resolveSecond: (value: unknown) => void\n\n\t\t\tmockRunCrossSeedScan\n\t\t\t\t.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))\n\t\t\t\t.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))\n\n\t\t\tconst firstTrigger = triggerManualScan(1, 1, false)\n\t\t\tawait new Promise((r) => setTimeout(r, 10))\n\n\t\t\tconst secondTrigger = triggerManualScan(2, 1, false)\n\t\t\tawait new Promise((r) => setTimeout(r, 10))\n\n\t\t\texpect(isInstanceRunning(1)).toBe(true)\n\t\t\texpect(isInstanceRunning(2)).toBe(true)\n\n\t\t\tresolveFirst!({ instanceId: 1 })\n\t\t\tresolveSecond!({ instanceId: 2 })\n\n\t\t\tawait firstTrigger\n\t\t\tawait secondTrigger\n\t\t})\n\n\t\tit('clears running state on error', async () => {\n\t\t\tmockRunCrossSeedScan.mockRejectedValueOnce(new Error('Scan failed'))\n\n\t\t\tawait expect(triggerManualScan(1, 1, false)).rejects.toThrow('Scan failed')\n\t\t\texpect(isInstanceRunning(1)).toBe(false)\n\t\t})\n\n\t\tit('clears running state on success', async () => {\n\t\t\tmockRunCrossSeedScan.mockResolvedValueOnce({\n\t\t\t\tinstanceId: 1,\n\t\t\t\ttorrentsTotal: 10,\n\t\t\t\ttorrentsScanned: 5,\n\t\t\t\ttorrentsSkipped: 5,\n\t\t\t\tmatchesFound: 1,\n\t\t\t\ttorrentsAdded: 1,\n\t\t\t\terrors: [],\n\t\t\t\tdryRun: false,\n\t\t\t\tstartedAt: Date.now(),\n\t\t\t\tcompletedAt: Date.now(),\n\t\t\t})\n\n\t\t\tawait triggerManualScan(1, 1, false)\n\t\t\texpect(isInstanceRunning(1)).toBe(false)\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "__tests__/server/crossSeedWorker.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'\n\nconst { state, db, fsMocks } = vi.hoisted(() => {\n\tconst state = {\n\t\tconfig: null as null | {\n\t\t\tinstance_id: number\n\t\t\tenabled: number\n\t\t\tinterval_hours: number\n\t\t\tdelay_seconds: number\n\t\t\tdry_run: number\n\t\t\tcategory_suffix: string\n\t\t\ttag: string\n\t\t\tskip_recheck: number\n\t\t\tintegration_id: number | null\n\t\t\tindexer_ids: string | null\n\t\t\tmatch_mode: 'strict' | 'flexible'\n\t\t\tlink_dir: string | null\n\t\t\tblocklist: string | null\n\t\t\tinclude_single_episodes: number\n\t\t\tlast_run: number | null\n\t\t\tnext_run: number | null\n\t\t\tupdated_at: number\n\t\t},\n\t\tintegration: null as null | {\n\t\t\tid: number\n\t\t\tuser_id: number\n\t\t\ttype: string\n\t\t\tlabel: string\n\t\t\turl: string\n\t\t\tapi_key_encrypted: string\n\t\t\tcreated_at: number\n\t\t},\n\t\tinstance: null as null | {\n\t\t\tid: number\n\t\t\tuser_id: number\n\t\t\tlabel: string\n\t\t\turl: string\n\t\t\tqbt_username: string | null\n\t\t\tqbt_password_encrypted: string | null\n\t\t\tskip_auth: number\n\t\t\tcreated_at: number\n\t\t},\n\t\tsearchees: new Map<string, {\n\t\t\tid: number\n\t\t\tinstance_id: number\n\t\t\ttorrent_hash: string\n\t\t\ttorrent_name: string\n\t\t\ttotal_size: number\n\t\t\tfile_count: number\n\t\t\tfile_sizes: string\n\t\t\tfirst_searched: number\n\t\t\tlast_searched: number\n\t\t}>(),\n\t\tdecisions: new Map<string, {\n\t\t\tsearchee_id?: number\n\t\t\tguid?: string\n\t\t\tinfo_hash: string | null\n\t\t\tcandidate_name?: string\n\t\t\tcandidate_size?: number\n\t\t\tdecision: string\n\t\t\tfirst_seen?: number\n\t\t\tlast_seen: number\n\t\t}>(),\n\t\tnextSearcheeId: 1,\n\t}\n\n\tconst db = {\n\t\tquery: vi.fn((sql: string) => ({\n\t\t\tget: (...params: unknown[]) => {\n\t\t\t\tif (sql.includes('FROM cross_seed_config')) {\n\t\t\t\t\treturn state.config && state.config.instance_id === params[0] ? state.config : undefined\n\t\t\t\t}\n\t\t\t\tif (sql.includes('FROM integrations')) {\n\t\t\t\t\treturn state.integration && state.integration.id === params[0] && state.integration.user_id === params[1]\n\t\t\t\t\t\t? state.integration\n\t\t\t\t\t\t: undefined\n\t\t\t\t}\n\t\t\t\tif (sql.includes('FROM instances')) {\n\t\t\t\t\treturn state.instance && state.instance.id === params[0] && state.instance.user_id === params[1]\n\t\t\t\t\t\t? state.instance\n\t\t\t\t\t\t: undefined\n\t\t\t\t}\n\t\t\t\tif (sql.includes('FROM cross_seed_searchee') && sql.includes('torrent_hash = ?')) {\n\t\t\t\t\tconst key = `${params[0]}:${params[1]}`\n\t\t\t\t\tconst row = state.searchees.get(key)\n\t\t\t\t\treturn row ? { id: row.id } : undefined\n\t\t\t\t}\n\t\t\t\tif (sql.includes('FROM cross_seed_decision') && sql.includes('guid = ?')) {\n\t\t\t\t\treturn state.decisions.get(`${params[0]}:${params[1]}`)\n\t\t\t\t}\n\t\t\t\treturn undefined\n\t\t\t},\n\t\t\tall: (...params: unknown[]) => {\n\t\t\t\tif (sql.includes('FROM cross_seed_searchee')) {\n\t\t\t\t\treturn Array.from(state.searchees.values()).filter((row) => row.instance_id === params[0])\n\t\t\t\t}\n\t\t\t\treturn []\n\t\t\t},\n\t\t})),\n\t\trun: vi.fn((sql: string, params: unknown[]) => {\n\t\t\tif (sql.startsWith('INSERT INTO cross_seed_searchee')) {\n\t\t\t\tconst [instanceId, hash, name, size, fileCount, fileSizesJson] = params\n\t\t\t\tconst key = `${instanceId}:${hash}`\n\t\t\t\tlet row = state.searchees.get(key)\n\t\t\t\tconst now = Math.floor(Date.now() / 1000)\n\t\t\t\tif (!row) {\n\t\t\t\t\trow = {\n\t\t\t\t\t\tid: state.nextSearcheeId++,\n\t\t\t\t\t\tinstance_id: instanceId as number,\n\t\t\t\t\t\ttorrent_hash: hash as string,\n\t\t\t\t\t\ttorrent_name: name as string,\n\t\t\t\t\t\ttotal_size: size as number,\n\t\t\t\t\t\tfile_count: fileCount as number,\n\t\t\t\t\t\tfile_sizes: fileSizesJson as string,\n\t\t\t\t\t\tfirst_searched: now,\n\t\t\t\t\t\tlast_searched: now,\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\trow.last_searched = now\n\t\t\t\t}\n\t\t\t\tstate.searchees.set(key, row)\n\t\t\t\treturn { changes: 1, lastInsertRowid: row.id }\n\t\t\t}\n\t\t\tif (sql.startsWith('INSERT INTO cross_seed_decision')) {\n\t\t\t\tconst [searcheeId, guid, info_hash, candidate_name, candidate_size, decision] = params\n\t\t\t\tconst key = `${searcheeId}:${guid}`\n\t\t\t\tconst now = Math.floor(Date.now() / 1000)\n\t\t\t\tconst existing = state.decisions.get(key)\n\t\t\t\tif (existing) {\n\t\t\t\t\texisting.info_hash = info_hash as string | null\n\t\t\t\t\texisting.decision = decision as string\n\t\t\t\t\texisting.last_seen = now\n\t\t\t\t} else {\n\t\t\t\t\tstate.decisions.set(key, {\n\t\t\t\t\t\tsearchee_id: searcheeId as number,\n\t\t\t\t\t\tguid: guid as string,\n\t\t\t\t\t\tinfo_hash: info_hash as string | null,\n\t\t\t\t\t\tcandidate_name: candidate_name as string,\n\t\t\t\t\t\tcandidate_size: candidate_size as number,\n\t\t\t\t\t\tdecision: decision as string,\n\t\t\t\t\t\tfirst_seen: now,\n\t\t\t\t\t\tlast_seen: now,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\treturn { changes: 1 }\n\t\t\t}\n\t\t\tif (sql.startsWith('UPDATE cross_seed_decision SET last_seen')) {\n\t\t\t\tconst [lastSeen, searcheeId, guid] = params\n\t\t\t\tconst key = `${searcheeId}:${guid}`\n\t\t\t\tconst entry = state.decisions.get(key)\n\t\t\t\tif (entry) entry.last_seen = lastSeen as number\n\t\t\t\treturn { changes: entry ? 1 : 0 }\n\t\t\t}\n\t\t\tif (sql.startsWith('UPDATE cross_seed_config SET last_run')) {\n\t\t\t\tconst [lastRun, instanceId] = params\n\t\t\t\tif (state.config && state.config.instance_id === instanceId) {\n\t\t\t\t\tstate.config.last_run = lastRun as number\n\t\t\t\t}\n\t\t\t\treturn { changes: 1 }\n\t\t\t}\n\t\t\treturn { changes: 0 }\n\t\t}),\n\t}\n\n\tconst fsMocks = {\n\t\tlink: vi.fn().mockResolvedValue(undefined),\n\t\tmkdir: vi.fn().mockResolvedValue(undefined),\n\t\tstat: vi.fn().mockResolvedValue({ dev: 1 }),\n\t\taccess: vi.fn().mockResolvedValue(undefined),\n\t}\n\n\treturn { state, db, fsMocks }\n})\n\nvi.mock('../../src/server/db', () => ({\n\tdb,\n\tCrossSeedDecisionType: {\n\t\tMATCH: 'MATCH',\n\t\tMATCH_SIZE_ONLY: 'MATCH_SIZE_ONLY',\n\t\tSIZE_MISMATCH: 'SIZE_MISMATCH',\n\t\tFILE_COUNT_MISMATCH: 'FILE_COUNT_MISMATCH',\n\t\tALREADY_EXISTS: 'ALREADY_EXISTS',\n\t\tDOWNLOAD_FAILED: 'DOWNLOAD_FAILED',\n\t\tNO_DOWNLOAD_LINK: 'NO_DOWNLOAD_LINK',\n\t},\n\tMatchMode: {\n\t\tSTRICT: 'strict',\n\t\tFLEXIBLE: 'flexible',\n\t},\n}))\n\nvi.mock('../../src/server/utils/qbt', () => ({\n\tloginToQbt: vi.fn(),\n}))\n\nvi.mock('../../src/server/utils/crypto', () => ({\n\tdecrypt: vi.fn(() => 'apikey'),\n}))\n\nvi.mock('../../src/server/utils/torznab', () => ({\n\tsearchAllIndexers: vi.fn(),\n\tdownloadTorrentDirect: vi.fn(),\n}))\n\nvi.mock('../../src/server/utils/crossSeedCache', () => ({\n\tcacheTorrent: vi.fn(),\n\tsaveTorrentToOutput: vi.fn(() => '/tmp/output.torrent'),\n}))\n\nvi.mock('fs/promises', () => ({\n\t...fsMocks,\n\tdefault: fsMocks,\n}))\n\nvi.mock('../../src/server/utils/fetch', () => ({\n\tfetchWithTls: vi.fn(),\n}))\n\nvi.mock('../../src/server/utils/logger', () => ({\n\tlog: {\n\t\tinfo: vi.fn(),\n\t\twarn: vi.fn(),\n\t\terror: vi.fn(),\n\t},\n}))\n\nimport { runCrossSeedScan } from '../../src/server/utils/crossSeedWorker'\nimport { loginToQbt } from '../../src/server/utils/qbt'\nimport { searchAllIndexers, downloadTorrentDirect } from '../../src/server/utils/torznab'\nimport { cacheTorrent, saveTorrentToOutput } from '../../src/server/utils/crossSeedCache'\nimport { fetchWithTls } from '../../src/server/utils/fetch'\n\nconst mockLoginToQbt = loginToQbt as Mock\nconst mockSearchAllIndexers = searchAllIndexers as Mock\nconst mockDownloadTorrentDirect = downloadTorrentDirect as Mock\nconst mockCacheTorrent = cacheTorrent as Mock\nconst mockSaveTorrentToOutput = saveTorrentToOutput as Mock\nconst mockFetchWithTls = fetchWithTls as Mock\n\nfunction makeTorrentData(name: string, length: number): Buffer {\n\treturn Buffer.from(`d4:infod4:name${name.length}:${name}6:lengthi${length}eee`)\n}\n\ntype BencodeValue = number | string | Buffer | BencodeValue[] | { [key: string]: BencodeValue }\n\nfunction encodeBencode(data: BencodeValue): Buffer {\n\tif (typeof data === 'number') {\n\t\treturn Buffer.from(`i${data}e`)\n\t}\n\tif (Buffer.isBuffer(data)) {\n\t\treturn Buffer.concat([Buffer.from(`${data.length}:`), data])\n\t}\n\tif (typeof data === 'string') {\n\t\tconst buf = Buffer.from(data)\n\t\treturn Buffer.concat([Buffer.from(`${buf.length}:`), buf])\n\t}\n\tif (Array.isArray(data)) {\n\t\tconst parts: Buffer[] = [Buffer.from('l')]\n\t\tfor (const item of data) {\n\t\t\tparts.push(encodeBencode(item))\n\t\t}\n\t\tparts.push(Buffer.from('e'))\n\t\treturn Buffer.concat(parts)\n\t}\n\tconst parts: Buffer[] = [Buffer.from('d')]\n\tconst keys = Object.keys(data).sort()\n\tfor (const key of keys) {\n\t\tparts.push(encodeBencode(key))\n\t\tparts.push(encodeBencode(data[key]))\n\t}\n\tparts.push(Buffer.from('e'))\n\treturn Buffer.concat(parts)\n}\n\nfunction makeMultiFileTorrentData(\n\tname: string,\n\tfiles: Array<{ path: string[]; length: number }>\n): Buffer {\n\treturn encodeBencode({\n\t\tinfo: {\n\t\t\tname,\n\t\t\tfiles: files.map((file) => ({ length: file.length, path: file.path })),\n\t\t},\n\t})\n}\n\nfunction resetState() {\n\tstate.config = {\n\t\tinstance_id: 1,\n\t\tenabled: 1,\n\t\tinterval_hours: 24,\n\t\tdelay_seconds: 0,\n\t\tdry_run: 0,\n\t\tcategory_suffix: '_cross-seed',\n\t\ttag: 'cross-seed',\n\t\tskip_recheck: 0,\n\t\tintegration_id: 10,\n\t\tindexer_ids: null,\n\t\tmatch_mode: 'strict',\n\t\tlink_dir: null,\n\t\tblocklist: null,\n\t\tinclude_single_episodes: 0,\n\t\tlast_run: null,\n\t\tnext_run: null,\n\t\tupdated_at: Math.floor(Date.now() / 1000),\n\t}\n\tstate.integration = {\n\t\tid: 10,\n\t\tuser_id: 1,\n\t\ttype: 'prowlarr',\n\t\tlabel: 'Prowlarr',\n\t\turl: 'http://prowlarr',\n\t\tapi_key_encrypted: 'encrypted',\n\t\tcreated_at: Math.floor(Date.now() / 1000),\n\t}\n\tstate.instance = {\n\t\tid: 1,\n\t\tuser_id: 1,\n\t\tlabel: 'QBT',\n\t\turl: 'http://qbt',\n\t\tqbt_username: 'user',\n\t\tqbt_password_encrypted: 'pass',\n\t\tskip_auth: 0,\n\t\tcreated_at: Math.floor(Date.now() / 1000),\n\t}\n\tstate.searchees.clear()\n\tstate.decisions.clear()\n\tstate.nextSearcheeId = 1\n}\n\nfunction mockQbtResponses(torrents: unknown[], files: unknown[]) {\n\tlet addedTorrent = false\n\tmockFetchWithTls.mockImplementation((url: string) => {\n\t\tif (url.endsWith('/api/v2/app/version')) {\n\t\t\treturn Promise.resolve(new Response('v5.0.0', { status: 200 }))\n\t\t}\n\t\tif (url.includes('/api/v2/torrents/info')) {\n\t\t\tconst hashMatch = url.match(/hashes=([a-fA-F0-9]+)/)\n\t\t\tif (hashMatch) {\n\t\t\t\tconst queriedHash = hashMatch[1].toUpperCase()\n\t\t\t\tconst found = (torrents as { hash: string }[]).find((t) => t.hash.toUpperCase() === queriedHash)\n\t\t\t\tif (found) {\n\t\t\t\t\treturn Promise.resolve(new Response(JSON.stringify([found]), { status: 200 }))\n\t\t\t\t}\n\t\t\t\tif (addedTorrent) {\n\t\t\t\t\treturn Promise.resolve(\n\t\t\t\t\t\tnew Response(\n\t\t\t\t\t\t\tJSON.stringify([\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\thash: queriedHash,\n\t\t\t\t\t\t\t\t\tname: 'Added',\n\t\t\t\t\t\t\t\t\tstate: 'pausedUP',\n\t\t\t\t\t\t\t\t\tamount_left: 0,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t\t\t{ status: 200 }\n\t\t\t\t\t\t)\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\treturn Promise.resolve(new Response('[]', { status: 200 }))\n\t\t\t}\n\t\t\treturn Promise.resolve(new Response(JSON.stringify(torrents), { status: 200 }))\n\t\t}\n\t\tif (url.includes('/api/v2/torrents/files')) {\n\t\t\treturn Promise.resolve(new Response(JSON.stringify(files), { status: 200 }))\n\t\t}\n\t\tif (url.endsWith('/api/v2/torrents/add')) {\n\t\t\taddedTorrent = true\n\t\t\treturn Promise.resolve(new Response('Ok.', { status: 200 }))\n\t\t}\n\t\tif (url.includes('/api/v2/torrents/stop') || url.includes('/api/v2/torrents/start') || url.includes('/api/v2/torrents/recheck')) {\n\t\t\treturn Promise.resolve(new Response('', { status: 200 }))\n\t\t}\n\t\treturn Promise.resolve(new Response('Not Found', { status: 404 }))\n\t})\n}\n\ndescribe('crossSeedWorker', () => {\n\tbeforeEach(() => {\n\t\tvi.clearAllMocks()\n\t\tresetState()\n\t\tmockLoginToQbt.mockResolvedValue({ success: true, cookie: 'SID=abc' })\n\t})\n\n\tit('adds a matched torrent when not dry-run', async () => {\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH1',\n\t\t\t\tname: 'Movie.2024.1080p.mkv',\n\t\t\t\tsize: 1000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'movies',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Movie.2024.1080p.mkv',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t]\n\t\tconst files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]\n\t\tmockQbtResponses(torrents, files)\n\n\t\tmockSearchAllIndexers.mockResolvedValue([\n\t\t\t{\n\t\t\t\tguid: 'guid-1',\n\t\t\t\ttitle: 'Movie 2024 1080p',\n\t\t\t\tlink: 'http://indexer/download/1',\n\t\t\t\tsize: 1000,\n\t\t\t\tpubDate: '',\n\t\t\t\tindexer: 'Test',\n\t\t\t\tindexerId: 1,\n\t\t\t},\n\t\t])\n\n\t\tmockDownloadTorrentDirect.mockResolvedValue(makeTorrentData('Movie.2024.1080p.mkv', 1000))\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(result.matchesFound).toBe(1)\n\t\texpect(result.torrentsAdded).toBe(1)\n\t\texpect(result.torrentsScanned).toBe(1)\n\t\texpect(result.torrentsSkipped).toBe(0)\n\t\texpect(mockCacheTorrent).toHaveBeenCalledTimes(1)\n\t\texpect(mockSaveTorrentToOutput).not.toHaveBeenCalled()\n\t\texpect(mockFetchWithTls.mock.calls.some((call) => String(call[0]).endsWith('/api/v2/torrents/add'))).toBe(true)\n\t})\n\n\tit('matches multi-file torrents in strict mode using basenames', async () => {\n\t\tstate.config!.match_mode = 'strict'\n\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH2',\n\t\t\t\tname: 'Show.S01',\n\t\t\t\tsize: 3000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'shows',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Show.S01',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t]\n\t\tconst files = [\n\t\t\t{ name: 'Show.S01/E01.mkv', size: 1000 },\n\t\t\t{ name: 'Show.S01/E02.mkv', size: 2000 },\n\t\t]\n\t\tmockQbtResponses(torrents, files)\n\n\t\tmockSearchAllIndexers.mockResolvedValue([\n\t\t\t{\n\t\t\t\tguid: 'guid-2',\n\t\t\t\ttitle: 'Show S01',\n\t\t\t\tlink: 'http://indexer/download/2',\n\t\t\t\tsize: 3000,\n\t\t\t\tpubDate: '',\n\t\t\t\tindexer: 'Test',\n\t\t\t\tindexerId: 1,\n\t\t\t},\n\t\t])\n\n\t\tmockDownloadTorrentDirect.mockResolvedValue(\n\t\t\tmakeMultiFileTorrentData('Show.S01', [\n\t\t\t\t{ path: ['E01.mkv'], length: 1000 },\n\t\t\t\t{ path: ['E02.mkv'], length: 2000 },\n\t\t\t])\n\t\t)\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(result.matchesFound).toBe(1)\n\t\texpect(result.torrentsAdded).toBe(1)\n\t\texpect(mockFetchWithTls.mock.calls.some((call) => String(call[0]).endsWith('/api/v2/torrents/add'))).toBe(true)\n\t})\n\n\tit('adds size-only matches in flexible mode using hardlinks', async () => {\n\t\tstate.config!.match_mode = 'flexible'\n\t\tstate.config!.link_dir = '/links'\n\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH3',\n\t\t\t\tname: 'Movie.2024.1080p.mkv',\n\t\t\t\tsize: 1000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'movies',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Movie.2024.1080p.mkv',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t]\n\t\tconst files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]\n\t\tmockQbtResponses(torrents, files)\n\n\t\tmockSearchAllIndexers.mockResolvedValue([\n\t\t\t{\n\t\t\t\tguid: 'guid-3',\n\t\t\t\ttitle: 'Movie 2024 1080p',\n\t\t\t\tlink: 'http://indexer/download/3',\n\t\t\t\tsize: 1000,\n\t\t\t\tpubDate: '',\n\t\t\t\tindexer: 'Test',\n\t\t\t\tindexerId: 1,\n\t\t\t},\n\t\t])\n\n\t\tmockDownloadTorrentDirect.mockResolvedValue(makeTorrentData('Movie.2024.1080p.REPACK.mkv', 1000))\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(result.matchesFound).toBe(1)\n\t\texpect(result.torrentsAdded).toBe(1)\n\t\texpect(fsMocks.link.mock.calls.length).toBeGreaterThan(0)\n\t\texpect(fsMocks.link.mock.calls[0][0]).toBe('/downloads/Movie.2024.1080p.mkv')\n\t\texpect(fsMocks.link.mock.calls[0][1]).toBe('/links/Movie.2024.1080p.REPACK.mkv')\n\t})\n\n\tit('detects structure mismatch when single-file source matches multi-file candidate with folder', async () => {\n\t\tstate.config!.match_mode = 'flexible'\n\t\tstate.config!.link_dir = '/links'\n\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH4',\n\t\t\t\tname: 'Movie.2024.1080p.mkv',\n\t\t\t\tsize: 1000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'movies',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Movie.2024.1080p.mkv',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t]\n\t\tconst files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]\n\t\tmockQbtResponses(torrents, files)\n\n\t\tmockSearchAllIndexers.mockResolvedValue([\n\t\t\t{\n\t\t\t\tguid: 'guid-4',\n\t\t\t\ttitle: 'Movie (2024)',\n\t\t\t\tlink: 'http://indexer/download/4',\n\t\t\t\tsize: 1000,\n\t\t\t\tpubDate: '',\n\t\t\t\tindexer: 'Test',\n\t\t\t\tindexerId: 1,\n\t\t\t},\n\t\t])\n\n\t\tmockDownloadTorrentDirect.mockResolvedValue(\n\t\t\tmakeMultiFileTorrentData('Movie (2024)', [{ path: ['Movie.2024.1080p.mkv'], length: 1000 }])\n\t\t)\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(result.matchesFound).toBe(1)\n\t\texpect(result.torrentsAdded).toBe(1)\n\t\texpect(fsMocks.link.mock.calls.length).toBeGreaterThan(0)\n\t\texpect(fsMocks.link.mock.calls[0][1]).toBe('/links/Movie (2024)/Movie.2024.1080p.mkv')\n\t})\n\n\tit('constructs correct source paths for multi-file hardlinks', async () => {\n\t\tstate.config!.match_mode = 'flexible'\n\t\tstate.config!.link_dir = '/links'\n\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH5',\n\t\t\t\tname: 'Show.S01',\n\t\t\t\tsize: 3000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'shows',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Show.S01',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t]\n\t\tconst files = [\n\t\t\t{ name: 'Show.S01/E01.mkv', size: 1000 },\n\t\t\t{ name: 'Show.S01/E02.mkv', size: 2000 },\n\t\t]\n\t\tmockQbtResponses(torrents, files)\n\n\t\tmockSearchAllIndexers.mockResolvedValue([\n\t\t\t{\n\t\t\t\tguid: 'guid-5',\n\t\t\t\ttitle: 'Show Season 1',\n\t\t\t\tlink: 'http://indexer/download/5',\n\t\t\t\tsize: 3000,\n\t\t\t\tpubDate: '',\n\t\t\t\tindexer: 'Test',\n\t\t\t\tindexerId: 1,\n\t\t\t},\n\t\t])\n\n\t\tmockDownloadTorrentDirect.mockResolvedValue(\n\t\t\tmakeMultiFileTorrentData('Show Season 1', [\n\t\t\t\t{ path: ['Episode01.mkv'], length: 1000 },\n\t\t\t\t{ path: ['Episode02.mkv'], length: 2000 },\n\t\t\t])\n\t\t)\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(result.matchesFound).toBe(1)\n\t\texpect(result.torrentsAdded).toBe(1)\n\t\texpect(fsMocks.link.mock.calls[0][0]).toBe('/downloads/Show.S01/E01.mkv')\n\t\texpect(fsMocks.link.mock.calls[0][1]).toBe('/links/Show Season 1/Episode01.mkv')\n\t\texpect(fsMocks.link.mock.calls[1][0]).toBe('/downloads/Show.S01/E02.mkv')\n\t\texpect(fsMocks.link.mock.calls[1][1]).toBe('/links/Show Season 1/Episode02.mkv')\n\t})\n\n\tit('saves to output in dry-run mode', async () => {\n\t\tstate.config!.dry_run = 1\n\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH1',\n\t\t\t\tname: 'Movie.2024.1080p.mkv',\n\t\t\t\tsize: 1000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'movies',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Movie.2024.1080p.mkv',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t]\n\t\tconst files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]\n\t\tmockQbtResponses(torrents, files)\n\n\t\tmockSearchAllIndexers.mockResolvedValue([\n\t\t\t{\n\t\t\t\tguid: 'guid-1',\n\t\t\t\ttitle: 'Movie 2024 1080p',\n\t\t\t\tlink: 'http://indexer/download/1',\n\t\t\t\tsize: 1000,\n\t\t\t\tpubDate: '',\n\t\t\t\tindexer: 'Test',\n\t\t\t\tindexerId: 1,\n\t\t\t},\n\t\t])\n\t\tmockDownloadTorrentDirect.mockResolvedValue(makeTorrentData('Movie.2024.1080p.mkv', 1000))\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(result.matchesFound).toBe(1)\n\t\texpect(result.torrentsAdded).toBe(0)\n\t\texpect(mockSaveTorrentToOutput).toHaveBeenCalledTimes(1)\n\t\texpect(mockFetchWithTls.mock.calls.some((call) => String(call[0]).endsWith('/api/v2/torrents/add'))).toBe(false)\n\t})\n\n\tit('skips torrents that were already searched when not forced', async () => {\n\t\tstate.searchees.set('1:HASH1', {\n\t\t\tid: 1,\n\t\t\tinstance_id: 1,\n\t\t\ttorrent_hash: 'HASH1',\n\t\t\ttorrent_name: 'Movie',\n\t\t\ttotal_size: 1000,\n\t\t\tfile_count: 1,\n\t\t\tfile_sizes: '[1000]',\n\t\t\tfirst_searched: 0,\n\t\t\tlast_searched: 0,\n\t\t})\n\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH1',\n\t\t\t\tname: 'Movie.2024.1080p.mkv',\n\t\t\t\tsize: 1000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'movies',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Movie.2024.1080p.mkv',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t]\n\t\tmockQbtResponses(torrents, [])\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(result.torrentsSkipped).toBe(1)\n\t\texpect(result.torrentsScanned).toBe(0)\n\t\texpect(mockSearchAllIndexers).not.toHaveBeenCalled()\n\t\texpect(mockDownloadTorrentDirect).not.toHaveBeenCalled()\n\t})\n\n\tit('skips candidates already in the client by infohash', async () => {\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH1',\n\t\t\t\tname: 'Movie.2024.1080p.mkv',\n\t\t\t\tsize: 1000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'movies',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Movie.2024.1080p.mkv',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t\t{\n\t\t\t\thash: 'EXISTING',\n\t\t\t\tname: 'Already added',\n\t\t\t\tsize: 1000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'movies',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Already added',\n\t\t\t\tprogress: 0.5,\n\t\t\t},\n\t\t]\n\t\tmockQbtResponses(torrents, [{ name: 'Movie.2024.1080p.mkv', size: 1000 }])\n\n\t\tmockSearchAllIndexers.mockResolvedValue([\n\t\t\t{\n\t\t\t\tguid: 'guid-1',\n\t\t\t\ttitle: 'Movie 2024 1080p',\n\t\t\t\tlink: 'http://indexer/download/1',\n\t\t\t\tsize: 1000,\n\t\t\t\tpubDate: '',\n\t\t\t\tindexer: 'Test',\n\t\t\t\tindexerId: 1,\n\t\t\t\tinfoHash: 'EXISTING',\n\t\t\t},\n\t\t])\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: true })\n\n\t\texpect(result.matchesFound).toBe(0)\n\t\texpect(mockDownloadTorrentDirect).not.toHaveBeenCalled()\n\t})\n\n\tit('passes configured indexer ids to search', async () => {\n\t\tstate.config!.indexer_ids = JSON.stringify([5, 9])\n\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH1',\n\t\t\t\tname: 'Movie.2024.1080p.mkv',\n\t\t\t\tsize: 1000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'movies',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Movie.2024.1080p.mkv',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t]\n\t\tconst files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]\n\t\tmockQbtResponses(torrents, files)\n\n\t\tmockSearchAllIndexers.mockResolvedValue([])\n\n\t\tawait runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(mockSearchAllIndexers).toHaveBeenCalledWith('http://prowlarr', 'apikey', 'Movie 2024 1080p', 10, [5, 9])\n\t})\n\n\tit('returns error when qBittorrent login fails', async () => {\n\t\tmockLoginToQbt.mockResolvedValueOnce({ success: false, error: 'bad credentials' })\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(result.errors[0]).toContain('qBittorrent login failed')\n\t\texpect(mockFetchWithTls).not.toHaveBeenCalled()\n\t})\n\n\tit('returns error when qBittorrent torrent list fetch fails', async () => {\n\t\tmockFetchWithTls.mockImplementation((url: string) => {\n\t\t\tif (url.endsWith('/api/v2/app/version')) {\n\t\t\t\treturn Promise.resolve(new Response('v5.0.0', { status: 200 }))\n\t\t\t}\n\t\t\tif (url.includes('/api/v2/torrents/info')) {\n\t\t\t\treturn Promise.resolve(new Response('fail', { status: 500 }))\n\t\t\t}\n\t\t\treturn Promise.resolve(new Response('Not Found', { status: 404 }))\n\t\t})\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(result.errors[0]).toContain('Failed to fetch torrents from qBittorrent')\n\t\texpect(mockSearchAllIndexers).not.toHaveBeenCalled()\n\t})\n\n\tit('records search errors when torznab search throws', async () => {\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH1',\n\t\t\t\tname: 'Movie.2024.1080p.mkv',\n\t\t\t\tsize: 1000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'movies',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Movie.2024.1080p.mkv',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t]\n\t\tconst files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]\n\t\tmockQbtResponses(torrents, files)\n\n\t\tmockSearchAllIndexers.mockRejectedValueOnce(new Error('torznab down'))\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(result.errors[0]).toContain('Search failed for Movie.2024.1080p.mkv')\n\t\texpect(result.matchesFound).toBe(0)\n\t})\n\n\tit('records add failure when qBittorrent rejects the torrent', async () => {\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH1',\n\t\t\t\tname: 'Movie.2024.1080p.mkv',\n\t\t\t\tsize: 1000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'movies',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Movie.2024.1080p.mkv',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t]\n\t\tconst files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]\n\n\t\tmockFetchWithTls.mockImplementation((url: string) => {\n\t\t\tif (url.endsWith('/api/v2/torrents/info')) {\n\t\t\t\treturn Promise.resolve(new Response(JSON.stringify(torrents), { status: 200 }))\n\t\t\t}\n\t\t\tif (url.includes('/api/v2/torrents/files')) {\n\t\t\t\treturn Promise.resolve(new Response(JSON.stringify(files), { status: 200 }))\n\t\t\t}\n\t\t\tif (url.endsWith('/api/v2/torrents/add')) {\n\t\t\t\treturn Promise.resolve(new Response('Nope', { status: 200 }))\n\t\t\t}\n\t\t\treturn Promise.resolve(new Response('Not Found', { status: 404 }))\n\t\t})\n\n\t\tmockSearchAllIndexers.mockResolvedValue([\n\t\t\t{\n\t\t\t\tguid: 'guid-1',\n\t\t\t\ttitle: 'Movie 2024 1080p',\n\t\t\t\tlink: 'http://indexer/download/1',\n\t\t\t\tsize: 1000,\n\t\t\t\tpubDate: '',\n\t\t\t\tindexer: 'Test',\n\t\t\t\tindexerId: 1,\n\t\t\t},\n\t\t])\n\t\tmockDownloadTorrentDirect.mockResolvedValue(makeTorrentData('Movie.2024.1080p.mkv', 1000))\n\n\t\tconst result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })\n\n\t\texpect(result.matchesFound).toBe(1)\n\t\texpect(result.torrentsAdded).toBe(0)\n\t\texpect(result.errors[0]).toContain('Failed to add torrent: Movie 2024 1080p')\n\t})\n\n\tit('updates last_seen for existing decisions', async () => {\n\t\tstate.searchees.set('1:HASH1', {\n\t\t\tid: 1,\n\t\t\tinstance_id: 1,\n\t\t\ttorrent_hash: 'HASH1',\n\t\t\ttorrent_name: 'Movie',\n\t\t\ttotal_size: 1000,\n\t\t\tfile_count: 1,\n\t\t\tfile_sizes: '[1000]',\n\t\t\tfirst_searched: 0,\n\t\t\tlast_searched: 0,\n\t\t})\n\t\tstate.decisions.set('1:guid-1', {\n\t\t\tdecision: 'SIZE_MISMATCH',\n\t\t\tinfo_hash: null,\n\t\t\tlast_seen: 100,\n\t\t})\n\n\t\tconst torrents = [\n\t\t\t{\n\t\t\t\thash: 'HASH1',\n\t\t\t\tname: 'Movie.2024.1080p.mkv',\n\t\t\t\tsize: 1000,\n\t\t\t\tstate: 'uploading',\n\t\t\t\tcategory: 'movies',\n\t\t\t\ttags: '',\n\t\t\t\tsave_path: '/downloads',\n\t\t\t\tcontent_path: '/downloads/Movie.2024.1080p.mkv',\n\t\t\t\tprogress: 1,\n\t\t\t},\n\t\t]\n\t\tconst files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]\n\t\tmockQbtResponses(torrents, files)\n\n\t\tmockSearchAllIndexers.mockResolvedValue([\n\t\t\t{\n\t\t\t\tguid: 'guid-1',\n\t\t\t\ttitle: 'Movie 2024 1080p',\n\t\t\t\tlink: 'http://indexer/download/1',\n\t\t\t\tsize: 1000,\n\t\t\t\tpubDate: '',\n\t\t\t\tindexer: 'Test',\n\t\t\t\tindexerId: 1,\n\t\t\t},\n\t\t])\n\t\tmockDownloadTorrentDirect.mockResolvedValue(makeTorrentData('Movie.2024.1080p.mkv', 2000))\n\n\t\tawait runCrossSeedScan({ instanceId: 1, userId: 1, force: true })\n\n\t\tconst updated = state.decisions.get('1:guid-1')\n\t\texpect(updated?.last_seen).toBeGreaterThan(100)\n\t})\n})\n"
  },
  {
    "path": "__tests__/server/fetch.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport { fetchWithTls } from '../../src/server/utils/fetch'\n\n// Mock global fetch\nconst mockFetch = vi.fn()\nvi.stubGlobal('fetch', mockFetch)\n\ndescribe('fetchWithTls', () => {\n    beforeEach(() => {\n        mockFetch.mockReset()\n    })\n\n    describe('successful requests', () => {\n        it('makes basic fetch request', async () => {\n            const mockResponse = new Response('OK', { status: 200 })\n            mockFetch.mockResolvedValueOnce(mockResponse)\n\n            const result = await fetchWithTls('http://localhost:8080/api')\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                'http://localhost:8080/api',\n                expect.anything()\n            )\n            expect(result).toBe(mockResponse)\n        })\n\n        it('passes through request options', async () => {\n            mockFetch.mockResolvedValueOnce(new Response('OK'))\n\n            await fetchWithTls('http://localhost:8080/api', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ test: true }),\n            })\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                'http://localhost:8080/api',\n                expect.objectContaining({\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                })\n            )\n        })\n    })\n\n    describe('error handling', () => {\n        it('rethrows non-certificate errors', async () => {\n            mockFetch.mockRejectedValueOnce(new Error('Network error'))\n\n            await expect(fetchWithTls('http://localhost:8080'))\n                .rejects.toThrow('Network error')\n        })\n\n        it('provides helpful message for self-signed cert errors', async () => {\n            mockFetch.mockRejectedValueOnce(new Error('self-signed certificate'))\n\n            await expect(fetchWithTls('http://localhost:8080'))\n                .rejects.toThrow('TLS certificate validation failed')\n        })\n\n        it('handles SELF_SIGNED_CERT_IN_CHAIN error', async () => {\n            mockFetch.mockRejectedValueOnce(new Error('SELF_SIGNED_CERT_IN_CHAIN'))\n\n            await expect(fetchWithTls('http://localhost:8080'))\n                .rejects.toThrow('TLS certificate validation failed')\n        })\n\n        it('handles certificate expired errors', async () => {\n            mockFetch.mockRejectedValueOnce(new Error('certificate has expired'))\n\n            await expect(fetchWithTls('http://localhost:8080'))\n                .rejects.toThrow('TLS certificate validation failed')\n        })\n\n        it('handles CERT_HAS_EXPIRED error', async () => {\n            mockFetch.mockRejectedValueOnce(new Error('CERT_HAS_EXPIRED'))\n\n            await expect(fetchWithTls('http://localhost:8080'))\n                .rejects.toThrow('TLS certificate validation failed')\n        })\n    })\n\n    describe('request types', () => {\n        it('handles GET requests', async () => {\n            mockFetch.mockResolvedValueOnce(new Response('{}'))\n\n            await fetchWithTls('http://localhost/api', { method: 'GET' })\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                expect.any(String),\n                expect.objectContaining({ method: 'GET' })\n            )\n        })\n\n        it('handles POST requests with body', async () => {\n            mockFetch.mockResolvedValueOnce(new Response('{}'))\n\n            await fetchWithTls('http://localhost/api', {\n                method: 'POST',\n                body: 'test=value',\n            })\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                expect.any(String),\n                expect.objectContaining({ method: 'POST', body: 'test=value' })\n            )\n        })\n\n        it('handles DELETE requests', async () => {\n            mockFetch.mockResolvedValueOnce(new Response(''))\n\n            await fetchWithTls('http://localhost/api/1', { method: 'DELETE' })\n\n            expect(mockFetch).toHaveBeenCalledWith(\n                expect.any(String),\n                expect.objectContaining({ method: 'DELETE' })\n            )\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/server/logger.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport { log } from '../../src/server/utils/logger'\n\ndescribe('logger utilities', () => {\n    let consoleSpy: { log: ReturnType<typeof vi.spyOn>; warn: ReturnType<typeof vi.spyOn>; error: ReturnType<typeof vi.spyOn> }\n\n    beforeEach(() => {\n        consoleSpy = {\n            log: vi.spyOn(console, 'log').mockImplementation(() => { }),\n            warn: vi.spyOn(console, 'warn').mockImplementation(() => { }),\n            error: vi.spyOn(console, 'error').mockImplementation(() => { }),\n        }\n    })\n\n    afterEach(() => {\n        vi.restoreAllMocks()\n    })\n\n    describe('log.info', () => {\n        it('logs message with INFO level', () => {\n            log.info('Test message')\n            expect(consoleSpy.log).toHaveBeenCalledOnce()\n            expect(consoleSpy.log.mock.calls[0][0]).toContain('[INFO]')\n            expect(consoleSpy.log.mock.calls[0][0]).toContain('Test message')\n        })\n\n        it('includes timestamp', () => {\n            log.info('Test')\n            const call = consoleSpy.log.mock.calls[0][0]\n            // Timestamp format: [2024-01-18T12:00:00.000Z]\n            expect(call).toMatch(/\\[\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/)\n        })\n    })\n\n    describe('log.warn', () => {\n        it('logs message with WARN level', () => {\n            log.warn('Warning message')\n            expect(consoleSpy.warn).toHaveBeenCalledOnce()\n            expect(consoleSpy.warn.mock.calls[0][0]).toContain('[WARN]')\n            expect(consoleSpy.warn.mock.calls[0][0]).toContain('Warning message')\n        })\n    })\n\n    describe('log.error', () => {\n        it('logs message with ERROR level', () => {\n            log.error('Error message')\n            expect(consoleSpy.error).toHaveBeenCalledOnce()\n            expect(consoleSpy.error.mock.calls[0][0]).toContain('[ERROR]')\n            expect(consoleSpy.error.mock.calls[0][0]).toContain('Error message')\n        })\n    })\n\n    describe('log formatting', () => {\n        it('handles empty messages', () => {\n            log.info('')\n            expect(consoleSpy.log).toHaveBeenCalledOnce()\n        })\n\n        it('handles special characters', () => {\n            log.info('Message with \"quotes\" and <brackets>')\n            expect(consoleSpy.log.mock.calls[0][0]).toContain('Message with \"quotes\" and <brackets>')\n        })\n\n        it('handles unicode characters', () => {\n            log.info('🚀 Unicode message ✅')\n            expect(consoleSpy.log.mock.calls[0][0]).toContain('🚀 Unicode message ✅')\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/server/rateLimit.test.ts",
    "content": "import { describe, it, expect, beforeEach, vi } from 'vitest'\nimport { checkRateLimit, resetRateLimit } from '../../src/server/utils/rateLimit'\n\ndescribe('rateLimit utilities', () => {\n    beforeEach(() => {\n        // Reset all rate limits before each test\n        resetRateLimit('test-key')\n        resetRateLimit('another-key')\n    })\n\n    describe('checkRateLimit', () => {\n        it('allows first request', () => {\n            const result = checkRateLimit('test-key')\n            expect(result.allowed).toBe(true)\n            expect(result.retryAfter).toBeUndefined()\n        })\n\n        it('allows requests up to the limit', () => {\n            for (let i = 0; i < 5; i++) {\n                const result = checkRateLimit('test-key')\n                expect(result.allowed).toBe(true)\n            }\n        })\n\n        it('blocks requests after limit is exceeded', () => {\n            // Use up all attempts\n            for (let i = 0; i < 5; i++) {\n                checkRateLimit('test-key')\n            }\n\n            // This should be blocked\n            const result = checkRateLimit('test-key')\n            expect(result.allowed).toBe(false)\n            expect(result.retryAfter).toBeDefined()\n            expect(result.retryAfter).toBeGreaterThan(0)\n        })\n\n        it('tracks limits per key independently', () => {\n            // Use up key1 limit\n            for (let i = 0; i < 5; i++) {\n                checkRateLimit('key1')\n            }\n\n            // key2 should still be allowed\n            const result = checkRateLimit('key2')\n            expect(result.allowed).toBe(true)\n        })\n\n        it('returns retryAfter in seconds', () => {\n            // Use up all attempts\n            for (let i = 0; i < 5; i++) {\n                checkRateLimit('test-key')\n            }\n\n            const result = checkRateLimit('test-key')\n            expect(result.allowed).toBe(false)\n            // retryAfter should be in seconds (less than 60 since window is 60s)\n            expect(result.retryAfter).toBeLessThanOrEqual(60)\n            expect(result.retryAfter).toBeGreaterThan(0)\n        })\n    })\n\n    describe('resetRateLimit', () => {\n        it('resets rate limit counter', () => {\n            // Use up all attempts\n            for (let i = 0; i < 5; i++) {\n                checkRateLimit('test-key')\n            }\n\n            // Should be blocked\n            expect(checkRateLimit('test-key').allowed).toBe(false)\n\n            // Reset\n            resetRateLimit('test-key')\n\n            // Should be allowed again\n            expect(checkRateLimit('test-key').allowed).toBe(true)\n        })\n\n        it('does not affect other keys', () => {\n            // Use up both keys\n            for (let i = 0; i < 5; i++) {\n                checkRateLimit('key1')\n                checkRateLimit('key2')\n            }\n\n            // Reset only key1\n            resetRateLimit('key1')\n\n            // key1 should be allowed, key2 should be blocked\n            expect(checkRateLimit('key1').allowed).toBe(true)\n            expect(checkRateLimit('key2').allowed).toBe(false)\n        })\n\n        it('handles resetting non-existent key', () => {\n            // This should not throw\n            expect(() => resetRateLimit('nonexistent')).not.toThrow()\n        })\n    })\n\n    describe('rate limit timing', () => {\n        it('resets after window expires', async () => {\n            vi.useFakeTimers()\n\n            // Use up all attempts\n            for (let i = 0; i < 5; i++) {\n                checkRateLimit('test-key')\n            }\n            expect(checkRateLimit('test-key').allowed).toBe(false)\n\n            // Advance time past the window (60 seconds)\n            vi.advanceTimersByTime(61 * 1000)\n\n            // Should be allowed again\n            expect(checkRateLimit('test-key').allowed).toBe(true)\n\n            vi.useRealTimers()\n        })\n\n        it('does not reset before window expires', async () => {\n            vi.useFakeTimers()\n\n            // Use up all attempts\n            for (let i = 0; i < 5; i++) {\n                checkRateLimit('test-key')\n            }\n\n            // Advance time but not past the window\n            vi.advanceTimersByTime(30 * 1000)\n\n            // Should still be blocked\n            expect(checkRateLimit('test-key').allowed).toBe(false)\n\n            vi.useRealTimers()\n        })\n    })\n\n    describe('concurrent usage patterns', () => {\n        it('handles rapid sequential requests', () => {\n            let blockedCount = 0\n            for (let i = 0; i < 10; i++) {\n                const result = checkRateLimit('rapid-test')\n                if (!result.allowed) blockedCount++\n            }\n            // 5 allowed, 5 blocked\n            expect(blockedCount).toBe(5)\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/server/url.test.ts",
    "content": "import { describe, it, expect } from 'vitest'\nimport { isUrlAllowed, validateUrl } from '../../src/server/utils/url'\n\ndescribe('url utilities', () => {\n    describe('isUrlAllowed', () => {\n        describe('valid URLs', () => {\n            it('allows http URLs', () => {\n                const result = isUrlAllowed('http://localhost:8080')\n                expect(result.allowed).toBe(true)\n            })\n\n            it('allows https URLs', () => {\n                const result = isUrlAllowed('https://example.com')\n                expect(result.allowed).toBe(true)\n            })\n\n            it('allows IP addresses', () => {\n                const result = isUrlAllowed('http://192.168.1.100:8080')\n                expect(result.allowed).toBe(true)\n            })\n\n            it('allows URLs with paths', () => {\n                const result = isUrlAllowed('http://localhost:8080/api/v1')\n                expect(result.allowed).toBe(true)\n            })\n\n            it('allows URLs with query strings', () => {\n                const result = isUrlAllowed('https://example.com/search?q=test')\n                expect(result.allowed).toBe(true)\n            })\n        })\n\n        describe('invalid URLs', () => {\n            it('rejects invalid URL format', () => {\n                const result = isUrlAllowed('not-a-url')\n                expect(result.allowed).toBe(false)\n                expect(result.reason).toBe('Invalid URL format')\n            })\n\n            it('rejects empty string', () => {\n                const result = isUrlAllowed('')\n                expect(result.allowed).toBe(false)\n                expect(result.reason).toBe('Invalid URL format')\n            })\n\n            it('rejects ftp protocol', () => {\n                const result = isUrlAllowed('ftp://example.com')\n                expect(result.allowed).toBe(false)\n                expect(result.reason).toBe('Only HTTP/HTTPS protocols allowed')\n            })\n\n            it('rejects file protocol', () => {\n                const result = isUrlAllowed('file:///etc/passwd')\n                expect(result.allowed).toBe(false)\n                expect(result.reason).toBe('Only HTTP/HTTPS protocols allowed')\n            })\n\n            it('rejects javascript protocol', () => {\n                const result = isUrlAllowed('javascript:alert(1)')\n                expect(result.allowed).toBe(false)\n                expect(result.reason).toBe('Only HTTP/HTTPS protocols allowed')\n            })\n        })\n\n        describe('cloud metadata protection', () => {\n            it('blocks AWS metadata endpoint', () => {\n                const result = isUrlAllowed('http://169.254.169.254/latest/meta-data/')\n                expect(result.allowed).toBe(false)\n                expect(result.reason).toBe('Cloud metadata endpoints not allowed')\n            })\n\n            it('blocks Google Cloud metadata endpoint', () => {\n                const result = isUrlAllowed('http://metadata.google.internal/computeMetadata/')\n                expect(result.allowed).toBe(false)\n                expect(result.reason).toBe('Cloud metadata endpoints not allowed')\n            })\n\n            it('blocks AWS metadata internal', () => {\n                const result = isUrlAllowed('http://metadata.aws.internal/')\n                expect(result.allowed).toBe(false)\n                expect(result.reason).toBe('Cloud metadata endpoints not allowed')\n            })\n\n            it('blocks ECS metadata endpoint', () => {\n                const result = isUrlAllowed('http://169.254.170.2/v2/credentials')\n                expect(result.allowed).toBe(false)\n                expect(result.reason).toBe('Cloud metadata endpoints not allowed')\n            })\n\n            it('blocks any 169.254.x.x link-local address', () => {\n                const result = isUrlAllowed('http://169.254.1.1/')\n                expect(result.allowed).toBe(false)\n                expect(result.reason).toBe('Cloud metadata endpoints not allowed')\n            })\n\n            it('handles case-insensitive hostnames', () => {\n                const result = isUrlAllowed('http://METADATA.GOOGLE.INTERNAL/')\n                expect(result.allowed).toBe(false)\n            })\n        })\n    })\n\n    describe('validateUrl', () => {\n        it('does not throw for valid URLs', () => {\n            expect(() => validateUrl('http://localhost:8080')).not.toThrow()\n            expect(() => validateUrl('https://example.com')).not.toThrow()\n        })\n\n        it('throws for invalid URL format', () => {\n            expect(() => validateUrl('not-a-url')).toThrow('Invalid URL format')\n        })\n\n        it('throws for invalid protocol', () => {\n            expect(() => validateUrl('ftp://example.com')).toThrow('Only HTTP/HTTPS protocols allowed')\n        })\n\n        it('throws for cloud metadata endpoints', () => {\n            expect(() => validateUrl('http://169.254.169.254/')).toThrow('Cloud metadata endpoints not allowed')\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/themes/themes.test.ts",
    "content": "import { describe, it, expect } from 'vitest'\nimport { themes, getThemeById } from '../../src/themes/index'\n\ndescribe('themes', () => {\n    describe('themes array', () => {\n        it('contains expected theme count', () => {\n            expect(themes.length).toBeGreaterThanOrEqual(5)\n        })\n\n        it('has default theme first', () => {\n            expect(themes[0].id).toBe('default')\n        })\n\n        it('all themes have required properties', () => {\n            for (const theme of themes) {\n                expect(theme.id).toBeTruthy()\n                expect(theme.name).toBeTruthy()\n                expect(theme.colors).toBeDefined()\n                expect(theme.colors.bgPrimary).toBeTruthy()\n                expect(theme.colors.bgSecondary).toBeTruthy()\n                expect(theme.colors.textPrimary).toBeTruthy()\n                expect(theme.colors.accent).toBeTruthy()\n                expect(theme.colors.error).toBeTruthy()\n                expect(theme.colors.warning).toBeTruthy()\n            }\n        })\n\n        it('all colors are valid hex codes', () => {\n            const hexPattern = /^#[0-9A-Fa-f]{6}$/\n\n            for (const theme of themes) {\n                for (const [key, value] of Object.entries(theme.colors)) {\n                    expect(value, `${theme.id}.colors.${key}`).toMatch(hexPattern)\n                }\n            }\n        })\n\n        it('has unique theme IDs', () => {\n            const ids = themes.map(t => t.id)\n            const uniqueIds = new Set(ids)\n            expect(uniqueIds.size).toBe(ids.length)\n        })\n\n        it('has unique theme names', () => {\n            const names = themes.map(t => t.name)\n            const uniqueNames = new Set(names)\n            expect(uniqueNames.size).toBe(names.length)\n        })\n    })\n\n    describe('individual themes', () => {\n        it('default (Midnight) theme has correct structure', () => {\n            const midnight = themes.find(t => t.id === 'default')\n            expect(midnight).toBeDefined()\n            expect(midnight?.name).toBe('Midnight')\n            expect(midnight?.colors.accent).toBe('#00d4aa')\n        })\n\n        it('catppuccin theme exists', () => {\n            const catppuccin = themes.find(t => t.id === 'catppuccin')\n            expect(catppuccin).toBeDefined()\n            expect(catppuccin?.name).toBe('Catppuccin')\n        })\n\n        it('dracula theme exists', () => {\n            const dracula = themes.find(t => t.id === 'dracula')\n            expect(dracula).toBeDefined()\n            expect(dracula?.colors.accent).toBe('#bd93f9')\n        })\n\n        it('nord theme exists', () => {\n            const nord = themes.find(t => t.id === 'nord')\n            expect(nord).toBeDefined()\n        })\n\n        it('gruvbox theme exists', () => {\n            const gruvbox = themes.find(t => t.id === 'gruvbox')\n            expect(gruvbox).toBeDefined()\n        })\n\n        it('everforest theme exists', () => {\n            const everforest = themes.find(t => t.id === 'everforest')\n            expect(everforest).toBeDefined()\n        })\n    })\n\n    describe('getThemeById', () => {\n        it('returns correct theme for valid ID', () => {\n            const theme = getThemeById('catppuccin')\n            expect(theme.id).toBe('catppuccin')\n            expect(theme.name).toBe('Catppuccin')\n        })\n\n        it('returns default theme for unknown ID', () => {\n            const theme = getThemeById('nonexistent')\n            expect(theme).toEqual(themes[0])\n            expect(theme.id).toBe('default')\n        })\n\n        it('returns default theme for empty string', () => {\n            const theme = getThemeById('')\n            expect(theme.id).toBe('default')\n        })\n\n        it('returns theme with all color properties', () => {\n            const theme = getThemeById('nord')\n            expect(theme.colors.bgPrimary).toBeDefined()\n            expect(theme.colors.bgSecondary).toBeDefined()\n            expect(theme.colors.bgTertiary).toBeDefined()\n            expect(theme.colors.textPrimary).toBeDefined()\n            expect(theme.colors.textSecondary).toBeDefined()\n            expect(theme.colors.textMuted).toBeDefined()\n            expect(theme.colors.accent).toBeDefined()\n            expect(theme.colors.accentContrast).toBeDefined()\n            expect(theme.colors.warning).toBeDefined()\n            expect(theme.colors.error).toBeDefined()\n            expect(theme.colors.border).toBeDefined()\n            expect(theme.colors.progress).toBeDefined()\n        })\n    })\n\n    describe('theme color accessibility', () => {\n        it('text colors are light on dark backgrounds', () => {\n            for (const theme of themes) {\n                // Primary text should be light (high value)\n                const textPrimary = parseInt(theme.colors.textPrimary.slice(1, 3), 16)\n                expect(textPrimary, `${theme.id} textPrimary should be light`).toBeGreaterThan(150)\n\n                // Primary background should be dark (low value)\n                const bgPrimary = parseInt(theme.colors.bgPrimary.slice(1, 3), 16)\n                expect(bgPrimary, `${theme.id} bgPrimary should be dark`).toBeLessThan(80)\n            }\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/utils/fileTree.test.ts",
    "content": "import { describe, it, expect } from 'vitest'\nimport { buildFileTree, flattenVisibleNodes, getInitialExpanded, type FileTreeNode } from '../../src/utils/fileTree'\nimport type { TorrentFile } from '../../src/types/torrentDetails'\n\n// Helper to create mock TorrentFile\nfunction createFile(name: string, size = 1000, priority = 1, progress = 0, availability = 1): TorrentFile {\n    return { name, size, priority, progress, availability, index: 0, piece_range: [0, 0], is_seed: false }\n}\n\ndescribe('buildFileTree', () => {\n    it('creates flat file list for single-level files', () => {\n        const files: TorrentFile[] = [\n            createFile('file1.txt'),\n            createFile('file2.txt'),\n        ]\n        const tree = buildFileTree(files)\n        expect(tree).toHaveLength(2)\n        expect(tree[0].name).toBe('file1.txt')\n        expect(tree[0].isFolder).toBe(false)\n        expect(tree[1].name).toBe('file2.txt')\n    })\n\n    it('creates folder structure from paths', () => {\n        const files: TorrentFile[] = [\n            createFile('folder/file1.txt'),\n            createFile('folder/file2.txt'),\n        ]\n        const tree = buildFileTree(files)\n        expect(tree).toHaveLength(1)\n        expect(tree[0].name).toBe('folder')\n        expect(tree[0].isFolder).toBe(true)\n        expect(tree[0].children).toHaveLength(2)\n    })\n\n    it('creates nested folder structure', () => {\n        const files: TorrentFile[] = [\n            createFile('a/b/c/file.txt'),\n        ]\n        const tree = buildFileTree(files)\n        expect(tree[0].name).toBe('a')\n        expect(tree[0].children[0].name).toBe('b')\n        expect(tree[0].children[0].children[0].name).toBe('c')\n        expect(tree[0].children[0].children[0].children[0].name).toBe('file.txt')\n    })\n\n    it('calculates folder sizes correctly', () => {\n        const files: TorrentFile[] = [\n            createFile('folder/file1.txt', 1000),\n            createFile('folder/file2.txt', 2000),\n        ]\n        const tree = buildFileTree(files)\n        expect(tree[0].size).toBe(3000)\n    })\n\n    it('sorts folders before files', () => {\n        const files: TorrentFile[] = [\n            createFile('zfile.txt'),\n            createFile('afolder/file.txt'),\n        ]\n        const tree = buildFileTree(files)\n        expect(tree[0].name).toBe('afolder')\n        expect(tree[0].isFolder).toBe(true)\n        expect(tree[1].name).toBe('zfile.txt')\n        expect(tree[1].isFolder).toBe(false)\n    })\n\n    it('sorts nodes alphabetically within their type', () => {\n        const files: TorrentFile[] = [\n            createFile('zfile.txt'),\n            createFile('afile.txt'),\n            createFile('mfile.txt'),\n        ]\n        const tree = buildFileTree(files)\n        expect(tree[0].name).toBe('afile.txt')\n        expect(tree[1].name).toBe('mfile.txt')\n        expect(tree[2].name).toBe('zfile.txt')\n    })\n\n    it('maps priority values correctly', () => {\n        const files: TorrentFile[] = [\n            createFile('skip.txt', 100, 0),\n            createFile('normal.txt', 100, 1),\n            createFile('high.txt', 100, 6),\n            createFile('max.txt', 100, 7),\n        ]\n        const tree = buildFileTree(files)\n        expect(tree.find(n => n.name === 'skip.txt')?.priority).toBe('skip')\n        expect(tree.find(n => n.name === 'normal.txt')?.priority).toBe('normal')\n        expect(tree.find(n => n.name === 'high.txt')?.priority).toBe('high')\n        expect(tree.find(n => n.name === 'max.txt')?.priority).toBe('max')\n    })\n\n    it('sets mixed priority for folders with different file priorities', () => {\n        const files: TorrentFile[] = [\n            createFile('folder/file1.txt', 100, 1),\n            createFile('folder/file2.txt', 100, 6),\n        ]\n        const tree = buildFileTree(files)\n        expect(tree[0].priority).toBe('mixed')\n    })\n\n    it('calculates folder progress correctly', () => {\n        const files: TorrentFile[] = [\n            createFile('folder/file1.txt', 1000, 1, 0.5),\n            createFile('folder/file2.txt', 1000, 1, 1.0),\n        ]\n        const tree = buildFileTree(files)\n        expect(tree[0].progress).toBeCloseTo(0.75)\n    })\n})\n\ndescribe('flattenVisibleNodes', () => {\n    function createTestTree(): FileTreeNode[] {\n        return [\n            {\n                name: 'folder1',\n                path: 'folder1',\n                isFolder: true,\n                size: 1000,\n                progress: 0,\n                priority: 'normal',\n                availability: 1,\n                fileIndices: [0, 1],\n                children: [\n                    {\n                        name: 'file1.txt',\n                        path: 'folder1/file1.txt',\n                        isFolder: false,\n                        size: 500,\n                        progress: 0,\n                        priority: 'normal',\n                        availability: 1,\n                        fileIndices: [0],\n                        children: [],\n                    },\n                    {\n                        name: 'file2.txt',\n                        path: 'folder1/file2.txt',\n                        isFolder: false,\n                        size: 500,\n                        progress: 0,\n                        priority: 'normal',\n                        availability: 1,\n                        fileIndices: [1],\n                        children: [],\n                    },\n                ],\n            },\n            {\n                name: 'file3.txt',\n                path: 'file3.txt',\n                isFolder: false,\n                size: 1000,\n                progress: 0,\n                priority: 'normal',\n                availability: 1,\n                fileIndices: [2],\n                children: [],\n            },\n        ]\n    }\n\n    it('returns only top-level nodes when nothing is expanded', () => {\n        const tree = createTestTree()\n        const expanded = new Set<string>()\n        const flattened = flattenVisibleNodes(tree, expanded)\n        expect(flattened).toHaveLength(2)\n        expect(flattened[0].node.name).toBe('folder1')\n        expect(flattened[0].depth).toBe(0)\n        expect(flattened[1].node.name).toBe('file3.txt')\n    })\n\n    it('includes children when folder is expanded', () => {\n        const tree = createTestTree()\n        const expanded = new Set(['folder1'])\n        const flattened = flattenVisibleNodes(tree, expanded)\n        expect(flattened).toHaveLength(4)\n        expect(flattened[0].node.name).toBe('folder1')\n        expect(flattened[0].depth).toBe(0)\n        expect(flattened[1].node.name).toBe('file1.txt')\n        expect(flattened[1].depth).toBe(1)\n        expect(flattened[2].node.name).toBe('file2.txt')\n        expect(flattened[2].depth).toBe(1)\n    })\n\n    it('correctly tracks depth for nested expansions', () => {\n        const tree: FileTreeNode[] = [{\n            name: 'a',\n            path: 'a',\n            isFolder: true,\n            size: 0,\n            progress: 0,\n            priority: 'normal',\n            availability: 0,\n            fileIndices: [],\n            children: [{\n                name: 'b',\n                path: 'a/b',\n                isFolder: true,\n                size: 0,\n                progress: 0,\n                priority: 'normal',\n                availability: 0,\n                fileIndices: [],\n                children: [{\n                    name: 'file.txt',\n                    path: 'a/b/file.txt',\n                    isFolder: false,\n                    size: 100,\n                    progress: 0,\n                    priority: 'normal',\n                    availability: 0,\n                    fileIndices: [0],\n                    children: [],\n                }],\n            }],\n        }]\n\n        const expanded = new Set(['a', 'a/b'])\n        const flattened = flattenVisibleNodes(tree, expanded)\n        expect(flattened).toHaveLength(3)\n        expect(flattened[0].depth).toBe(0)\n        expect(flattened[1].depth).toBe(1)\n        expect(flattened[2].depth).toBe(2)\n    })\n})\n\ndescribe('getInitialExpanded', () => {\n    it('returns empty set for files only', () => {\n        const tree: FileTreeNode[] = [\n            {\n                name: 'file.txt',\n                path: 'file.txt',\n                isFolder: false,\n                size: 100,\n                progress: 0,\n                priority: 'normal',\n                availability: 0,\n                fileIndices: [0],\n                children: [],\n            },\n        ]\n        const expanded = getInitialExpanded(tree)\n        expect(expanded.size).toBe(0)\n    })\n\n    it('expands single-child folder paths', () => {\n        const tree: FileTreeNode[] = [{\n            name: 'a',\n            path: 'a',\n            isFolder: true,\n            size: 100,\n            progress: 0,\n            priority: 'normal',\n            availability: 0,\n            fileIndices: [],\n            children: [{\n                name: 'b',\n                path: 'a/b',\n                isFolder: true,\n                size: 100,\n                progress: 0,\n                priority: 'normal',\n                availability: 0,\n                fileIndices: [],\n                children: [{\n                    name: 'file.txt',\n                    path: 'a/b/file.txt',\n                    isFolder: false,\n                    size: 100,\n                    progress: 0,\n                    priority: 'normal',\n                    availability: 0,\n                    fileIndices: [0],\n                    children: [],\n                }],\n            }],\n        }]\n\n        const expanded = getInitialExpanded(tree)\n        expect(expanded.has('a')).toBe(true)\n        expect(expanded.has('a/b')).toBe(true)\n    })\n\n    it('stops expanding when there are multiple folders', () => {\n        const tree: FileTreeNode[] = [{\n            name: 'a',\n            path: 'a',\n            isFolder: true,\n            size: 0,\n            progress: 0,\n            priority: 'normal',\n            availability: 0,\n            fileIndices: [],\n            children: [\n                {\n                    name: 'b',\n                    path: 'a/b',\n                    isFolder: true,\n                    size: 0,\n                    progress: 0,\n                    priority: 'normal',\n                    availability: 0,\n                    fileIndices: [],\n                    children: [],\n                },\n                {\n                    name: 'c',\n                    path: 'a/c',\n                    isFolder: true,\n                    size: 0,\n                    progress: 0,\n                    priority: 'normal',\n                    availability: 0,\n                    fileIndices: [],\n                    children: [],\n                },\n            ],\n        }]\n\n        const expanded = getInitialExpanded(tree)\n        expect(expanded.has('a')).toBe(true)\n        expect(expanded.has('a/b')).toBe(false)\n        expect(expanded.has('a/c')).toBe(false)\n    })\n})\n"
  },
  {
    "path": "__tests__/utils/format.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport {\n    formatSpeed,\n    formatSize,\n    formatCompactSpeed,\n    formatCompactSize,\n    formatEta,\n    formatDate,\n    formatDuration,\n    formatRelativeTime,\n    formatRelativeDate,\n    normalizeSearch,\n} from '../../src/utils/format'\n\ndescribe('formatSpeed', () => {\n    it('formats bytes per second', () => {\n        expect(formatSpeed(0)).toBe('0 B/s')\n        expect(formatSpeed(512)).toBe('512 B/s')\n        expect(formatSpeed(1023)).toBe('1023 B/s')\n    })\n\n    it('formats kibibytes per second', () => {\n        expect(formatSpeed(1024)).toBe('1.0 KiB/s')\n        expect(formatSpeed(1536)).toBe('1.5 KiB/s')\n        expect(formatSpeed(1024 * 1024 - 1)).toBe('1024.0 KiB/s')\n    })\n\n    it('formats mebibytes per second', () => {\n        expect(formatSpeed(1024 * 1024)).toBe('1.00 MiB/s')\n        expect(formatSpeed(1.5 * 1024 * 1024)).toBe('1.50 MiB/s')\n        expect(formatSpeed(100 * 1024 * 1024)).toBe('100.00 MiB/s')\n    })\n\n    it('returns dash when showZero is false and value is 0', () => {\n        expect(formatSpeed(0, false)).toBe('—')\n    })\n})\n\ndescribe('formatSize', () => {\n    it('formats bytes', () => {\n        expect(formatSize(0)).toBe('0 B')\n        expect(formatSize(1)).toBe('1 B')\n        expect(formatSize(1023)).toBe('1023 B')\n    })\n\n    it('formats kibibytes', () => {\n        expect(formatSize(1024)).toBe('1.0 KiB')\n        expect(formatSize(1536)).toBe('1.5 KiB')\n    })\n\n    it('formats mebibytes', () => {\n        expect(formatSize(1024 * 1024)).toBe('1.0 MiB')\n        expect(formatSize(500 * 1024 * 1024)).toBe('500.0 MiB')\n    })\n\n    it('formats gibibytes', () => {\n        expect(formatSize(1024 * 1024 * 1024)).toBe('1.00 GiB')\n        expect(formatSize(4.7 * 1024 * 1024 * 1024)).toBe('4.70 GiB')\n    })\n\n    it('formats tebibytes', () => {\n        expect(formatSize(1024 * 1024 * 1024 * 1024)).toBe('1.00 TiB')\n        expect(formatSize(2.5 * 1024 * 1024 * 1024 * 1024)).toBe('2.50 TiB')\n    })\n})\n\ndescribe('formatCompactSpeed', () => {\n    it('returns dash for zero', () => {\n        expect(formatCompactSpeed(0)).toBe('-')\n    })\n\n    it('formats compact bytes', () => {\n        expect(formatCompactSpeed(512)).toBe('512B')\n    })\n\n    it('formats compact kibibytes', () => {\n        expect(formatCompactSpeed(1024)).toBe('1Ki')\n        expect(formatCompactSpeed(2048)).toBe('2Ki')\n    })\n\n    it('formats compact mebibytes', () => {\n        expect(formatCompactSpeed(1024 * 1024)).toBe('1.0Mi')\n        expect(formatCompactSpeed(10.5 * 1024 * 1024)).toBe('10.5Mi')\n    })\n})\n\ndescribe('formatCompactSize', () => {\n    it('formats compact bytes', () => {\n        expect(formatCompactSize(512)).toBe('512B')\n    })\n\n    it('formats compact kibibytes', () => {\n        expect(formatCompactSize(1024)).toBe('1Ki')\n    })\n\n    it('formats compact mebibytes', () => {\n        expect(formatCompactSize(1024 * 1024)).toBe('1Mi')\n    })\n\n    it('formats compact gibibytes', () => {\n        expect(formatCompactSize(1024 * 1024 * 1024)).toBe('1.0Gi')\n    })\n})\n\ndescribe('formatEta', () => {\n    it('returns infinity for negative values', () => {\n        expect(formatEta(-1)).toBe('∞')\n        expect(formatEta(-1000)).toBe('∞')\n    })\n\n    it('returns infinity for qBittorrent unknown value', () => {\n        expect(formatEta(8640000)).toBe('∞')\n    })\n\n    it('formats seconds', () => {\n        expect(formatEta(0)).toBe('0s')\n        expect(formatEta(30)).toBe('30s')\n        expect(formatEta(59)).toBe('59s')\n    })\n\n    it('formats minutes', () => {\n        expect(formatEta(60)).toBe('1m')\n        expect(formatEta(120)).toBe('2m')\n        expect(formatEta(3599)).toBe('59m')\n    })\n\n    it('formats hours and minutes', () => {\n        expect(formatEta(3600)).toBe('1h 0m')\n        expect(formatEta(3661)).toBe('1h 1m')\n        expect(formatEta(7200)).toBe('2h 0m')\n    })\n\n    it('formats days', () => {\n        expect(formatEta(86400)).toBe('1d')\n        expect(formatEta(172800)).toBe('2d')\n    })\n})\n\ndescribe('formatDate', () => {\n    it('returns dash for zero or negative timestamp', () => {\n        expect(formatDate(0)).toBe('—')\n        expect(formatDate(-1)).toBe('—')\n    })\n\n    it('formats valid timestamps', () => {\n        // Just verify it returns a non-empty string for valid timestamps\n        const result = formatDate(1704067200) // Jan 1, 2024 00:00:00 UTC\n        expect(result).toBeTruthy()\n        expect(result).not.toBe('—')\n    })\n})\n\ndescribe('formatDuration', () => {\n    it('returns dash for negative values', () => {\n        expect(formatDuration(-1)).toBe('—')\n    })\n\n    it('formats seconds only', () => {\n        expect(formatDuration(0)).toBe('0s')\n        expect(formatDuration(30)).toBe('30s')\n        expect(formatDuration(59)).toBe('59s')\n    })\n\n    it('formats minutes and seconds', () => {\n        expect(formatDuration(60)).toBe('1m 0s')\n        expect(formatDuration(125)).toBe('2m 5s')\n    })\n\n    it('formats hours, minutes, and seconds', () => {\n        expect(formatDuration(3600)).toBe('1h 0m 0s')\n        expect(formatDuration(3665)).toBe('1h 1m 5s')\n    })\n\n    it('formats days, hours, and minutes', () => {\n        expect(formatDuration(86400)).toBe('1d 0h 0m')\n        expect(formatDuration(90061)).toBe('1d 1h 1m')\n    })\n})\n\ndescribe('formatRelativeTime', () => {\n    beforeEach(() => {\n        vi.useFakeTimers()\n        vi.setSystemTime(new Date('2024-01-15T12:00:00Z'))\n    })\n\n    afterEach(() => {\n        vi.useRealTimers()\n    })\n\n    it('returns Never for zero or negative timestamp', () => {\n        expect(formatRelativeTime(0)).toBe('Never')\n        expect(formatRelativeTime(-1)).toBe('Never')\n    })\n\n    it('returns Just now for recent timestamps', () => {\n        const now = Math.floor(Date.now() / 1000)\n        expect(formatRelativeTime(now)).toBe('Just now')\n        expect(formatRelativeTime(now - 30)).toBe('Just now')\n    })\n\n    it('formats minutes ago', () => {\n        const now = Math.floor(Date.now() / 1000)\n        expect(formatRelativeTime(now - 60)).toBe('1m ago')\n        expect(formatRelativeTime(now - 300)).toBe('5m ago')\n    })\n\n    it('formats hours ago', () => {\n        const now = Math.floor(Date.now() / 1000)\n        expect(formatRelativeTime(now - 3600)).toBe('1h ago')\n        expect(formatRelativeTime(now - 7200)).toBe('2h ago')\n    })\n\n    it('formats days ago', () => {\n        const now = Math.floor(Date.now() / 1000)\n        expect(formatRelativeTime(now - 86400)).toBe('1d ago')\n        expect(formatRelativeTime(now - 259200)).toBe('3d ago')\n    })\n\n    it('formats weeks ago', () => {\n        const now = Math.floor(Date.now() / 1000)\n        expect(formatRelativeTime(now - 604800)).toBe('1w ago')\n        expect(formatRelativeTime(now - 1209600)).toBe('2w ago')\n    })\n})\n\ndescribe('formatRelativeDate', () => {\n    beforeEach(() => {\n        vi.useFakeTimers()\n        vi.setSystemTime(new Date('2024-01-15T12:00:00Z'))\n    })\n\n    afterEach(() => {\n        vi.useRealTimers()\n    })\n\n    it('returns dash for zero or negative timestamp', () => {\n        expect(formatRelativeDate(0)).toBe('-')\n        expect(formatRelativeDate(-1)).toBe('-')\n    })\n\n    it('returns Today for same day', () => {\n        const todayTimestamp = Math.floor(Date.now() / 1000)\n        expect(formatRelativeDate(todayTimestamp)).toBe('Today')\n    })\n\n    it('returns Yesterday for previous day', () => {\n        const yesterdayTimestamp = Math.floor(Date.now() / 1000) - 86400\n        expect(formatRelativeDate(yesterdayTimestamp)).toBe('Yesterday')\n    })\n\n    it('formats days ago within a week', () => {\n        const threeDaysAgo = Math.floor(Date.now() / 1000) - 86400 * 3\n        expect(formatRelativeDate(threeDaysAgo)).toBe('3d ago')\n    })\n})\n\ndescribe('normalizeSearch', () => {\n    it('converts to lowercase', () => {\n        expect(normalizeSearch('HELLO')).toBe('hello')\n        expect(normalizeSearch('Hello World')).toBe('hello world')\n    })\n\n    it('replaces dots, underscores, and hyphens with spaces', () => {\n        expect(normalizeSearch('hello.world')).toBe('hello world')\n        expect(normalizeSearch('hello_world')).toBe('hello world')\n        expect(normalizeSearch('hello-world')).toBe('hello world')\n    })\n\n    it('normalizes multiple separators', () => {\n        expect(normalizeSearch('hello...world')).toBe('hello world')\n        expect(normalizeSearch('hello___world')).toBe('hello world')\n        expect(normalizeSearch('hello---world')).toBe('hello world')\n        expect(normalizeSearch('hello._-world')).toBe('hello world')\n    })\n\n    it('handles torrent-style names', () => {\n        expect(normalizeSearch('Movie.Name.2024.1080p.BluRay')).toBe('movie name 2024 1080p bluray')\n    })\n\n    it('handles empty string', () => {\n        expect(normalizeSearch('')).toBe('')\n    })\n\n    it('handles strings with only separators', () => {\n        expect(normalizeSearch('...')).toBe(' ')\n        expect(normalizeSearch('___')).toBe(' ')\n    })\n\n    it('preserves numbers', () => {\n        expect(normalizeSearch('file123.txt')).toBe('file123 txt')\n    })\n\n    it('handles mixed separators at start and end', () => {\n        expect(normalizeSearch('.hello.')).toBe(' hello ')\n        expect(normalizeSearch('-test-')).toBe(' test ')\n    })\n})\n\n// Additional edge case tests\ndescribe('format edge cases', () => {\n    describe('formatSpeed edge cases', () => {\n        it('handles very large values', () => {\n            expect(formatSpeed(1024 * 1024 * 1024)).toBe('1024.00 MiB/s')\n        })\n\n        it('handles floating point precision', () => {\n            expect(formatSpeed(1536)).toBe('1.5 KiB/s')\n        })\n    })\n\n    describe('formatSize edge cases', () => {\n        it('handles exact boundary values', () => {\n            expect(formatSize(1024)).toBe('1.0 KiB')\n            expect(formatSize(1024 * 1024)).toBe('1.0 MiB')\n            expect(formatSize(1024 * 1024 * 1024)).toBe('1.00 GiB')\n            expect(formatSize(1024 * 1024 * 1024 * 1024)).toBe('1.00 TiB')\n        })\n\n        it('handles values just below boundaries', () => {\n            expect(formatSize(1023)).toBe('1023 B')\n            expect(formatSize(1024 * 1024 - 1)).toBe('1024.0 KiB')\n        })\n    })\n\n    describe('formatEta edge cases', () => {\n        it('handles exact boundary transitions', () => {\n            expect(formatEta(59)).toBe('59s')\n            expect(formatEta(60)).toBe('1m')\n            expect(formatEta(3599)).toBe('59m')\n            expect(formatEta(3600)).toBe('1h 0m')\n            expect(formatEta(86399)).toBe('23h 59m')\n            expect(formatEta(86400)).toBe('1d')\n        })\n    })\n\n    describe('formatDuration edge cases', () => {\n        it('handles exact day boundary', () => {\n            expect(formatDuration(86400)).toBe('1d 0h 0m')\n        })\n\n        it('handles complex durations', () => {\n            expect(formatDuration(90061)).toBe('1d 1h 1m')\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/utils/pagination.test.ts",
    "content": "import { describe, it, expect } from 'vitest'\nimport { PER_PAGE_OPTIONS } from '../../src/utils/pagination'\n\ndescribe('pagination', () => {\n    describe('PER_PAGE_OPTIONS', () => {\n        it('contains expected values', () => {\n            expect(PER_PAGE_OPTIONS).toEqual([25, 50, 100, 200])\n        })\n\n        it('is readonly', () => {\n            // TypeScript should prevent mutation, but verify the values\n            expect(PER_PAGE_OPTIONS[0]).toBe(25)\n            expect(PER_PAGE_OPTIONS.length).toBe(4)\n        })\n\n        it('values are in ascending order', () => {\n            for (let i = 1; i < PER_PAGE_OPTIONS.length; i++) {\n                expect(PER_PAGE_OPTIONS[i]).toBeGreaterThan(PER_PAGE_OPTIONS[i - 1])\n            }\n        })\n\n        it('starts with a reasonable minimum', () => {\n            expect(PER_PAGE_OPTIONS[0]).toBeGreaterThanOrEqual(10)\n        })\n\n        it('has a reasonable maximum', () => {\n            expect(PER_PAGE_OPTIONS[PER_PAGE_OPTIONS.length - 1]).toBeLessThanOrEqual(500)\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/utils/ratioThresholds.test.ts",
    "content": "import { describe, it, expect, beforeEach, vi } from 'vitest'\nimport { loadRatioThreshold, saveRatioThreshold } from '../../src/utils/ratioThresholds'\n\ndescribe('ratioThresholds', () => {\n    // Mock localStorage\n    const localStorageMock = (() => {\n        let store: Record<string, string> = {}\n        return {\n            getItem: vi.fn((key: string) => store[key] ?? null),\n            setItem: vi.fn((key: string, value: string) => {\n                store[key] = value\n            }),\n            clear: () => {\n                store = {}\n            },\n        }\n    })()\n\n    beforeEach(() => {\n        localStorageMock.clear()\n        vi.stubGlobal('localStorage', localStorageMock)\n    })\n\n    describe('loadRatioThreshold', () => {\n        it('returns default threshold (1.0) when no value stored', () => {\n            expect(loadRatioThreshold()).toBe(1.0)\n        })\n\n        it('returns stored threshold when valid', () => {\n            localStorageMock.setItem('ratioThreshold', '2.5')\n            expect(loadRatioThreshold()).toBe(2.5)\n        })\n\n        it('returns default for invalid stored value', () => {\n            localStorageMock.setItem('ratioThreshold', 'not-a-number')\n            expect(loadRatioThreshold()).toBe(1.0)\n        })\n\n        it('returns default for negative stored value', () => {\n            localStorageMock.setItem('ratioThreshold', '-1')\n            expect(loadRatioThreshold()).toBe(1.0)\n        })\n\n        it('accepts zero as valid threshold', () => {\n            localStorageMock.setItem('ratioThreshold', '0')\n            expect(loadRatioThreshold()).toBe(0)\n        })\n\n        it('handles decimal values correctly', () => {\n            localStorageMock.setItem('ratioThreshold', '0.5')\n            expect(loadRatioThreshold()).toBe(0.5)\n        })\n\n        it('handles large values', () => {\n            localStorageMock.setItem('ratioThreshold', '100')\n            expect(loadRatioThreshold()).toBe(100)\n        })\n    })\n\n    describe('saveRatioThreshold', () => {\n        it('saves threshold to localStorage', () => {\n            saveRatioThreshold(2.0)\n            expect(localStorageMock.setItem).toHaveBeenCalledWith('ratioThreshold', '2')\n        })\n\n        it('saves decimal values', () => {\n            saveRatioThreshold(1.5)\n            expect(localStorageMock.setItem).toHaveBeenCalledWith('ratioThreshold', '1.5')\n        })\n\n        it('saves zero', () => {\n            saveRatioThreshold(0)\n            expect(localStorageMock.setItem).toHaveBeenCalledWith('ratioThreshold', '0')\n        })\n    })\n})\n"
  },
  {
    "path": "__tests__/utils/search.test.ts",
    "content": "import { describe, it, expect } from 'vitest'\nimport { extractTags, sortResults, filterResults } from '../../src/utils/search'\n\ndescribe('extractTags', () => {\n    it('extracts resolution tags', () => {\n        const titles = [\n            'Movie.2024.1080p.BluRay',\n            'Show.S01E01.720p.WEB-DL',\n            'Film.2024.2160p.UHD',\n        ]\n        const tags = extractTags(titles)\n        expect(tags.find(t => t.tag === '1080P')).toBeTruthy()\n        expect(tags.find(t => t.tag === '720P')).toBeTruthy()\n        expect(tags.find(t => t.tag === '2160P')).toBeTruthy()\n    })\n\n    it('extracts codec tags', () => {\n        const titles = ['Movie.x264.mkv', 'Film.x265.mp4', 'Show.HEVC.avi']\n        const tags = extractTags(titles)\n        expect(tags.find(t => t.tag === 'X264')).toBeTruthy()\n        expect(tags.find(t => t.tag === 'X265')).toBeTruthy()\n        expect(tags.find(t => t.tag === 'HEVC')).toBeTruthy()\n    })\n\n    it('extracts source tags', () => {\n        const titles = [\n            'Movie.BluRay.x264',\n            'Show.WEB-DL.1080p',\n            'Film.HDRip.720p',\n        ]\n        const tags = extractTags(titles)\n        expect(tags.find(t => t.tag === 'BLURAY')).toBeTruthy()\n        expect(tags.find(t => t.tag === 'WEB-DL')).toBeTruthy()\n        expect(tags.find(t => t.tag === 'HDRIP')).toBeTruthy()\n    })\n\n    it('counts tag occurrences', () => {\n        const titles = [\n            'Movie1.1080p.x264',\n            'Movie2.1080p.x265',\n            'Movie3.1080p.HEVC',\n        ]\n        const tags = extractTags(titles)\n        const tag1080p = tags.find(t => t.tag === '1080P')\n        expect(tag1080p?.count).toBe(3)\n    })\n\n    it('sorts by count descending', () => {\n        const titles = [\n            'Movie1.1080p.x264',\n            'Movie2.1080p.x264',\n            'Movie3.720p.x264',\n        ]\n        const tags = extractTags(titles)\n        // x264 appears 3 times, 1080p appears 2 times\n        expect(tags[0].tag).toBe('X264')\n        expect(tags[0].count).toBe(3)\n    })\n\n    it('returns empty array for titles with no tags', () => {\n        const titles = ['just a regular title', 'another title here']\n        const tags = extractTags(titles)\n        expect(tags).toHaveLength(0)\n    })\n})\n\ndescribe('sortResults', () => {\n    const mockResults = [\n        { title: 'Movie A', seeders: 100, size: 1000, publishDate: '2024-01-15' },\n        { title: 'Movie B', seeders: 50, size: 2000, publishDate: '2024-01-10' },\n        { title: 'Movie C', seeders: 200, size: 500, publishDate: '2024-01-20' },\n    ]\n\n    describe('sorting by seeders', () => {\n        it('sorts by seeders descending (default)', () => {\n            const sorted = sortResults(mockResults, 'seeders', false)\n            expect(sorted[0].title).toBe('Movie C')\n            expect(sorted[1].title).toBe('Movie A')\n            expect(sorted[2].title).toBe('Movie B')\n        })\n\n        it('sorts by seeders ascending', () => {\n            const sorted = sortResults(mockResults, 'seeders', true)\n            expect(sorted[0].title).toBe('Movie B')\n            expect(sorted[1].title).toBe('Movie A')\n            expect(sorted[2].title).toBe('Movie C')\n        })\n    })\n\n    describe('sorting by size', () => {\n        it('sorts by size descending (default)', () => {\n            const sorted = sortResults(mockResults, 'size', false)\n            expect(sorted[0].title).toBe('Movie B')\n            expect(sorted[1].title).toBe('Movie A')\n            expect(sorted[2].title).toBe('Movie C')\n        })\n\n        it('sorts by size ascending', () => {\n            const sorted = sortResults(mockResults, 'size', true)\n            expect(sorted[0].title).toBe('Movie C')\n            expect(sorted[1].title).toBe('Movie A')\n            expect(sorted[2].title).toBe('Movie B')\n        })\n    })\n\n    describe('sorting by age', () => {\n        it('sorts by age descending (newest first)', () => {\n            const sorted = sortResults(mockResults, 'age', false)\n            expect(sorted[0].title).toBe('Movie C')\n            expect(sorted[1].title).toBe('Movie A')\n            expect(sorted[2].title).toBe('Movie B')\n        })\n\n        it('sorts by age ascending (oldest first)', () => {\n            const sorted = sortResults(mockResults, 'age', true)\n            expect(sorted[0].title).toBe('Movie B')\n            expect(sorted[1].title).toBe('Movie A')\n            expect(sorted[2].title).toBe('Movie C')\n        })\n    })\n\n    it('handles missing seeders', () => {\n        const results = [\n            { title: 'A', size: 100, publishDate: '2024-01-01' },\n            { title: 'B', seeders: 10, size: 100, publishDate: '2024-01-01' },\n        ]\n        const sorted = sortResults(results, 'seeders', false)\n        expect(sorted[0].title).toBe('B')\n        expect(sorted[1].title).toBe('A')\n    })\n\n    it('does not mutate original array', () => {\n        const original = [...mockResults]\n        sortResults(mockResults, 'seeders', false)\n        expect(mockResults).toEqual(original)\n    })\n})\n\ndescribe('filterResults', () => {\n    const mockResults = [\n        { title: 'The Matrix 1999', extra: 'data' },\n        { title: 'Matrix Reloaded 2003', extra: 'info' },\n        { title: 'Inception 2010', extra: 'value' },\n    ]\n\n    it('returns all results when filter is empty', () => {\n        expect(filterResults(mockResults, '')).toHaveLength(3)\n    })\n\n    it('filters by title case-insensitively', () => {\n        const filtered = filterResults(mockResults, 'matrix')\n        expect(filtered).toHaveLength(2)\n        expect(filtered[0].title).toBe('The Matrix 1999')\n        expect(filtered[1].title).toBe('Matrix Reloaded 2003')\n    })\n\n    it('handles uppercase filter', () => {\n        const filtered = filterResults(mockResults, 'INCEPTION')\n        expect(filtered).toHaveLength(1)\n        expect(filtered[0].title).toBe('Inception 2010')\n    })\n\n    it('returns empty array when no matches', () => {\n        const filtered = filterResults(mockResults, 'nonexistent')\n        expect(filtered).toHaveLength(0)\n    })\n\n    it('matches partial strings', () => {\n        const filtered = filterResults(mockResults, 'rix')\n        expect(filtered).toHaveLength(2)\n    })\n})\n"
  },
  {
    "path": "docs/.vitepress/config.ts",
    "content": "import { defineConfig } from 'vitepress'\n\nexport default defineConfig({\n\ttitle: 'qbitwebui',\n\tdescription: 'Modern web interface for qBittorrent',\n\tbase: '/qbitwebui/',\n\thead: [\n\t\t['link', { rel: 'icon', href: '/qbitwebui/logo.svg' }]\n\t],\n\tthemeConfig: {\n\t\tlogo: '/logo.svg',\n\t\tnav: [\n\t\t\t{ text: 'Guide', link: '/guide/getting-started' },\n\t\t\t{ text: 'GitHub', link: 'https://github.com/Maciejonos/qbitwebui' },\n\t\t],\n\t\tsidebar: [\n\t\t\t{\n\t\t\t\ttext: 'Guide',\n\t\t\t\titems: [\n\t\t\t\t\t{ text: 'Getting Started', link: '/guide/getting-started' },\n\t\t\t\t\t{ text: 'Configuration', link: '/guide/configuration' },\n\t\t\t\t\t{ text: 'Features', link: '/guide/features' },\n\t\t\t\t\t{ text: 'Docker', link: '/guide/docker' },\n\t\t\t\t],\n\t\t\t},\n\t\t\t{\n\t\t\t\ttext: 'Add-ons',\n\t\t\t\titems: [{ text: 'Network Agent', link: '/guide/network-agent/' }],\n\t\t\t},\n\t\t],\n\t\tsocialLinks: [{ icon: 'github', link: 'https://github.com/Maciejonos/qbitwebui' }],\n\t\tsearch: { provider: 'local' },\n\t\tfooter: { message: 'Released under the MIT License.' },\n\t},\n})\n"
  },
  {
    "path": "docs/.vitepress/theme/custom.css",
    "content": ":root {\n    --vp-c-brand-1: #0d7a6e;\n    --vp-c-brand-2: #0f665c;\n    --vp-c-brand-3: #15803d;\n    --vp-c-brand-soft: rgba(13, 122, 110, 0.1);\n}\n\n.dark {\n    --vp-c-bg: #07070a;\n    --vp-c-bg-alt: #0a0a0f;\n    --vp-sidebar-bg-color: #0a0a0f;\n    --vp-c-bg-soft: #0e0e14;\n    --vp-code-block-bg: #0e0e14;\n    --vp-c-border: #32323e;\n    --vp-c-divider: #32323e;\n    --vp-c-gutter: #32323e;\n    --vp-c-text-1: #e8e8ed;\n    --vp-c-text-2: #b8b8c8;\n    --vp-c-text-3: #8a8a9e;\n    --vp-c-brand-1: #00d4aa;\n    --vp-c-brand-2: #33eec9;\n    --vp-c-brand-3: #00b38f;\n    --vp-c-brand-soft: rgba(0, 212, 170, 0.15);\n    --vp-c-warning-1: #f7b731;\n    --vp-c-warning-2: #e0a01f;\n    --vp-c-danger-1: #f43f5e;\n    --vp-c-danger-2: #e11d48;\n}\n\n.VPHero .name {\n    -webkit-background-clip: text;\n    background-clip: text;\n    -webkit-text-fill-color: transparent;\n}\n\n:root .VPHero .name {\n    background-image: linear-gradient(135deg, #16a34a 0%, #0d9488 100%);\n}\n\n.dark .VPHero .name {\n    background-image: linear-gradient(135deg, #9bda65 0%, #33c9a9 50%, #1ec6b7 100%);\n}\n\n.VPButton.brand {\n    border-color: transparent;\n    transition: all 0.2s ease;\n}\n\n:root .VPButton.brand {\n    color: white !important;\n    background-image: linear-gradient(135deg, #0d9488 0%, #0d7a6e 100%);\n}\n\n.dark .VPButton.brand {\n    background-color: #00d4aa;\n    background-image: none;\n    color: #070a09 !important;\n    font-weight: 600;\n}\n\n.dark .VPButton.brand:hover {\n    background-color: #33eec9;\n    color: #070a09 !important;\n}\n\n::selection {\n    background: rgba(0, 212, 170, 0.3);\n    color: inherit;\n}"
  },
  {
    "path": "docs/.vitepress/theme/index.ts",
    "content": "import DefaultTheme from 'vitepress/theme'\nimport './custom.css'\n\nexport default DefaultTheme"
  },
  {
    "path": "docs/guide/configuration.md",
    "content": "# Configuration\n\nAll configuration is done through environment variables.\n\n## Required\n\n| Variable | Description |\n|----------|-------------|\n| `ENCRYPTION_KEY` | AES-256 key for encrypting stored credentials. Minimum 32 characters. |\n\nGenerate a key:\n```bash\nopenssl rand -hex 32\n```\n\n## Server\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `PORT` | `3000` | HTTP server port |\n| `DATABASE_PATH` | `./data/qbitwebui.db` | SQLite database location |\n| `SALT_PATH` | `./data/.salt` | Encryption salt file location |\n\n## Authentication\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `DISABLE_AUTH` | `false` | Skip authentication entirely |\n| `DISABLE_REGISTRATION` | `false` | Prevent new user signups |\n\n### Disable Auth\n\nUse when running behind an authenticating reverse proxy (Authelia, Authentik, etc.):\n\n```yaml\nenvironment:\n  - DISABLE_AUTH=true\n```\n\n::: danger\nOnly use behind a properly secured reverse proxy. Anyone who can reach qbitwebui will have full access.\n:::\n\n### Disable Registration\n\nLock down to existing users only. On first start with no users, generates a random admin password printed to logs:\n\n```yaml\nenvironment:\n  - DISABLE_REGISTRATION=true\n```\n\n## Features\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `DOWNLOADS_PATH` | - | Enable file browser at this path |\n| `ALLOW_SELF_SIGNED_CERTS` | `false` | Accept self-signed TLS certificates |\n\n### File Browser\n\nMount your downloads directory and set the path:\n\n```yaml\nenvironment:\n  - DOWNLOADS_PATH=/downloads\nvolumes:\n  - /path/to/downloads:/downloads:ro\n```\n\nThe `:ro` makes it read-only. Remove for write access (delete, move, rename).\n\n### Self-Signed Certificates\n\nIf your qBittorrent uses HTTPS with a self-signed certificate:\n\n```yaml\nenvironment:\n  - ALLOW_SELF_SIGNED_CERTS=true\n```\n\n## Database\n\nSQLite database stores:\n\n| Data | Security |\n|------|----------|\n| Users | Passwords hashed with bcrypt (cost 12) |\n| Sessions | Random tokens, 7-day expiry |\n| Instances | Credentials encrypted with AES-256-GCM |\n| Integrations | API keys encrypted with AES-256-GCM |\n\n### Backup\n\n```bash\ncp ./data/qbitwebui.db ./backup/\n```\n\n### Restore\n\n```bash\ncp ./backup/qbitwebui.db ./data/\n```\n\nUse the same `ENCRYPTION_KEY` after restore.\n"
  },
  {
    "path": "docs/guide/docker.md",
    "content": "# Docker Deployment\n\n## Images\n\n| Image | Description |\n|-------|-------------|\n| `ghcr.io/maciejonos/qbitwebui` | Main application |\n| `ghcr.io/maciejonos/qbitwebui-agent` | Network diagnostics agent |\n\nBoth support `linux/amd64` and `linux/arm64`.\n\n## Quick Start\n\n```bash\ndocker run -d \\\n  --name qbitwebui \\\n  -p 3000:3000 \\\n  -v ./data:/data \\\n  -e ENCRYPTION_KEY=$(openssl rand -hex 32) \\\n  ghcr.io/maciejonos/qbitwebui:latest\n```\n\n## Docker Compose Examples\n\n### Basic\n\n```yaml\nservices:\n  qbitwebui:\n    image: ghcr.io/maciejonos/qbitwebui:latest\n    container_name: qbitwebui\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - ./qbitwebui-data:/data\n    environment:\n      - ENCRYPTION_KEY=your-32-character-minimum-key-here\n    restart: unless-stopped\n```\n\n### With File Browser\n\n```yaml\nservices:\n  qbitwebui:\n    image: ghcr.io/maciejonos/qbitwebui:latest\n    container_name: qbitwebui\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - ./qbitwebui-data:/data\n      - /path/to/your/downloads:/downloads:ro\n    environment:\n      - ENCRYPTION_KEY=your-32-character-minimum-key-here\n      - DOWNLOADS_PATH=/downloads\n    restart: unless-stopped\n```\n\n### Full Stack (qBittorrent + Agent + qbitwebui)\n\nComplete setup with all components:\n\n```yaml\nservices:\n  qbittorrent:\n    image: linuxserver/qbittorrent:latest\n    container_name: qbittorrent\n    environment:\n      - PUID=1000\n      - PGID=1000\n      - TZ=Europe/London\n      - WEBUI_PORT=8080\n    volumes:\n      - ./qbittorrent-config:/config\n      - ./downloads:/downloads\n    ports:\n      - \"8080:8080\"      # qBittorrent WebUI\n      - \"6881:6881\"      # BitTorrent TCP\n      - \"6881:6881/udp\"  # BitTorrent UDP\n      - \"9876:9876\"      # Network Agent\n    restart: unless-stopped\n\n  net-agent:\n    image: ghcr.io/maciejonos/qbitwebui-agent:latest\n    container_name: net-agent\n    network_mode: \"service:qbittorrent\"\n    environment:\n      - QBT_URL=http://localhost:8080\n    depends_on:\n      - qbittorrent\n    restart: unless-stopped\n\n  qbitwebui:\n    image: ghcr.io/maciejonos/qbitwebui:latest\n    container_name: qbitwebui\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - ./qbitwebui-data:/data\n      - ./downloads:/downloads:ro\n    environment:\n      - ENCRYPTION_KEY=your-32-character-minimum-key-here\n      - DOWNLOADS_PATH=/downloads\n    depends_on:\n      - qbittorrent\n    restart: unless-stopped\n```\n\n### With VPN (Gluetun)\n\nRoute qBittorrent through a VPN:\n\n```yaml\nservices:\n  gluetun:\n    image: qmcgaw/gluetun:latest\n    container_name: gluetun\n    cap_add:\n      - NET_ADMIN\n    devices:\n      - /dev/net/tun:/dev/net/tun\n    environment:\n      - VPN_SERVICE_PROVIDER=mullvad  # or your provider\n      - VPN_TYPE=wireguard\n      - WIREGUARD_PRIVATE_KEY=your-private-key\n      - WIREGUARD_ADDRESSES=10.x.x.x/32\n      - SERVER_COUNTRIES=Sweden\n    ports:\n      - \"8080:8080\"      # qBittorrent WebUI\n      - \"6881:6881\"      # BitTorrent\n      - \"6881:6881/udp\"\n      - \"9876:9876\"      # Network Agent\n    restart: unless-stopped\n\n  qbittorrent:\n    image: linuxserver/qbittorrent:latest\n    container_name: qbittorrent\n    network_mode: \"service:gluetun\"\n    environment:\n      - PUID=1000\n      - PGID=1000\n      - WEBUI_PORT=8080\n    volumes:\n      - ./qbittorrent-config:/config\n      - ./downloads:/downloads\n    depends_on:\n      - gluetun\n    restart: unless-stopped\n\n  net-agent:\n    image: ghcr.io/maciejonos/qbitwebui-agent:latest\n    container_name: net-agent\n    network_mode: \"service:gluetun\"\n    environment:\n      - QBT_URL=http://localhost:8080\n    depends_on:\n      - qbittorrent\n    restart: unless-stopped\n\n  qbitwebui:\n    image: ghcr.io/maciejonos/qbitwebui:latest\n    container_name: qbitwebui\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - ./qbitwebui-data:/data\n      - ./downloads:/downloads:ro\n    environment:\n      - ENCRYPTION_KEY=your-32-character-minimum-key-here\n      - DOWNLOADS_PATH=/downloads\n    depends_on:\n      - qbittorrent\n    restart: unless-stopped\n```\n\n::: tip\nWith VPN setup, use the Network Agent to verify your VPN is working correctly by checking the external IP.\n:::\n\n### Multiple Instances\n\nManage multiple qBittorrent instances:\n\n```yaml\nservices:\n  qbittorrent-1:\n    image: linuxserver/qbittorrent:latest\n    container_name: qbittorrent-1\n    environment:\n      - PUID=1000\n      - PGID=1000\n      - WEBUI_PORT=8080\n    volumes:\n      - ./qbt1-config:/config\n      - ./downloads-1:/downloads\n    ports:\n      - \"8080:8080\"\n      - \"6881:6881\"\n      - \"6881:6881/udp\"\n    restart: unless-stopped\n\n  qbittorrent-2:\n    image: linuxserver/qbittorrent:latest\n    container_name: qbittorrent-2\n    environment:\n      - PUID=1000\n      - PGID=1000\n      - WEBUI_PORT=8080\n    volumes:\n      - ./qbt2-config:/config\n      - ./downloads-2:/downloads\n    ports:\n      - \"8081:8080\"\n      - \"6882:6881\"\n      - \"6882:6881/udp\"\n    restart: unless-stopped\n\n  qbitwebui:\n    image: ghcr.io/maciejonos/qbitwebui:latest\n    container_name: qbitwebui\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - ./qbitwebui-data:/data\n    environment:\n      - ENCRYPTION_KEY=your-32-character-minimum-key-here\n    restart: unless-stopped\n```\n\nAdd both instances in qbitwebui with their respective URLs (`http://host:8080` and `http://host:8081`).\n\n## Reverse Proxy\n\n### Nginx\n\n```nginx\nserver {\n    listen 443 ssl http2;\n    server_name qbit.example.com;\n\n    ssl_certificate /path/to/cert.pem;\n    ssl_certificate_key /path/to/key.pem;\n\n    location / {\n        proxy_pass http://localhost:3000;\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n```\n\n### Caddy\n\n```\nqbit.example.com {\n    reverse_proxy localhost:3000\n}\n```\n\n### Traefik (Labels)\n\n```yaml\nservices:\n  qbitwebui:\n    image: ghcr.io/maciejonos/qbitwebui:latest\n    labels:\n      - \"traefik.enable=true\"\n      - \"traefik.http.routers.qbitwebui.rule=Host(`qbit.example.com`)\"\n      - \"traefik.http.routers.qbitwebui.entrypoints=websecure\"\n      - \"traefik.http.routers.qbitwebui.tls=true\"\n      - \"traefik.http.routers.qbitwebui.tls.certresolver=letsencrypt\"\n      - \"traefik.http.services.qbitwebui.loadbalancer.server.port=3000\"\n    # ... rest of config\n```\n\n### With External Authentication\n\nUsing Authelia, Authentik, or similar:\n\n```yaml\nservices:\n  qbitwebui:\n    image: ghcr.io/maciejonos/qbitwebui:latest\n    environment:\n      - ENCRYPTION_KEY=your-key\n      - DISABLE_AUTH=true  # Let reverse proxy handle auth\n    # ... rest of config\n```\n\n## Updating\n\n### Manual\n\n```bash\ndocker compose pull\ndocker compose up -d\n```\n\n### Watchtower (Automatic)\n\n```yaml\nservices:\n  watchtower:\n    image: containrrr/watchtower:latest\n    container_name: watchtower\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n    environment:\n      - WATCHTOWER_CLEANUP=true\n      - WATCHTOWER_SCHEDULE=0 0 4 * * *  # 4 AM daily\n    restart: unless-stopped\n```\n\n## Volumes\n\n| Path | Description |\n|------|-------------|\n| `/data` | Database and encryption salt (required) |\n| `/downloads` | Downloads directory for file browser (optional) |\n\n## Ports\n\n| Port | Service |\n|------|---------|\n| `3000` | qbitwebui web interface |\n| `9876` | Network agent (exposed through qBittorrent container) |\n\n## Health Check\n\nqbitwebui exposes `/api/config` for health checks:\n\n```yaml\nhealthcheck:\n  test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:3000/api/config\"]\n  interval: 30s\n  timeout: 10s\n  retries: 3\n```\n"
  },
  {
    "path": "docs/guide/features.md",
    "content": "# Features\n\n## Multi-Instance Dashboard\n\nManage multiple qBittorrent instances from one interface:\n\n- Overview cards showing status, speeds, and torrent counts\n- Aggregate statistics across all instances\n- Quick switching between instances\n- Connection testing and version display\n\n### Instance Options\n\n| Option | Description |\n|--------|-------------|\n| Skip Authentication | Use when qBittorrent has IP bypass enabled |\n| Enable Network Agent | Connect to net-agent for diagnostics |\n\n## Torrent Management\n\n### List View\n\n- Sortable columns (name, size, progress, speed, ratio, etc.)\n- Filter by status: All, Downloading, Seeding, Completed, Paused, Active, Inactive, Stalled, Checking, Error\n- Filter by category, tag, or tracker\n- Search by torrent name\n- Customizable columns with drag-to-reorder\n- Resizable column widths (persisted)\n\n### Actions\n\n| Action | Description |\n|--------|-------------|\n| Start/Stop | Resume or pause torrents |\n| Recheck | Verify torrent data integrity |\n| Reannounce | Force tracker announce |\n| Delete | Remove torrent, optionally with files |\n| Rename | Change torrent name |\n| Export | Download .torrent file |\n| Set Category | Assign to a category |\n| Add/Remove Tags | Manage torrent tags |\n\n### Details Panel\n\nExpandable panel showing:\n\n- **General**: Size, progress, ratio, ETA, speeds, seeds/peers, dates, save path\n- **Trackers**: List with status, add/remove trackers\n- **Peers**: Connected peers with client, flags, progress, speeds\n- **Files**: File tree with individual progress and priority control\n- **HTTP Sources**: Web seed URLs\n\n### Keyboard Shortcuts\n\n| Key | Action |\n|-----|--------|\n| `↑` `↓` | Navigate between torrents |\n| `Ctrl+A` | Select all torrents |\n| `Escape` | Clear selection |\n\n### Context Menu\n\nRight-click any torrent for quick actions including category/tag submenus.\n\n## Custom Views\n\nSave your current filter and column setup:\n\n1. Configure filters, columns, and sort order\n2. Click the view selector → Save View\n3. Name your view\n4. Switch between saved views instantly\n\n## Categories & Tags\n\n### Categories\n\n- Create categories with optional custom save paths\n- Edit save paths for existing categories\n- Delete categories\n- Assign via context menu or details panel\n\n### Tags\n\n- Create and delete tags\n- Add/remove tags from torrents\n- Filter torrents by tag\n\n## Prowlarr Integration\n\nSearch across all your indexers without leaving qbitwebui.\n\n### Setup\n\n1. Go to any instance → Settings icon → Integrations tab\n2. Click **Add Prowlarr**\n3. Enter Prowlarr URL and API key\n4. Test connection and save\n\n### Searching\n\n1. Click the search icon in the header\n2. Enter search query\n3. Filter by indexer or category\n4. View results with seeders, size, age, freeleech status\n5. Click grab → select instance → optionally set category/path → confirm\n\n## RSS Manager\n\nManage RSS feeds and auto-download rules.\n\n### Feeds\n\n- Add feeds by URL\n- Organize in folders\n- Refresh feeds manually\n- View articles with grab option\n\n### Auto-Download Rules\n\n- Create rules with name patterns (regex supported)\n- Filter by category, episode, season\n- Set target category and save path\n- Preview matching articles\n\n## File Browser\n\nBrowse and manage downloaded files (requires `DOWNLOADS_PATH`).\n\n### Operations\n\n| Operation | Description |\n|-----------|-------------|\n| Browse | Navigate directories |\n| Download | Download files or folders (as tar) |\n| Delete | Remove files/directories |\n| Move | Move to another location |\n| Copy | Copy to another location |\n| Rename | Rename file or directory |\n\n## Cross-Seed (Experimental)\n\nFind cross-seeding opportunities using Prowlarr indexers.\n\n### How It Works\n\n1. Configure Prowlarr integration\n2. Select which indexers to search\n3. Run scan on your torrents\n4. Review matches (size, name similarity)\n5. Add matches to start cross-seeding\n\n### Options\n\n- Match mode: Strict or Flexible\n- Dry run: Preview without adding\n- Category suffix for cross-seeded torrents\n- Blocklist for excluding certain releases\n\n## Orphan Manager\n\nDetect and clean up problematic torrents.\n\n### Detects\n\n- Torrents with missing files on disk\n- Torrents with unregistered tracker status\n\n### Actions\n\n- Scan all instances at once\n- Bulk select orphans\n- Delete with or without files\n\n## Statistics\n\nView transfer history with multiple time periods:\n\n- 15 minutes, 30 minutes, 1 hour\n- 4 hours, 12 hours, 24 hours\n- 7 days, 30 days, all-time\n\nToggle between per-instance and aggregate views.\n\n## Log Viewer\n\nView qBittorrent logs in real-time.\n\n### Application Logs\n\nFilter by level:\n- Normal\n- Info\n- Warning\n- Critical\n\n### Peer Logs\n\nConnection events with IP, client, and direction.\n\nAuto-refresh available for both.\n\n## Settings Panel\n\nEdit qBittorrent preferences directly.\n\n### Tabs\n\n| Tab | Settings |\n|-----|----------|\n| Behavior | Language, startup, power management |\n| Downloads | Save paths, pre-allocation, torrent handling |\n| Connection | Ports, protocols, proxy |\n| Speed | Global/per-torrent limits, scheduling |\n| BitTorrent | DHT, PeX, encryption, queueing |\n| RSS | Auto-download, refresh interval |\n| WebUI | Address, auth, HTTPS, custom UI |\n| Advanced | Memory, disk cache, network options |\n\nOnly changed values are saved.\n\n## Themes\n\n### Built-in Themes\n\nMultiple themes available including Dark, Light, Catppuccin variants, Nord, and more.\n\n### Custom Themes\n\nCreate your own theme with the theme editor:\n\n- Background colors (primary, secondary, tertiary)\n- Text colors (primary, secondary, muted)\n- Accent color\n- Border colors\n- Status colors (success, warning, error)\n\nThemes are saved in browser localStorage.\n\n## Mobile Support\n\nFully responsive with dedicated mobile interface:\n\n- Touch-optimized torrent list\n- Swipe actions\n- Mobile-specific navigation\n- All features accessible\n"
  },
  {
    "path": "docs/guide/getting-started.md",
    "content": "# Getting Started\n\n## Requirements\n\n- Docker (recommended) or Bun runtime\n- A running qBittorrent instance with WebUI enabled\n\n## Quick Start\n\n```bash\ndocker run -d \\\n  --name qbitwebui \\\n  -p 3000:3000 \\\n  -v ./data:/data \\\n  -e ENCRYPTION_KEY=$(openssl rand -hex 32) \\\n  ghcr.io/maciejonos/qbitwebui:latest\n```\n\nOpen `http://localhost:3000` in your browser.\n\n## First Setup\n\n1. **Create Account** - Register with username and password (first user is admin)\n2. **Add Instance** - Click **+** and enter your qBittorrent details:\n   - Label: A name for this instance (e.g., \"Seedbox\")\n   - URL: qBittorrent WebUI address (e.g., `http://192.168.1.100:8080`)\n   - Username & Password: Your qBittorrent credentials\n3. **Connect** - Click the instance card to start managing torrents\n\n::: tip\nIf qBittorrent has \"Bypass authentication for clients on localhost\" enabled, check **Skip authentication** when adding the instance.\n:::\n\n## Docker Compose\n\n### Minimal Setup\n\n```yaml\nservices:\n  qbitwebui:\n    image: ghcr.io/maciejonos/qbitwebui:latest\n    container_name: qbitwebui\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - ./data:/data\n    environment:\n      - ENCRYPTION_KEY=generate-a-32-char-key-here\n    restart: unless-stopped\n```\n\n### With File Browser\n\n```yaml\nservices:\n  qbitwebui:\n    image: ghcr.io/maciejonos/qbitwebui:latest\n    container_name: qbitwebui\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - ./data:/data\n      - /path/to/downloads:/downloads:ro\n    environment:\n      - ENCRYPTION_KEY=generate-a-32-char-key-here\n      - DOWNLOADS_PATH=/downloads\n    restart: unless-stopped\n```\n\n## Generating Encryption Key\n\nThe `ENCRYPTION_KEY` is used to encrypt stored credentials. Generate a secure one:\n\n```bash\nopenssl rand -hex 32\n```\n\n::: warning\nSave this key securely. If you lose it, you'll need to re-add all instances.\n:::\n\n## Next Steps\n\n- [Configuration](/guide/configuration) - Environment variables and options\n- [Features](/guide/features) - All features explained\n- [Docker](/guide/docker) - Full deployment examples\n- [Network Agent](/guide/network-agent/) - Network diagnostics\n"
  },
  {
    "path": "docs/guide/network-agent/index.md",
    "content": "# Network Agent\n\nA lightweight companion service that provides network diagnostics from your qBittorrent host's perspective.\n\n## Why Use It?\n\nWhen qBittorrent runs behind a VPN or on a remote server, you need to verify the network from that machine's perspective:\n\n- **VPN Verification** - Check external IP to confirm VPN is active\n- **Speed Testing** - Run speedtests from the actual download location\n- **DNS Debugging** - View configured DNS servers, check for leaks\n- **Connectivity Testing** - Ping, traceroute, dig from the host\n\nThe agent runs in the same network namespace as qBittorrent, so all diagnostics reflect exactly what qBittorrent sees.\n\n## Features\n\n| Feature | Description |\n|---------|-------------|\n| **IP Check** | External IP, city, region, country, ISP via ipinfo.io |\n| **Speedtest** | Ookla speedtest with server selection |\n| **DNS** | View /etc/resolv.conf nameservers |\n| **Interfaces** | List network interfaces with IPs and status |\n| **Terminal** | Execute ping, dig, nslookup, traceroute, curl, wget |\n\n## Setup\n\n### Basic (alongside qBittorrent)\n\n```yaml\nservices:\n  qbittorrent:\n    image: linuxserver/qbittorrent:latest\n    container_name: qbittorrent\n    ports:\n      - \"8080:8080\"\n      - \"9876:9876\"  # Agent port\n    volumes:\n      - ./config:/config\n      - ./downloads:/downloads\n    restart: unless-stopped\n\n  net-agent:\n    image: ghcr.io/Maciejonos/qbitwebui-agent:latest\n    container_name: net-agent\n    network_mode: \"service:qbittorrent\"\n    environment:\n      - QBT_URL=http://localhost:8080\n    depends_on:\n      - qbittorrent\n    restart: unless-stopped\n```\n\n### With VPN (Gluetun)\n\n```yaml\nservices:\n  gluetun:\n    image: qmcgaw/gluetun\n    container_name: gluetun\n    cap_add:\n      - NET_ADMIN\n    devices:\n      - /dev/net/tun:/dev/net/tun\n    ports:\n      - \"8080:8080\"\n      - \"9876:9876\"\n    volumes:\n      - ./gluetun:/gluetun\n    environment:\n      - VPN_SERVICE_PROVIDER=your-provider\n      - VPN_TYPE=wireguard\n      # ... your VPN config\n    healthcheck:\n      test: ping -c 1 1.1.1.1 || exit 1\n      interval: 20s\n      timeout: 10s\n      retries: 5\n    restart: unless-stopped\n\n  qbittorrent:\n    image: lscr.io/linuxserver/qbittorrent:latest\n    container_name: qbittorrent\n    network_mode: service:gluetun\n    depends_on:\n      gluetun:\n        condition: service_healthy\n        restart: true\n    environment:\n      - PUID=1000\n      - PGID=1000\n      - TZ=Etc/UTC\n    volumes:\n      - ./qbittorrent:/config\n      - ./downloads:/downloads\n    healthcheck:\n      test: ping -c 1 1.1.1.1 || exit 1\n      interval: 60s\n      retries: 3\n      start_period: 20s\n      timeout: 10s\n    restart: unless-stopped\n\n  net-agent:\n    image: ghcr.io/maciejonos/qbitwebui-agent:latest\n    container_name: net-agent\n    network_mode: service:gluetun\n    environment:\n      - QBT_URL=http://localhost:8080\n    depends_on:\n      qbittorrent:\n        condition: service_healthy\n        restart: true\n    restart: unless-stopped\n```\n\n::: tip\nWith VPN setups, running the IP check will show the VPN's IP, not your home IP - confirming the VPN is working.\n:::\n\n## Enable in qbitwebui\n\n1. Go to the dashboard\n2. Click edit on your instance\n3. Check **Enable Network Agent**\n4. Save\n\nThe **Network Tools** section will appear in the Tools menu.\n\n## Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `PORT` | `9876` | Port the agent listens on |\n| `QBT_URL` | `http://localhost:8080` | qBittorrent WebUI URL for auth |\n| `ALLOW_SELF_SIGNED_CERTS` | `false` | Accept self-signed TLS certificates |\n\n## Authentication\n\nThe agent validates requests by checking the qBittorrent session (SID). Only users with valid qBittorrent sessions can access the agent.\n\n**Auto-detection**: If qBittorrent has authentication disabled (localhost bypass), the agent automatically detects this and skips SID validation.\n\n## Terminal Commands\n\nThe terminal supports these commands:\n\n| Command | Example |\n|---------|---------|\n| `ping` | `ping -c 4 8.8.8.8` |\n| `dig` | `dig google.com` |\n| `nslookup` | `nslookup example.com` |\n| `traceroute` | `traceroute 1.1.1.1` |\n| `curl` | `curl -I https://example.com` |\n| `wget` | `wget -q -O- https://ifconfig.me` |\n\n## Troubleshooting\n\n### Agent shows \"Offline\"\n\n1. **Check container is running**:\n   ```bash\n   docker ps | grep net-agent\n   ```\n\n2. **Check logs**:\n   ```bash\n   docker logs net-agent\n   ```\n\n3. **Verify port is exposed**: Port 9876 must be exposed on the container that owns the network:\n   - Without VPN: on qBittorrent container\n   - With VPN: on Gluetun container\n\n4. **Test connectivity**:\n   ```bash\n   curl http://your-host:9876/health\n   # Should return: {\"status\":\"ok\"}\n   ```\n\n### Authentication Errors\n\n- **Wrong QBT_URL**: Verify the URL is correct and accessible from inside the container\n- **Self-signed cert**: Set `ALLOW_SELF_SIGNED_CERTS=true` if qBittorrent uses HTTPS with self-signed certificate\n- **qBittorrent not ready**: Ensure qBittorrent is fully started before agent tries to connect\n\n### Speedtest Fails\n\n- Check agent logs for specific errors\n- Some networks block speedtest servers\n- The Ookla CLI auto-accepts license on first run\n\n## API Endpoints\n\nFor advanced users or automation:\n\n| Endpoint | Auth | Description |\n|----------|------|-------------|\n| `GET /health` | No | Health check |\n| `GET /ip` | Yes | External IP info |\n| `GET /speedtest` | Yes | Run speedtest (`?server=ID` optional) |\n| `GET /speedtest/servers` | Yes | List nearby servers |\n| `GET /dns` | Yes | DNS configuration |\n| `GET /interfaces` | Yes | Network interfaces |\n| `GET /exec?cmd=...` | Yes | Execute command |\n\nPass authentication via `X-QBT-SID` header or `SID` cookie.\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\nlayout: home\nhero:\n  name: qbitwebui\n  text: Modern qBittorrent Web UI\n  actions:\n    - theme: brand\n      text: Get Started\n      link: /guide/getting-started\n    - theme: alt\n      text: GitHub\n      link: https://github.com/Maciejonos/qbitwebui\nfeatures:\n  - title: Multi-Instance\n    details: Manage multiple qBittorrent instances from one dashboard\n  - title: Prowlarr Search\n    details: Search indexers and add torrents directly\n  - title: Cross-Seed\n    details: Find cross-seeding opportunities automatically\n  - title: Network Agent\n    details: Speedtest, IP check, and diagnostics from your qBittorrent host\n  - title: File Browser\n    details: Browse and manage downloaded files\n  - title: Themes\n    details: Built-in themes with custom theme editor\n---\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import js from '@eslint/js'\nimport globals from 'globals'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport reactRefresh from 'eslint-plugin-react-refresh'\nimport tseslint from 'typescript-eslint'\nimport { defineConfig, globalIgnores } from 'eslint/config'\n\nexport default defineConfig([\n  globalIgnores(['dist', 'docs']),\n  {\n    files: ['**/*.{ts,tsx}'],\n    extends: [\n      js.configs.recommended,\n      tseslint.configs.recommended,\n      reactHooks.configs.flat.recommended,\n      reactRefresh.configs.vite,\n    ],\n    languageOptions: {\n      ecmaVersion: 2020,\n      globals: globals.browser,\n    },\n  },\n])\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/logo.svg\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>qbitwebui</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "net-agent/Dockerfile",
    "content": "FROM golang:1.22-alpine AS builder\n\nWORKDIR /build\nCOPY go.mod .\nCOPY main.go .\n\nRUN CGO_ENABLED=0 go build -ldflags=\"-s -w\" -o net-agent .\n\nFROM alpine:3.19\n\nRUN apk add --no-cache \\\n\tcurl \\\n\twget \\\n\tca-certificates \\\n\tbind-tools \\\n\tiproute2 \\\n\tiputils \\\n\ttraceroute\n\nRUN ARCH=$(uname -m) && \\\n\tcase \"$ARCH\" in \\\n\tx86_64) ARCH=\"x86_64\" ;; \\\n\taarch64) ARCH=\"aarch64\" ;; \\\n\t*) echo \"Unsupported arch: $ARCH\" && exit 1 ;; \\\n\tesac && \\\n\twget -q -O /tmp/speedtest.tgz \\\n\t\"https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-linux-${ARCH}.tgz\" && \\\n\ttar -xzf /tmp/speedtest.tgz -C /usr/local/bin speedtest && \\\n\trm /tmp/speedtest.tgz && \\\n\tchmod +x /usr/local/bin/speedtest\n\nCOPY --from=builder /build/net-agent /usr/local/bin/net-agent\n\nENV PORT=9876\nENV QBT_URL=http://localhost:8080\n\nEXPOSE 9876\n\nENTRYPOINT [\"/usr/local/bin/net-agent\"]\n"
  },
  {
    "path": "net-agent/README.md",
    "content": "# net-agent\n\nLightweight network utility agent for qbitwebui. Runs alongside qBittorrent to provide network diagnostics from the same network perspective.\n\n## Usage\n\nDeploy with `network_mode: \"service:qbittorrent\"` to share qBittorrent's network namespace:\n\n```yaml\nservices:\n  qbittorrent:\n    image: linuxserver/qbittorrent\n    ports:\n      - \"8080:8080\"\n      - \"9876:9876\"  # net-agent port\n\n  net-agent:\n    image: ghcr.io/mac-torreon/qbitwebui-agent:latest\n    network_mode: \"service:qbittorrent\"\n    environment:\n      - QBT_URL=http://localhost:8080  # qBittorrent is on localhost due to shared network\n```\n\n## Authentication\n\nAll endpoints (except `/health`) require a valid qBittorrent session. Pass the SID via:\n- Header: `X-QBT-SID: <sid>`\n- Cookie: `SID=<sid>`\n\nThe agent validates the SID by calling qBittorrent's API. If qBittorrent has authentication disabled (IP bypass), the agent auto-detects this and skips SID validation.\n\n## Endpoints\n\n| Endpoint | Description |\n|----------|-------------|\n| `GET /health` | Health check (no auth required) |\n| `GET /ip` | External IP info via ipinfo.io |\n| `GET /speedtest` | Run Ookla speedtest (optional `?server=ID`) |\n| `GET /speedtest/servers` | List nearby speedtest servers |\n| `GET /dns` | Show configured DNS servers |\n| `GET /interfaces` | Network interface information |\n| `GET /exec?cmd=...` | Execute allowed commands (curl, wget, dig, ping, etc.) |\n\n## Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `PORT` | `9876` | Port to listen on |\n| `QBT_URL` | `http://localhost:8080` | qBittorrent WebUI URL for auth validation |\n| `ALLOW_SELF_SIGNED_CERTS` | `false` | Accept self-signed TLS certificates for qBittorrent |\n\n## Building\n\n```bash\ndocker build -t qbitwebui-agent .\n```\n\n## Local Development\n\n```bash\ngo run main.go\n```\n"
  },
  {
    "path": "net-agent/go.mod",
    "content": "module github.com/mac-torreon/qbitwebui/net-agent\n\ngo 1.22\n"
  },
  {
    "path": "net-agent/main.go",
    "content": "package main\n\nimport (\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"time\"\n)\n\nvar (\n\tqbtURL     string\n\thttpClient *http.Client\n\tskipAuth   bool\n)\n\nfunc main() {\n\tlog.SetFlags(log.Ldate | log.Ltime)\n\n\tport := os.Getenv(\"PORT\")\n\tif port == \"\" {\n\t\tport = \"9876\"\n\t}\n\tqbtURL = os.Getenv(\"QBT_URL\")\n\tif qbtURL == \"\" {\n\t\tqbtURL = \"http://localhost:8080\"\n\t}\n\n\tif os.Getenv(\"ALLOW_SELF_SIGNED_CERTS\") == \"true\" {\n\t\thttpClient = &http.Client{\n\t\t\tTimeout:   5 * time.Second,\n\t\t\tTransport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},\n\t\t}\n\t} else {\n\t\thttpClient = &http.Client{Timeout: 5 * time.Second}\n\t}\n\n\tskipAuth = detectSkipAuth()\n\tif skipAuth {\n\t\tlog.Printf(\"qBittorrent localhost auth enabled, using passthrough mode\")\n\t}\n\n\thttp.HandleFunc(\"/health\", withLogging(handleHealth))\n\thttp.HandleFunc(\"/ip\", withLogging(withAuth(handleIP)))\n\thttp.HandleFunc(\"/speedtest\", withLogging(withAuth(handleSpeedtest)))\n\thttp.HandleFunc(\"/speedtest/servers\", withLogging(withAuth(handleSpeedtestServers)))\n\thttp.HandleFunc(\"/dns\", withLogging(withAuth(handleDNS)))\n\thttp.HandleFunc(\"/interfaces\", withLogging(withAuth(handleInterfaces)))\n\thttp.HandleFunc(\"/exec\", withLogging(withAuth(handleExec)))\n\n\tlog.Printf(\"net-agent listening on :%s (qbt: %s)\", port, qbtURL)\n\tif err := http.ListenAndServe(\":\"+port, nil); err != nil {\n\t\tlog.Fatalf(\"server error: %v\", err)\n\t}\n}\n\nfunc detectSkipAuth() bool {\n\treq, err := http.NewRequest(\"GET\", qbtURL+\"/api/v2/app/version\", nil)\n\tif err != nil {\n\t\treturn false\n\t}\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn false\n\t}\n\tdefer resp.Body.Close()\n\treturn resp.StatusCode == http.StatusOK\n}\n\nfunc withLogging(next http.HandlerFunc) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tstart := time.Now()\n\t\twrapped := &statusWriter{ResponseWriter: w, status: 200}\n\t\tnext(wrapped, r)\n\t\tlog.Printf(\"%s %s %d %v\", r.Method, r.URL.Path, wrapped.status, time.Since(start).Round(time.Millisecond))\n\t}\n}\n\ntype statusWriter struct {\n\thttp.ResponseWriter\n\tstatus int\n}\n\nfunc (w *statusWriter) WriteHeader(status int) {\n\tw.status = status\n\tw.ResponseWriter.WriteHeader(status)\n}\n\nfunc withAuth(next http.HandlerFunc) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tif skipAuth {\n\t\t\tnext(w, r)\n\t\t\treturn\n\t\t}\n\t\tsid := r.Header.Get(\"X-QBT-SID\")\n\t\tif sid == \"\" {\n\t\t\tif cookie, err := r.Cookie(\"SID\"); err == nil {\n\t\t\t\tsid = cookie.Value\n\t\t\t}\n\t\t}\n\t\tif sid == \"\" {\n\t\t\tlog.Printf(\"auth failed: missing SID for %s\", r.URL.Path)\n\t\t\thttp.Error(w, `{\"error\":\"missing SID\"}`, http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tif !validateSID(sid) {\n\t\t\tlog.Printf(\"auth failed: invalid SID for %s\", r.URL.Path)\n\t\t\thttp.Error(w, `{\"error\":\"invalid SID\"}`, http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tnext(w, r)\n\t}\n}\n\nfunc validateSID(sid string) bool {\n\treq, err := http.NewRequest(\"GET\", qbtURL+\"/api/v2/app/version\", nil)\n\tif err != nil {\n\t\treturn false\n\t}\n\treq.AddCookie(&http.Cookie{Name: \"SID\", Value: sid})\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn false\n\t}\n\tdefer resp.Body.Close()\n\treturn resp.StatusCode == http.StatusOK\n}\n\nfunc handleHealth(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"ok\"})\n}\n\nfunc handleIP(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\tresp, err := client.Get(\"https://ipinfo.io/json\")\n\tif err != nil {\n\t\tlog.Printf(\"ip lookup failed: %v\", err)\n\t\thttp.Error(w, fmt.Sprintf(`{\"error\":\"%s\"}`, err.Error()), http.StatusBadGateway)\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\tio.Copy(w, resp.Body)\n}\n\nfunc handleSpeedtest(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tserverID := r.URL.Query().Get(\"server\")\n\targs := []string{\"--accept-license\", \"--accept-gdpr\", \"--format=json\"}\n\tif serverID != \"\" {\n\t\targs = append(args, \"--server-id=\"+serverID)\n\t\tlog.Printf(\"speedtest starting with server %s\", serverID)\n\t} else {\n\t\tlog.Printf(\"speedtest starting (auto server)\")\n\t}\n\tout, err := exec.Command(\"speedtest\", args...).Output()\n\tif err != nil {\n\t\terrMsg := err.Error()\n\t\tif exitErr, ok := err.(*exec.ExitError); ok {\n\t\t\terrMsg = string(exitErr.Stderr)\n\t\t}\n\t\tlog.Printf(\"speedtest failed: %s\", errMsg)\n\t\thttp.Error(w, fmt.Sprintf(`{\"error\":\"%s\"}`, strings.ReplaceAll(errMsg, `\"`, `\\\"`)), http.StatusInternalServerError)\n\t\treturn\n\t}\n\tw.Write(out)\n}\n\nfunc handleSpeedtestServers(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tout, err := exec.Command(\"speedtest\", \"--accept-license\", \"--accept-gdpr\", \"--format=json\", \"--servers\").Output()\n\tif err != nil {\n\t\terrMsg := err.Error()\n\t\tif exitErr, ok := err.(*exec.ExitError); ok {\n\t\t\terrMsg = string(exitErr.Stderr)\n\t\t}\n\t\tlog.Printf(\"speedtest servers failed: %s\", errMsg)\n\t\thttp.Error(w, fmt.Sprintf(`{\"error\":\"%s\"}`, strings.ReplaceAll(errMsg, `\"`, `\\\"`)), http.StatusInternalServerError)\n\t\treturn\n\t}\n\tw.Write(out)\n}\n\nfunc handleDNS(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tout, err := exec.Command(\"cat\", \"/etc/resolv.conf\").Output()\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(`{\"error\":\"%s\"}`, err.Error()), http.StatusInternalServerError)\n\t\treturn\n\t}\n\tlines := strings.Split(string(out), \"\\n\")\n\tvar servers []string\n\tfor _, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\tif strings.HasPrefix(line, \"nameserver\") {\n\t\t\tparts := strings.Fields(line)\n\t\t\tif len(parts) >= 2 {\n\t\t\t\tservers = append(servers, parts[1])\n\t\t\t}\n\t\t}\n\t}\n\tjson.NewEncoder(w).Encode(map[string]interface{}{\"servers\": servers, \"raw\": string(out)})\n}\n\nfunc handleInterfaces(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tout, err := exec.Command(\"ip\", \"-j\", \"addr\").Output()\n\tif err != nil {\n\t\toutFallback, errFallback := exec.Command(\"ip\", \"addr\").Output()\n\t\tif errFallback != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(`{\"error\":\"%s\"}`, err.Error()), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tjson.NewEncoder(w).Encode(map[string]string{\"raw\": string(outFallback)})\n\t\treturn\n\t}\n\tw.Write(out)\n}\n\nfunc handleExec(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tcmd := r.URL.Query().Get(\"cmd\")\n\tif cmd == \"\" {\n\t\thttp.Error(w, `{\"error\":\"missing cmd parameter\"}`, http.StatusBadRequest)\n\t\treturn\n\t}\n\tallowed := map[string]bool{\"curl\": true, \"wget\": true, \"dig\": true, \"nslookup\": true, \"ping\": true, \"traceroute\": true}\n\tparts := strings.Fields(cmd)\n\tif len(parts) == 0 {\n\t\thttp.Error(w, `{\"error\":\"empty command\"}`, http.StatusBadRequest)\n\t\treturn\n\t}\n\tif !allowed[parts[0]] {\n\t\tlog.Printf(\"exec blocked: command not allowed: %s\", parts[0])\n\t\thttp.Error(w, fmt.Sprintf(`{\"error\":\"command not allowed: %s\"}`, parts[0]), http.StatusForbidden)\n\t\treturn\n\t}\n\tlog.Printf(\"exec: %s\", cmd)\n\texecCmd := exec.CommandContext(r.Context(), parts[0], parts[1:]...)\n\tout, err := execCmd.CombinedOutput()\n\tif err != nil {\n\t\tlog.Printf(\"exec error: %v\", err)\n\t\tjson.NewEncoder(w).Encode(map[string]interface{}{\"output\": string(out), \"error\": err.Error()})\n\t\treturn\n\t}\n\tjson.NewEncoder(w).Encode(map[string]string{\"output\": string(out)})\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"qbitwebui\",\n  \"private\": true,\n  \"version\": \"2.43.0\",\n  \"type\": \"module\",\n  \"packageManager\": \"bun@1.3.2\",\n  \"engines\": {\n    \"bun\": \">=1.3.2\"\n  },\n  \"scripts\": {\n    \"preinstall\": \"npx only-allow bun\",\n    \"dev\": \"concurrently \\\"bun run dev:server\\\" \\\"vite\\\"\",\n    \"dev:server\": \"bun run --watch src/server/index.ts\",\n    \"dev:client\": \"vite\",\n    \"build\": \"tsc -b && vite build\",\n    \"start\": \"bun run src/server/index.ts\",\n    \"check\": \"tsc --noEmit\",\n    \"lint\": \"eslint .\",\n    \"format\": \"prettier --write src/\",\n    \"preview\": \"vite preview\",\n    \"test\": \"vitest run\",\n    \"test:watch\": \"vitest\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"docs:dev\": \"vitepress dev docs\",\n    \"docs:build\": \"vitepress build docs\",\n    \"docs:preview\": \"vitepress preview docs\"\n  },\n  \"dependencies\": {\n    \"@tanstack/react-query\": \"^5.99.0\",\n    \"colord\": \"^2.9.3\",\n    \"hono\": \"^4.12.14\",\n    \"jszip\": \"^3.10.1\",\n    \"lucide-react\": \"^0.563.0\",\n    \"react\": \"^19.2.5\",\n    \"react-colorful\": \"^5.6.1\",\n    \"react-dom\": \"^19.2.5\",\n    \"tar-stream\": \"^3.1.8\",\n    \"vaul\": \"^1.1.2\",\n    \"xml2js\": \"^0.6.2\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.4\",\n    \"@tailwindcss/vite\": \"^4.2.2\",\n    \"@testing-library/dom\": \"^10.4.1\",\n    \"@testing-library/react\": \"^16.3.2\",\n    \"@types/bun\": \"^1.3.12\",\n    \"@types/jsdom\": \"^27.0.0\",\n    \"@types/node\": \"^24.12.2\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@types/tar-stream\": \"^3.1.4\",\n    \"@types/xml2js\": \"^0.4.14\",\n    \"@vitejs/plugin-react\": \"^5.2.0\",\n    \"@vitest/coverage-v8\": \"4.0.17\",\n    \"concurrently\": \"^9.2.1\",\n    \"eslint\": \"^9.39.4\",\n    \"eslint-plugin-react-hooks\": \"^7.0.1\",\n    \"eslint-plugin-react-refresh\": \"^0.4.26\",\n    \"globals\": \"^16.5.0\",\n    \"jsdom\": \"^27.4.0\",\n    \"picocolors\": \"^1.1.1\",\n    \"prettier\": \"^3.8.3\",\n    \"tailwindcss\": \"^4.2.2\",\n    \"typescript\": \"~5.9.3\",\n    \"typescript-eslint\": \"^8.58.2\",\n    \"vite\": \"^7.3.2\",\n    \"vitepress\": \"^1.6.4\",\n    \"vitest\": \"^4.1.4\"\n  }\n}\n"
  },
  {
    "path": "src/App.tsx",
    "content": "import { useState, useEffect, useCallback, lazy, Suspense } from 'react'\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport { ThemeProvider } from './contexts/ThemeProvider'\nimport { InstanceProvider } from './contexts/InstanceProvider'\nimport { PaginationProvider } from './contexts/PaginationProvider'\nimport { Layout } from './components/Layout'\nimport { AuthForm } from './components/AuthForm'\nimport { InstanceManager } from './components/InstanceManager'\nimport { TorrentList } from './components/TorrentList'\nimport { getMe, type User } from './api/auth'\nimport { getInstances, type Instance } from './api/instances'\n\nconst MobileApp = lazy(() => import('./mobile/MobileApp').then((m) => ({ default: m.MobileApp })))\n\nconst queryClient = new QueryClient({\n\tdefaultOptions: {\n\t\tqueries: {\n\t\t\tretry: 1,\n\t\t\tstaleTime: 1000,\n\t\t},\n\t},\n})\n\nconst isMobile = () => window.innerWidth < 768\n\ntype View = 'loading' | 'auth' | 'instances' | 'torrents' | 'mobile'\ntype Tab = 'dashboard' | 'tools'\ntype Tool = 'indexers' | 'files' | 'orphans' | 'rss' | 'logs' | 'cross-seed' | 'statistics' | 'network' | null\n\nfunction parseHash(): { tab: Tab; instanceId: number | null; tool: Tool } {\n\tconst hash = window.location.hash.slice(1)\n\tif (hash === 'tools') return { tab: 'tools', instanceId: null, tool: null }\n\tif (hash.startsWith('tools/')) {\n\t\tconst toolName = hash.slice(6) as Tool\n\t\tconst validTools: Tool[] = ['indexers', 'files', 'orphans', 'rss', 'logs', 'cross-seed', 'statistics', 'network']\n\t\tif (validTools.includes(toolName)) {\n\t\t\treturn { tab: 'tools', instanceId: null, tool: toolName }\n\t\t}\n\t\treturn { tab: 'tools', instanceId: null, tool: null }\n\t}\n\tif (hash.startsWith('instance/')) {\n\t\tconst id = parseInt(hash.slice(9), 10)\n\t\tif (!isNaN(id)) return { tab: 'dashboard', instanceId: id, tool: null }\n\t}\n\treturn { tab: 'dashboard', instanceId: null, tool: null }\n}\n\nfunction setHash(tab: Tab, instanceId: number | null, tool?: Tool) {\n\tif (instanceId) {\n\t\twindow.location.hash = `instance/${instanceId}`\n\t} else if (tab === 'tools') {\n\t\twindow.location.hash = tool ? `tools/${tool}` : 'tools'\n\t} else {\n\t\twindow.location.hash = ''\n\t}\n}\n\nexport default function App() {\n\tconst [view, setView] = useState<View>('loading')\n\tconst [user, setUser] = useState<User | null>(null)\n\tconst [currentInstance, setCurrentInstance] = useState<Instance | null>(null)\n\tconst [authDisabled, setAuthDisabled] = useState(false)\n\tconst [initialTab, setInitialTab] = useState<Tab>('dashboard')\n\tconst [initialTool, setInitialTool] = useState<Tool>(null)\n\n\tconst applyRoute = useCallback(async (authenticated: boolean) => {\n\t\tif (!authenticated) return\n\t\tconst { tab, instanceId, tool } = parseHash()\n\t\tsetInitialTab(tab)\n\t\tsetInitialTool(tool)\n\t\tif (instanceId) {\n\t\t\tconst instances = await getInstances().catch(() => [])\n\t\t\tconst instance = instances.find((i) => i.id === instanceId)\n\t\t\tif (instance) {\n\t\t\t\tsetCurrentInstance(instance)\n\t\t\t\tsetView('torrents')\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tconst autoSelect = localStorage.getItem('autoSelectSingleInstance') === 'true'\n\t\tif (autoSelect) {\n\t\t\tconst instances = await getInstances().catch(() => [])\n\t\t\tif (instances.length === 1) {\n\t\t\t\tsetCurrentInstance(instances[0])\n\t\t\t\tsetView('torrents')\n\t\t\t\tsetHash('dashboard', instances[0].id)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tsetCurrentInstance(null)\n\t\tsetView(isMobile() ? 'mobile' : 'instances')\n\t}, [])\n\n\tuseEffect(() => {\n\t\tfetch('/api/config')\n\t\t\t.then((r) => r.json())\n\t\t\t.then(({ authDisabled }) => {\n\t\t\t\tif (authDisabled) {\n\t\t\t\t\tsetAuthDisabled(true)\n\t\t\t\t\tsetUser({ id: 1, username: 'guest' })\n\t\t\t\t\tapplyRoute(true)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tgetMe()\n\t\t\t\t\t.then((u) => {\n\t\t\t\t\t\tif (u) {\n\t\t\t\t\t\t\tsetUser(u)\n\t\t\t\t\t\t\tapplyRoute(true)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tsetView('auth')\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.catch(() => setView('auth'))\n\t\t\t})\n\t\t\t.catch(() => setView('auth'))\n\t}, [applyRoute])\n\n\tuseEffect(() => {\n\t\tfunction handleHashChange() {\n\t\t\tconst { tab, instanceId, tool } = parseHash()\n\t\t\tsetInitialTab(tab)\n\t\t\tsetInitialTool(tool)\n\t\t\tif (instanceId && currentInstance?.id !== instanceId) {\n\t\t\t\tgetInstances()\n\t\t\t\t\t.then((instances) => {\n\t\t\t\t\t\tconst instance = instances.find((i) => i.id === instanceId)\n\t\t\t\t\t\tif (instance) {\n\t\t\t\t\t\t\tsetCurrentInstance(instance)\n\t\t\t\t\t\t\tsetView('torrents')\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tsetCurrentInstance(null)\n\t\t\t\t\t\t\tsetView('instances')\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.catch(() => {\n\t\t\t\t\t\tsetCurrentInstance(null)\n\t\t\t\t\t\tsetView('instances')\n\t\t\t\t\t})\n\t\t\t} else if (!instanceId && currentInstance !== null) {\n\t\t\t\tsetCurrentInstance(null)\n\t\t\t\tsetView(isMobile() ? 'mobile' : 'instances')\n\t\t\t}\n\t\t}\n\t\twindow.addEventListener('hashchange', handleHashChange)\n\t\treturn () => window.removeEventListener('hashchange', handleHashChange)\n\t}, [currentInstance])\n\n\tfunction selectInstance(instance: Instance) {\n\t\tsetCurrentInstance(instance)\n\t\tsetInitialTab('dashboard')\n\t\tsetView('torrents')\n\t\tsetHash('dashboard', instance.id)\n\t}\n\n\tfunction goToTab(tab: Tab) {\n\t\tsetCurrentInstance(null)\n\t\tsetInitialTab(tab)\n\t\tsetInitialTool(null)\n\t\tsetView('instances')\n\t\tsetHash(tab, null)\n\t}\n\n\tfunction handleTabChange(tab: Tab) {\n\t\tsetInitialTab(tab)\n\t\tsetInitialTool(null)\n\t\tsetHash(tab, null)\n\t}\n\n\tfunction handleToolChange(tool: Tool) {\n\t\tsetInitialTool(tool)\n\t\tsetHash('tools', null, tool)\n\t}\n\n\tif (view === 'loading') {\n\t\treturn (\n\t\t\t<ThemeProvider>\n\t\t\t\t<div className=\"min-h-screen flex items-center justify-center\" style={{ backgroundColor: 'var(--bg-primary)' }}>\n\t\t\t\t\t<div className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tLoading...\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</ThemeProvider>\n\t\t)\n\t}\n\n\tif (view === 'auth') {\n\t\treturn (\n\t\t\t<ThemeProvider>\n\t\t\t\t<AuthForm\n\t\t\t\t\tonSuccess={(u) => {\n\t\t\t\t\t\tsetUser(u)\n\t\t\t\t\t\tsetView(isMobile() ? 'mobile' : 'instances')\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</ThemeProvider>\n\t\t)\n\t}\n\n\tif (view === 'mobile') {\n\t\treturn (\n\t\t\t<ThemeProvider>\n\t\t\t\t<Suspense\n\t\t\t\t\tfallback={\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"min-h-screen flex items-center justify-center\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-primary)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tLoading...\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t}\n\t\t\t\t>\n\t\t\t\t\t<MobileApp\n\t\t\t\t\t\tusername={user?.username || ''}\n\t\t\t\t\t\tonLogout={() => {\n\t\t\t\t\t\t\tsetUser(null)\n\t\t\t\t\t\t\tsetView('auth')\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tauthDisabled={authDisabled}\n\t\t\t\t\t/>\n\t\t\t\t</Suspense>\n\t\t\t</ThemeProvider>\n\t\t)\n\t}\n\n\tif (view === 'instances' || !currentInstance) {\n\t\treturn (\n\t\t\t<ThemeProvider>\n\t\t\t\t<QueryClientProvider client={queryClient}>\n\t\t\t\t\t<InstanceManager\n\t\t\t\t\t\tusername={user?.username || ''}\n\t\t\t\t\t\tonSelectInstance={selectInstance}\n\t\t\t\t\t\tonLogout={() => {\n\t\t\t\t\t\t\tsetUser(null)\n\t\t\t\t\t\t\tsetCurrentInstance(null)\n\t\t\t\t\t\t\tsetView('auth')\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tauthDisabled={authDisabled}\n\t\t\t\t\t\tinitialTab={initialTab}\n\t\t\t\t\t\tinitialTool={initialTool}\n\t\t\t\t\t\tonTabChange={handleTabChange}\n\t\t\t\t\t\tonToolChange={handleToolChange}\n\t\t\t\t\t/>\n\t\t\t\t</QueryClientProvider>\n\t\t\t</ThemeProvider>\n\t\t)\n\t}\n\n\treturn (\n\t\t<ThemeProvider>\n\t\t\t<QueryClientProvider client={queryClient}>\n\t\t\t\t<InstanceProvider instance={currentInstance}>\n\t\t\t\t\t<PaginationProvider>\n\t\t\t\t\t\t<Layout\n\t\t\t\t\t\t\tonTabChange={goToTab}\n\t\t\t\t\t\t\tusername={user?.username}\n\t\t\t\t\t\t\tauthDisabled={authDisabled}\n\t\t\t\t\t\t\tonLogout={() => {\n\t\t\t\t\t\t\t\tsetUser(null)\n\t\t\t\t\t\t\t\tsetCurrentInstance(null)\n\t\t\t\t\t\t\t\tsetView('auth')\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<TorrentList />\n\t\t\t\t\t\t</Layout>\n\t\t\t\t\t</PaginationProvider>\n\t\t\t\t</InstanceProvider>\n\t\t\t</QueryClientProvider>\n\t\t</ThemeProvider>\n\t)\n}\n"
  },
  {
    "path": "src/api/auth.ts",
    "content": "export interface User {\n\tid: number\n\tusername: string\n}\n\nexport async function register(username: string, password: string): Promise<User> {\n\tconst res = await fetch('/api/auth/register', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify({ username, password }),\n\t})\n\tif (!res.ok) {\n\t\tconst data = await res.json()\n\t\tthrow new Error(data.error || 'Registration failed')\n\t}\n\treturn res.json()\n}\n\nexport async function login(username: string, password: string): Promise<User> {\n\tconst res = await fetch('/api/auth/login', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify({ username, password }),\n\t})\n\tif (!res.ok) {\n\t\tconst data = await res.json()\n\t\tthrow new Error(data.error || 'Login failed')\n\t}\n\treturn res.json()\n}\n\nexport async function logout(): Promise<void> {\n\tawait fetch('/api/auth/logout', {\n\t\tmethod: 'POST',\n\t\tcredentials: 'include',\n\t})\n}\n\nexport async function getMe(): Promise<User | null> {\n\tconst res = await fetch('/api/auth/me', {\n\t\tcredentials: 'include',\n\t})\n\tif (!res.ok) return null\n\treturn res.json()\n}\n\nexport async function changePassword(currentPassword: string, newPassword: string): Promise<void> {\n\tconst res = await fetch('/api/auth/password', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify({ currentPassword, newPassword }),\n\t})\n\tif (!res.ok) {\n\t\tconst data = await res.json()\n\t\tthrow new Error(data.error || 'Failed to change password')\n\t}\n}\n"
  },
  {
    "path": "src/api/crossSeed.ts",
    "content": "export type MatchMode = 'strict' | 'flexible'\n\nexport interface CrossSeedConfig {\n\tinstance_id: number\n\tenabled: boolean\n\tinterval_hours: number\n\tdelay_seconds: number\n\tdry_run: boolean\n\tcategory_suffix: string\n\ttag: string\n\tskip_recheck: boolean\n\tintegration_id: number | null\n\tindexer_ids: number[]\n\tmatch_mode: MatchMode\n\tlink_dir: string | null\n\tblocklist: string[]\n\tinclude_single_episodes: boolean\n\tlast_run: number | null\n\tnext_run: number | null\n}\n\nexport interface TorznabIndexer {\n\tid: number\n\tname: string\n\tprotocol: string\n\tsupportsSearch: boolean\n\tcategories: number[]\n}\n\nexport interface ScanResult {\n\tinstanceId: number\n\ttorrentsTotal: number\n\ttorrentsScanned: number\n\ttorrentsSkipped: number\n\tmatchesFound: number\n\ttorrentsAdded: number\n\terrors: string[]\n\tdryRun: boolean\n\tstartedAt: number\n\tcompletedAt: number\n}\n\nexport interface SchedulerStatus {\n\tinstanceId: number\n\tinstanceLabel: string\n\tenabled: boolean\n\tintervalHours: number\n\tdryRun: boolean\n\tlastRun: number | null\n\tnextRun: number | null\n\trunning: boolean\n\tlastResult: ScanResult | null\n}\n\nexport interface CacheStats {\n\tcache: { count: number; totalSize: number }\n\toutput: { count: number; files: string[] }\n}\n\nexport interface Searchee {\n\tid: number\n\tinstance_id: number\n\ttorrent_hash: string\n\ttorrent_name: string\n\ttotal_size: number\n\tfile_count: number\n\tfile_sizes: string\n\tfirst_searched: number\n\tlast_searched: number\n\tdecision_count: number\n}\n\nexport interface Decision {\n\tid: number\n\tsearchee_id: number\n\tguid: string\n\tinfo_hash: string | null\n\tcandidate_name: string\n\tcandidate_size: number | null\n\tdecision: string\n\tfirst_seen: number\n\tlast_seen: number\n}\n\nexport async function getCrossSeedConfig(instanceId: number): Promise<CrossSeedConfig> {\n\tconst res = await fetch(`/api/cross-seed/config/${instanceId}`, { credentials: 'include' })\n\tif (!res.ok) throw new Error('Failed to fetch cross-seed config')\n\treturn res.json()\n}\n\nexport async function updateCrossSeedConfig(\n\tinstanceId: number,\n\tconfig: Partial<Omit<CrossSeedConfig, 'instance_id' | 'last_run' | 'next_run'>>\n): Promise<{ linkDirValid?: boolean }> {\n\tconst res = await fetch(`/api/cross-seed/config/${instanceId}`, {\n\t\tmethod: 'PUT',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify(config),\n\t})\n\tif (!res.ok) {\n\t\tconst err = await res.json()\n\t\tthrow new Error(err.error || 'Failed to update config')\n\t}\n\treturn res.json()\n}\n\nexport async function getIndexers(instanceId: number, integrationId?: number): Promise<TorznabIndexer[]> {\n\tconst params = integrationId ? `?integrationId=${integrationId}` : ''\n\tconst res = await fetch(`/api/cross-seed/indexers/${instanceId}${params}`, { credentials: 'include' })\n\tif (!res.ok) {\n\t\tconst err = await res.json()\n\t\tthrow new Error(err.error || 'Failed to fetch indexers')\n\t}\n\treturn res.json()\n}\n\nexport async function triggerScan(instanceId: number, force = false): Promise<ScanResult> {\n\tconst res = await fetch(`/api/cross-seed/scan/${instanceId}`, {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify({ force }),\n\t})\n\tif (!res.ok) {\n\t\tconst err = await res.json()\n\t\tthrow new Error(err.error || 'Scan failed')\n\t}\n\treturn res.json()\n}\n\nexport async function getSchedulerStatus(): Promise<SchedulerStatus[]> {\n\tconst res = await fetch('/api/cross-seed/status', { credentials: 'include' })\n\tif (!res.ok) throw new Error('Failed to fetch scheduler status')\n\treturn res.json()\n}\n\nexport async function getInstanceStatus(instanceId: number): Promise<SchedulerStatus> {\n\tconst res = await fetch(`/api/cross-seed/status/${instanceId}`, { credentials: 'include' })\n\tif (!res.ok) throw new Error('Failed to fetch instance status')\n\treturn res.json()\n}\n\nexport async function clearCache(instanceId: number): Promise<{ cacheCleared: number; outputCleared: number }> {\n\tconst res = await fetch(`/api/cross-seed/cache/${instanceId}/clear`, {\n\t\tmethod: 'POST',\n\t\tcredentials: 'include',\n\t})\n\tif (!res.ok) throw new Error('Failed to clear cache')\n\treturn res.json()\n}\n\nexport async function getCacheStats(instanceId: number): Promise<CacheStats> {\n\tconst res = await fetch(`/api/cross-seed/cache/${instanceId}/stats`, { credentials: 'include' })\n\tif (!res.ok) throw new Error('Failed to fetch cache stats')\n\treturn res.json()\n}\n\nexport async function getSearchHistory(\n\tinstanceId: number,\n\tlimit = 100,\n\toffset = 0\n): Promise<{ searchees: Searchee[]; total: number }> {\n\tconst params = new URLSearchParams({ limit: String(limit), offset: String(offset) })\n\tconst res = await fetch(`/api/cross-seed/history/${instanceId}?${params}`, { credentials: 'include' })\n\tif (!res.ok) throw new Error('Failed to fetch search history')\n\treturn res.json()\n}\n\nexport async function getDecisions(instanceId: number, searcheeId: number): Promise<Decision[]> {\n\tconst res = await fetch(`/api/cross-seed/history/${instanceId}/${searcheeId}/decisions`, {\n\t\tcredentials: 'include',\n\t})\n\tif (!res.ok) throw new Error('Failed to fetch decisions')\n\treturn res.json()\n}\n\nexport async function stopScan(instanceId: number): Promise<{ stopped: boolean }> {\n\tconst res = await fetch(`/api/cross-seed/stop/${instanceId}`, {\n\t\tmethod: 'POST',\n\t\tcredentials: 'include',\n\t})\n\tif (!res.ok) {\n\t\tconst err = await res.json()\n\t\tthrow new Error(err.error || 'Failed to stop scan')\n\t}\n\treturn res.json()\n}\n\nexport interface LogEntry {\n\ttimestamp: string\n\tlevel: 'INFO' | 'WARN' | 'ERROR'\n\tmessage: string\n}\n\nexport async function getLogs(limit = 100): Promise<LogEntry[]> {\n\tconst res = await fetch(`/api/cross-seed/logs?limit=${limit}`, { credentials: 'include' })\n\tif (!res.ok) throw new Error('Failed to fetch logs')\n\treturn res.json()\n}\n"
  },
  {
    "path": "src/api/files.ts",
    "content": "export interface FileEntry {\n\tname: string\n\tsize: number\n\tisDirectory: boolean\n\tmodified: number\n}\n\nexport async function listFiles(path: string): Promise<FileEntry[]> {\n\tconst res = await fetch(`/api/files?path=${encodeURIComponent(path)}`, {\n\t\tcredentials: 'include',\n\t})\n\tif (!res.ok) {\n\t\tconst error = await res.json()\n\t\tthrow new Error(error.error || 'Failed to list files')\n\t}\n\treturn res.json()\n}\n\nexport function getDownloadUrl(path: string): string {\n\treturn `/api/files/download?path=${encodeURIComponent(path)}`\n}\n\nexport async function checkWritable(): Promise<boolean> {\n\tconst res = await fetch('/api/files/writable', { credentials: 'include' })\n\tif (!res.ok) return false\n\tconst data = await res.json()\n\treturn data.writable\n}\n\nexport async function deleteFiles(paths: string[]): Promise<void> {\n\tconst res = await fetch('/api/files/delete', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify({ paths }),\n\t})\n\tif (!res.ok) {\n\t\tconst error = await res.json()\n\t\tthrow new Error(error.error || 'Failed to delete files')\n\t}\n}\n\nexport async function moveFiles(paths: string[], destination: string): Promise<void> {\n\tconst res = await fetch('/api/files/move', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify({ paths, destination }),\n\t})\n\tif (!res.ok) {\n\t\tconst error = await res.json()\n\t\tthrow new Error(error.error || 'Failed to move files')\n\t}\n}\n\nexport async function copyFiles(paths: string[], destination: string): Promise<void> {\n\tconst res = await fetch('/api/files/copy', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify({ paths, destination }),\n\t})\n\tif (!res.ok) {\n\t\tconst error = await res.json()\n\t\tthrow new Error(error.error || 'Failed to copy files')\n\t}\n}\n\nexport async function renameFile(path: string, newName: string): Promise<void> {\n\tconst res = await fetch('/api/files/rename', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify({ path, newName }),\n\t})\n\tif (!res.ok) {\n\t\tconst error = await res.json()\n\t\tthrow new Error(error.error || 'Failed to rename file')\n\t}\n}\n"
  },
  {
    "path": "src/api/instances.ts",
    "content": "export interface Instance {\n\tid: number\n\tlabel: string\n\turl: string\n\tqbt_username: string | null\n\tskip_auth: boolean\n\tagent_enabled: boolean\n\tagent_url: string | null\n\tcreated_at: number\n}\n\nexport interface CreateInstanceData {\n\tlabel: string\n\turl: string\n\tqbt_username?: string\n\tqbt_password?: string\n\tskip_auth?: boolean\n\tagent_enabled?: boolean\n\tagent_url?: string\n}\n\nexport interface UpdateInstanceData {\n\tlabel?: string\n\turl?: string\n\tqbt_username?: string\n\tqbt_password?: string\n\tskip_auth?: boolean\n\tagent_enabled?: boolean\n\tagent_url?: string | null\n}\n\nexport async function getInstances(): Promise<Instance[]> {\n\tconst res = await fetch('/api/instances', {\n\t\tcredentials: 'include',\n\t})\n\tif (!res.ok) throw new Error('Failed to fetch instances')\n\treturn res.json()\n}\n\nexport async function createInstance(data: CreateInstanceData): Promise<Instance> {\n\tconst res = await fetch('/api/instances', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify(data),\n\t})\n\tif (!res.ok) {\n\t\tconst error = await res.json()\n\t\tthrow new Error(error.error || 'Failed to create instance')\n\t}\n\treturn res.json()\n}\n\nexport async function updateInstance(id: number, data: UpdateInstanceData): Promise<Instance> {\n\tconst res = await fetch(`/api/instances/${id}`, {\n\t\tmethod: 'PUT',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify(data),\n\t})\n\tif (!res.ok) {\n\t\tconst error = await res.json()\n\t\tthrow new Error(error.error || 'Failed to update instance')\n\t}\n\treturn res.json()\n}\n\nexport async function deleteInstance(id: number): Promise<void> {\n\tconst res = await fetch(`/api/instances/${id}`, {\n\t\tmethod: 'DELETE',\n\t\tcredentials: 'include',\n\t})\n\tif (!res.ok) throw new Error('Failed to delete instance')\n}\n"
  },
  {
    "path": "src/api/integrations.ts",
    "content": "export interface Integration {\n\tid: number\n\ttype: string\n\tlabel: string\n\turl: string\n\tcreated_at: number\n}\n\nexport interface CreateIntegrationData {\n\ttype: string\n\tlabel: string\n\turl: string\n\tapi_key: string\n}\n\nexport interface Indexer {\n\tid: number\n\tname: string\n\tenable: boolean\n\tprotocol: string\n}\n\nexport interface ProwlarrCategory {\n\tid: number\n\tname: string\n\tsubCategories?: ProwlarrCategory[]\n}\n\nexport interface SearchResult {\n\tguid: string\n\tindexerId: number\n\tindexer: string\n\ttitle: string\n\tsize: number\n\tpublishDate: string\n\tdownloadUrl?: string\n\tmagnetUrl?: string\n\tseeders?: number\n\tleechers?: number\n\tcategories: { id: number; name: string }[]\n\tindexerFlags?: string[]\n}\n\nexport async function getIntegrations(): Promise<Integration[]> {\n\tconst res = await fetch('/api/integrations', { credentials: 'include' })\n\tif (!res.ok) throw new Error('Failed to fetch integrations')\n\treturn res.json()\n}\n\nexport async function createIntegration(data: CreateIntegrationData): Promise<Integration> {\n\tconst res = await fetch('/api/integrations', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify(data),\n\t})\n\tif (!res.ok) {\n\t\tconst err = await res.json()\n\t\tthrow new Error(err.error || 'Failed to create integration')\n\t}\n\treturn res.json()\n}\n\nexport async function deleteIntegration(id: number): Promise<void> {\n\tconst res = await fetch(`/api/integrations/${id}`, {\n\t\tmethod: 'DELETE',\n\t\tcredentials: 'include',\n\t})\n\tif (!res.ok) throw new Error('Failed to delete integration')\n}\n\nexport async function testIntegrationConnection(\n\turl: string,\n\tapiKey: string\n): Promise<{ success: boolean; version?: string; error?: string }> {\n\tconst res = await fetch('/api/integrations/test', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify({ url, api_key: apiKey }),\n\t})\n\tif (!res.ok) {\n\t\tconst err = await res.json().catch(() => ({}))\n\t\treturn { success: false, error: err.error || 'Connection test failed' }\n\t}\n\treturn res.json()\n}\n\nexport async function getIndexers(integrationId: number): Promise<Indexer[]> {\n\tconst res = await fetch(`/api/integrations/${integrationId}/indexers`, { credentials: 'include' })\n\tif (!res.ok) throw new Error('Failed to fetch indexers')\n\treturn res.json()\n}\n\nexport async function getProwlarrCategories(integrationId: number): Promise<ProwlarrCategory[]> {\n\tconst res = await fetch(`/api/integrations/${integrationId}/categories`, { credentials: 'include' })\n\tif (!res.ok) throw new Error('Failed to fetch categories')\n\treturn res.json()\n}\n\nexport async function search(\n\tintegrationId: number,\n\tquery: string,\n\toptions: { indexerIds?: string; categories?: string; type?: string } = {}\n): Promise<SearchResult[]> {\n\tconst params = new URLSearchParams({ query })\n\tif (options.indexerIds) params.set('indexerIds', options.indexerIds)\n\tif (options.categories) params.set('categories', options.categories)\n\tif (options.type) params.set('type', options.type)\n\n\tconst res = await fetch(`/api/integrations/${integrationId}/search?${params}`, { credentials: 'include' })\n\tif (!res.ok) {\n\t\tconst err = await res.json()\n\t\tthrow new Error(err.error || 'Search failed')\n\t}\n\treturn res.json()\n}\n\nexport async function grabRelease(\n\tintegrationId: number,\n\trelease: { guid: string; indexerId: number; downloadUrl?: string; magnetUrl?: string },\n\tinstanceId: number,\n\toptions?: { category?: string; savepath?: string; downloadPath?: string }\n): Promise<void> {\n\tconst res = await fetch(`/api/integrations/${integrationId}/grab`, {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tcredentials: 'include',\n\t\tbody: JSON.stringify({ ...release, instanceId, ...options }),\n\t})\n\tif (!res.ok) {\n\t\tconst err = await res.json()\n\t\tthrow new Error(err.error || 'Failed to grab release')\n\t}\n}\n"
  },
  {
    "path": "src/api/netAgent.ts",
    "content": "export interface IpInfo {\n\tip: string\n\tcity: string\n\tregion: string\n\tcountry: string\n\tloc: string\n\torg: string\n\tpostal: string\n\ttimezone: string\n}\n\nexport interface SpeedtestResult {\n\ttype: string\n\ttimestamp: string\n\tping: { jitter: number; latency: number; low: number; high: number }\n\tdownload: { bandwidth: number; bytes: number; elapsed: number }\n\tupload: { bandwidth: number; bytes: number; elapsed: number }\n\tisp: string\n\tinterface: { internalIp: string; name: string; externalIp: string; isVpn: boolean }\n\tserver: { id: number; host: string; name: string; location: string; country: string }\n\tresult: { url: string }\n}\n\nexport interface SpeedtestServer {\n\tid: number\n\thost: string\n\tport: number\n\tname: string\n\tlocation: string\n\tcountry: string\n}\n\nexport interface DnsInfo {\n\tservers: string[]\n\traw: string\n}\n\nexport interface NetworkInterface {\n\tifindex: number\n\tifname: string\n\tflags: string[]\n\tmtu: number\n\toperstate: string\n\taddress: string\n\taddr_info: { family: string; local: string; prefixlen: number; scope: string }[]\n}\n\nasync function agentRequest<T>(instanceId: number, endpoint: string): Promise<T> {\n\tconst res = await fetch(`/api/instances/${instanceId}/agent${endpoint}`, {\n\t\tcredentials: 'include',\n\t})\n\tif (!res.ok) {\n\t\tconst text = await res.text()\n\t\tthrow new Error(text || `Agent error: ${res.status}`)\n\t}\n\treturn res.json()\n}\n\nexport async function getIpInfo(instanceId: number): Promise<IpInfo> {\n\treturn agentRequest<IpInfo>(instanceId, '/ip')\n}\n\nexport async function runSpeedtest(instanceId: number, serverId?: number): Promise<SpeedtestResult> {\n\tconst endpoint = serverId ? `/speedtest?server=${serverId}` : '/speedtest'\n\treturn agentRequest<SpeedtestResult>(instanceId, endpoint)\n}\n\nexport async function getSpeedtestServers(instanceId: number): Promise<{ servers: SpeedtestServer[] }> {\n\treturn agentRequest<{ servers: SpeedtestServer[] }>(instanceId, '/speedtest/servers')\n}\n\nexport async function getDnsInfo(instanceId: number): Promise<DnsInfo> {\n\treturn agentRequest<DnsInfo>(instanceId, '/dns')\n}\n\nexport async function getInterfaces(instanceId: number): Promise<NetworkInterface[]> {\n\treturn agentRequest<NetworkInterface[]>(instanceId, '/interfaces')\n}\n\nexport async function execCommand(instanceId: number, cmd: string): Promise<{ output: string; error?: string }> {\n\treturn agentRequest<{ output: string; error?: string }>(instanceId, `/exec?cmd=${encodeURIComponent(cmd)}`)\n}\n\nexport async function checkAgentHealth(instanceId: number): Promise<boolean> {\n\ttry {\n\t\tconst res = await fetch(`/api/instances/${instanceId}/agent/health`, {\n\t\t\tcredentials: 'include',\n\t\t})\n\t\treturn res.ok\n\t} catch {\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "src/api/qbittorrent.ts",
    "content": "import JSZip from 'jszip'\nimport type { Torrent, TorrentFilter, TransferInfo, SyncMaindata } from '../types/qbittorrent'\nimport type { TorrentProperties, Tracker, PeersResponse, TorrentFile, WebSeed } from '../types/torrentDetails'\nimport type { QBittorrentPreferences } from '../types/preferences'\nimport type { RSSItems, RSSRules, RSSRule, MatchingArticles } from '../types/rss'\n\nfunction getBase(instanceId: number): string {\n\treturn `/api/instances/${instanceId}/qbt/v2`\n}\n\nasync function request<T>(instanceId: number, endpoint: string, options?: RequestInit): Promise<T> {\n\tconst res = await fetch(`${getBase(instanceId)}${endpoint}`, {\n\t\t...options,\n\t\tcredentials: 'include',\n\t})\n\tif (!res.ok) {\n\t\tthrow new Error(`API error: ${res.status}`)\n\t}\n\tconst text = await res.text()\n\tif (!text) {\n\t\tthrow new Error('Empty response from API')\n\t}\n\ttry {\n\t\treturn JSON.parse(text)\n\t} catch {\n\t\tthrow new Error(`Invalid JSON response: ${text.slice(0, 100)}`)\n\t}\n}\n\nasync function action(instanceId: number, endpoint: string, options?: RequestInit): Promise<void> {\n\tconst res = await fetch(`${getBase(instanceId)}${endpoint}`, {\n\t\t...options,\n\t\tcredentials: 'include',\n\t})\n\tif (!res.ok) {\n\t\tthrow new Error(`API error: ${res.status}`)\n\t}\n}\n\nexport interface TorrentFilterOptions {\n\tfilter?: TorrentFilter\n\tcategory?: string\n\ttag?: string\n}\n\nexport async function getTorrents(instanceId: number, options: TorrentFilterOptions = {}): Promise<Torrent[]> {\n\tconst params = new URLSearchParams()\n\tif (options.filter && options.filter !== 'all') params.set('filter', options.filter)\n\tif (options.category) params.set('category', options.category)\n\tif (options.tag) params.set('tag', options.tag)\n\tconst query = params.toString()\n\treturn request<Torrent[]>(instanceId, `/torrents/info${query ? `?${query}` : ''}`)\n}\n\nexport async function getTransferInfo(instanceId: number): Promise<TransferInfo> {\n\treturn request<TransferInfo>(instanceId, '/transfer/info')\n}\n\nexport async function getSyncMaindata(instanceId: number): Promise<SyncMaindata> {\n\treturn request<SyncMaindata>(instanceId, '/sync/maindata?rid=0')\n}\n\nexport async function stopTorrents(instanceId: number, hashes: string[]): Promise<void> {\n\tawait action(instanceId, '/torrents/stop', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hashes: hashes.join('|') }),\n\t})\n}\n\nexport async function startTorrents(instanceId: number, hashes: string[]): Promise<void> {\n\tawait action(instanceId, '/torrents/start', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hashes: hashes.join('|') }),\n\t})\n}\n\nexport async function recheckTorrents(instanceId: number, hashes: string[]): Promise<void> {\n\tawait action(instanceId, '/torrents/recheck', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hashes: hashes.join('|') }),\n\t})\n}\n\nexport async function reannounceTorrents(instanceId: number, hashes: string[]): Promise<void> {\n\tawait action(instanceId, '/torrents/reannounce', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hashes: hashes.join('|') }),\n\t})\n}\n\nexport async function deleteTorrents(instanceId: number, hashes: string[], deleteFiles = false): Promise<void> {\n\tawait action(instanceId, '/torrents/delete', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({\n\t\t\thashes: hashes.join('|'),\n\t\t\tdeleteFiles: deleteFiles.toString(),\n\t\t}),\n\t})\n}\n\nexport interface AddTorrentOptions {\n\turls?: string\n\tsavepath?: string\n\tcategory?: string\n\ttags?: string\n\tpaused?: boolean\n\tsequentialDownload?: boolean\n\tfirstLastPiecePrio?: boolean\n\tautoTMM?: boolean\n}\n\nexport async function addTorrent(instanceId: number, options: AddTorrentOptions, files?: File[]): Promise<void> {\n\tconst formData = new FormData()\n\tif (files) {\n\t\tfiles.forEach((file) => formData.append('torrents', file))\n\t}\n\tif (options.urls) {\n\t\tformData.append('urls', options.urls)\n\t}\n\tif (options.savepath) {\n\t\tformData.append('savepath', options.savepath)\n\t}\n\tif (options.category) {\n\t\tformData.append('category', options.category)\n\t}\n\tif (options.tags) {\n\t\tformData.append('tags', options.tags)\n\t}\n\tif (options.paused !== undefined) {\n\t\tformData.append('paused', options.paused.toString())\n\t}\n\tif (options.sequentialDownload) {\n\t\tformData.append('sequentialDownload', 'true')\n\t}\n\tif (options.firstLastPiecePrio) {\n\t\tformData.append('firstLastPiecePrio', 'true')\n\t}\n\tif (options.autoTMM !== undefined) {\n\t\tformData.append('autoTMM', options.autoTMM.toString())\n\t}\n\tconst res = await fetch(`${getBase(instanceId)}/torrents/add`, {\n\t\tmethod: 'POST',\n\t\tcredentials: 'include',\n\t\tbody: formData,\n\t})\n\tif (!res.ok) {\n\t\tthrow new Error(`Failed to add torrent: ${res.status}`)\n\t}\n}\n\nexport interface Category {\n\tname: string\n\tsavePath: string\n}\n\nexport async function getCategories(instanceId: number): Promise<Record<string, Category>> {\n\treturn request<Record<string, Category>>(instanceId, '/torrents/categories')\n}\n\nexport async function getTorrentProperties(instanceId: number, hash: string): Promise<TorrentProperties> {\n\treturn request<TorrentProperties>(instanceId, `/torrents/properties?hash=${hash}`)\n}\n\nexport async function getTorrentTrackers(instanceId: number, hash: string): Promise<Tracker[]> {\n\treturn request<Tracker[]>(instanceId, `/torrents/trackers?hash=${hash}`)\n}\n\nexport async function getTorrentPeers(instanceId: number, hash: string): Promise<PeersResponse> {\n\treturn request<PeersResponse>(instanceId, `/sync/torrentPeers?hash=${hash}`)\n}\n\nexport async function getTorrentFiles(instanceId: number, hash: string): Promise<TorrentFile[]> {\n\treturn request<TorrentFile[]>(instanceId, `/torrents/files?hash=${hash}`)\n}\n\nexport async function getTorrentWebSeeds(instanceId: number, hash: string): Promise<WebSeed[]> {\n\treturn request<WebSeed[]>(instanceId, `/torrents/webseeds?hash=${hash}`)\n}\n\nexport async function setCategory(instanceId: number, hashes: string[], category: string): Promise<void> {\n\tawait action(instanceId, '/torrents/setCategory', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hashes: hashes.join('|'), category }),\n\t})\n}\n\nexport async function addTags(instanceId: number, hashes: string[], tags: string): Promise<void> {\n\tawait action(instanceId, '/torrents/addTags', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hashes: hashes.join('|'), tags }),\n\t})\n}\n\nexport async function removeTags(instanceId: number, hashes: string[], tags: string): Promise<void> {\n\tawait action(instanceId, '/torrents/removeTags', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hashes: hashes.join('|'), tags }),\n\t})\n}\n\nexport async function getTags(instanceId: number): Promise<string[]> {\n\treturn request<string[]>(instanceId, '/torrents/tags')\n}\n\nexport async function createTags(instanceId: number, tags: string): Promise<void> {\n\tawait action(instanceId, '/torrents/createTags', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ tags }),\n\t})\n}\n\nexport async function deleteTags(instanceId: number, tags: string): Promise<void> {\n\tawait action(instanceId, '/torrents/deleteTags', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ tags }),\n\t})\n}\n\nexport async function createCategory(instanceId: number, category: string, savePath?: string): Promise<void> {\n\tconst params: Record<string, string> = { category }\n\tif (savePath) params.savePath = savePath\n\tawait action(instanceId, '/torrents/createCategory', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams(params),\n\t})\n}\n\nexport async function editCategory(instanceId: number, category: string, savePath: string): Promise<void> {\n\tawait action(instanceId, '/torrents/editCategory', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ category, savePath }),\n\t})\n}\n\nexport async function removeCategories(instanceId: number, categories: string[]): Promise<void> {\n\tawait action(instanceId, '/torrents/removeCategories', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ categories: categories.join('\\n') }),\n\t})\n}\n\nexport async function setFilePriority(\n\tinstanceId: number,\n\thash: string,\n\tids: number[],\n\tpriority: number\n): Promise<void> {\n\tawait action(instanceId, '/torrents/filePrio', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hash, id: ids.join('|'), priority: priority.toString() }),\n\t})\n}\n\nexport async function renameTorrent(instanceId: number, hash: string, name: string): Promise<void> {\n\tawait action(instanceId, '/torrents/rename', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hash, name }),\n\t})\n}\n\nexport async function setTorrentLocation(instanceId: number, hashes: string[], location: string): Promise<void> {\n\tawait action(instanceId, '/torrents/setLocation', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hashes: hashes.join('|'), location }),\n\t})\n}\n\nexport async function setTorrentDownloadPath(instanceId: number, hashes: string[], downloadPath: string): Promise<void> {\n\tawait action(instanceId, '/torrents/setDownloadPath', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hashes: hashes.join('|'), downloadPath }),\n\t})\n}\n\nexport async function addTrackers(instanceId: number, hash: string, urls: string[]): Promise<void> {\n\tawait action(instanceId, '/torrents/addTrackers', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hash, urls: urls.join('\\n') }),\n\t})\n}\n\nexport async function removeTrackers(instanceId: number, hash: string, urls: string[]): Promise<void> {\n\tawait action(instanceId, '/torrents/removeTrackers', {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ hash, urls: urls.join('|') }),\n\t})\n}\n\nasync function fetchTorrentBlob(instanceId: number, hash: string): Promise<Blob> {\n\tconst res = await fetch(`${getBase(instanceId)}/torrents/export?hash=${hash}`, { credentials: 'include' })\n\tif (!res.ok) throw new Error(`Export failed: ${res.status}`)\n\treturn res.blob()\n}\n\nfunction downloadBlob(blob: Blob, filename: string) {\n\tconst url = URL.createObjectURL(blob)\n\tconst a = document.createElement('a')\n\ta.href = url\n\ta.download = filename\n\ta.click()\n\tURL.revokeObjectURL(url)\n}\n\nexport async function exportTorrents(instanceId: number, torrents: { hash: string; name: string }[]): Promise<void> {\n\tif (torrents.length === 1) {\n\t\tconst blob = await fetchTorrentBlob(instanceId, torrents[0].hash)\n\t\tdownloadBlob(blob, `${torrents[0].name}.torrent`)\n\t\treturn\n\t}\n\tconst zip = new JSZip()\n\tfor (const t of torrents) {\n\t\tconst blob = await fetchTorrentBlob(instanceId, t.hash)\n\t\tzip.file(`${t.name}.torrent`, blob)\n\t}\n\tconst zipBlob = await zip.generateAsync({ type: 'blob' })\n\tdownloadBlob(zipBlob, 'torrents.zip')\n}\n\nexport async function getSpeedLimitsMode(instanceId: number): Promise<number> {\n\tconst res = await fetch(`${getBase(instanceId)}/transfer/speedLimitsMode`, { credentials: 'include' })\n\treturn Number(await res.text())\n}\n\nexport async function toggleSpeedLimitsMode(instanceId: number): Promise<void> {\n\tawait fetch(`${getBase(instanceId)}/transfer/toggleSpeedLimitsMode`, {\n\t\tmethod: 'POST',\n\t\tcredentials: 'include',\n\t})\n}\n\nexport async function getPreferences(instanceId: number): Promise<QBittorrentPreferences> {\n\treturn request<QBittorrentPreferences>(instanceId, '/app/preferences')\n}\n\nexport async function setPreferences(instanceId: number, prefs: Partial<QBittorrentPreferences>): Promise<void> {\n\tconst res = await fetch(`${getBase(instanceId)}/app/setPreferences`, {\n\t\tmethod: 'POST',\n\t\tcredentials: 'include',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({ json: JSON.stringify(prefs) }),\n\t})\n\tif (!res.ok) {\n\t\tthrow new Error(`Failed to save preferences: ${res.status}`)\n\t}\n}\n\nexport async function getRSSItems(instanceId: number, withData = false): Promise<RSSItems> {\n\treturn request<RSSItems>(instanceId, `/rss/items?withData=${withData}`)\n}\n\nasync function postRSS(instanceId: number, endpoint: string, params: Record<string, string>): Promise<void> {\n\tconst res = await fetch(`${getBase(instanceId)}${endpoint}`, {\n\t\tmethod: 'POST',\n\t\tcredentials: 'include',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams(params),\n\t})\n\tif (!res.ok) {\n\t\tconst text = await res.text()\n\t\tthrow new Error(text || `API error: ${res.status}`)\n\t}\n}\n\nexport async function addRSSFeed(instanceId: number, url: string, path?: string): Promise<void> {\n\tconst params: Record<string, string> = { url }\n\tif (path) params.path = path\n\tawait postRSS(instanceId, '/rss/addFeed', params)\n}\n\nexport async function addRSSFolder(instanceId: number, path: string): Promise<void> {\n\tawait postRSS(instanceId, '/rss/addFolder', { path })\n}\n\nexport async function removeRSSItem(instanceId: number, path: string): Promise<void> {\n\tawait postRSS(instanceId, '/rss/removeItem', { path })\n}\n\nexport async function moveRSSItem(instanceId: number, itemPath: string, destPath: string): Promise<void> {\n\tawait postRSS(instanceId, '/rss/moveItem', { itemPath, destPath })\n}\n\nexport async function refreshRSSItem(instanceId: number, itemPath: string): Promise<void> {\n\tawait postRSS(instanceId, '/rss/refreshItem', { itemPath })\n}\n\nexport async function markRSSAsRead(instanceId: number, itemPath: string, articleId?: string): Promise<void> {\n\tconst params: Record<string, string> = { itemPath }\n\tif (articleId) params.articleId = articleId\n\tawait postRSS(instanceId, '/rss/markAsRead', params)\n}\n\nexport async function getRSSRules(instanceId: number): Promise<RSSRules> {\n\treturn request<RSSRules>(instanceId, '/rss/rules')\n}\n\nexport async function setRSSRule(instanceId: number, ruleName: string, ruleDef: Partial<RSSRule>): Promise<void> {\n\tawait postRSS(instanceId, '/rss/setRule', { ruleName, ruleDef: JSON.stringify(ruleDef) })\n}\n\nexport async function removeRSSRule(instanceId: number, ruleName: string): Promise<void> {\n\tawait postRSS(instanceId, '/rss/removeRule', { ruleName })\n}\n\nexport async function renameRSSRule(instanceId: number, ruleName: string, newRuleName: string): Promise<void> {\n\tawait postRSS(instanceId, '/rss/renameRule', { ruleName, newRuleName })\n}\n\nexport async function getMatchingArticles(instanceId: number, ruleName: string): Promise<MatchingArticles> {\n\treturn request<MatchingArticles>(instanceId, `/rss/matchingArticles?ruleName=${encodeURIComponent(ruleName)}`)\n}\n\nexport interface LogEntry {\n\tid: number\n\tmessage: string\n\ttimestamp: number\n\ttype: number\n}\n\nexport interface PeerLogEntry {\n\tid: number\n\tip: string\n\ttimestamp: number\n\tblocked: boolean\n\treason: string\n}\n\nexport interface LogOptions {\n\tnormal?: boolean\n\tinfo?: boolean\n\twarning?: boolean\n\tcritical?: boolean\n\tlastKnownId?: number\n}\n\nexport async function getLog(instanceId: number, options: LogOptions = {}): Promise<LogEntry[]> {\n\tconst params = new URLSearchParams()\n\tif (options.normal !== undefined) params.set('normal', String(options.normal))\n\tif (options.info !== undefined) params.set('info', String(options.info))\n\tif (options.warning !== undefined) params.set('warning', String(options.warning))\n\tif (options.critical !== undefined) params.set('critical', String(options.critical))\n\tif (options.lastKnownId !== undefined) params.set('last_known_id', String(options.lastKnownId))\n\tconst query = params.toString()\n\treturn request<LogEntry[]>(instanceId, `/log/main${query ? `?${query}` : ''}`)\n}\n\nexport async function getPeerLog(instanceId: number, lastKnownId?: number): Promise<PeerLogEntry[]> {\n\tconst params = lastKnownId !== undefined ? `?last_known_id=${lastKnownId}` : ''\n\treturn request<PeerLogEntry[]>(instanceId, `/log/peers${params}`)\n}\n"
  },
  {
    "path": "src/api/stats.ts",
    "content": "export interface PeriodStats {\n\tinstanceId: number\n\tinstanceLabel: string\n\tuploaded: number\n\tdownloaded: number\n\thasData: boolean\n\tdataPoints: number\n}\n\nexport async function getStats(period: string): Promise<PeriodStats[]> {\n\tconst res = await fetch(`/api/stats?period=${period}`, { credentials: 'include' })\n\tif (!res.ok) throw new Error('Failed to fetch stats')\n\treturn res.json()\n}\n\nexport async function getPeriods(): Promise<string[]> {\n\tconst res = await fetch('/api/stats/periods', { credentials: 'include' })\n\tif (!res.ok) throw new Error('Failed to fetch periods')\n\treturn res.json()\n}\n"
  },
  {
    "path": "src/components/AddTorrentModal.tsx",
    "content": "import { useState, useRef } from 'react'\nimport { Plus, X, Upload, CheckCircle, Check } from 'lucide-react'\nimport { useAddTorrent, useCategories } from '../hooks/useTorrents'\n\ninterface Props {\n\topen: boolean\n\tonClose: () => void\n}\n\ntype Tab = 'link' | 'file'\n\nexport function AddTorrentModal({ open, onClose }: Props) {\n\tconst [tab, setTab] = useState<Tab>('link')\n\tconst [url, setUrl] = useState('')\n\tconst [files, setFiles] = useState<File[]>([])\n\tconst [category, setCategory] = useState('')\n\tconst [tags, setTags] = useState('')\n\tconst [savepath, setSavepath] = useState('')\n\tconst [startTorrent, setStartTorrent] = useState(true)\n\tconst [sequential, setSequential] = useState(false)\n\tconst fileInputRef = useRef<HTMLInputElement>(null)\n\n\tconst { data: categories = {} } = useCategories()\n\tconst addMutation = useAddTorrent()\n\n\tif (!open) return null\n\n\tfunction handleSubmit(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tif (tab === 'link' && !url.trim()) return\n\t\tif (tab === 'file' && files.length === 0) return\n\n\t\taddMutation.mutate(\n\t\t\t{\n\t\t\t\toptions: {\n\t\t\t\t\turls: tab === 'link' ? url.trim() : undefined,\n\t\t\t\t\tcategory: category || undefined,\n\t\t\t\t\ttags: tags || undefined,\n\t\t\t\t\tsavepath: savepath || undefined,\n\t\t\t\t\tpaused: !startTorrent,\n\t\t\t\t\tsequentialDownload: sequential,\n\t\t\t\t},\n\t\t\t\tfiles: tab === 'file' ? files : undefined,\n\t\t\t},\n\t\t\t{\n\t\t\t\tonSuccess: () => {\n\t\t\t\t\tsetUrl('')\n\t\t\t\t\tsetFiles([])\n\t\t\t\t\tsetCategory('')\n\t\t\t\t\tsetTags('')\n\t\t\t\t\tsetSavepath('')\n\t\t\t\t\tsetStartTorrent(true)\n\t\t\t\t\tsetSequential(false)\n\t\t\t\t\tonClose()\n\t\t\t\t},\n\t\t\t}\n\t\t)\n\t}\n\n\tfunction handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {\n\t\tconst selected = Array.from(e.target.files || []).filter((f) => f.name.endsWith('.torrent'))\n\t\tif (selected.length > 0) setFiles((prev) => [...prev, ...selected])\n\t}\n\n\tfunction handleDrop(e: React.DragEvent) {\n\t\te.preventDefault()\n\t\tconst dropped = Array.from(e.dataTransfer.files).filter((f) => f.name.endsWith('.torrent'))\n\t\tif (dropped.length > 0) {\n\t\t\tsetFiles((prev) => [...prev, ...dropped])\n\t\t\tsetTab('file')\n\t\t}\n\t}\n\n\tfunction removeFile(index: number) {\n\t\tsetFiles((prev) => prev.filter((_, i) => i !== index))\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tclassName=\"fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50\"\n\t\t\tonDragOver={(e) => e.preventDefault()}\n\t\t\tonDrop={handleDrop}\n\t\t>\n\t\t\t<div className=\"relative w-full max-w-md mx-4 opacity-0 animate-in\">\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute -inset-px rounded-2xl\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackground: 'linear-gradient(to bottom, color-mix(in srgb, var(--accent) 20%, transparent), transparent)',\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"relative rounded-2xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex items-center justify-between p-5 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"w-10 h-10 rounded-xl flex items-center justify-center\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--accent) 10%, transparent)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Plus className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<h3 className=\"text-base font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tAdd Torrent\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={onClose}\n\t\t\t\t\t\t\tclassName=\"p-2 rounded-lg transition-colors hover:opacity-80\"\n\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<X className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<form onSubmit={handleSubmit} className=\"p-5 space-y-4\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"flex p-1 rounded-xl border\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => setTab('link')}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-2 px-3 rounded-lg text-xs font-medium transition-all\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: tab === 'link' ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\tcolor: tab === 'link' ? 'var(--accent-contrast)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tMagnet / URL\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => setTab('file')}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-2 px-3 rounded-lg text-xs font-medium transition-all\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: tab === 'file' ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\tcolor: tab === 'file' ? 'var(--accent-contrast)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tTorrent File\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{tab === 'link' ? (\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-xs font-medium mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tMagnet link or URL\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\t\t\tvalue={url}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setUrl(e.target.value)}\n\t\t\t\t\t\t\t\t\tplaceholder=\"magnet:?xt=urn:btih:... or https://...\"\n\t\t\t\t\t\t\t\t\trows={3}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-sm resize-none focus:outline-none transition-colors\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-xs font-medium mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tTorrent files\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\tref={fileInputRef}\n\t\t\t\t\t\t\t\t\ttype=\"file\"\n\t\t\t\t\t\t\t\t\taccept=\".torrent\"\n\t\t\t\t\t\t\t\t\tmultiple\n\t\t\t\t\t\t\t\t\tonChange={handleFileChange}\n\t\t\t\t\t\t\t\t\tclassName=\"hidden\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonClick={() => fileInputRef.current?.click()}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full py-4 px-4 rounded-xl border border-dashed text-sm transition-colors hover:opacity-80\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"flex flex-col items-center gap-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t<Upload className=\"w-6 h-6\" strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t\t<span>Click or drop .torrent files</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t{files.length > 0 && (\n\t\t\t\t\t\t\t\t\t<div className=\"mt-2 space-y-1 max-h-24 overflow-y-auto\">\n\t\t\t\t\t\t\t\t\t\t{files.map((f, i) => (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<CheckCircle className=\"w-4 h-4 shrink-0\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"truncate flex-1\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{f.name}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => removeFile(i)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-0.5 rounded hover:opacity-70\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<X className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-xs font-medium mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tCategory\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\tvalue={category}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setCategory(e.target.value)}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2.5 rounded-xl border text-sm focus:outline-none transition-colors appearance-none cursor-pointer\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<option value=\"\">None</option>\n\t\t\t\t\t\t\t\t\t{Object.keys(categories).map((cat) => (\n\t\t\t\t\t\t\t\t\t\t<option key={cat} value={cat}>\n\t\t\t\t\t\t\t\t\t\t\t{cat}\n\t\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-xs font-medium mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tTags\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tvalue={tags}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setTags(e.target.value)}\n\t\t\t\t\t\t\t\t\tplaceholder=\"tag1, tag2\"\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2.5 rounded-xl border text-sm focus:outline-none transition-colors\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-xs font-medium mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tSave path\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={savepath}\n\t\t\t\t\t\t\t\tonChange={(e) => setSavepath(e.target.value)}\n\t\t\t\t\t\t\t\tplaceholder=\"Default\"\n\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2.5 rounded-xl border text-sm focus:outline-none transition-colors\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"flex items-center gap-4 pt-2\">\n\t\t\t\t\t\t\t<label className=\"flex items-center gap-2 cursor-pointer\">\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\tchecked={startTorrent}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setStartTorrent(e.target.checked)}\n\t\t\t\t\t\t\t\t\tclassName=\"sr-only peer\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"w-4 h-4 rounded border-2 flex items-center justify-center transition-colors\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tborderColor: startTorrent ? 'var(--accent)' : 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: startTorrent ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{startTorrent && (\n\t\t\t\t\t\t\t\t\t\t<Check className=\"w-2.5 h-2.5\" style={{ color: 'var(--accent-contrast)' }} strokeWidth={4} />\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tStart torrent\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<label className=\"flex items-center gap-2 cursor-pointer\">\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\tchecked={sequential}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setSequential(e.target.checked)}\n\t\t\t\t\t\t\t\t\tclassName=\"sr-only peer\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"w-4 h-4 rounded border-2 flex items-center justify-center transition-colors\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tborderColor: sequential ? 'var(--accent)' : 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: sequential ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{sequential && (\n\t\t\t\t\t\t\t\t\t\t<Check className=\"w-2.5 h-2.5\" style={{ color: 'var(--accent-contrast)' }} strokeWidth={4} />\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tSequential\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"flex gap-3 pt-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={onClose}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl border text-sm font-medium transition-colors hover:opacity-80\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-muted)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\tdisabled={\n\t\t\t\t\t\t\t\t\taddMutation.isPending || (tab === 'link' && !url.trim()) || (tab === 'file' && files.length === 0)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{addMutation.isPending ? 'Adding...' : 'Add Torrent'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</form>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/AuthForm.tsx",
    "content": "import { useState, useEffect } from 'react'\nimport { Download } from 'lucide-react'\nimport { register, login, type User } from '../api/auth'\n\ninterface Props {\n\tonSuccess: (user: User) => void\n}\n\nexport function AuthForm({ onSuccess }: Props) {\n\tconst [mode, setMode] = useState<'login' | 'register'>('login')\n\tconst [registrationDisabled, setRegistrationDisabled] = useState<boolean | null>(null)\n\n\tuseEffect(() => {\n\t\tfetch('/api/config')\n\t\t\t.then((r) => r.json())\n\t\t\t.then((c) => setRegistrationDisabled(c.registrationDisabled ?? false))\n\t\t\t.catch(() => setRegistrationDisabled(true))\n\t}, [])\n\tconst [username, setUsername] = useState('')\n\tconst [password, setPassword] = useState('')\n\tconst [confirmPassword, setConfirmPassword] = useState('')\n\tconst [error, setError] = useState('')\n\tconst [loading, setLoading] = useState(false)\n\n\tasync function handleSubmit(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tsetError('')\n\n\t\tif (mode === 'register' && password !== confirmPassword) {\n\t\t\tsetError('Passwords do not match')\n\t\t\treturn\n\t\t}\n\n\t\tsetLoading(true)\n\t\ttry {\n\t\t\tconst user = mode === 'register' ? await register(username, password) : await login(username, password)\n\t\t\tonSuccess(user)\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : 'Operation failed')\n\t\t} finally {\n\t\t\tsetLoading(false)\n\t\t}\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tclassName=\"min-h-screen flex items-center justify-center p-4 relative overflow-hidden\"\n\t\t\tstyle={{ backgroundColor: 'var(--bg-primary)' }}\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName=\"absolute inset-0\"\n\t\t\t\tstyle={{\n\t\t\t\t\tbackground:\n\t\t\t\t\t\t'radial-gradient(ellipse at top, color-mix(in srgb, var(--accent) 8%, var(--bg-primary)), var(--bg-primary))',\n\t\t\t\t}}\n\t\t\t/>\n\t\t\t<div\n\t\t\t\tclassName=\"absolute top-1/4 -left-32 w-96 h-96 rounded-full blur-3xl\"\n\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--accent) 5%, transparent)' }}\n\t\t\t/>\n\n\t\t\t<form onSubmit={handleSubmit} className=\"relative w-full max-w-sm opacity-0 animate-in\">\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute -inset-px rounded-2xl\"\n\t\t\t\t\tstyle={{ background: 'linear-gradient(to bottom, color-mix(in srgb, white 8%, transparent), transparent)' }}\n\t\t\t\t/>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"relative backdrop-blur-xl rounded-2xl p-8 border\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--bg-secondary) 80%, transparent)',\n\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex items-center gap-3 mb-8\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-10 h-10 rounded-xl flex items-center justify-center\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackground:\n\t\t\t\t\t\t\t\t\t'linear-gradient(to bottom right, var(--accent), color-mix(in srgb, var(--accent) 70%, black))',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Download className=\"w-5 h-5\" style={{ color: 'var(--accent-contrast)' }} strokeWidth={2.5} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<h1 className=\"text-lg font-semibold tracking-tight\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tqbitwebui\n\t\t\t\t\t\t\t</h1>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{registrationDisabled === false && (\n\t\t\t\t\t\t<div className=\"flex mb-6 rounded-lg p-1\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetMode('login')\n\t\t\t\t\t\t\t\t\tsetError('')\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-2 text-sm font-medium rounded-md transition-colors\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: mode === 'login' ? 'var(--bg-secondary)' : 'transparent',\n\t\t\t\t\t\t\t\t\tcolor: mode === 'login' ? 'var(--text-primary)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tSign In\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetMode('register')\n\t\t\t\t\t\t\t\t\tsetError('')\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-2 text-sm font-medium rounded-md transition-colors\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: mode === 'register' ? 'var(--bg-secondary)' : 'transparent',\n\t\t\t\t\t\t\t\t\tcolor: mode === 'register' ? 'var(--text-primary)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tRegister\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{error && (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"mb-6 px-4 py-3 rounded-lg text-sm font-medium\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 10%, transparent)', color: 'var(--error)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{error}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<div className=\"space-y-4\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tUsername\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={username}\n\t\t\t\t\t\t\t\tonChange={(e) => setUsername(e.target.value)}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-lg border text-sm font-mono transition-all\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tplaceholder=\"username\"\n\t\t\t\t\t\t\t\tautoComplete=\"username\"\n\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tPassword\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\t\tvalue={password}\n\t\t\t\t\t\t\t\tonChange={(e) => setPassword(e.target.value)}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-lg border text-sm font-mono transition-all\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tplaceholder=\"••••••••\"\n\t\t\t\t\t\t\t\tautoComplete={mode === 'register' ? 'new-password' : 'current-password'}\n\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{mode === 'register' && (\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tConfirm Password\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\t\t\tvalue={confirmPassword}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setConfirmPassword(e.target.value)}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-lg border text-sm font-mono transition-all\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tplaceholder=\"••••••••\"\n\t\t\t\t\t\t\t\t\tautoComplete=\"new-password\"\n\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\tdisabled={loading}\n\t\t\t\t\t\tclassName=\"relative w-full mt-6 py-3 rounded-lg font-medium text-sm transition-all overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackground: 'linear-gradient(to right, var(--accent), color-mix(in srgb, var(--accent) 80%, black))',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<span className=\"relative font-semibold\" style={{ color: 'var(--accent-contrast)' }}>\n\t\t\t\t\t\t\t{loading ? 'Please wait...' : mode === 'register' ? 'Create Account' : 'Sign In'}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</button>\n\n\t\t\t\t\t<p className=\"mt-6 text-center text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t{mode === 'register' && registrationDisabled === false\n\t\t\t\t\t\t\t? 'Create an account to manage your instances'\n\t\t\t\t\t\t\t: registrationDisabled\n\t\t\t\t\t\t\t\t? 'Registration is disabled. Contact administrator for access.'\n\t\t\t\t\t\t\t\t: 'Sign in to manage your qBittorrent instances'}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t</form>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/CategoryTagManager.tsx",
    "content": "import { useState } from 'react'\nimport { Settings, X, Check, Pencil, Trash2 } from 'lucide-react'\nimport {\n\tuseCategories,\n\tuseTags,\n\tuseCreateCategory,\n\tuseEditCategory,\n\tuseDeleteCategory,\n\tuseCreateTag,\n\tuseDeleteTag,\n} from '../hooks/useTorrents'\n\ninterface Props {\n\topen: boolean\n\tonClose: () => void\n}\n\ntype Tab = 'categories' | 'tags'\n\nexport function CategoryTagManager({ open, onClose }: Props) {\n\tconst [tab, setTab] = useState<Tab>('categories')\n\tconst [newCategoryName, setNewCategoryName] = useState('')\n\tconst [newCategorySavePath, setNewCategorySavePath] = useState('')\n\tconst [newTag, setNewTag] = useState('')\n\tconst [editingCategory, setEditingCategory] = useState<string | null>(null)\n\tconst [editSavePath, setEditSavePath] = useState('')\n\n\tconst { data: categories = {} } = useCategories()\n\tconst { data: tags = [] } = useTags()\n\tconst createCategoryMutation = useCreateCategory()\n\tconst editCategoryMutation = useEditCategory()\n\tconst deleteCategoryMutation = useDeleteCategory()\n\tconst createTagMutation = useCreateTag()\n\tconst deleteTagMutation = useDeleteTag()\n\n\tif (!open) return null\n\n\tfunction handleCreateCategory(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tif (!newCategoryName.trim()) return\n\t\tcreateCategoryMutation.mutate(\n\t\t\t{ name: newCategoryName.trim(), savePath: newCategorySavePath.trim() || undefined },\n\t\t\t{\n\t\t\t\tonSuccess: () => {\n\t\t\t\t\tsetNewCategoryName('')\n\t\t\t\t\tsetNewCategorySavePath('')\n\t\t\t\t},\n\t\t\t}\n\t\t)\n\t}\n\n\tfunction handleEditCategory(name: string) {\n\t\tif (!editSavePath.trim() && editSavePath !== '') return\n\t\teditCategoryMutation.mutate(\n\t\t\t{ name, savePath: editSavePath },\n\t\t\t{\n\t\t\t\tonSuccess: () => setEditingCategory(null),\n\t\t\t}\n\t\t)\n\t}\n\n\tfunction startEditCategory(name: string, currentPath: string) {\n\t\tsetEditingCategory(name)\n\t\tsetEditSavePath(currentPath)\n\t}\n\n\tfunction handleCreateTag(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tif (!newTag.trim()) return\n\t\tcreateTagMutation.mutate(newTag.trim(), {\n\t\t\tonSuccess: () => setNewTag(''),\n\t\t})\n\t}\n\n\treturn (\n\t\t<div className=\"fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50\">\n\t\t\t<div className=\"relative w-full max-w-lg mx-4 opacity-0 animate-in\">\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute -inset-px rounded-2xl\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackground: 'linear-gradient(to bottom, color-mix(in srgb, var(--accent) 20%, transparent), transparent)',\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"relative rounded-2xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex items-center justify-between p-5 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"w-10 h-10 rounded-xl flex items-center justify-center\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--accent) 10%, transparent)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Settings className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<h3 className=\"text-base font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tManage Categories & Tags\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={onClose}\n\t\t\t\t\t\t\tclassName=\"p-2 rounded-lg transition-colors hover:opacity-80\"\n\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<X className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className=\"p-5 space-y-4\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"flex p-1 rounded-xl border\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => setTab('categories')}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-2 px-3 rounded-lg text-xs font-medium transition-all\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: tab === 'categories' ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\tcolor: tab === 'categories' ? 'var(--accent-contrast)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCategories\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => setTab('tags')}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-2 px-3 rounded-lg text-xs font-medium transition-all\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: tab === 'tags' ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\tcolor: tab === 'tags' ? 'var(--accent-contrast)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tTags\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{tab === 'categories' ? (\n\t\t\t\t\t\t\t<div className=\"space-y-3\">\n\t\t\t\t\t\t\t\t<div className=\"max-h-64 overflow-y-auto space-y-1\">\n\t\t\t\t\t\t\t\t\t{Object.entries(categories).length === 0 ? (\n\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs text-center py-4\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tNo categories\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\tObject.entries(categories).map(([name, cat]) => (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tkey={name}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-2 rounded-lg\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{editingCategory === name ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs font-medium shrink-0\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{name}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={editSavePath}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => setEditSavePath(e.target.value)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tif (e.key === 'Enter') handleEditCategory(name)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tif (e.key === 'Escape') setEditingCategory(null)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"Save path\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 px-2 py-1 rounded border text-xs focus:outline-none\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleEditCategory(name)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded hover:opacity-70\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--success)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Check className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => setEditingCategory(null)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded hover:opacity-70\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<X className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{name}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs truncate flex-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{cat.savePath || '(default)'}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => startEditCategory(name, cat.savePath)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded hover:opacity-70\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Pencil className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => deleteCategoryMutation.mutate(name)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={deleteCategoryMutation.isPending}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded hover:opacity-70\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Trash2 className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<form onSubmit={handleCreateCategory} className=\"flex gap-2\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={newCategoryName}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setNewCategoryName(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Name\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 px-3 py-2 rounded-lg border text-xs focus:outline-none\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={newCategorySavePath}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setNewCategorySavePath(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Save path (optional)\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 px-3 py-2 rounded-lg border text-xs focus:outline-none\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\t\t\tdisabled={!newCategoryName.trim() || createCategoryMutation.isPending}\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tAdd\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div className=\"space-y-3\">\n\t\t\t\t\t\t\t\t<div className=\"max-h-64 overflow-y-auto space-y-1\">\n\t\t\t\t\t\t\t\t\t{tags.length === 0 ? (\n\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs text-center py-4\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tNo tags\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\ttags.map((tag) => (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center justify-between px-3 py-2 rounded-lg\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => deleteTagMutation.mutate(tag)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={deleteTagMutation.isPending}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded hover:opacity-70\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Trash2 className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<form onSubmit={handleCreateTag} className=\"flex gap-2\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={newTag}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setNewTag(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"New tag name\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 px-3 py-2 rounded-lg border text-xs focus:outline-none\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\t\t\tdisabled={!newTag.trim() || createTagMutation.isPending}\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tAdd\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/ContextMenu.tsx",
    "content": "import { useState, useRef, useEffect } from 'react'\nimport { ChevronRight } from 'lucide-react'\nimport {\n\tuseCategories,\n\tuseTags,\n\tuseStartTorrents,\n\tuseStopTorrents,\n\tuseRecheckTorrents,\n\tuseReannounceTorrents,\n\tuseDeleteTorrents,\n\tuseSetCategory,\n\tuseAddTags,\n\tuseRemoveTags,\n\tuseRenameTorrent,\n\tuseSetTorrentDownloadPath,\n\tuseSetTorrentLocation,\n\tuseExportTorrents,\n} from '../hooks/useTorrents'\nimport type { Torrent } from '../types/qbittorrent'\n\ninterface Props {\n\tx: number\n\ty: number\n\ttorrents: Torrent[]\n\tonClose: () => void\n}\n\ntype Submenu = 'category' | 'addTag' | 'removeTag' | 'delete' | null\ntype EditorMode = 'rename' | 'savePath' | 'downloadPath' | null\n\nexport function ContextMenu({ x, y, torrents, onClose }: Props) {\n\tconst [submenu, setSubmenu] = useState<Submenu>(null)\n\tconst [editorMode, setEditorMode] = useState<EditorMode>(null)\n\tconst [inputValue, setInputValue] = useState('')\n\tconst ref = useRef<HTMLDivElement>(null)\n\tconst inputRef = useRef<HTMLInputElement>(null)\n\n\tconst { data: categories = {} } = useCategories()\n\tconst { data: tags = [] } = useTags()\n\tconst startMutation = useStartTorrents()\n\tconst stopMutation = useStopTorrents()\n\tconst recheckMutation = useRecheckTorrents()\n\tconst reannounceMutation = useReannounceTorrents()\n\tconst setCategoryMutation = useSetCategory()\n\tconst addTagsMutation = useAddTags()\n\tconst removeTagsMutation = useRemoveTags()\n\tconst renameMutation = useRenameTorrent()\n\tconst setLocationMutation = useSetTorrentLocation()\n\tconst setDownloadPathMutation = useSetTorrentDownloadPath()\n\tconst deleteMutation = useDeleteTorrents()\n\tconst exportMutation = useExportTorrents()\n\n\tconst hashes = torrents.map((t) => t.hash)\n\tconst isSingle = torrents.length === 1\n\tconst currentTags = isSingle\n\t\t? torrents[0].tags\n\t\t\t\t.split(',')\n\t\t\t\t.map((t) => t.trim())\n\t\t\t\t.filter(Boolean)\n\t\t: []\n\n\tuseEffect(() => {\n\t\tfunction handleClickOutside(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) onClose()\n\t\t}\n\t\tfunction handleEscape(e: KeyboardEvent) {\n\t\t\tif (e.key === 'Escape') onClose()\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClickOutside)\n\t\tdocument.addEventListener('keydown', handleEscape)\n\t\treturn () => {\n\t\t\tdocument.removeEventListener('mousedown', handleClickOutside)\n\t\t\tdocument.removeEventListener('keydown', handleEscape)\n\t\t}\n\t}, [onClose])\n\n\tuseEffect(() => {\n\t\tif (editorMode && inputRef.current) {\n\t\t\tinputRef.current.focus()\n\t\t\tinputRef.current.select()\n\t\t}\n\t}, [editorMode])\n\n\tconst menuStyle: React.CSSProperties = {\n\t\tposition: 'fixed',\n\t\tleft: Math.min(x, window.innerWidth - 200),\n\t\ttop: Math.min(y, window.innerHeight - 300),\n\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\tborderColor: 'var(--border)',\n\t}\n\n\tfunction handleStart() {\n\t\tstartMutation.mutate(hashes)\n\t\tonClose()\n\t}\n\n\tfunction handleStop() {\n\t\tstopMutation.mutate(hashes)\n\t\tonClose()\n\t}\n\n\tfunction handleRecheck() {\n\t\trecheckMutation.mutate(hashes)\n\t\tonClose()\n\t}\n\n\tfunction handleReannounce() {\n\t\treannounceMutation.mutate(hashes)\n\t\tonClose()\n\t}\n\n\tfunction handleSetCategory(category: string) {\n\t\tsetCategoryMutation.mutate({ hashes, category })\n\t\tonClose()\n\t}\n\n\tfunction handleAddTag(tag: string) {\n\t\taddTagsMutation.mutate({ hashes, tags: tag })\n\t\tonClose()\n\t}\n\n\tfunction handleRemoveTag(tag: string) {\n\t\tremoveTagsMutation.mutate({ hashes, tags: tag })\n\t\tonClose()\n\t}\n\n\tfunction handleEditorSubmit() {\n\t\tconst value = inputValue.trim()\n\t\tif (!value) return\n\n\t\tif (editorMode === 'rename' && isSingle) {\n\t\t\trenameMutation.mutate({ hash: hashes[0], name: value })\n\t\t\tonClose()\n\t\t\treturn\n\t\t}\n\n\t\tif (editorMode === 'savePath') {\n\t\t\tsetLocationMutation.mutate({ hashes, location: value })\n\t\t\tonClose()\n\t\t\treturn\n\t\t}\n\n\t\tif (editorMode === 'downloadPath') {\n\t\t\tsetDownloadPathMutation.mutate({ hashes, downloadPath: value })\n\t\t\tonClose()\n\t\t}\n\t}\n\n\tfunction startRename() {\n\t\tsetInputValue(torrents[0].name)\n\t\tsetEditorMode('rename')\n\t\tsetSubmenu(null)\n\t}\n\n\tfunction startSetSavePath() {\n\t\tconst uniquePaths = new Set(torrents.map((torrent) => torrent.save_path).filter(Boolean))\n\t\tsetInputValue(uniquePaths.size === 1 ? torrents[0].save_path : '')\n\t\tsetEditorMode('savePath')\n\t\tsetSubmenu(null)\n\t}\n\n\tfunction startSetDownloadPath() {\n\t\tconst uniquePaths = new Set(torrents.map((torrent) => torrent.download_path).filter(Boolean))\n\t\tsetInputValue(uniquePaths.size === 1 ? torrents[0].download_path : '')\n\t\tsetEditorMode('downloadPath')\n\t\tsetSubmenu(null)\n\t}\n\n\tconst showDownloadPath = torrents.some((t) => t.download_path && t.progress < 1)\n\n\tfunction handleDelete(deleteFiles: boolean) {\n\t\tdeleteMutation.mutate({ hashes, deleteFiles })\n\t\tonClose()\n\t}\n\n\tfunction handleExport() {\n\t\texportMutation.mutate(torrents.map((t) => ({ hash: t.hash, name: t.name })))\n\t\tonClose()\n\t}\n\n\tconst editorTitle =\n\t\teditorMode === 'rename'\n\t\t\t? 'Rename torrent'\n\t\t\t: editorMode === 'savePath'\n\t\t\t\t? 'Change save path'\n\t\t\t\t: 'Change download path'\n\tconst editorActionLabel = editorMode === 'rename' ? 'Rename' : 'Save'\n\tconst editorPlaceholder =\n\t\teditorMode === 'rename'\n\t\t\t? 'Enter torrent name'\n\t\t\t: editorMode === 'savePath'\n\t\t\t\t? 'Enter save path'\n\t\t\t\t: 'Enter download path'\n\n\tif (editorMode && (editorMode !== 'rename' || isSingle)) {\n\t\treturn (\n\t\t\t<div ref={ref} className=\"rounded-lg border shadow-xl z-[200] p-3\" style={menuStyle}>\n\t\t\t\t<div className=\"text-xs font-medium mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{editorTitle}\n\t\t\t\t</div>\n\t\t\t\t<input\n\t\t\t\t\tref={inputRef}\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t\tvalue={inputValue}\n\t\t\t\t\tonChange={(e) => setInputValue(e.target.value)}\n\t\t\t\t\tplaceholder={editorPlaceholder}\n\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\tif (e.key === 'Enter') handleEditorSubmit()\n\t\t\t\t\t\tif (e.key === 'Escape') onClose()\n\t\t\t\t\t}}\n\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm mb-2\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t/>\n\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={onClose}\n\t\t\t\t\t\tclassName=\"flex-1 py-1.5 rounded-lg text-xs\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-muted)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\tCancel\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={handleEditorSubmit}\n\t\t\t\t\t\tclassName=\"flex-1 py-1.5 rounded-lg text-xs\"\n\t\t\t\t\t\tdisabled={!inputValue.trim()}\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{editorActionLabel}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t)\n\t}\n\n\treturn (\n\t\t<div ref={ref} className=\"rounded-lg border shadow-xl z-[200] py-1 min-w-[160px]\" style={menuStyle}>\n\t\t\t<MenuItem onClick={handleStart}>Start</MenuItem>\n\t\t\t<MenuItem onClick={handleStop}>Stop</MenuItem>\n\t\t\t<MenuItem onClick={handleRecheck}>Force Recheck</MenuItem>\n\t\t\t<MenuItem onClick={handleReannounce}>Force Reannounce</MenuItem>\n\t\t\t<div className=\"h-px my-1\" style={{ backgroundColor: 'var(--border)' }} />\n\t\t\t<MenuItem onClick={() => setSubmenu(submenu === 'category' ? null : 'category')} hasSubmenu>\n\t\t\t\tSet Category\n\t\t\t</MenuItem>\n\t\t\t{submenu === 'category' && (\n\t\t\t\t<div className=\"pl-2\">\n\t\t\t\t\t<MenuItem onClick={() => handleSetCategory('')} small>\n\t\t\t\t\t\tNone\n\t\t\t\t\t</MenuItem>\n\t\t\t\t\t{Object.keys(categories).map((cat) => (\n\t\t\t\t\t\t<MenuItem key={cat} onClick={() => handleSetCategory(cat)} small>\n\t\t\t\t\t\t\t{cat}\n\t\t\t\t\t\t</MenuItem>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t\t<MenuItem onClick={() => setSubmenu(submenu === 'addTag' ? null : 'addTag')} hasSubmenu>\n\t\t\t\tAdd Tag\n\t\t\t</MenuItem>\n\t\t\t{submenu === 'addTag' && (\n\t\t\t\t<div className=\"pl-2\">\n\t\t\t\t\t{tags.length === 0 ? (\n\t\t\t\t\t\t<div className=\"px-3 py-1.5 text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tNo tags\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\ttags.map((tag) => (\n\t\t\t\t\t\t\t<MenuItem key={tag} onClick={() => handleAddTag(tag)} small>\n\t\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t\t</MenuItem>\n\t\t\t\t\t\t))\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t\t{isSingle && currentTags.length > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t<MenuItem onClick={() => setSubmenu(submenu === 'removeTag' ? null : 'removeTag')} hasSubmenu>\n\t\t\t\t\t\tRemove Tag\n\t\t\t\t\t</MenuItem>\n\t\t\t\t\t{submenu === 'removeTag' && (\n\t\t\t\t\t\t<div className=\"pl-2\">\n\t\t\t\t\t\t\t{currentTags.map((tag) => (\n\t\t\t\t\t\t\t\t<MenuItem key={tag} onClick={() => handleRemoveTag(tag)} small>\n\t\t\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t\t\t</MenuItem>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</>\n\t\t\t)}\n\t\t\t{isSingle && (\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"h-px my-1\" style={{ backgroundColor: 'var(--border)' }} />\n\t\t\t\t\t<MenuItem onClick={startRename}>Rename</MenuItem>\n\t\t\t\t</>\n\t\t\t)}\n\t\t\t<MenuItem onClick={startSetSavePath}>Change Save Path</MenuItem>\n\t\t\t{showDownloadPath && <MenuItem onClick={startSetDownloadPath}>Change Download Path</MenuItem>}\n\t\t\t<div className=\"h-px my-1\" style={{ backgroundColor: 'var(--border)' }} />\n\t\t\t<MenuItem onClick={handleExport}>Export</MenuItem>\n\t\t\t<MenuItem onClick={() => setSubmenu(submenu === 'delete' ? null : 'delete')} hasSubmenu>\n\t\t\t\tDelete\n\t\t\t</MenuItem>\n\t\t\t{submenu === 'delete' && (\n\t\t\t\t<div className=\"pl-2\">\n\t\t\t\t\t<MenuItem onClick={() => handleDelete(false)} small>\n\t\t\t\t\t\tKeep files\n\t\t\t\t\t</MenuItem>\n\t\t\t\t\t<MenuItem onClick={() => handleDelete(true)} small>\n\t\t\t\t\t\tDelete files\n\t\t\t\t\t</MenuItem>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\nfunction MenuItem({\n\tchildren,\n\tonClick,\n\thasSubmenu,\n\tsmall,\n}: {\n\tchildren: React.ReactNode\n\tonClick: () => void\n\thasSubmenu?: boolean\n\tsmall?: boolean\n}) {\n\treturn (\n\t\t<button\n\t\t\tonClick={onClick}\n\t\t\tclassName={`w-full flex items-center justify-between px-3 ${small ? 'py-1' : 'py-1.5'} text-xs text-left transition-colors hover:opacity-80`}\n\t\t\tstyle={{ color: 'var(--text-primary)', backgroundColor: 'transparent' }}\n\t\t>\n\t\t\t<span>{children}</span>\n\t\t\t{hasSubmenu && <ChevronRight className=\"w-3 h-3\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />}\n\t\t</button>\n\t)\n}\n\n"
  },
  {
    "path": "src/components/CrossSeedManager.tsx",
    "content": "import { type Instance } from '../api/instances'\nimport { formatSize, formatCountdown } from '../utils/format'\nimport { Toggle, Select, MultiSelect } from './ui'\nimport { useCrossSeed, formatTimestamp, LOG_LEVEL_COLORS } from '../hooks/useCrossSeed'\n\ninterface Props {\n\tinstances: Instance[]\n}\n\nexport function CrossSeedManager({ instances }: Props) {\n\tconst {\n\t\tselectedInstance,\n\t\tsetSelectedInstance,\n\t\tconfig,\n\t\tsetConfig,\n\t\tstatus,\n\t\tcacheStats,\n\t\tavailableIndexers,\n\t\tlogs,\n\t\tloading,\n\t\terror,\n\t\tsuccess,\n\t\tsaving,\n\t\tautoScroll,\n\t\tsetAutoScroll,\n\t\tlogsContainerRef,\n\t\tprowlarrIntegrations,\n\t\tisRunning,\n\t\tstopping,\n\t\thandleSave,\n\t\thandleScan,\n\t\thandleStop,\n\t\thandleClearCache,\n\t} = useCrossSeed(instances)\n\n\tif (instances.length === 0) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclassName=\"text-center py-12 rounded-lg border\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<p className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tNo instances configured\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (loading) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclassName=\"p-6 rounded-lg border flex items-center gap-3\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"w-5 h-5 border-2 rounded-full animate-spin\"\n\t\t\t\t\tstyle={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}\n\t\t\t\t/>\n\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tLoading...\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (!config) return null\n\n\treturn (\n\t\t<div className=\"flex flex-col h-full relative\">\n\t\t\t<a\n\t\t\t\thref=\"https://github.com/Maciejonos/qbitwebui/issues\"\n\t\t\t\ttarget=\"_blank\"\n\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\tclassName=\"absolute top-0 right-0 px-3 py-2 rounded-lg border text-xs\"\n\t\t\t\tstyle={{ borderColor: 'var(--error)', backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)' }}\n\t\t\t>\n\t\t\t\t<span style={{ color: 'var(--error)' }}>Experimental</span> · Report issues\n\t\t\t</a>\n\t\t\t<div\n\t\t\t\tclassName=\"shrink-0 flex items-center justify-between pb-4 border-b mb-4\"\n\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t<h1 className=\"text-base font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tCross-Seed\n\t\t\t\t\t</h1>\n\t\t\t\t\t{instances.length > 1 && (\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\tvalue={String(selectedInstance ?? '')}\n\t\t\t\t\t\t\tonChange={(v) => setSelectedInstance(Number(v))}\n\t\t\t\t\t\t\toptions={instances.map((i) => ({ value: String(i.id), label: i.label }))}\n\t\t\t\t\t\t\tminWidth=\"140px\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t{(error || success) && (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName=\"text-xs px-3 py-1 rounded\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: error\n\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, var(--error) 15%, transparent)'\n\t\t\t\t\t\t\t\t\t: 'color-mix(in srgb, #a6e3a1 15%, transparent)',\n\t\t\t\t\t\t\t\tcolor: error ? 'var(--error)' : '#a6e3a1',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{error || success}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t)}\n\t\t\t\t\t{prowlarrIntegrations.length === 0 && (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName=\"text-xs px-3 py-1 rounded\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--warning) 15%, transparent)',\n\t\t\t\t\t\t\t\tcolor: 'var(--warning)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tNo Prowlarr\n\t\t\t\t\t\t</span>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div\n\t\t\t\tclassName=\"shrink-0 grid grid-cols-5 gap-4 p-4 rounded-lg border mb-4\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<div>\n\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tScheduler\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"text-sm font-medium\" style={{ color: status?.enabled ? '#a6e3a1' : 'var(--text-muted)' }}>\n\t\t\t\t\t\t{status?.enabled ? 'On' : 'Off'}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div>\n\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tStatus\n\t\t\t\t\t</div>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"text-sm font-medium flex items-center gap-2\"\n\t\t\t\t\t\tstyle={{ color: stopping ? 'var(--warning)' : isRunning ? 'var(--accent)' : 'var(--text-secondary)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{(isRunning || stopping) && (\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName=\"w-2 h-2 rounded-full animate-pulse\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: stopping ? 'var(--warning)' : 'var(--accent)' }}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{stopping ? 'Stopping' : isRunning ? 'Running' : 'Idle'}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div>\n\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tLast Run\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"text-sm\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t{formatTimestamp(status?.lastRun ?? null)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div>\n\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tNext\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"text-sm\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t{status?.enabled ? formatCountdown(status?.nextRun ?? null) : '—'}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div>\n\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tCache\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"text-sm\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t{cacheStats ? `${cacheStats.cache.count} (${formatSize(cacheStats.cache.totalSize)})` : '0'}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"flex-1 grid grid-cols-2 grid-rows-[1fr] gap-4 min-h-0 overflow-hidden\">\n\t\t\t\t<div className=\"flex flex-col gap-4 min-h-0 overflow-auto\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"shrink-0 p-4 rounded-lg border\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"text-xs font-medium uppercase tracking-wider mb-4\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tConfiguration\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"space-y-4\">\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tEnabled\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Toggle checked={config.enabled} onChange={(v) => setConfig({ ...config, enabled: v })} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-1.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tProwlarr\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\tvalue={config.integration_id ? String(config.integration_id) : ''}\n\t\t\t\t\t\t\t\t\tonChange={(v) => setConfig({ ...config, integration_id: v ? Number(v) : null, indexer_ids: [] })}\n\t\t\t\t\t\t\t\t\toptions={[\n\t\t\t\t\t\t\t\t\t\t{ value: '', label: 'None' },\n\t\t\t\t\t\t\t\t\t\t...prowlarrIntegrations.map((i) => ({ value: String(i.id), label: i.label })),\n\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\tminWidth=\"100%\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-1.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tInterval (hours)\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\tmin=\"1\"\n\t\t\t\t\t\t\t\t\t\tmax=\"168\"\n\t\t\t\t\t\t\t\t\t\tvalue={config.interval_hours}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setConfig({ ...config, interval_hours: parseInt(e.target.value) || 24 })}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-1.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tDelay (30-3600s)\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\tmin=\"30\"\n\t\t\t\t\t\t\t\t\t\tmax=\"3600\"\n\t\t\t\t\t\t\t\t\t\tvalue={config.delay_seconds}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\t\tsetConfig({ ...config, delay_seconds: Math.max(30, parseInt(e.target.value) || 30) })\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{availableIndexers.length > 0 && (\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-1.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tIndexers ({config.indexer_ids.length}/{availableIndexers.length})\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<MultiSelect\n\t\t\t\t\t\t\t\t\t\toptions={availableIndexers.map((idx) => ({ value: idx.id, label: idx.name }))}\n\t\t\t\t\t\t\t\t\t\tselected={config.indexer_ids}\n\t\t\t\t\t\t\t\t\t\tonChange={(ids) => setConfig({ ...config, indexer_ids: ids })}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Select indexers...\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-1.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tCategory Suffix\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={config.category_suffix}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setConfig({ ...config, category_suffix: e.target.value })}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-1.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tTag\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={config.tag}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setConfig({ ...config, tag: e.target.value })}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-1.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tMatch Mode\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\tvalue={config.match_mode}\n\t\t\t\t\t\t\t\t\tonChange={(v) => setConfig({ ...config, match_mode: v as 'strict' | 'flexible' })}\n\t\t\t\t\t\t\t\t\toptions={[\n\t\t\t\t\t\t\t\t\t\t{ value: 'strict', label: 'Strict (names must match)' },\n\t\t\t\t\t\t\t\t\t\t{ value: 'flexible', label: 'Flexible (sizes only, requires link_dir)' },\n\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\tminWidth=\"100%\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{config.match_mode === 'flexible' && (\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-1.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tLink Directory (for flexible mode)\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={config.link_dir || ''}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setConfig({ ...config, link_dir: e.target.value || null })}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"/path/to/links\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<p className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tHardlinks will be created here when file names differ\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-1.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tBlocklist (one per line)\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\t\t\tvalue={config.blocklist.join('\\n')}\n\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\tsetConfig({\n\t\t\t\t\t\t\t\t\t\t\t...config,\n\t\t\t\t\t\t\t\t\t\t\tblocklist: e.target.value\n\t\t\t\t\t\t\t\t\t\t\t\t.split('\\n')\n\t\t\t\t\t\t\t\t\t\t\t\t.map((s) => s.trim())\n\t\t\t\t\t\t\t\t\t\t\t\t.filter(Boolean),\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tplaceholder=\"name:YIFY&#10;nameRegex:.*-RARBG$&#10;category:movies&#10;tag:private&#10;sizeBelow:100MB\"\n\t\t\t\t\t\t\t\t\trows={4}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm font-mono\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\tresize: 'vertical',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<p className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tFormat: type:value (name, nameRegex, folder, folderRegex, category, tag, tracker, infoHash, sizeBelow,\n\t\t\t\t\t\t\t\t\tsizeAbove)\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tInclude Single Episodes\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\t\tchecked={config.include_single_episodes}\n\t\t\t\t\t\t\t\t\tonChange={(v) => setConfig({ ...config, include_single_episodes: v })}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tDry Run\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Toggle checked={config.dry_run} onChange={(v) => setConfig({ ...config, dry_run: v })} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tSkip Recheck\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Toggle checked={config.skip_recheck} onChange={(v) => setConfig({ ...config, skip_recheck: v })} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-end pt-2\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={handleSave}\n\t\t\t\t\t\t\t\t\tdisabled={saving}\n\t\t\t\t\t\t\t\t\tclassName=\"px-5 py-2 rounded-lg text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{saving ? 'Saving...' : 'Save Configuration'}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"shrink-0 p-4 rounded-lg border\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"text-xs font-medium uppercase tracking-wider mb-4\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tActions\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => handleScan(false)}\n\t\t\t\t\t\t\t\tdisabled={isRunning || !config.integration_id}\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 rounded-lg text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tScan\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => handleScan(true)}\n\t\t\t\t\t\t\t\tdisabled={isRunning || !config.integration_id}\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 rounded-lg text-sm border disabled:opacity-50 flex items-center justify-center gap-1.5\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tForce Scan\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\ttitle=\"Re-scans all torrents, including those already searched. Use when indexers have new content or after changing settings.\"\n\t\t\t\t\t\t\t\t\tclassName=\"inline-flex items-center justify-center w-4 h-4 rounded-full text-xs cursor-help\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t?\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleStop}\n\t\t\t\t\t\t\t\tdisabled={!isRunning || stopping}\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 rounded-lg text-sm border disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tborderColor: isRunning ? 'var(--error)' : 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: isRunning ? 'var(--error)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{stopping ? 'Stopping...' : 'Stop'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleClearCache}\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 rounded-lg text-sm border flex items-center justify-center gap-1.5\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tClear Torrents\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\ttitle=\"Removes cached .torrent files.\"\n\t\t\t\t\t\t\t\t\tclassName=\"inline-flex items-center justify-center w-4 h-4 rounded-full text-xs cursor-help\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t?\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"flex flex-col p-4 rounded-lg border h-full min-h-0 overflow-hidden\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"shrink-0 flex items-center justify-between mb-4\">\n\t\t\t\t\t\t<div className=\"text-xs font-medium uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tLogs\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t{logs.length} entries\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<label className=\"flex items-center gap-2 cursor-pointer\">\n\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tAuto-scroll\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Toggle checked={autoScroll} onChange={setAutoScroll} />\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div\n\t\t\t\t\t\tref={logsContainerRef}\n\t\t\t\t\t\tclassName=\"h-0 grow overflow-y-auto rounded-lg p-3 font-mono text-xs leading-relaxed\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{logs.length === 0 ? (\n\t\t\t\t\t\t\t<div className=\"text-center py-8\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tNo logs\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\tlogs.map((log, i) => {\n\t\t\t\t\t\t\t\tconst isMatch = log.message.includes('MATCH:')\n\t\t\t\t\t\t\t\tconst isAdded = log.message.includes('Added torrent:')\n\t\t\t\t\t\t\t\tconst isInjection = isMatch || isAdded\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<div key={i} className=\"py-0.5 whitespace-pre-wrap break-all\">\n\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>{log.timestamp.slice(11, 19)}</span>{' '}\n\t\t\t\t\t\t\t\t\t\t<span style={{ color: LOG_LEVEL_COLORS[log.level] || 'var(--text-muted)' }}>[{log.level}]</span>{' '}\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: isInjection ? '#a6e3a1' : undefined,\n\t\t\t\t\t\t\t\t\t\t\t\tfontWeight: isInjection ? 500 : undefined,\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{log.message}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/DateSettingsPopup.tsx",
    "content": "import { useEffect, useRef } from 'react'\nimport { Checkbox } from './ui'\n\ninterface Props {\n\tanchor: HTMLElement\n\thideTime: boolean\n\tonSave: (hideTime: boolean) => void\n\tonClose: () => void\n}\n\nexport function DateSettingsPopup({ anchor, hideTime, onSave, onClose }: Props) {\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\tuseEffect(() => {\n\t\tfunction handleClick(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) onClose()\n\t\t}\n\t\tfunction handleKey(e: KeyboardEvent) {\n\t\t\tif (e.key === 'Escape') onClose()\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClick)\n\t\tdocument.addEventListener('keydown', handleKey)\n\t\treturn () => {\n\t\t\tdocument.removeEventListener('mousedown', handleClick)\n\t\t\tdocument.removeEventListener('keydown', handleKey)\n\t\t}\n\t}, [onClose])\n\n\tconst rect = anchor.getBoundingClientRect()\n\n\treturn (\n\t\t<div\n\t\t\tref={ref}\n\t\t\tclassName=\"rounded-lg border shadow-xl p-3\"\n\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\tstyle={{\n\t\t\t\tposition: 'fixed',\n\t\t\t\tleft: Math.min(rect.left, window.innerWidth - 150),\n\t\t\t\ttop: rect.bottom + 4,\n\t\t\t\tzIndex: 100,\n\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\tborderColor: 'var(--border)',\n\t\t\t}}\n\t\t>\n\t\t\t<Checkbox\n\t\t\t\tlabel=\"Hide time\"\n\t\t\t\tchecked={hideTime}\n\t\t\t\tonChange={(checked) => {\n\t\t\t\t\tonSave(checked)\n\t\t\t\t\tonClose()\n\t\t\t\t}}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/FileBrowser.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react'\nimport { ChevronLeft, RefreshCw, Folder, File, Download, Check, X } from 'lucide-react'\nimport {\n\tlistFiles,\n\tgetDownloadUrl,\n\tcheckWritable,\n\tdeleteFiles,\n\tmoveFiles,\n\tcopyFiles,\n\trenameFile,\n\ttype FileEntry,\n} from '../api/files'\nimport { formatSize } from '../utils/format'\n\nfunction formatDate(timestamp: number): string {\n\treturn new Date(timestamp).toLocaleString(undefined, {\n\t\tyear: 'numeric',\n\t\tmonth: 'short',\n\t\tday: 'numeric',\n\t\thour: '2-digit',\n\t\tminute: '2-digit',\n\t})\n}\n\ninterface FolderPickerProps {\n\ttitle: string\n\tonConfirm: (destination: string) => void\n\tonCancel: () => void\n}\n\nfunction FolderPicker({ title, onConfirm, onCancel }: FolderPickerProps) {\n\tconst [pickerPath, setPickerPath] = useState('/')\n\tconst [folders, setFolders] = useState<FileEntry[]>([])\n\tconst [loading, setLoading] = useState(true)\n\n\tuseEffect(() => {\n\t\tlet cancelled = false\n\t\tlistFiles(pickerPath)\n\t\t\t.then((files) => !cancelled && setFolders(files.filter((f) => f.isDirectory)))\n\t\t\t.catch(() => !cancelled && setFolders([]))\n\t\t\t.finally(() => !cancelled && setLoading(false))\n\t\treturn () => {\n\t\t\tcancelled = true\n\t\t}\n\t}, [pickerPath])\n\n\tfunction navigateTo(newPath: string) {\n\t\tsetLoading(true)\n\t\tsetPickerPath(newPath)\n\t}\n\n\tconst pathParts = pickerPath.split('/').filter(Boolean)\n\n\treturn (\n\t\t<div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50\" onClick={onCancel}>\n\t\t\t<div\n\t\t\t\tclassName=\"w-full max-w-md rounded-lg border shadow-xl\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}\n\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-center justify-between p-4 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t<h3 className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t{title}\n\t\t\t\t\t</h3>\n\t\t\t\t\t<button onClick={onCancel} className=\"p-1 rounded hover:bg-[var(--bg-tertiary)]\">\n\t\t\t\t\t\t<X className=\"w-5 h-5\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"p-4 space-y-3\">\n\t\t\t\t\t<div className=\"flex items-center gap-1 text-sm overflow-x-auto pb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => navigateTo('/')}\n\t\t\t\t\t\t\tclassName=\"px-1 py-0.5 rounded hover:bg-[var(--bg-tertiary)] shrink-0\"\n\t\t\t\t\t\t\tstyle={{ color: pickerPath === '/' ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t/\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{pathParts.map((part, i) => (\n\t\t\t\t\t\t\t<div key={i} className=\"flex items-center shrink-0\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => navigateTo(`/${pathParts.slice(0, i + 1).join('/')}`)}\n\t\t\t\t\t\t\t\t\tclassName=\"px-1 py-0.5 rounded hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: i === pathParts.length - 1 ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{part}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<span>/</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"h-64 overflow-y-auto rounded-md border\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{loading ? (\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-center h-full text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tLoading...\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : folders.length === 0 ? (\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-center h-full text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tNo subfolders\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div className=\"p-2 space-y-1\">\n\t\t\t\t\t\t\t\t{folders.map((folder) => (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tkey={folder.name}\n\t\t\t\t\t\t\t\t\t\tonClick={() => navigateTo(pickerPath === '/' ? `/${folder.name}` : `${pickerPath}/${folder.name}`)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm text-left hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Folder className=\"w-4 h-4 shrink-0\" style={{ color: 'var(--warning)' }} fill=\"currentColor\" />\n\t\t\t\t\t\t\t\t\t\t{folder.name}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex justify-end gap-2 p-4 border-t\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={onCancel}\n\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-md text-sm font-medium\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\tCancel\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => onConfirm(pickerPath)}\n\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-md text-sm font-medium border\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--accent)', color: 'var(--accent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\tSelect \"{pickerPath}\"\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\ninterface Props {\n\tenabled: boolean\n}\n\nexport function FileBrowser({ enabled }: Props) {\n\tconst [path, setPath] = useState('/')\n\tconst [files, setFiles] = useState<FileEntry[]>([])\n\tconst [loading, setLoading] = useState(true)\n\tconst [error, setError] = useState('')\n\tconst [writable, setWritable] = useState(false)\n\tconst [selected, setSelected] = useState<Set<string>>(new Set())\n\tconst [folderPickerMode, setFolderPickerMode] = useState<'move' | 'copy' | null>(null)\n\tconst [renameTarget, setRenameTarget] = useState<string | null>(null)\n\tconst [renameValue, setRenameValue] = useState('')\n\tconst [deleteConfirm, setDeleteConfirm] = useState(false)\n\tconst [actionLoading, setActionLoading] = useState(false)\n\n\tuseEffect(() => {\n\t\tif (enabled) checkWritable().then(setWritable)\n\t}, [enabled])\n\n\tconst loadFiles = useCallback(async () => {\n\t\tif (!enabled) return\n\t\tsetLoading(true)\n\t\tsetError('')\n\t\ttry {\n\t\t\tconst data = await listFiles(path)\n\t\t\tsetFiles(data)\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : 'Failed to load files')\n\t\t\tsetFiles([])\n\t\t} finally {\n\t\t\tsetLoading(false)\n\t\t}\n\t}, [path, enabled])\n\n\tuseEffect(() => {\n\t\tif (!enabled) return\n\t\tloadFiles()\n\t\tsetSelected(new Set())\n\t}, [loadFiles, enabled])\n\n\tif (!enabled) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclassName=\"text-center py-12 rounded-xl border\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<Folder className=\"w-12 h-12 mx-auto mb-4\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t<p className=\"text-sm mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tFile Browser is not configured\n\t\t\t\t</p>\n\t\t\t\t<p className=\"text-xs mb-4\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tSet DOWNLOADS_PATH environment variable to enable\n\t\t\t\t</p>\n\t\t\t\t<a\n\t\t\t\t\thref=\"https://maciejonos.github.io/qbitwebui/guide/configuration\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\tclassName=\"text-xs underline\"\n\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t>\n\t\t\t\t\tHow to configure\n\t\t\t\t</a>\n\t\t\t</div>\n\t\t)\n\t}\n\n\tfunction handleNavigate(name: string) {\n\t\tsetPath(path === '/' ? `/${name}` : `${path}/${name}`)\n\t}\n\n\tfunction handleBack() {\n\t\tconst parts = path.split('/').filter(Boolean)\n\t\tparts.pop()\n\t\tsetPath(parts.length ? `/${parts.join('/')}` : '/')\n\t}\n\n\tfunction handleBreadcrumb(index: number) {\n\t\tconst parts = path.split('/').filter(Boolean)\n\t\tsetPath(index === -1 ? '/' : `/${parts.slice(0, index + 1).join('/')}`)\n\t}\n\n\tfunction getFullPath(name: string) {\n\t\treturn path === '/' ? `/${name}` : `${path}/${name}`\n\t}\n\n\tfunction toggleSelect(name: string) {\n\t\tconst next = new Set(selected)\n\t\tif (next.has(name)) next.delete(name)\n\t\telse next.add(name)\n\t\tsetSelected(next)\n\t}\n\n\tfunction toggleSelectAll() {\n\t\tif (selected.size === files.length) setSelected(new Set())\n\t\telse setSelected(new Set(files.map((f) => f.name)))\n\t}\n\n\tasync function handleDelete() {\n\t\tsetError('')\n\t\tsetActionLoading(true)\n\t\ttry {\n\t\t\tawait deleteFiles(Array.from(selected).map(getFullPath))\n\t\t\tsetSelected(new Set())\n\t\t\tsetDeleteConfirm(false)\n\t\t\tawait loadFiles()\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : 'Delete failed')\n\t\t} finally {\n\t\t\tsetActionLoading(false)\n\t\t}\n\t}\n\n\tasync function handleMoveOrCopy(destination: string) {\n\t\tconst mode = folderPickerMode\n\t\tsetError('')\n\t\tsetActionLoading(true)\n\t\ttry {\n\t\t\tconst paths = Array.from(selected).map(getFullPath)\n\t\t\tif (mode === 'move') await moveFiles(paths, destination)\n\t\t\telse await copyFiles(paths, destination)\n\t\t\tsetSelected(new Set())\n\t\t\tsetFolderPickerMode(null)\n\t\t\tawait loadFiles()\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : `${mode} failed`)\n\t\t} finally {\n\t\t\tsetActionLoading(false)\n\t\t}\n\t}\n\n\tasync function handleRename() {\n\t\tif (!renameTarget || !renameValue.trim()) return\n\t\tsetError('')\n\t\tsetActionLoading(true)\n\t\ttry {\n\t\t\tawait renameFile(getFullPath(renameTarget), renameValue.trim())\n\t\t\tsetRenameTarget(null)\n\t\t\tsetRenameValue('')\n\t\t\tawait loadFiles()\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : 'Rename failed')\n\t\t} finally {\n\t\t\tsetActionLoading(false)\n\t\t}\n\t}\n\n\tfunction openRename() {\n\t\tconst name = Array.from(selected)[0]\n\t\tsetRenameTarget(name)\n\t\tsetRenameValue(name)\n\t}\n\n\tconst pathParts = path.split('/').filter(Boolean)\n\n\treturn (\n\t\t<div className=\"space-y-4\">\n\t\t\t<div\n\t\t\t\tclassName=\"flex items-center gap-2 p-3 rounded-lg border\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<button\n\t\t\t\t\tonClick={handleBack}\n\t\t\t\t\tdisabled={path === '/'}\n\t\t\t\t\tclassName=\"p-1.5 rounded-md transition-colors disabled:opacity-30\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t>\n\t\t\t\t\t<ChevronLeft className=\"w-4 h-4\" style={{ color: 'var(--text-secondary)' }} strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t\t<div className=\"flex items-center text-sm overflow-x-auto\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => handleBreadcrumb(-1)}\n\t\t\t\t\t\tclassName=\"px-1 py-1 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors shrink-0\"\n\t\t\t\t\t\tstyle={{ color: path === '/' ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t/\n\t\t\t\t\t</button>\n\t\t\t\t\t{pathParts.map((part, i) => (\n\t\t\t\t\t\t<div key={i} className=\"flex items-center shrink-0\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => handleBreadcrumb(i)}\n\t\t\t\t\t\t\t\tclassName=\"px-1 py-1 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\tstyle={{ color: i === pathParts.length - 1 ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{part}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>/</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t\t<button\n\t\t\t\t\tonClick={loadFiles}\n\t\t\t\t\tclassName=\"ml-auto p-1.5 rounded-md transition-colors\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\ttitle=\"Refresh\"\n\t\t\t\t>\n\t\t\t\t\t<RefreshCw\n\t\t\t\t\t\tclassName={`w-4 h-4 ${loading ? 'animate-spin' : ''}`}\n\t\t\t\t\t\tstyle={{ color: 'var(--text-secondary)' }}\n\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t/>\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t{writable && selected.size > 0 && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"flex items-center gap-2 p-3 rounded-lg border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t{selected.size} selected\n\t\t\t\t\t</span>\n\t\t\t\t\t<div className=\"ml-auto flex items-center gap-2\">\n\t\t\t\t\t\t{selected.size === 1 && (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={openRename}\n\t\t\t\t\t\t\t\tdisabled={actionLoading}\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-md text-sm font-medium transition-colors disabled:opacity-50 border\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tRename\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setFolderPickerMode('move')}\n\t\t\t\t\t\t\tdisabled={actionLoading}\n\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-md text-sm font-medium transition-colors disabled:opacity-50 border\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tMove\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setFolderPickerMode('copy')}\n\t\t\t\t\t\t\tdisabled={actionLoading}\n\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-md text-sm font-medium transition-colors disabled:opacity-50 border\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tCopy\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setDeleteConfirm(true)}\n\t\t\t\t\t\t\tdisabled={actionLoading}\n\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-md text-sm font-medium transition-colors disabled:opacity-50\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 15%, transparent)', color: 'var(--error)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{error && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"p-4 rounded-lg text-sm\"\n\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 10%, transparent)', color: 'var(--error)' }}\n\t\t\t\t>\n\t\t\t\t\t{error}\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t<div\n\t\t\t\tclassName=\"rounded-lg border overflow-hidden\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<table className=\"w-full\">\n\t\t\t\t\t<thead>\n\t\t\t\t\t\t<tr className=\"border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t{writable && (\n\t\t\t\t\t\t\t\t<th className=\"w-10 px-3 py-3\">\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tonClick={toggleSelectAll}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-4 h-4 rounded flex items-center justify-center cursor-pointer border\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\t\t\t\tfiles.length > 0 && selected.size === files.length ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\t\t\tborderColor:\n\t\t\t\t\t\t\t\t\t\t\t\tfiles.length > 0 && selected.size === files.length ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{files.length > 0 && selected.size === files.length && (\n\t\t\t\t\t\t\t\t\t\t\t<Check className=\"w-3 h-3 text-white\" strokeWidth={3} />\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\tclassName=\"text-left px-4 py-3 text-[10px] font-medium uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tName\n\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\tclassName=\"text-right px-4 py-3 text-[10px] font-medium uppercase tracking-wider w-28\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tSize\n\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\tclassName=\"text-right px-4 py-3 text-[10px] font-medium uppercase tracking-wider w-44\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tModified\n\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t<th className=\"w-16\"></th>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t</thead>\n\t\t\t\t\t<tbody>\n\t\t\t\t\t\t{loading && files.length === 0 ? (\n\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t<td\n\t\t\t\t\t\t\t\t\tcolSpan={writable ? 5 : 4}\n\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-8 text-center text-sm\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tLoading...\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t) : files.length === 0 ? (\n\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t<td\n\t\t\t\t\t\t\t\t\tcolSpan={writable ? 5 : 4}\n\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-8 text-center text-sm\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tEmpty directory\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\tfiles.map((file) => (\n\t\t\t\t\t\t\t\t<tr\n\t\t\t\t\t\t\t\t\tkey={file.name}\n\t\t\t\t\t\t\t\t\tclassName=\"border-b last:border-b-0 hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{writable && (\n\t\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-2.5\">\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => toggleSelect(file.name)}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-4 h-4 rounded flex items-center justify-center cursor-pointer border\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: selected.has(file.name) ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: selected.has(file.name) ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{selected.has(file.name) && <Check className=\"w-3 h-3 text-white\" strokeWidth={3} />}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-2.5\">\n\t\t\t\t\t\t\t\t\t\t{file.isDirectory ? (\n\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleNavigate(file.name)}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 text-sm hover:underline\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<Folder className=\"w-4 h-4 shrink-0\" style={{ color: 'var(--warning)' }} fill=\"currentColor\" />\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"truncate\">{file.name}</span>\n\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t<File className=\"w-4 h-4 shrink-0\" style={{ color: 'var(--text-muted)' }} fill=\"currentColor\" />\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"truncate\">{file.name}</span>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-2.5 text-right text-sm tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{file.isDirectory ? '—' : formatSize(file.size)}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-2.5 text-right text-sm tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{formatDate(file.modified)}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-2.5 text-right\">\n\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\thref={getDownloadUrl(path === '/' ? `/${file.name}` : `${path}/${file.name}`)}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t\ttitle={file.isDirectory ? 'Download as .tar' : 'Download'}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<Download className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t))\n\t\t\t\t\t\t)}\n\t\t\t\t\t</tbody>\n\t\t\t\t</table>\n\t\t\t</div>\n\n\t\t\t{folderPickerMode && (\n\t\t\t\t<FolderPicker\n\t\t\t\t\ttitle={folderPickerMode === 'move' ? 'Move to...' : 'Copy to...'}\n\t\t\t\t\tonConfirm={handleMoveOrCopy}\n\t\t\t\t\tonCancel={() => setFolderPickerMode(null)}\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t{renameTarget && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50\"\n\t\t\t\t\tonClick={() => setRenameTarget(null)}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full max-w-sm rounded-lg border shadow-xl\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"p-4 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<h3 className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tRename\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"p-4\">\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={renameValue}\n\t\t\t\t\t\t\t\tonChange={(e) => setRenameValue(e.target.value)}\n\t\t\t\t\t\t\t\tonKeyDown={(e) => e.key === 'Enter' && handleRename()}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-md border text-sm\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex justify-end gap-2 p-4 border-t\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setRenameTarget(null)}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-md text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleRename}\n\t\t\t\t\t\t\t\tdisabled={!renameValue.trim() || renameValue === renameTarget || actionLoading}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-md text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'white' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tRename\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{deleteConfirm && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50\"\n\t\t\t\t\tonClick={() => setDeleteConfirm(false)}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full max-w-sm rounded-lg border shadow-xl\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"p-4 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<h3 className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tConfirm Delete\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"p-4\">\n\t\t\t\t\t\t\t<p className=\"text-sm\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\tDelete {selected.size} item{selected.size > 1 ? 's' : ''}? This cannot be undone.\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t{selected.size <= 5 && (\n\t\t\t\t\t\t\t\t<ul className=\"mt-3 text-sm space-y-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t{Array.from(selected).map((name) => (\n\t\t\t\t\t\t\t\t\t\t<li key={name} className=\"truncate\">\n\t\t\t\t\t\t\t\t\t\t\t• {name}\n\t\t\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</ul>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex justify-end gap-2 p-4 border-t\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setDeleteConfirm(false)}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-md text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleDelete}\n\t\t\t\t\t\t\t\tdisabled={actionLoading}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-md text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/FilterBar.tsx",
    "content": "import { useState, useRef, useCallback, type FC } from 'react'\nimport { createPortal } from 'react-dom'\nimport {\n\tLayoutGrid,\n\tDownload,\n\tUpload,\n\tCheckCircle,\n\tSquare,\n\tZap,\n\tSearch,\n\tFolder,\n\tTag,\n\tSettings,\n\tRepeat,\n\tColumns3,\n\tGripHorizontal,\n\tCheck,\n} from 'lucide-react'\nimport type { TorrentFilter } from '../types/qbittorrent'\nimport type { Category } from '../api/qbittorrent'\nimport type { ColumnDef } from './columns'\nimport { useClickOutside } from '../hooks/useClickOutside'\n\nconst filters: { value: TorrentFilter; label: string; Icon: FC<{ className?: string; strokeWidth?: number }> }[] = [\n\t{ value: 'all', label: 'All', Icon: LayoutGrid },\n\t{ value: 'downloading', label: 'Downloading', Icon: Download },\n\t{ value: 'seeding', label: 'Seeding', Icon: Upload },\n\t{ value: 'completed', label: 'Completed', Icon: CheckCircle },\n\t{ value: 'stopped', label: 'Stopped', Icon: Square },\n\t{ value: 'active', label: 'Active', Icon: Zap },\n]\n\ninterface Props {\n\tfilter: TorrentFilter\n\tonFilterChange: (f: TorrentFilter) => void\n}\n\nexport function FilterBar({ filter, onFilterChange }: Props) {\n\treturn (\n\t\t<>\n\t\t\t{filters.map((f) => (\n\t\t\t\t<button\n\t\t\t\t\tkey={f.value}\n\t\t\t\t\tonClick={() => onFilterChange(f.value)}\n\t\t\t\t\ttitle={f.label}\n\t\t\t\t\tclassName=\"relative flex items-center gap-1.5 px-2 py-1 rounded transition-all duration-150\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tcolor: filter === f.value ? 'var(--accent-contrast)' : 'var(--text-muted)',\n\t\t\t\t\t\tbackgroundColor: filter === f.value ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<f.Icon className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t\t<span className=\"text-xs font-medium\">{f.label}</span>\n\t\t\t\t</button>\n\t\t\t))}\n\t\t</>\n\t)\n}\n\nexport function SearchInput({ value, onChange }: { value: string; onChange: (s: string) => void }) {\n\treturn (\n\t\t<div className=\"relative flex-1 min-w-[120px] max-w-[280px]\">\n\t\t\t<Search\n\t\t\t\tclassName=\"absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5\"\n\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\tstrokeWidth={2}\n\t\t\t/>\n\t\t\t<input\n\t\t\t\ttype=\"text\"\n\t\t\t\tplaceholder=\"Search...\"\n\t\t\t\tvalue={value}\n\t\t\t\tonChange={(e) => onChange(e.target.value)}\n\t\t\t\tclassName=\"w-full h-7 pl-8 pr-3 rounded text-xs transition-all duration-150\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)' }}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n\ninterface DropdownProps<T extends string> {\n\tvalue: T | null\n\tonChange: (v: T | null) => void\n\toptions: { value: T; label: string; count?: number }[]\n\tplaceholder: string\n\tIcon: FC<{ className?: string; strokeWidth?: number }>\n}\n\nfunction Dropdown<T extends string>({ value, onChange, options, placeholder, Icon }: DropdownProps<T>) {\n\tconst [open, setOpen] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\tconst close = useCallback(() => setOpen(false), [])\n\tuseClickOutside(ref, close)\n\n\tconst selected = options.find((o) => o.value === value)\n\n\treturn (\n\t\t<div ref={ref} className=\"relative\">\n\t\t\t<button\n\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\ttitle={selected?.label ?? placeholder}\n\t\t\t\tclassName=\"flex items-center gap-1.5 px-2 py-1 rounded transition-all duration-150\"\n\t\t\t\tstyle={{\n\t\t\t\t\tcolor: value ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\tbackgroundColor: value ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<Icon className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t<span className=\"text-xs font-medium max-w-[60px] truncate\">{selected?.label ?? placeholder}</span>\n\t\t\t</button>\n\t\t\t{open && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute top-full left-0 mt-1 min-w-[160px] max-h-[300px] overflow-auto rounded border shadow-xl z-[100]\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tonChange(null)\n\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"w-full flex items-center px-2.5 py-1.5 text-xs text-left transition-colors\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tcolor: !value ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\tbackgroundColor: !value ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\tAll {placeholder}s\n\t\t\t\t\t</button>\n\t\t\t\t\t{options.map((o) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={o.value}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tonChange(o.value)\n\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-2.5 py-1.5 text-xs text-left transition-colors\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tcolor: value === o.value ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\tvalue === o.value ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className=\"truncate\">{o.label}</span>\n\t\t\t\t\t\t\t{o.count !== undefined && (\n\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }} className=\"ml-2 text-xs\">\n\t\t\t\t\t\t\t\t\t{o.count}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\ninterface CategoryDropdownProps {\n\tvalue: string | null\n\tonChange: (v: string | null) => void\n\tcategories: Record<string, Category>\n}\n\nexport function CategoryDropdown({ value, onChange, categories }: CategoryDropdownProps) {\n\tconst [open, setOpen] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\tconst close = useCallback(() => setOpen(false), [])\n\tuseClickOutside(ref, close)\n\n\tconst names = Object.keys(categories)\n\tconst selected = names.find((n) => n === value)\n\n\treturn (\n\t\t<div ref={ref} className=\"relative\">\n\t\t\t<button\n\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\ttitle={selected ?? 'Category'}\n\t\t\t\tclassName=\"flex items-center gap-1.5 px-2 py-1 rounded transition-all duration-150\"\n\t\t\t\tstyle={{\n\t\t\t\t\tcolor: value ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\tbackgroundColor: value ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<Folder className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t<span className=\"text-xs font-medium max-w-[60px] truncate\">{selected ?? 'Category'}</span>\n\t\t\t</button>\n\t\t\t{open && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute top-full left-0 mt-1 min-w-[160px] max-h-[300px] overflow-auto rounded border shadow-xl z-[100]\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tonChange(null)\n\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"w-full flex items-center px-2.5 py-1.5 text-xs text-left transition-colors\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tcolor: !value ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\tbackgroundColor: !value ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\tAll Categories\n\t\t\t\t\t</button>\n\t\t\t\t\t{names.map((name) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={name}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tonChange(name)\n\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"w-full px-2.5 py-1.5 text-xs text-left transition-colors truncate\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tcolor: value === name ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\tbackgroundColor: value === name ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{name}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\ninterface TagDropdownProps {\n\tvalue: string | null\n\tonChange: (v: string | null) => void\n\ttags: string[]\n}\n\nexport function TagDropdown({ value, onChange, tags }: TagDropdownProps) {\n\tconst [open, setOpen] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\tconst close = useCallback(() => setOpen(false), [])\n\tuseClickOutside(ref, close)\n\n\tconst selected = tags.find((t) => t === value)\n\n\treturn (\n\t\t<div ref={ref} className=\"relative\">\n\t\t\t<button\n\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\ttitle={selected ?? 'Tag'}\n\t\t\t\tclassName=\"flex items-center gap-1.5 px-2 py-1 rounded transition-all duration-150\"\n\t\t\t\tstyle={{\n\t\t\t\t\tcolor: value ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\tbackgroundColor: value ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<Tag className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t<span className=\"text-xs font-medium max-w-[60px] truncate\">{selected ?? 'Tag'}</span>\n\t\t\t</button>\n\t\t\t{open && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute top-full left-0 mt-1 min-w-[160px] max-h-[300px] overflow-auto rounded border shadow-xl z-[100]\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tonChange(null)\n\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"w-full flex items-center px-2.5 py-1.5 text-xs text-left transition-colors\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tcolor: !value ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\tbackgroundColor: !value ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\tAll Tags\n\t\t\t\t\t</button>\n\t\t\t\t\t{tags.map((tag) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tonChange(tag)\n\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"w-full px-2.5 py-1.5 text-xs text-left transition-colors truncate\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tcolor: value === tag ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\tbackgroundColor: value === tag ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\nexport function ManageButton({ onClick }: { onClick: () => void }) {\n\treturn (\n\t\t<button\n\t\t\tonClick={onClick}\n\t\t\tclassName=\"flex items-center gap-1.5 px-2 py-1 rounded transition-all duration-150 hover:opacity-80\"\n\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\ttitle=\"Manage categories & tags\"\n\t\t>\n\t\t\t<Settings className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t<span className=\"text-xs font-medium\">Manage</span>\n\t\t</button>\n\t)\n}\n\ninterface TrackerDropdownProps {\n\tvalue: string | null\n\tonChange: (v: string | null) => void\n\ttrackers: string[]\n}\n\nexport function TrackerDropdown({ value, onChange, trackers }: TrackerDropdownProps) {\n\tconst options = trackers.map((t) => {\n\t\ttry {\n\t\t\tconst url = new URL(t)\n\t\t\treturn { value: t, label: url.hostname }\n\t\t} catch {\n\t\t\treturn { value: t, label: t }\n\t\t}\n\t})\n\treturn <Dropdown value={value} onChange={onChange} options={options} placeholder=\"Tracker\" Icon={Repeat} />\n}\n\ninterface ColumnSelectorProps {\n\tcolumns: ColumnDef[]\n\tvisible: Set<string>\n\tonChange: (visible: Set<string>) => void\n\tcolumnOrder: string[]\n\tonReorder: (order: string[]) => void\n\tonReset: () => void\n}\n\nexport function ColumnSelector({ columns, visible, onChange, columnOrder, onReorder, onReset }: ColumnSelectorProps) {\n\tconst [open, setOpen] = useState(false)\n\tconst [draggedId, setDraggedId] = useState<string | null>(null)\n\tconst [pos, setPos] = useState<{ top: number; right: number } | null>(null)\n\tconst buttonRef = useRef<HTMLButtonElement>(null)\n\n\tfunction handleToggle() {\n\t\tif (open) {\n\t\t\tsetOpen(false)\n\t\t\treturn\n\t\t}\n\t\tconst rect = buttonRef.current?.getBoundingClientRect()\n\t\tif (rect) setPos({ top: rect.bottom, right: window.innerWidth - rect.right })\n\t\tsetOpen(true)\n\t}\n\n\tfunction toggle(id: string) {\n\t\tconst next = new Set(visible)\n\t\tif (next.has(id)) next.delete(id)\n\t\telse next.add(id)\n\t\tonChange(next)\n\t}\n\n\tfunction handleDragStart(e: React.DragEvent, id: string) {\n\t\tsetDraggedId(id)\n\t\te.dataTransfer.effectAllowed = 'move'\n\t}\n\n\tfunction handleDragOver(e: React.DragEvent, targetId: string) {\n\t\te.preventDefault()\n\t\tif (!draggedId || draggedId === targetId) return\n\t\tconst dragIdx = columnOrder.indexOf(draggedId)\n\t\tconst targetIdx = columnOrder.indexOf(targetId)\n\t\tif (dragIdx === -1 || targetIdx === -1) return\n\t\tconst newOrder = [...columnOrder]\n\t\tnewOrder.splice(dragIdx, 1)\n\t\tnewOrder.splice(targetIdx, 0, draggedId)\n\t\tonReorder(newOrder)\n\t}\n\n\tfunction handleDragEnd() {\n\t\tsetDraggedId(null)\n\t}\n\n\tconst orderedColumns = columnOrder\n\t\t.map((id) => columns.find((c) => c.id === id))\n\t\t.filter((c): c is ColumnDef => c !== undefined)\n\n\treturn (\n\t\t<>\n\t\t\t<button\n\t\t\t\tref={buttonRef}\n\t\t\t\tonClick={handleToggle}\n\t\t\t\tclassName=\"flex items-center gap-1.5 px-2 py-1 rounded transition-all duration-150\"\n\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\ttitle=\"Configure columns\"\n\t\t\t>\n\t\t\t\t<Columns3 className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t<span className=\"text-xs font-medium\">Columns</span>\n\t\t\t</button>\n\t\t\t{open && pos && createPortal(\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tonMouseDown={() => setOpen(false)}\n\t\t\t\t\t\tstyle={{ position: 'fixed', inset: 0, zIndex: 99 }}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"min-w-[180px] max-h-[400px] overflow-auto rounded border shadow-xl\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tposition: 'fixed',\n\t\t\t\t\t\t\ttop: pos.top + 4,\n\t\t\t\t\t\t\tright: pos.right,\n\t\t\t\t\t\t\tzIndex: 100,\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"flex items-center justify-between px-2.5 py-1.5 border-b\"\n\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<span className=\"text-xs uppercase tracking-widest font-medium\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tColumns\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tonReset()\n\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"text-xs transition-colors hover:opacity-80\"\n\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tReset\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t\t{orderedColumns.map((col) => (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tkey={col.id}\n\t\t\t\t\t\t\tdraggable\n\t\t\t\t\t\t\tonDragStart={(e) => handleDragStart(e, col.id)}\n\t\t\t\t\t\t\tonDragOver={(e) => handleDragOver(e, col.id)}\n\t\t\t\t\t\t\tonDragEnd={handleDragEnd}\n\t\t\t\t\t\t\tclassName={`flex items-center gap-1.5 px-2 py-1.5 text-xs transition-colors hover:bg-white/5 cursor-move ${draggedId === col.id ? 'opacity-50' : ''}`}\n\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<GripHorizontal className=\"w-3 h-3 shrink-0\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<button onClick={() => toggle(col.id)} className=\"flex-1 flex items-center justify-between text-left\">\n\t\t\t\t\t\t\t\t<span>{col.label}</span>\n\t\t\t\t\t\t\t\t{visible.has(col.id) && (\n\t\t\t\t\t\t\t\t\t<Check className=\"w-3 h-3\" style={{ color: 'var(--accent)' }} strokeWidth={3} />\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</>,\n\t\t\t\tdocument.body\n\t\t\t)}\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "src/components/Header.tsx",
    "content": "import { useState } from 'react'\nimport { Check, Info, User, Lock, LogOut } from 'lucide-react'\nimport { ThemeSwitcher } from './ThemeSwitcher'\nimport { useUpdateCheck } from '../hooks/useUpdateCheck'\nimport { renderMarkdown } from '../utils/markdown'\n\ndeclare const __APP_VERSION__: string\n\ntype Tab = 'dashboard' | 'tools'\n\ninterface Props {\n\tactiveTab?: Tab | null\n\tonTabChange?: (tab: Tab) => void\n\tusername?: string\n\tauthDisabled?: boolean\n\tonLogout?: () => void\n\tonPasswordChange?: () => void\n}\n\nexport function Header({ activeTab, onTabChange, username, authDisabled, onLogout, onPasswordChange }: Props) {\n\tconst [userMenuOpen, setUserMenuOpen] = useState(false)\n\tconst {\n\t\thasUpdate,\n\t\tlatestVersion,\n\t\treleaseNotes,\n\t\treleaseUrl,\n\t\tisLoading: updateLoading,\n\t\terror: updateError,\n\t} = useUpdateCheck()\n\n\treturn (\n\t\t<header className=\"flex items-center justify-between px-6 py-3 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t<img src=\"/logo.svg\" alt=\"qbitwebui\" className=\"w-8 h-8\" />\n\t\t\t\t\t<span className=\"text-sm font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tqbitwebui\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center gap-1 p-1 rounded-lg\" style={{ backgroundColor: 'var(--bg-secondary)' }}>\n\t\t\t\t\t{[\n\t\t\t\t\t\t{ id: 'dashboard' as Tab, label: 'Dashboard' },\n\t\t\t\t\t\t{ id: 'tools' as Tab, label: 'Tools' },\n\t\t\t\t\t].map((t) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={t.id}\n\t\t\t\t\t\t\tonClick={() => onTabChange?.(t.id)}\n\t\t\t\t\t\t\tclassName=\"px-3 py-1 rounded-md text-xs font-medium transition-all\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: activeTab === t.id ? 'var(--bg-primary)' : 'transparent',\n\t\t\t\t\t\t\t\tcolor: activeTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\tboxShadow: activeTab === t.id ? '0 1px 2px rgba(0,0,0,0.1)' : 'none',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t.label}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t<ThemeSwitcher />\n\t\t\t\t<div className=\"relative group\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-1.5 rounded-lg border text-xs font-mono\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tcolor: 'var(--text-muted)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t\ttitle={hasUpdate ? `Update available: v${latestVersion}` : 'Up to date'}\n\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t>\n\t\t\t\t\t\tv{__APP_VERSION__}\n\t\t\t\t\t\t{hasUpdate ? (\n\t\t\t\t\t\t\t<Info className=\"w-3.5 h-3.5\" style={{ color: 'var(--warning)' }} strokeWidth={2} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<Check className=\"w-3.5 h-3.5\" style={{ color: '#a6e3a1' }} strokeWidth={2.5} />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"absolute right-0 top-full mt-2 z-50 opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-80 max-h-80 overflow-auto rounded-lg border shadow-xl p-3\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between mb-2\">\n\t\t\t\t\t\t\t\t<span className=\"text-xs font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tRelease notes{latestVersion ? ` v${latestVersion}` : ''}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t{releaseUrl && (\n\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\thref={releaseUrl}\n\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"text-[10px] uppercase tracking-wide\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tView\n\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{updateLoading ? (\n\t\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tLoading release notes...\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t) : updateError ? (\n\t\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t\t\tFailed to load release notes.\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t) : releaseNotes ? (\n\t\t\t\t\t\t\t\t<div className=\"space-y-2\">{renderMarkdown(releaseNotes)}</div>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tNo release notes available.\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t{!authDisabled && username && (\n\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setUserMenuOpen(!userMenuOpen)}\n\t\t\t\t\t\t\tclassName=\"w-8 h-8 rounded-full flex items-center justify-center transition-colors hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<User className=\"w-5 h-5\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{userMenuOpen && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<div className=\"fixed inset-0 z-10\" onClick={() => setUserMenuOpen(false)} />\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"absolute right-0 top-full mt-2 z-20 min-w-[160px] rounded-lg border shadow-lg overflow-hidden\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"px-3 py-2 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{username}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t{onPasswordChange && (\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\tsetUserMenuOpen(false)\n\t\t\t\t\t\t\t\t\t\t\t\tonPasswordChange()\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-3 py-2 text-sm hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<Lock className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t\t\tChange Password\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t{onLogout && (\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\tsetUserMenuOpen(false)\n\t\t\t\t\t\t\t\t\t\t\t\tonLogout()\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-3 py-2 text-sm hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<LogOut className=\"w-4 h-4\" strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t\t\tLogout\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</header>\n\t)\n}\n"
  },
  {
    "path": "src/components/InstanceManager.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react'\nimport {\n\tSearch,\n\tFolderOpen,\n\tTrash2,\n\tRss,\n\tFileText,\n\tArrowLeftRight,\n\tAlertTriangle,\n\tChevronLeft,\n\tChevronRight,\n\tArrowDown,\n\tArrowUp,\n\tServer,\n\tSettings,\n\tPencil,\n\tBarChart3,\n\tGlobe,\n} from 'lucide-react'\nimport {\n\tgetInstances,\n\tcreateInstance,\n\tupdateInstance,\n\tdeleteInstance,\n\ttype Instance,\n\ttype CreateInstanceData,\n} from '../api/instances'\nimport { logout, changePassword } from '../api/auth'\nimport { Header } from './Header'\nimport { SettingsPanel } from './SettingsPanel'\nimport { SearchPanel } from './SearchPanel'\nimport { FileBrowser } from './FileBrowser'\nimport { OrphanManager } from './OrphanManager'\nimport { RSSManager } from './RSSManager'\nimport { LogViewer } from './LogViewer'\nimport { CrossSeedManager } from './CrossSeedManager'\nimport { Statistics } from './Statistics'\nimport { NetworkTools } from './NetworkTools'\nimport { Checkbox } from './ui'\nimport { formatSpeed, formatSize } from '../utils/format'\n\ntype Tab = 'dashboard' | 'tools'\ntype Tool = 'indexers' | 'files' | 'orphans' | 'rss' | 'logs' | 'cross-seed' | 'statistics' | 'network' | null\n\ninterface InstanceStats {\n\tid: number\n\tlabel: string\n\tonline: boolean\n\ttotal: number\n\tdownloading: number\n\tseeding: number\n\tpaused: number\n\terror: number\n\tdlSpeed: number\n\tupSpeed: number\n\tallTimeDownload: number\n\tallTimeUpload: number\n\tfreeSpaceOnDisk: number\n}\n\nfunction SpeedGraph({ history, color }: { history: number[]; color: string }) {\n\tconst h = 32\n\tconst w = 80\n\tconst values = history.length >= 5 ? history.slice(-5) : [...Array(5 - history.length).fill(0), ...history]\n\tconst max = Math.max(...values, 1)\n\tconst points = values.map((v, i) => `${(i / 4) * w},${h - 2 - (v / max) * (h - 4)}`).join(' ')\n\n\treturn (\n\t\t<svg width={w} height={h} className=\"opacity-60\">\n\t\t\t<polyline\n\t\t\t\tpoints={points}\n\t\t\t\tfill=\"none\"\n\t\t\t\tstroke={color}\n\t\t\t\tstrokeWidth=\"2\"\n\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t/>\n\t\t</svg>\n\t)\n}\n\ninterface Props {\n\tusername: string\n\tonSelectInstance: (instance: Instance) => void\n\tonLogout: () => void\n\tauthDisabled?: boolean\n\tinitialTab?: Tab\n\tinitialTool?: Tool\n\tonTabChange: (tab: Tab) => void\n\tonToolChange: (tool: Tool) => void\n}\n\nexport function InstanceManager({\n\tusername,\n\tonSelectInstance,\n\tonLogout,\n\tauthDisabled,\n\tinitialTab = 'dashboard',\n\tinitialTool = null,\n\tonTabChange,\n\tonToolChange,\n}: Props) {\n\tconst tab = initialTab\n\tconst [instances, setInstances] = useState<Instance[]>([])\n\tconst [stats, setStats] = useState<InstanceStats[]>([])\n\tconst [loading, setLoading] = useState(true)\n\tconst [showForm, setShowForm] = useState(false)\n\tconst [editingId, setEditingId] = useState<number | null>(null)\n\tconst [formData, setFormData] = useState<CreateInstanceData>({\n\t\tlabel: '',\n\t\turl: '',\n\t\tqbt_username: '',\n\t\tqbt_password: '',\n\t\tskip_auth: false,\n\t\tagent_enabled: false,\n\t\tagent_url: '',\n\t})\n\tconst [error, setError] = useState('')\n\tconst [submitting, setSubmitting] = useState(false)\n\tconst [deleteConfirm, setDeleteConfirm] = useState<Instance | null>(null)\n\tconst [testing, setTesting] = useState(false)\n\tconst [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)\n\tconst [agentTesting, setAgentTesting] = useState(false)\n\tconst [showPasswordModal, setShowPasswordModal] = useState(false)\n\tconst [passwordData, setPasswordData] = useState({ current: '', new: '', confirm: '' })\n\tconst [passwordError, setPasswordError] = useState('')\n\tconst [changingPassword, setChangingPassword] = useState(false)\n\tconst [dlHistory, setDlHistory] = useState<number[]>([])\n\tconst [upHistory, setUpHistory] = useState<number[]>([])\n\tconst [settingsInstance, setSettingsInstance] = useState<Instance | null>(null)\n\tconst [filesEnabled, setFilesEnabled] = useState(false)\n\tconst [autoSelectSingle, setAutoSelectSingle] = useState(\n\t\t() => localStorage.getItem('autoSelectSingleInstance') === 'true'\n\t)\n\tconst [showQuickSettings, setShowQuickSettings] = useState(\n\t\t() => localStorage.getItem('showQuickSettings') !== 'false'\n\t)\n\n\tuseEffect(() => {\n\t\tfetch('/api/config')\n\t\t\t.then((r) => r.json())\n\t\t\t.then((c) => setFilesEnabled(c.filesEnabled))\n\t\t\t.catch(() => {})\n\t}, [])\n\n\tconst loadInstances = useCallback(async () => {\n\t\ttry {\n\t\t\tconst data = await getInstances()\n\t\t\tsetInstances(data)\n\t\t} catch {\n\t\t\tsetError('Failed to load instances')\n\t\t} finally {\n\t\t\tsetLoading(false)\n\t\t}\n\t}, [])\n\n\tconst loadStats = useCallback(async () => {\n\t\tconst res = await fetch('/api/instances/stats', { credentials: 'include' }).catch(() => null)\n\t\tif (res?.ok) {\n\t\t\tsetStats(await res.json())\n\t\t}\n\t}, [])\n\n\tuseEffect(() => {\n\t\tloadInstances()\n\t}, [loadInstances])\n\n\tuseEffect(() => {\n\t\tif (instances.length > 0) {\n\t\t\tloadStats()\n\t\t\tconst interval = setInterval(loadStats, 2000)\n\t\t\treturn () => clearInterval(interval)\n\t\t}\n\t}, [instances.length, loadStats])\n\n\tuseEffect(() => {\n\t\tif (stats.length > 0) {\n\t\t\tconst totalDl = stats.reduce((a, s) => a + s.dlSpeed, 0)\n\t\t\tconst totalUp = stats.reduce((a, s) => a + s.upSpeed, 0)\n\t\t\tsetDlHistory((prev) => [...prev.slice(-4), totalDl])\n\t\t\tsetUpHistory((prev) => [...prev.slice(-4), totalUp])\n\t\t}\n\t}, [stats])\n\n\tasync function handleSubmit(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tsetError('')\n\t\tsetSubmitting(true)\n\t\ttry {\n\t\t\tconst submitData = { ...formData }\n\t\t\tif (editingId && !submitData.qbt_password) {\n\t\t\t\tdelete (submitData as { qbt_password?: string }).qbt_password\n\t\t\t}\n\t\t\tif (editingId) {\n\t\t\t\tawait updateInstance(editingId, submitData)\n\t\t\t} else {\n\t\t\t\tawait createInstance(submitData)\n\t\t\t}\n\t\t\tsetShowForm(false)\n\t\t\tsetEditingId(null)\n\t\t\tsetFormData({\n\t\t\t\tlabel: '',\n\t\t\t\turl: '',\n\t\t\t\tqbt_username: '',\n\t\t\t\tqbt_password: '',\n\t\t\t\tskip_auth: false,\n\t\t\t\tagent_enabled: false,\n\t\t\t\tagent_url: '',\n\t\t\t})\n\t\t\tsetTestResult(null)\n\t\t\tawait loadInstances()\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : 'Operation failed')\n\t\t} finally {\n\t\t\tsetSubmitting(false)\n\t\t}\n\t}\n\n\tasync function handleDelete() {\n\t\tif (!deleteConfirm) return\n\t\ttry {\n\t\t\tawait deleteInstance(deleteConfirm.id)\n\t\t\tsetDeleteConfirm(null)\n\t\t\tawait loadInstances()\n\t\t} catch {\n\t\t\tsetError('Failed to delete instance')\n\t\t}\n\t}\n\n\tasync function testConnection() {\n\t\tsetTesting(true)\n\t\tsetTestResult(null)\n\t\ttry {\n\t\t\tlet res: Response\n\t\t\tif (editingId && !formData.qbt_password && !formData.skip_auth) {\n\t\t\t\tres = await fetch(`/api/instances/${editingId}/test`, {\n\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\tcredentials: 'include',\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tres = await fetch('/api/instances/test', {\n\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\t\t\tcredentials: 'include',\n\t\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\t\turl: formData.url,\n\t\t\t\t\t\tusername: formData.qbt_username,\n\t\t\t\t\t\tpassword: formData.qbt_password,\n\t\t\t\t\t\tskip_auth: formData.skip_auth,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\t\t\tconst data = await res.json()\n\t\t\tif (res.ok) {\n\t\t\t\tsetTestResult({ success: true, message: `Connected! qBittorrent ${data.version}` })\n\t\t\t} else {\n\t\t\t\tsetTestResult({ success: false, message: data.error || 'Connection failed' })\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tsetTestResult({ success: false, message: err instanceof Error ? err.message : 'Connection failed' })\n\t\t} finally {\n\t\t\tsetTesting(false)\n\t\t}\n\t}\n\n\tconst [useCustomAgentUrl, setUseCustomAgentUrl] = useState(false)\n\n\tasync function handleAgentToggle(enabled: boolean) {\n\t\tsetFormData({ ...formData, agent_enabled: enabled, agent_url: enabled ? formData.agent_url : '' })\n\t\tsetUseCustomAgentUrl(false)\n\t\tif (!enabled) return\n\t\tif (!formData.url) return setTestResult({ success: false, message: 'Enter a qBittorrent URL first' })\n\n\t\tsetAgentTesting(true)\n\t\tsetTestResult(null)\n\t\ttry {\n\t\t\tconst res = await fetch('/api/instances/test-agent', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\t\tcredentials: 'include',\n\t\t\t\tbody: JSON.stringify({ url: formData.url }),\n\t\t\t})\n\t\t\tconst data = await res.json()\n\t\t\tsetTestResult(\n\t\t\t\tres.ok\n\t\t\t\t\t? { success: true, message: 'Agent is reachable' }\n\t\t\t\t\t: { success: false, message: data.error || 'Agent not reachable - try custom URL' }\n\t\t\t)\n\t\t} catch {\n\t\t\tsetTestResult({ success: false, message: 'Failed to test agent connection' })\n\t\t} finally {\n\t\t\tsetAgentTesting(false)\n\t\t}\n\t}\n\n\tasync function testAgentConnection() {\n\t\tconst url = formData.agent_url || formData.url\n\t\tif (!url) return setTestResult({ success: false, message: 'Enter a URL first' })\n\n\t\tsetAgentTesting(true)\n\t\tsetTestResult(null)\n\t\ttry {\n\t\t\tconst res = await fetch('/api/instances/test-agent', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\t\tcredentials: 'include',\n\t\t\t\tbody: JSON.stringify({ url: formData.url, agent_url: formData.agent_url || undefined }),\n\t\t\t})\n\t\t\tconst data = await res.json()\n\t\t\tsetTestResult(\n\t\t\t\tres.ok\n\t\t\t\t\t? { success: true, message: 'Agent is reachable' }\n\t\t\t\t\t: { success: false, message: data.error || 'Agent not reachable' }\n\t\t\t)\n\t\t} catch {\n\t\t\tsetTestResult({ success: false, message: 'Failed to test agent connection' })\n\t\t} finally {\n\t\t\tsetAgentTesting(false)\n\t\t}\n\t}\n\n\tfunction openEdit(instance: Instance) {\n\t\tsetEditingId(instance.id)\n\t\tsetFormData({\n\t\t\tlabel: instance.label,\n\t\t\turl: instance.url,\n\t\t\tqbt_username: instance.qbt_username || '',\n\t\t\tqbt_password: '',\n\t\t\tskip_auth: instance.skip_auth,\n\t\t\tagent_enabled: instance.agent_enabled,\n\t\t\tagent_url: instance.agent_url || '',\n\t\t})\n\t\tsetUseCustomAgentUrl(!!instance.agent_url)\n\t\tsetTestResult(null)\n\t\tsetShowForm(true)\n\t}\n\n\tfunction closeForm() {\n\t\tsetShowForm(false)\n\t\tsetEditingId(null)\n\t\tsetTestResult(null)\n\t}\n\n\tconst testButtonLabel = testing\n\t\t? 'Testing...'\n\t\t: editingId && !formData.qbt_password && !formData.skip_auth\n\t\t\t? 'Test Saved'\n\t\t\t: 'Test Connection'\n\n\tasync function handleLogout() {\n\t\tawait logout()\n\t\tonLogout()\n\t}\n\n\tfunction toggleAutoSelect(value: boolean) {\n\t\tsetAutoSelectSingle(value)\n\t\tlocalStorage.setItem('autoSelectSingleInstance', String(value))\n\t}\n\n\tfunction toggleQuickSettings() {\n\t\tconst next = !showQuickSettings\n\t\tsetShowQuickSettings(next)\n\t\tlocalStorage.setItem('showQuickSettings', String(next))\n\t}\n\n\tasync function handlePasswordChange(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tsetPasswordError('')\n\t\tif (passwordData.new !== passwordData.confirm) {\n\t\t\tsetPasswordError('New passwords do not match')\n\t\t\treturn\n\t\t}\n\t\tif (passwordData.new.length < 8) {\n\t\t\tsetPasswordError('Password must be at least 8 characters')\n\t\t\treturn\n\t\t}\n\t\tsetChangingPassword(true)\n\t\ttry {\n\t\t\tawait changePassword(passwordData.current, passwordData.new)\n\t\t\tsetShowPasswordModal(false)\n\t\t\tsetPasswordData({ current: '', new: '', confirm: '' })\n\t\t} catch (err) {\n\t\t\tsetPasswordError(err instanceof Error ? err.message : 'Failed to change password')\n\t\t} finally {\n\t\t\tsetChangingPassword(false)\n\t\t}\n\t}\n\n\tif (loading) {\n\t\treturn (\n\t\t\t<div className=\"min-h-screen flex items-center justify-center\" style={{ backgroundColor: 'var(--bg-primary)' }}>\n\t\t\t\t<div className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tLoading...\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t)\n\t}\n\n\tconst displayInstances = instances.filter((i) => i.id !== editingId && i.id !== settingsInstance?.id)\n\tconst showingPanel = showForm || settingsInstance\n\n\treturn (\n\t\t<div className=\"min-h-screen\" style={{ backgroundColor: 'var(--bg-primary)' }}>\n\t\t\t<Header\n\t\t\t\tactiveTab={tab}\n\t\t\t\tonTabChange={onTabChange}\n\t\t\t\tusername={username}\n\t\t\t\tauthDisabled={authDisabled}\n\t\t\t\tonLogout={handleLogout}\n\t\t\t\tonPasswordChange={() => setShowPasswordModal(true)}\n\t\t\t/>\n\n\t\t\t<main className=\"max-w-6xl mx-auto p-6\">\n\t\t\t\t{tab === 'tools' ? (\n\t\t\t\t\tinitialTool ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => onToolChange(null)}\n\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 mb-6 text-sm hover:underline\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<ChevronLeft className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t\tBack to Tools\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{initialTool === 'indexers' && <SearchPanel />}\n\t\t\t\t\t\t\t{initialTool === 'files' && <FileBrowser enabled={filesEnabled} />}\n\t\t\t\t\t\t\t{initialTool === 'orphans' && <OrphanManager instances={instances} />}\n\t\t\t\t\t\t\t{initialTool === 'rss' && <RSSManager instances={instances} />}\n\t\t\t\t\t\t\t{initialTool === 'logs' && <LogViewer instances={instances} />}\n\t\t\t\t\t\t\t{initialTool === 'cross-seed' && <CrossSeedManager instances={instances} />}\n\t\t\t\t\t\t\t{initialTool === 'statistics' && <Statistics />}\n\t\t\t\t\t\t\t{initialTool === 'network' && <NetworkTools instances={instances} />}\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<h1 className=\"text-xl font-semibold mb-6\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tTools\n\t\t\t\t\t\t\t</h1>\n\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 md:grid-cols-3 gap-4\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => onToolChange('indexers')}\n\t\t\t\t\t\t\t\t\tclassName=\"p-6 rounded-xl border text-left transition-all hover:border-[var(--accent)]\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Search className=\"w-8 h-8 mb-3\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t<div className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\tProwlarr\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tSearch indexers\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => onToolChange('files')}\n\t\t\t\t\t\t\t\t\tclassName=\"p-6 rounded-xl border text-left transition-all hover:border-[var(--accent)]\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<FolderOpen className=\"w-8 h-8 mb-3\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t<div className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\tFile Browser\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tBrowse downloads\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => onToolChange('orphans')}\n\t\t\t\t\t\t\t\t\tclassName=\"p-6 rounded-xl border text-left transition-all hover:border-[var(--accent)]\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Trash2 className=\"w-8 h-8 mb-3\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t<div className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\tOrphan Manager\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tClean up torrents\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => onToolChange('rss')}\n\t\t\t\t\t\t\t\t\tclassName=\"p-6 rounded-xl border text-left transition-all hover:border-[var(--accent)]\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Rss className=\"w-8 h-8 mb-3\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t<div className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\tRSS Manager\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tFeeds & auto-download\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => onToolChange('logs')}\n\t\t\t\t\t\t\t\t\tclassName=\"p-6 rounded-xl border text-left transition-all hover:border-[var(--accent)]\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<FileText className=\"w-8 h-8 mb-3\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t<div className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\tLog Viewer\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tApplication logs\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => onToolChange('cross-seed')}\n\t\t\t\t\t\t\t\t\tclassName=\"p-6 rounded-xl border text-left transition-all hover:border-[var(--accent)] relative\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span className=\"absolute top-3 right-3 cursor-help\" title=\"Experimental feature\">\n\t\t\t\t\t\t\t\t\t\t<AlertTriangle className=\"w-6 h-6\" style={{ color: 'var(--error)' }} />\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<ArrowLeftRight className=\"w-8 h-8 mb-3\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t<div className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\tCross-Seed\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tFind matching torrents\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => onToolChange('statistics')}\n\t\t\t\t\t\t\t\t\tclassName=\"p-6 rounded-xl border text-left transition-all hover:border-[var(--accent)]\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<BarChart3 className=\"w-8 h-8 mb-3\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t<div className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\tStatistics\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tTransfer history\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => onToolChange('network')}\n\t\t\t\t\t\t\t\t\tclassName=\"p-6 rounded-xl border text-left transition-all hover:border-[var(--accent)]\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Globe className=\"w-8 h-8 mb-3\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t<div className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\tNetwork\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tIP info, speedtest, DNS\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</>\n\t\t\t\t\t)\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{stats.length > 0 && !showingPanel && (\n\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-6\">\n\t\t\t\t\t\t\t\t{[\n\t\t\t\t\t\t\t\t\t{ label: 'Total', value: stats.reduce((a, s) => a + s.total, 0), color: 'var(--text-primary)' },\n\t\t\t\t\t\t\t\t\t{ label: 'Leeching', value: stats.reduce((a, s) => a + s.downloading, 0), color: 'var(--accent)' },\n\t\t\t\t\t\t\t\t\t{ label: 'Seeding', value: stats.reduce((a, s) => a + s.seeding, 0), color: '#a6e3a1' },\n\t\t\t\t\t\t\t\t\t{ label: 'Stopped', value: stats.reduce((a, s) => a + s.paused, 0), color: 'var(--text-muted)' },\n\t\t\t\t\t\t\t\t].map((item) => (\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tkey={item.label}\n\t\t\t\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{item.label}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"text-2xl font-semibold\" style={{ color: item.color }}>\n\t\t\t\t\t\t\t\t\t\t\t{item.value}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{stats.length > 0 && !showingPanel && (\n\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-6\">\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border flex items-center justify-between\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t\t\t\t\t<ArrowDown className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\tDownload\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-lg font-medium tabular-nums\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{formatSpeed(stats.reduce((a, s) => a + s.dlSpeed, 0))}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<SpeedGraph history={dlHistory} color=\"var(--accent)\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border flex items-center justify-between\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t\t\t\t\t<ArrowUp className=\"w-5 h-5\" style={{ color: '#a6e3a1' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\tUpload\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-lg font-medium tabular-nums\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{formatSpeed(stats.reduce((a, s) => a + s.upSpeed, 0))}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<SpeedGraph history={upHistory} color=\"#a6e3a1\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tAll-Time Down\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-lg font-medium tabular-nums\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t\t\t{formatSize(stats.reduce((a, s) => a + s.allTimeDownload, 0))}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tAll-Time Up\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-lg font-medium tabular-nums\" style={{ color: '#a6e3a1' }}>\n\t\t\t\t\t\t\t\t\t\t{formatSize(stats.reduce((a, s) => a + s.allTimeUpload, 0))}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t<div className=\"flex items-center justify-between mb-6\">\n\t\t\t\t\t\t\t<h1 className=\"text-xl font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tInstances\n\t\t\t\t\t\t\t</h1>\n\t\t\t\t\t\t\t{!showingPanel && (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tsetShowForm(true)\n\t\t\t\t\t\t\t\t\t\tsetEditingId(null)\n\t\t\t\t\t\t\t\t\t\tsetFormData({\n\t\t\t\t\t\t\t\t\t\t\tlabel: '',\n\t\t\t\t\t\t\t\t\t\t\turl: '',\n\t\t\t\t\t\t\t\t\t\t\tqbt_username: '',\n\t\t\t\t\t\t\t\t\t\t\tqbt_password: '',\n\t\t\t\t\t\t\t\t\t\t\tskip_auth: false,\n\t\t\t\t\t\t\t\t\t\t\tagent_enabled: false,\n\t\t\t\t\t\t\t\t\t\t\tagent_url: '',\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium transition-colors\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tAdd Instance\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{error && (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"mb-6 px-4 py-3 rounded-lg text-sm\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 10%, transparent)', color: 'var(--error)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{error}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{showForm && (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"mb-6 p-6 rounded-xl border\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<h2 className=\"text-lg font-medium mb-4\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t{editingId ? 'Edit Instance' : 'Add Instance'}\n\t\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t\t<form onSubmit={handleSubmit} className=\"space-y-4\">\n\t\t\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-4\">\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tLabel\n\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={formData.label}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => setFormData({ ...formData, label: e.target.value })}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"Home Server\"\n\t\t\t\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tURL\n\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"url\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={formData.url}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => setFormData({ ...formData, url: e.target.value })}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"http://192.168.1.100:8080\"\n\t\t\t\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tqBittorrent Username\n\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={formData.qbt_username}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => setFormData({ ...formData, qbt_username: e.target.value })}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\topacity: formData.skip_auth ? 0.5 : 1,\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"admin\"\n\t\t\t\t\t\t\t\t\t\t\t\trequired={!formData.skip_auth}\n\t\t\t\t\t\t\t\t\t\t\t\tdisabled={formData.skip_auth}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tqBittorrent Password\n\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={formData.qbt_password}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => setFormData({ ...formData, qbt_password: e.target.value })}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\topacity: formData.skip_auth ? 0.5 : 1,\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder={editingId ? '••••••••  (unchanged)' : '••••••••'}\n\t\t\t\t\t\t\t\t\t\t\t\trequired={!formData.skip_auth && !editingId}\n\t\t\t\t\t\t\t\t\t\t\t\tdisabled={formData.skip_auth}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\tchecked={formData.skip_auth ?? false}\n\t\t\t\t\t\t\t\t\t\tonChange={(v) => setFormData({ ...formData, skip_auth: v })}\n\t\t\t\t\t\t\t\t\t\tlabel=\"Skip authentication (enable if qBittorrent has IP bypass enabled)\"\n\t\t\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\tchecked={formData.agent_enabled ?? false}\n\t\t\t\t\t\t\t\t\t\tonChange={handleAgentToggle}\n\t\t\t\t\t\t\t\t\t\tlabel={\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\tEnable net-agent (network diagnostics: IP info, speedtest, etc.){' '}\n\t\t\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\t\t\thref=\"https://maciejonos.github.io/qbitwebui/guide/network-agent/\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"underline\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\tHow to set up\n\t\t\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t\t\t{formData.agent_enabled && (\n\t\t\t\t\t\t\t\t\t\t<div className=\"ml-6 space-y-2\">\n\t\t\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\t\t\tchecked={useCustomAgentUrl}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(v) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetUseCustomAgentUrl(v)\n\t\t\t\t\t\t\t\t\t\t\t\t\tif (!v) setFormData({ ...formData, agent_url: '' })\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tlabel=\"Use custom agent URL\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t{useCustomAgentUrl && (\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 max-w-md\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"url\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={formData.agent_url || ''}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => setFormData({ ...formData, agent_url: e.target.value })}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 min-w-0 px-2 py-1 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"https://agent.mydomain.com\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={testAgentConnection}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={agentTesting || !formData.agent_url}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-2 py-1 rounded text-xs border disabled:opacity-50 shrink-0\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{agentTesting ? '...' : 'Test'}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t\t\t{testResult && (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-lg text-sm\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: testResult.success\n\t\t\t\t\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, #a6e3a1 10%, transparent)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t: 'color-mix(in srgb, var(--error) 10%, transparent)',\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: testResult.success ? '#a6e3a1' : 'var(--error)',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{testResult.message}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t\t\t<div className=\"flex gap-3\">\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\t\t\t\tdisabled={submitting}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{submitting ? 'Saving...' : editingId ? 'Update' : 'Add'}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={testConnection}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={\n\t\t\t\t\t\t\t\t\t\t\t\ttesting ||\n\t\t\t\t\t\t\t\t\t\t\t\t!formData.url ||\n\t\t\t\t\t\t\t\t\t\t\t\t(!formData.skip_auth && (!formData.qbt_username || (!editingId && !formData.qbt_password)))\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm border disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{testButtonLabel}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={closeForm}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm border\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{settingsInstance && (\n\t\t\t\t\t\t\t<SettingsPanel instance={settingsInstance} onClose={() => setSettingsInstance(null)} />\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{displayInstances.length === 0 && !showingPanel ? (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"text-center py-12 rounded-xl border\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<p className=\"text-sm mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tNo instances configured\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tAdd your first qBittorrent instance to get started\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\tdisplayInstances.length > 0 && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<div className=\"grid gap-4\">\n\t\t\t\t\t\t\t\t\t\t{displayInstances.map((instance) => {\n\t\t\t\t\t\t\t\t\t\t\tconst instanceStats = stats.find((s) => s.id === instance.id)\n\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={instance.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border group cursor-pointer transition-colors hover:border-[var(--accent)]\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => onSelectInstance(instance)}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-10 h-10 rounded-lg flex items-center justify-center relative\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Server className=\"w-5 h-5\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{instanceStats && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"absolute -top-1 -right-1 w-3 h-3 rounded-full border-2\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: instanceStats.online ? '#a6e3a1' : 'var(--error)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{instance.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{instance.url}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetSettingsInstance(instance)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-2 rounded-lg transition-colors hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttitle=\"Settings\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Settings className=\"w-4 h-4\" strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\topenEdit(instance)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-2 rounded-lg transition-colors hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Pencil className=\"w-4 h-4\" strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetDeleteConfirm(instance)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-2 rounded-lg transition-colors hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Trash2 className=\"w-4 h-4\" strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{instanceStats?.online && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"mt-3 pt-3 border-t grid grid-cols-3 items-center text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-secondary)' }}>{instanceStats.total}</span> torrents\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--accent)' }}>{instanceStats.downloading}</span> leech\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: '#a6e3a1' }}>{instanceStats.seeding}</span> seed\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-center\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tFree space: {formatSize(instanceStats.freeSpaceOnDisk)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3 justify-end\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--accent)' }}>↓ {formatSpeed(instanceStats.dlSpeed)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: '#a6e3a1' }}>↑ {formatSpeed(instanceStats.upSpeed)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t{instances.length === 1 && !showingPanel && (\n\t\t\t\t\t\t\t\t\t\t<div className=\"mt-4\">\n\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={toggleQuickSettings}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-1 text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<ChevronRight\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-3 h-3 transition-transform\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ transform: showQuickSettings ? 'rotate(90deg)' : 'rotate(0deg)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\tDefault behaviour\n\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t{showQuickSettings && (\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"mt-2 ml-4\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tchecked={autoSelectSingle}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={toggleAutoSelect}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tlabel=\"Skip dashboard and go directly to torrents view by default\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t)}\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t</main>\n\n\t\t\t{deleteConfirm && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-50 flex items-center justify-center p-4\"\n\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.5)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full max-w-sm rounded-xl border p-6\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-medium mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tDelete Instance\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mb-6\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tAre you sure you want to delete{' '}\n\t\t\t\t\t\t\t<strong style={{ color: 'var(--text-primary)' }}>{deleteConfirm.label}</strong>? This action cannot be\n\t\t\t\t\t\t\tundone.\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<div className=\"flex gap-3 justify-end\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setDeleteConfirm(null)}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm border\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleDelete}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{showPasswordModal && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-50 flex items-center justify-center p-4\"\n\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.5)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full max-w-sm rounded-xl border p-6\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-medium mb-4\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tChange Password\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<form onSubmit={handlePasswordChange} className=\"space-y-4\">\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tCurrent Password\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\t\t\tvalue={passwordData.current}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setPasswordData({ ...passwordData, current: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tNew Password\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\t\t\tvalue={passwordData.new}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setPasswordData({ ...passwordData, new: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tConfirm New Password\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\t\t\tvalue={passwordData.confirm}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setPasswordData({ ...passwordData, confirm: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{passwordError && (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-lg text-sm\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--error) 10%, transparent)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--error)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{passwordError}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<div className=\"flex gap-3 justify-end pt-2\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tsetShowPasswordModal(false)\n\t\t\t\t\t\t\t\t\t\tsetPasswordData({ current: '', new: '', confirm: '' })\n\t\t\t\t\t\t\t\t\t\tsetPasswordError('')\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm border\"\n\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\t\tdisabled={changingPassword}\n\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{changingPassword ? 'Changing...' : 'Change Password'}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</form>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/Layout.tsx",
    "content": "import type { ReactNode } from 'react'\nimport { Header } from './Header'\nimport { StatusBar } from './StatusBar'\n\ntype Tab = 'dashboard' | 'tools'\n\ninterface Props {\n\tchildren: ReactNode\n\tonTabChange?: (tab: Tab) => void\n\tusername?: string\n\tauthDisabled?: boolean\n\tonLogout?: () => void\n\tonPasswordChange?: () => void\n}\n\nexport function Layout({ children, onTabChange, username, authDisabled, onLogout, onPasswordChange }: Props) {\n\treturn (\n\t\t<div className=\"flex flex-col h-screen\" style={{ backgroundColor: 'var(--bg-primary)' }}>\n\t\t\t<Header\n\t\t\t\tactiveTab={null}\n\t\t\t\tonTabChange={onTabChange}\n\t\t\t\tusername={username}\n\t\t\t\tauthDisabled={authDisabled}\n\t\t\t\tonLogout={onLogout}\n\t\t\t\tonPasswordChange={onPasswordChange}\n\t\t\t/>\n\t\t\t<main className=\"flex-1 overflow-hidden flex flex-col\">{children}</main>\n\t\t\t<StatusBar />\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/LogViewer.tsx",
    "content": "import { useState, useEffect, useRef, useCallback, useMemo } from 'react'\nimport { Loader2, Server, FileText } from 'lucide-react'\nimport { type Instance } from '../api/instances'\nimport { getLog, getPeerLog, type LogEntry, type PeerLogEntry } from '../api/qbittorrent'\nimport { Select } from './ui'\n\nconst LOG_TYPES = {\n\t1: { label: 'Normal', color: 'var(--text-secondary)', bg: 'var(--bg-tertiary)' },\n\t2: { label: 'Info', color: 'var(--accent)', bg: 'color-mix(in srgb, var(--accent) 12%, transparent)' },\n\t4: { label: 'Warning', color: 'var(--warning)', bg: 'color-mix(in srgb, var(--warning) 12%, transparent)' },\n\t8: { label: 'Critical', color: 'var(--error)', bg: 'color-mix(in srgb, var(--error) 12%, transparent)' },\n} as const\n\ntype LogTab = 'main' | 'peers'\ntype SortOrder = 'newest' | 'oldest'\n\ninterface Props {\n\tinstances: Instance[]\n}\n\nexport function LogViewer({ instances }: Props) {\n\tconst [selectedInstance, setSelectedInstance] = useState<number>(instances[0]?.id ?? 0)\n\tconst [tab, setTab] = useState<LogTab>('main')\n\tconst [mainLogs, setMainLogs] = useState<LogEntry[]>([])\n\tconst [peerLogs, setPeerLogs] = useState<PeerLogEntry[]>([])\n\tconst [loading, setLoading] = useState(false)\n\tconst [autoRefresh, setAutoRefresh] = useState(false)\n\tconst [sortOrder, setSortOrder] = useState<SortOrder>('newest')\n\tconst [filters, setFilters] = useState({ normal: true, info: true, warning: true, critical: true })\n\tconst lastMainIdRef = useRef(-1)\n\tconst lastPeerIdRef = useRef(-1)\n\tconst containerRef = useRef<HTMLDivElement>(null)\n\n\tconst fetchLogs = useCallback(\n\t\tasync (incremental = false) => {\n\t\t\tif (!selectedInstance) return\n\t\t\tsetLoading(true)\n\t\t\ttry {\n\t\t\t\tif (tab === 'main') {\n\t\t\t\t\tconst lastId = incremental ? lastMainIdRef.current : -1\n\t\t\t\t\tconst entries = await getLog(selectedInstance, { ...filters, lastKnownId: lastId })\n\t\t\t\t\tif (entries.length > 0) {\n\t\t\t\t\t\tlastMainIdRef.current = Math.max(...entries.map((e) => e.id))\n\t\t\t\t\t\tsetMainLogs((prev) => (incremental ? [...prev, ...entries] : entries))\n\t\t\t\t\t} else if (!incremental) {\n\t\t\t\t\t\tsetMainLogs([])\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst lastId = incremental ? lastPeerIdRef.current : -1\n\t\t\t\t\tconst entries = await getPeerLog(selectedInstance, lastId === -1 ? undefined : lastId)\n\t\t\t\t\tif (entries.length > 0) {\n\t\t\t\t\t\tlastPeerIdRef.current = Math.max(...entries.map((e) => e.id))\n\t\t\t\t\t\tsetPeerLogs((prev) => (incremental ? [...prev, ...entries] : entries))\n\t\t\t\t\t} else if (!incremental) {\n\t\t\t\t\t\tsetPeerLogs([])\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\tif (!incremental) {\n\t\t\t\t\tif (tab === 'main') setMainLogs([])\n\t\t\t\t\telse setPeerLogs([])\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\tsetLoading(false)\n\t\t\t}\n\t\t},\n\t\t[selectedInstance, tab, filters]\n\t)\n\n\tuseEffect(() => {\n\t\tif (tab === 'main') {\n\t\t\tlastMainIdRef.current = -1\n\t\t\tsetMainLogs([])\n\t\t} else {\n\t\t\tlastPeerIdRef.current = -1\n\t\t\tsetPeerLogs([])\n\t\t}\n\t\tif (selectedInstance) fetchLogs()\n\t}, [selectedInstance, tab, filters, fetchLogs])\n\n\tuseEffect(() => {\n\t\tif (!autoRefresh || !selectedInstance) return\n\t\tconst interval = setInterval(() => fetchLogs(true), 5000)\n\t\treturn () => clearInterval(interval)\n\t}, [autoRefresh, selectedInstance, fetchLogs])\n\n\tuseEffect(() => {\n\t\tif (autoRefresh && containerRef.current) {\n\t\t\tcontainerRef.current.scrollTop = sortOrder === 'newest' ? 0 : containerRef.current.scrollHeight\n\t\t}\n\t}, [mainLogs, peerLogs, autoRefresh, sortOrder])\n\n\tconst sortedMainLogs = useMemo(() => {\n\t\treturn sortOrder === 'newest' ? [...mainLogs].reverse() : mainLogs\n\t}, [mainLogs, sortOrder])\n\n\tconst sortedPeerLogs = useMemo(() => {\n\t\treturn sortOrder === 'newest' ? [...peerLogs].reverse() : peerLogs\n\t}, [peerLogs, sortOrder])\n\n\tconst logCount = tab === 'main' ? sortedMainLogs.length : sortedPeerLogs.length\n\n\tconst instanceOptions = useMemo(() => instances.map((i) => ({ value: i.id, label: i.label })), [instances])\n\n\tconst sortOptions: { value: SortOrder; label: string }[] = [\n\t\t{ value: 'newest', label: 'Newest first' },\n\t\t{ value: 'oldest', label: 'Oldest first' },\n\t]\n\n\tfunction formatTime(ts: number) {\n\t\treturn new Date(ts * 1000).toLocaleString()\n\t}\n\n\tfunction toggleFilter(key: keyof typeof filters) {\n\t\tsetFilters((f) => ({ ...f, [key]: !f[key] }))\n\t}\n\n\tconst activeFilterCount = Object.values(filters).filter(Boolean).length\n\n\treturn (\n\t\t<div>\n\t\t\t<div className=\"flex items-center justify-between mb-6\">\n\t\t\t\t<div>\n\t\t\t\t\t<h1 className=\"text-xl font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tLog Viewer\n\t\t\t\t\t</h1>\n\t\t\t\t\t<p className=\"text-sm mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tView qBittorrent application and peer logs\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t\t<button\n\t\t\t\t\tonClick={() => fetchLogs()}\n\t\t\t\t\tdisabled={loading || !selectedInstance}\n\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:opacity-50 hover:opacity-90 active:scale-[0.98]\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t>\n\t\t\t\t\t{loading ? (\n\t\t\t\t\t\t<span className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<Loader2 className=\"w-4 h-4 animate-spin\" />\n\t\t\t\t\t\t\tLoading\n\t\t\t\t\t\t</span>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t'Refresh'\n\t\t\t\t\t)}\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t<div\n\t\t\t\tclassName=\"rounded-xl border p-4 mb-4 space-y-4\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<div className=\"flex flex-wrap items-center gap-4\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<label className=\"text-xs font-medium uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tInstance\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\tvalue={selectedInstance}\n\t\t\t\t\t\t\toptions={instanceOptions}\n\t\t\t\t\t\t\tonChange={setSelectedInstance}\n\t\t\t\t\t\t\tminWidth=\"160px\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<label className=\"text-xs font-medium uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tSort\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<Select value={sortOrder} options={sortOptions} onChange={setSortOrder} minWidth=\"130px\" />\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"flex items-center gap-1 p-1 rounded-lg ml-auto\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{(['main', 'peers'] as const).map((t) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={t}\n\t\t\t\t\t\t\t\tonClick={() => setTab(t)}\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-md text-xs font-medium transition-all\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: tab === t ? 'var(--bg-secondary)' : 'transparent',\n\t\t\t\t\t\t\t\t\tcolor: tab === t ? 'var(--text-primary)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\tboxShadow: tab === t ? '0 1px 2px rgba(0,0,0,0.15), 0 0 0 1px var(--border)' : 'none',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t === 'main' ? 'Application' : 'Peers'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"flex flex-wrap items-center justify-between gap-4 pt-3 border-t\"\n\t\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t{tab === 'main' && (\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName=\"text-xs font-medium uppercase tracking-wider mr-1\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tTypes\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{Object.entries(LOG_TYPES).map(([type, { label, color, bg }]) => {\n\t\t\t\t\t\t\t\tconst key = label.toLowerCase() as keyof typeof filters\n\t\t\t\t\t\t\t\tconst active = filters[key]\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tkey={type}\n\t\t\t\t\t\t\t\t\t\tonClick={() => toggleFilter(key)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-2.5 py-1 rounded-md text-xs font-medium transition-all border\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: active ? bg : 'transparent',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: active ? color : 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: active ? color : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t\topacity: active ? 1 : 0.6,\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{label}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t{activeFilterCount < 4 && (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => setFilters({ normal: true, info: true, warning: true, critical: true })}\n\t\t\t\t\t\t\t\t\tclassName=\"text-xs underline ml-1\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tReset\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<label className=\"flex items-center gap-2 cursor-pointer select-none ml-auto\">\n\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tAuto-refresh\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-9 h-5 rounded-full p-0.5 transition-colors cursor-pointer\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: autoRefresh ? 'var(--accent)' : 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\tonClick={() => setAutoRefresh(!autoRefresh)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"w-4 h-4 rounded-full shadow transition-transform\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'white',\n\t\t\t\t\t\t\t\t\ttransform: autoRefresh ? 'translateX(16px)' : 'translateX(0)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</label>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{!selectedInstance ? (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"text-center py-16 rounded-xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<Server className=\"w-12 h-12 mx-auto mb-3\" style={{ color: 'var(--text-muted)' }} strokeWidth={1} />\n\t\t\t\t\t<p className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tNo instances configured\n\t\t\t\t\t</p>\n\t\t\t\t\t<p className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tAdd an instance to view logs\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t) : logCount === 0 && !loading ? (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"text-center py-16 rounded-xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<FileText className=\"w-12 h-12 mx-auto mb-3\" style={{ color: 'var(--text-muted)' }} strokeWidth={1} />\n\t\t\t\t\t<p className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tNo log entries\n\t\t\t\t\t</p>\n\t\t\t\t\t<p className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t{tab === 'main' && activeFilterCount < 4 ? 'Try adjusting your filters' : 'Logs will appear here'}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<div\n\t\t\t\t\tref={containerRef}\n\t\t\t\t\tclassName=\"rounded-xl border overflow-hidden\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)', maxHeight: '60vh' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"overflow-auto\" style={{ maxHeight: '60vh' }}>\n\t\t\t\t\t\t<table className=\"w-full text-xs\">\n\t\t\t\t\t\t\t<thead className=\"sticky top-0 z-10\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\t\t\tclassName=\"text-left px-4 py-2.5 font-medium whitespace-nowrap\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)', width: '150px' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tTimestamp\n\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t{tab === 'main' ? (\n\t\t\t\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-left px-4 py-2.5 font-medium\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)', width: '90px' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tLevel\n\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-left px-4 py-2.5 font-medium\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)', width: '90px' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tStatus\n\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t<th className=\"text-left px-4 py-2.5 font-medium\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{tab === 'main' ? 'Message' : 'IP Address'}\n\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t{tab === 'peers' && (\n\t\t\t\t\t\t\t\t\t\t<th className=\"text-left px-4 py-2.5 font-medium\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tReason\n\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t</thead>\n\t\t\t\t\t\t\t<tbody className=\"font-mono\">\n\t\t\t\t\t\t\t\t{tab === 'main'\n\t\t\t\t\t\t\t\t\t? sortedMainLogs.map((entry) => {\n\t\t\t\t\t\t\t\t\t\t\tconst typeInfo = LOG_TYPES[entry.type as keyof typeof LOG_TYPES] || LOG_TYPES[1]\n\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t<tr\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={entry.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"border-t transition-colors hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'color-mix(in srgb, var(--border) 50%, transparent)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 whitespace-nowrap tabular-nums\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatTime(entry.timestamp)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"inline-block px-2 py-0.5 rounded text-xs font-medium\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: typeInfo.bg, color: typeInfo.color }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{typeInfo.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-2 break-all\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{entry.message}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t: sortedPeerLogs.map((entry) => (\n\t\t\t\t\t\t\t\t\t\t\t<tr\n\t\t\t\t\t\t\t\t\t\t\t\tkey={entry.id}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"border-t transition-colors hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'color-mix(in srgb, var(--border) 50%, transparent)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-2 whitespace-nowrap tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{formatTime(entry.timestamp)}\n\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"inline-block px-2 py-0.5 rounded text-xs font-medium\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: entry.blocked\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, var(--error) 12%, transparent)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'color-mix(in srgb, var(--accent) 12%, transparent)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: entry.blocked ? 'var(--error)' : 'var(--accent)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{entry.blocked ? 'Blocked' : 'Connected'}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{entry.ip}\n\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-2 break-all\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{entry.reason || '—'}\n\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t</table>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"px-4 py-2 text-xs border-t flex items-center justify-between\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)', color: 'var(--text-muted)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<span>{logCount} entries</span>\n\t\t\t\t\t\t{autoRefresh && (\n\t\t\t\t\t\t\t<span className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t<span className=\"w-1.5 h-1.5 rounded-full animate-pulse\" style={{ backgroundColor: 'var(--accent)' }} />\n\t\t\t\t\t\t\t\tLive\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/NetworkTools.tsx",
    "content": "import { useState, useEffect, useRef } from 'react'\nimport {\n\tGlobe,\n\tGauge,\n\tServer,\n\tNetwork,\n\tLoader2,\n\tMapPin,\n\tBuilding2,\n\tTerminal,\n\tArrowDown,\n\tArrowUp,\n\tClock,\n\tZap,\n\tActivity,\n} from 'lucide-react'\nimport type { Instance } from '../api/instances'\nimport { Select } from './ui'\nimport {\n\tgetIpInfo,\n\trunSpeedtest,\n\tgetSpeedtestServers,\n\tgetDnsInfo,\n\tgetInterfaces,\n\texecCommand,\n\tcheckAgentHealth,\n\ttype IpInfo,\n\ttype SpeedtestResult,\n\ttype SpeedtestServer,\n\ttype DnsInfo,\n\ttype NetworkInterface,\n} from '../api/netAgent'\n\ninterface Props {\n\tinstances: Instance[]\n}\n\ntype CardStatus = 'idle' | 'loading' | 'success' | 'error'\n\ninterface CardState<T> {\n\tstatus: CardStatus\n\tdata: T | null\n\terror: string | null\n}\n\nfunction formatBandwidth(bps: number): string {\n\tconst mbps = (bps * 8) / 1_000_000\n\treturn mbps.toFixed(1)\n}\n\nfunction RunButton({ onClick, disabled, loading }: { onClick: () => void; disabled: boolean; loading: boolean }) {\n\treturn (\n\t\t<button\n\t\t\tonClick={onClick}\n\t\t\tdisabled={disabled || loading}\n\t\t\tclassName=\"w-12 h-7 rounded text-xs font-medium transition-all disabled:opacity-40 flex items-center justify-center\"\n\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t>\n\t\t\t{loading ? <Loader2 className=\"w-3 h-3 animate-spin\" /> : 'Run'}\n\t\t</button>\n\t)\n}\n\nexport function NetworkTools({ instances }: Props) {\n\tconst agentInstances = instances.filter((i) => i.agent_enabled)\n\tconst [selectedInstance, setSelectedInstance] = useState<Instance | null>(agentInstances[0] || null)\n\tconst [agentOnline, setAgentOnline] = useState<boolean | null>(null)\n\n\tuseEffect(() => {\n\t\tconst current = instances.filter((i) => i.agent_enabled)\n\t\tsetSelectedInstance((prev) => {\n\t\t\tif (prev && current.some((i) => i.id === prev.id)) {\n\t\t\t\treturn current.find((i) => i.id === prev.id) || null\n\t\t\t}\n\t\t\treturn current[0] || null\n\t\t})\n\t}, [instances])\n\n\tconst [ipInfo, setIpInfo] = useState<CardState<IpInfo>>({ status: 'idle', data: null, error: null })\n\tconst [speedtest, setSpeedtest] = useState<CardState<SpeedtestResult>>({ status: 'idle', data: null, error: null })\n\tconst [speedtestServers, setSpeedtestServers] = useState<SpeedtestServer[]>([])\n\tconst [selectedServer, setSelectedServer] = useState<number | null>(null)\n\tconst [loadingServers, setLoadingServers] = useState(false)\n\tconst lastLoadedInstanceId = useRef<number | null>(null)\n\tconst [dns, setDns] = useState<CardState<DnsInfo>>({ status: 'idle', data: null, error: null })\n\tconst [ifaces, setIfaces] = useState<CardState<NetworkInterface[]>>({ status: 'idle', data: null, error: null })\n\tconst [command, setCommand] = useState('')\n\tconst [commandHistory, setCommandHistory] = useState<string[]>([])\n\tconst [commandResult, setCommandResult] = useState<CardState<{ output: string; error?: string }>>({\n\t\tstatus: 'idle',\n\t\tdata: null,\n\t\terror: null,\n\t})\n\n\tuseEffect(() => {\n\t\tif (!selectedInstance) {\n\t\t\tsetAgentOnline(null)\n\t\t\treturn\n\t\t}\n\t\tcheckAgentHealth(selectedInstance.id).then(setAgentOnline)\n\t}, [selectedInstance])\n\n\tuseEffect(() => {\n\t\tif (!selectedInstance || !agentOnline || speedtestServers.length !== 0) return\n\t\tif (lastLoadedInstanceId.current === selectedInstance.id) return\n\t\tlastLoadedInstanceId.current = selectedInstance.id\n\n\t\tsetLoadingServers(true)\n\t\tvoid (async () => {\n\t\t\ttry {\n\t\t\t\tconst data = await getSpeedtestServers(selectedInstance.id)\n\t\t\t\tsetSpeedtestServers(data.servers || [])\n\t\t\t} catch {\n\t\t\t\tsetSpeedtestServers([])\n\t\t\t} finally {\n\t\t\t\tsetLoadingServers(false)\n\t\t\t}\n\t\t})()\n\t}, [selectedInstance, agentOnline, speedtestServers.length])\n\n\tasync function handleRunIpInfo() {\n\t\tif (!selectedInstance) return\n\t\tsetIpInfo((prev) => ({ ...prev, status: 'loading', error: null }))\n\t\ttry {\n\t\t\tconst data = await getIpInfo(selectedInstance.id)\n\t\t\tsetIpInfo({ status: 'success', data, error: null })\n\t\t} catch (e) {\n\t\t\tsetIpInfo((prev) => ({ ...prev, status: 'error', error: e instanceof Error ? e.message : 'Failed' }))\n\t\t}\n\t}\n\n\tasync function handleRunSpeedtest() {\n\t\tif (!selectedInstance) return\n\t\tsetSpeedtest({ status: 'loading', data: null, error: null })\n\t\ttry {\n\t\t\tconst data = await runSpeedtest(selectedInstance.id, selectedServer || undefined)\n\t\t\tsetSpeedtest({ status: 'success', data, error: null })\n\t\t} catch (e) {\n\t\t\tsetSpeedtest({ status: 'error', data: null, error: e instanceof Error ? e.message : 'Failed' })\n\t\t}\n\t}\n\n\tasync function handleRunDns() {\n\t\tif (!selectedInstance) return\n\t\tsetDns((prev) => ({ ...prev, status: 'loading', error: null }))\n\t\ttry {\n\t\t\tconst data = await getDnsInfo(selectedInstance.id)\n\t\t\tsetDns({ status: 'success', data, error: null })\n\t\t} catch (e) {\n\t\t\tsetDns((prev) => ({ ...prev, status: 'error', error: e instanceof Error ? e.message : 'Failed' }))\n\t\t}\n\t}\n\n\tasync function handleRunInterfaces() {\n\t\tif (!selectedInstance) return\n\t\tsetIfaces((prev) => ({ ...prev, status: 'loading', error: null }))\n\t\ttry {\n\t\t\tconst data = await getInterfaces(selectedInstance.id)\n\t\t\tsetIfaces({ status: 'success', data, error: null })\n\t\t} catch (e) {\n\t\t\tsetIfaces((prev) => ({ ...prev, status: 'error', error: e instanceof Error ? e.message : 'Failed' }))\n\t\t}\n\t}\n\n\tasync function handleRunCommand() {\n\t\tif (!selectedInstance || !command.trim()) return\n\t\tconst cmd = command.trim()\n\t\tsetCommandResult({ status: 'loading', data: null, error: null })\n\t\tsetCommandHistory((h) => [cmd, ...h.filter((c) => c !== cmd)].slice(0, 10))\n\t\ttry {\n\t\t\tconst data = await execCommand(selectedInstance.id, cmd)\n\t\t\tsetCommandResult({ status: 'success', data, error: null })\n\t\t} catch (e) {\n\t\t\tsetCommandResult({ status: 'error', data: null, error: e instanceof Error ? e.message : 'Failed' })\n\t\t}\n\t}\n\n\tif (agentInstances.length === 0) {\n\t\treturn (\n\t\t\t<div className=\"flex flex-col items-center justify-center py-20\">\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"w-16 h-16 rounded-2xl flex items-center justify-center mb-6\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<Network className=\"w-8 h-8\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t</div>\n\t\t\t\t<h2 className=\"text-lg font-medium mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\tNo Agent Configured\n\t\t\t\t</h2>\n\t\t\t\t<p className=\"text-sm mb-6 text-center max-w-md\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tEnable net-agent on an instance to run network diagnostics from your qBittorrent host\n\t\t\t\t</p>\n\t\t\t\t<a\n\t\t\t\t\thref=\"https://maciejonos.github.io/qbitwebui/guide/network-agent/\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium transition-colors\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t>\n\t\t\t\t\tSetup Guide\n\t\t\t\t</a>\n\t\t\t</div>\n\t\t)\n\t}\n\n\tconst canRun = selectedInstance && agentOnline\n\tconst serverOptions = [\n\t\t{ value: 0, label: loadingServers ? 'Loading...' : 'Auto (nearest)' },\n\t\t...speedtestServers.map((s) => ({ value: s.id, label: `${s.name} - ${s.location}` })),\n\t]\n\n\treturn (\n\t\t<div className=\"space-y-6\">\n\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t<div>\n\t\t\t\t\t<h1 className=\"text-xl font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tNetwork Diagnostics\n\t\t\t\t\t</h1>\n\t\t\t\t\t<p className=\"text-sm mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tRun tests from your qBittorrent instance's network\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t{agentOnline !== null && (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-1.5 rounded-lg\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"w-2 h-2 rounded-full\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: agentOnline ? '#a6e3a1' : 'var(--error)' }}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: agentOnline ? '#a6e3a1' : 'var(--error)' }}>\n\t\t\t\t\t\t\t\t{agentOnline ? 'Agent Online' : 'Agent Offline'}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t{agentInstances.length > 1 && (\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\tvalue={selectedInstance?.id || 0}\n\t\t\t\t\t\t\toptions={agentInstances.map((i) => ({ value: i.id, label: i.label }))}\n\t\t\t\t\t\t\tonChange={(v) => {\n\t\t\t\t\t\t\t\tconst inst = agentInstances.find((i) => i.id === v)\n\t\t\t\t\t\t\t\tsetSelectedInstance(inst || null)\n\t\t\t\t\t\t\t\tsetAgentOnline(null)\n\t\t\t\t\t\t\t\tsetSpeedtestServers([])\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tminWidth=\"160px\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{agentOnline === false && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"px-4 py-3 rounded-lg text-sm flex items-center gap-3\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--error) 8%, transparent)',\n\t\t\t\t\t\tborder: '1px solid color-mix(in srgb, var(--error) 20%, transparent)',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Activity className=\"w-4 h-4 shrink-0\" style={{ color: 'var(--error)' }} />\n\t\t\t\t\t<span style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\tAgent not reachable at port 9876. Ensure net-agent is running on the qBittorrent host.\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t<div className=\"grid grid-cols-1 lg:grid-cols-3 gap-4\">\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"rounded-xl flex flex-col\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"px-4 py-3 flex items-center justify-between rounded-t-xl\"\n\t\t\t\t\t\tstyle={{ borderBottom: '1px solid var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2.5\">\n\t\t\t\t\t\t\t<Globe className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tExternal IP\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<RunButton onClick={handleRunIpInfo} disabled={!canRun} loading={ipInfo.status === 'loading'} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"p-4 min-h-[140px] rounded-b-xl flex-1\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t{!ipInfo.data && ipInfo.status !== 'error' && (\n\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tClick Run to fetch IP information\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{ipInfo.status === 'error' && !ipInfo.data && (\n\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t\t{ipInfo.error}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{ipInfo.data && (\n\t\t\t\t\t\t\t<div className=\"space-y-3\">\n\t\t\t\t\t\t\t\t<div className=\"text-2xl font-mono font-semibold tracking-tight\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t\t{ipInfo.data.ip}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"space-y-1.5\">\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t\t<MapPin className=\"w-3 h-3\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t\t\t\t\t{ipInfo.data.city}, {ipInfo.data.region}, {ipInfo.data.country}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t\t<Building2 className=\"w-3 h-3\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t\t\t\t\t{ipInfo.data.org}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t<Clock className=\"w-3 h-3\" />\n\t\t\t\t\t\t\t\t\t\t{ipInfo.data.timezone}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"rounded-xl flex flex-col\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"px-4 py-3 flex items-center justify-between rounded-t-xl\"\n\t\t\t\t\t\tstyle={{ borderBottom: '1px solid var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2.5\">\n\t\t\t\t\t\t\t<Server className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tDNS\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<RunButton onClick={handleRunDns} disabled={!canRun} loading={dns.status === 'loading'} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"p-4 min-h-[140px] rounded-b-xl flex-1\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t{!dns.data && dns.status !== 'error' && (\n\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tClick Run to show DNS servers\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{dns.status === 'error' && !dns.data && (\n\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t\t{dns.error}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{dns.data && (\n\t\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t\t{dns.data.servers.map((server, i) => (\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-2 rounded-lg\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div className=\"w-1.5 h-1.5 rounded-full\" style={{ backgroundColor: 'var(--accent)' }} />\n\t\t\t\t\t\t\t\t\t\t<span className=\"font-mono text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{server}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t{dns.data.servers.length === 0 && (\n\t\t\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tNo DNS servers found\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"rounded-xl flex flex-col\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"px-4 py-3 flex items-center justify-between rounded-t-xl\"\n\t\t\t\t\t\tstyle={{ borderBottom: '1px solid var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2.5\">\n\t\t\t\t\t\t\t<Network className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tInterfaces\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<RunButton onClick={handleRunInterfaces} disabled={!canRun} loading={ifaces.status === 'loading'} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"p-4 min-h-[140px] rounded-b-xl flex-1\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t{!ifaces.data && ifaces.status !== 'error' && (\n\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tClick Run to list network interfaces\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{ifaces.status === 'error' && !ifaces.data && (\n\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t\t{ifaces.error}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{ifaces.data && (\n\t\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t\t{ifaces.data\n\t\t\t\t\t\t\t\t\t.filter((iface) => iface.addr_info?.some((a) => a.family === 'inet'))\n\t\t\t\t\t\t\t\t\t.map((iface) => (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={iface.ifname}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center justify-between px-3 py-2 rounded-lg\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-2 h-2 rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: iface.operstate === 'UP' ? '#a6e3a1' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs font-medium\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{iface.ifname}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"font-mono text-xs\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{iface.addr_info?.find((a) => a.family === 'inet')?.local}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"rounded-xl\" style={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"px-4 py-3 flex items-center justify-between relative z-10 rounded-t-xl\"\n\t\t\t\t\tstyle={{ borderBottom: '1px solid var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex items-center gap-2.5\">\n\t\t\t\t\t\t<Gauge className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tSpeedtest\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName=\"text-xs px-1.5 py-0.5 rounded\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tOokla\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\tvalue={selectedServer || 0}\n\t\t\t\t\t\t\toptions={serverOptions}\n\t\t\t\t\t\t\tonChange={(v) => setSelectedServer(v || null)}\n\t\t\t\t\t\t\tminWidth=\"180px\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<RunButton onClick={handleRunSpeedtest} disabled={!canRun} loading={speedtest.status === 'loading'} />\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"p-4 min-h-[140px] rounded-b-xl\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t{speedtest.status === 'idle' && (\n\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tSelect a server or use auto-detection, then click Run\n\t\t\t\t\t\t</p>\n\t\t\t\t\t)}\n\t\t\t\t\t{speedtest.status === 'loading' && (\n\t\t\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t\t\t<div className=\"relative w-20 h-20\">\n\t\t\t\t\t\t\t\t<svg className=\"w-20 h-20 -rotate-90\">\n\t\t\t\t\t\t\t\t\t<circle cx=\"40\" cy=\"40\" r=\"36\" fill=\"none\" stroke=\"var(--border)\" strokeWidth=\"6\" />\n\t\t\t\t\t\t\t\t\t<circle\n\t\t\t\t\t\t\t\t\t\tcx=\"40\"\n\t\t\t\t\t\t\t\t\t\tcy=\"40\"\n\t\t\t\t\t\t\t\t\t\tr=\"36\"\n\t\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\t\tstroke=\"var(--accent)\"\n\t\t\t\t\t\t\t\t\t\tstrokeWidth=\"6\"\n\t\t\t\t\t\t\t\t\t\tstrokeDasharray=\"226\"\n\t\t\t\t\t\t\t\t\t\tstrokeDashoffset=\"56\"\n\t\t\t\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"animate-spin\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ transformOrigin: 'center', animationDuration: '1.5s' }}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t<Zap\n\t\t\t\t\t\t\t\t\tclassName=\"w-6 h-6 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<p className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tRunning speedtest...\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t<p className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tThis typically takes 30-60 seconds\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t{speedtest.status === 'error' && (\n\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t{speedtest.error}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t)}\n\t\t\t\t\t{speedtest.status === 'success' && speedtest.data && (\n\t\t\t\t\t\t<div className=\"grid grid-cols-1 md:grid-cols-4 gap-4\">\n\t\t\t\t\t\t\t<div className=\"p-4 rounded-xl\" style={{ backgroundColor: 'var(--bg-secondary)' }}>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 mb-2\">\n\t\t\t\t\t\t\t\t\t<ArrowDown className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} />\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tDownload\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex items-baseline gap-1\">\n\t\t\t\t\t\t\t\t\t<span className=\"text-3xl font-mono font-bold\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t\t\t{formatBandwidth(speedtest.data.download.bandwidth)}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tMbps\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"p-4 rounded-xl\" style={{ backgroundColor: 'var(--bg-secondary)' }}>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 mb-2\">\n\t\t\t\t\t\t\t\t\t<ArrowUp className=\"w-4 h-4\" style={{ color: '#a6e3a1' }} />\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tUpload\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex items-baseline gap-1\">\n\t\t\t\t\t\t\t\t\t<span className=\"text-3xl font-mono font-bold\" style={{ color: '#a6e3a1' }}>\n\t\t\t\t\t\t\t\t\t\t{formatBandwidth(speedtest.data.upload.bandwidth)}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tMbps\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"p-4 rounded-xl\" style={{ backgroundColor: 'var(--bg-secondary)' }}>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 mb-2\">\n\t\t\t\t\t\t\t\t\t<Activity className=\"w-4 h-4\" style={{ color: 'var(--warning)' }} />\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tPing\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex items-baseline gap-1\">\n\t\t\t\t\t\t\t\t\t<span className=\"text-3xl font-mono font-bold\" style={{ color: 'var(--warning)' }}>\n\t\t\t\t\t\t\t\t\t\t{speedtest.data.ping.latency.toFixed(0)}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tms\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"p-4 rounded-xl\" style={{ backgroundColor: 'var(--bg-secondary)' }}>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 mb-2\">\n\t\t\t\t\t\t\t\t\t<Server className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tServer\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"text-sm font-medium truncate\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t{speedtest.data.server.name}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"text-xs mt-0.5 truncate\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t{speedtest.data.server.location} · {speedtest.data.isp}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"rounded-xl\" style={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"px-4 py-3 flex items-center justify-between rounded-t-xl\"\n\t\t\t\t\tstyle={{ borderBottom: '1px solid var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex items-center gap-2.5\">\n\t\t\t\t\t\t<Terminal className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tTerminal\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t{['ping', 'dig', 'traceroute', 'curl'].map((cmd) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={cmd}\n\t\t\t\t\t\t\t\tonClick={() => setCommand(cmd + ' ')}\n\t\t\t\t\t\t\t\tclassName=\"px-2 py-1 rounded text-xs transition-colors hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{cmd}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"p-4 rounded-b-xl\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t<form\n\t\t\t\t\t\tonSubmit={(e) => {\n\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\thandleRunCommand()\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"flex gap-2 mb-3\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex-1 relative\">\n\t\t\t\t\t\t\t<span className=\"absolute left-3 top-1/2 -translate-y-1/2 text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t$\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={command}\n\t\t\t\t\t\t\t\tonChange={(e) => setCommand(e.target.value)}\n\t\t\t\t\t\t\t\tplaceholder=\"ping 8.8.8.8 -c 4\"\n\t\t\t\t\t\t\t\tdisabled={!canRun || commandResult.status === 'loading'}\n\t\t\t\t\t\t\t\tclassName=\"w-full pl-7 pr-3 py-2.5 rounded-lg text-sm font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\t\t\tborder: '1px solid var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tlist=\"cmd-history\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t{commandHistory.length > 0 && (\n\t\t\t\t\t\t\t\t<datalist id=\"cmd-history\">\n\t\t\t\t\t\t\t\t\t{commandHistory.map((c, i) => (\n\t\t\t\t\t\t\t\t\t\t<option key={i} value={c} />\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</datalist>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\tdisabled={!canRun || commandResult.status === 'loading' || !command.trim()}\n\t\t\t\t\t\t\tclassName=\"px-4 py-2.5 rounded-lg text-sm font-medium transition-all disabled:opacity-40 flex items-center gap-2\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{commandResult.status === 'loading' ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : 'Execute'}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</form>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"rounded-lg p-4 min-h-[120px] max-h-[400px] overflow-auto font-mono text-xs leading-relaxed\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-primary)', border: '1px solid var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{commandResult.status === 'idle' && (\n\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tAllowed commands: ping, dig, nslookup, traceroute, curl, wget\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{commandResult.status === 'loading' && (\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t<Loader2 className=\"w-3 h-3 animate-spin\" style={{ color: 'var(--accent)' }} />\n\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>Executing...</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{commandResult.status === 'error' && <span style={{ color: 'var(--error)' }}>{commandResult.error}</span>}\n\t\t\t\t\t\t{commandResult.status === 'success' && commandResult.data && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<pre className=\"whitespace-pre-wrap break-all\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t{commandResult.data.output || '(no output)'}\n\t\t\t\t\t\t\t\t</pre>\n\t\t\t\t\t\t\t\t{commandResult.data.error && (\n\t\t\t\t\t\t\t\t\t<div className=\"mt-3 pt-3\" style={{ borderTop: '1px solid var(--border)', color: 'var(--warning)' }}>\n\t\t\t\t\t\t\t\t\t\tExit: {commandResult.data.error}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/OrphanManager.tsx",
    "content": "import { useState, useMemo } from 'react'\nimport { Check, Server } from 'lucide-react'\nimport { type Instance } from '../api/instances'\nimport { deleteTorrents } from '../api/qbittorrent'\nimport { formatSize } from '../utils/format'\n\ninterface OrphanTorrent {\n\tinstanceId: number\n\tinstanceLabel: string\n\thash: string\n\tname: string\n\tsize: number\n\treason: 'missingFiles' | 'unregistered'\n\ttrackerMessage?: string\n}\n\ninterface Props {\n\tinstances: Instance[]\n}\n\nexport function OrphanManager({ instances }: Props) {\n\tconst [scanning, setScanning] = useState(false)\n\tconst [orphans, setOrphans] = useState<OrphanTorrent[]>([])\n\tconst [selected, setSelected] = useState<Set<string>>(new Set())\n\tconst [scanned, setScanned] = useState(false)\n\tconst [deleteFiles, setDeleteFiles] = useState(false)\n\tconst [deleting, setDeleting] = useState(false)\n\tconst [showConfirm, setShowConfirm] = useState(false)\n\n\tasync function scan() {\n\t\tsetScanning(true)\n\t\tsetOrphans([])\n\t\tsetSelected(new Set())\n\t\tsetScanned(false)\n\t\ttry {\n\t\t\tconst res = await fetch('/api/tools/orphans/scan', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\tcredentials: 'include',\n\t\t\t})\n\t\t\tif (!res.ok) throw new Error('Scan failed')\n\t\t\tconst data = await res.json()\n\t\t\tsetOrphans(data.orphans)\n\t\t\tsetScanned(true)\n\t\t} finally {\n\t\t\tsetScanning(false)\n\t\t}\n\t}\n\n\tfunction toggleSelect(key: string) {\n\t\tsetSelected((prev) => {\n\t\t\tconst next = new Set(prev)\n\t\t\tif (next.has(key)) next.delete(key)\n\t\t\telse next.add(key)\n\t\t\treturn next\n\t\t})\n\t}\n\n\tfunction selectAll() {\n\t\tif (selected.size === orphans.length) {\n\t\t\tsetSelected(new Set())\n\t\t} else {\n\t\t\tsetSelected(new Set(orphans.map((o) => `${o.instanceId}:${o.hash}`)))\n\t\t}\n\t}\n\n\tasync function handleDelete() {\n\t\tsetDeleting(true)\n\t\ttry {\n\t\t\tconst byInstance = new Map<number, string[]>()\n\t\t\tfor (const key of selected) {\n\t\t\t\tconst [instanceId, hash] = key.split(':')\n\t\t\t\tconst id = parseInt(instanceId, 10)\n\t\t\t\tif (!byInstance.has(id)) byInstance.set(id, [])\n\t\t\t\tbyInstance.get(id)!.push(hash)\n\t\t\t}\n\t\t\tfor (const [instanceId, hashes] of byInstance) {\n\t\t\t\tawait deleteTorrents(instanceId, hashes, deleteFiles)\n\t\t\t}\n\t\t\tsetOrphans((prev) => prev.filter((o) => !selected.has(`${o.instanceId}:${o.hash}`)))\n\t\t\tsetSelected(new Set())\n\t\t\tsetShowConfirm(false)\n\t\t} finally {\n\t\t\tsetDeleting(false)\n\t\t}\n\t}\n\n\tconst groupedByInstance = useMemo(() => {\n\t\tconst acc: Record<string, OrphanTorrent[]> = {}\n\t\tfor (const o of orphans) {\n\t\t\tif (!acc[o.instanceLabel]) acc[o.instanceLabel] = []\n\t\t\tacc[o.instanceLabel].push(o)\n\t\t}\n\t\treturn acc\n\t}, [orphans])\n\n\treturn (\n\t\t<div>\n\t\t\t<div className=\"flex items-center justify-between mb-6\">\n\t\t\t\t<div>\n\t\t\t\t\t<h1 className=\"text-xl font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tOrphan Manager\n\t\t\t\t\t</h1>\n\t\t\t\t\t<p className=\"text-sm mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tFind torrents with missing files or unregistered from trackers\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t\t<button\n\t\t\t\t\tonClick={scan}\n\t\t\t\t\tdisabled={scanning || instances.length === 0}\n\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t>\n\t\t\t\t\t{scanning ? 'Scanning...' : 'Scan All Instances'}\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t{scanning && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"mb-6 p-4 rounded-xl border flex items-center gap-3\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-5 h-5 border-2 rounded-full animate-spin\"\n\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}\n\t\t\t\t\t/>\n\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\tScanning instances... (check server logs for details)\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{instances.length === 0 && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"text-center py-12 rounded-xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<p className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tNo instances configured\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{scanned && orphans.length === 0 && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"text-center py-12 rounded-xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<Check className=\"w-12 h-12 mx-auto mb-3\" style={{ color: '#a6e3a1' }} strokeWidth={1.5} />\n\t\t\t\t\t<p className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tAll clear!\n\t\t\t\t\t</p>\n\t\t\t\t\t<p className=\"text-sm mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tNo orphaned torrents found\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{orphans.length > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"flex items-center justify-between mb-4\">\n\t\t\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t\t\t<button onClick={selectAll} className=\"text-sm hover:underline\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t{selected.size === orphans.length ? 'Deselect all' : 'Select all'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t{selected.size} of {orphans.length} selected\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{selected.size > 0 && (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setShowConfirm(true)}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tDelete Selected\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{Object.entries(groupedByInstance).map(([instanceLabel, items]) => (\n\t\t\t\t\t\t<div key={instanceLabel} className=\"mb-6\">\n\t\t\t\t\t\t\t<h2 className=\"text-sm font-medium mb-3 flex items-center gap-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t<Server className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t{instanceLabel}\n\t\t\t\t\t\t\t\t<span className=\"font-normal\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t({items.length})\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"rounded-xl border overflow-hidden\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{items.map((item, idx) => {\n\t\t\t\t\t\t\t\t\tconst key = `${item.instanceId}:${item.hash}`\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={key}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderTop: idx > 0 ? '1px solid var(--border)' : undefined }}\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => toggleSelect(key)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-5 h-5 rounded flex items-center justify-center border shrink-0 transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: selected.has(key) ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: selected.has(key) ? 'var(--accent)' : 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{selected.has(key) && <Check className=\"w-3 h-3\" style={{ color: 'white' }} strokeWidth={3} />}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-sm font-medium truncate\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{item.name}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs mt-0.5 flex items-center gap-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span>{formatSize(item.size)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span>•</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{item.reason === 'missingFiles' ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--warning)' }}>Missing files</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--error)' }} title={item.trackerMessage}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tUnregistered\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{showConfirm && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-50 flex items-center justify-center p-4\"\n\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.5)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full max-w-md rounded-xl border p-6\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-medium mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tDelete Torrents\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mb-4\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tAre you sure you want to delete <strong style={{ color: 'var(--text-primary)' }}>{selected.size}</strong>{' '}\n\t\t\t\t\t\t\ttorrent{selected.size !== 1 ? 's' : ''}?\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<label className=\"flex items-center gap-3 mb-6 cursor-pointer\">\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"w-5 h-5 rounded flex items-center justify-center border shrink-0 transition-colors\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: deleteFiles ? 'var(--error)' : 'transparent',\n\t\t\t\t\t\t\t\t\tborderColor: deleteFiles ? 'var(--error)' : 'var(--border)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonClick={() => setDeleteFiles(!deleteFiles)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{deleteFiles && <Check className=\"w-3 h-3\" style={{ color: 'white' }} strokeWidth={3} />}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\tAlso delete downloaded files\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<div className=\"flex gap-3 justify-end\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setShowConfirm(false)}\n\t\t\t\t\t\t\t\tdisabled={deleting}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm border disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleDelete}\n\t\t\t\t\t\t\t\tdisabled={deleting}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{deleting ? 'Deleting...' : 'Delete'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/RSSManager.tsx",
    "content": "import { useState } from 'react'\nimport { Plus, ChevronDown, ChevronRight, Rss, RefreshCw, X } from 'lucide-react'\nimport { type Instance } from '../api/instances'\nimport { useRSSManager } from '../hooks/useRSSManager'\nimport type { RSSArticle } from '../types/rss'\nimport { Checkbox, Select } from './ui'\n\ntype Tab = 'feeds' | 'rules'\n\ninterface ArticleDownloadProps {\n\tarticle: RSSArticle\n\tidx: number\n\tinstances: Instance[]\n\trss: ReturnType<typeof useRSSManager>\n}\n\nfunction ArticleDownload({ article, idx, instances, rss }: ArticleDownloadProps) {\n\tconst articleId = article.id || String(idx)\n\tconst isGrabbing = rss.grabbing === articleId\n\tconst grabResult = rss.grabResult?.id === articleId ? rss.grabResult : null\n\n\tif (grabResult) {\n\t\treturn (\n\t\t\t<span\n\t\t\t\tclassName=\"px-2 py-1 rounded text-[10px] font-medium\"\n\t\t\t\tstyle={{\n\t\t\t\t\tbackgroundColor: grabResult.success\n\t\t\t\t\t\t? 'color-mix(in srgb, #a6e3a1 20%, transparent)'\n\t\t\t\t\t\t: 'color-mix(in srgb, var(--error) 20%, transparent)',\n\t\t\t\t\tcolor: grabResult.success ? '#a6e3a1' : 'var(--error)',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{grabResult.success ? 'Added!' : 'Failed'}\n\t\t\t</span>\n\t\t)\n\t}\n\n\tif (instances.length === 1) {\n\t\treturn (\n\t\t\t<button\n\t\t\t\tonClick={() => rss.handleGrabArticle(article.torrentURL!, articleId, instances[0].id)}\n\t\t\t\tdisabled={isGrabbing}\n\t\t\t\tclassName=\"px-2 py-1 rounded text-[10px] font-medium disabled:opacity-50\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t>\n\t\t\t\t{isGrabbing ? '...' : 'Download'}\n\t\t\t</button>\n\t\t)\n\t}\n\n\tconst isOpen = rss.instanceDropdown === articleId\n\n\treturn (\n\t\t<div className=\"relative\">\n\t\t\t<button\n\t\t\t\tonClick={() => rss.setInstanceDropdown(isOpen ? null : articleId)}\n\t\t\t\tdisabled={isGrabbing}\n\t\t\t\tclassName=\"flex items-center gap-1 px-2 py-1 rounded text-[10px] font-medium disabled:opacity-50\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t>\n\t\t\t\t{isGrabbing ? '...' : 'Download'}\n\t\t\t\t<ChevronDown className=\"w-2.5 h-2.5\" strokeWidth={2.5} />\n\t\t\t</button>\n\t\t\t{isOpen && (\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"fixed inset-0 z-10\" onClick={() => rss.setInstanceDropdown(null)} />\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"absolute right-0 top-full mt-1 z-20 min-w-[120px] rounded-lg border shadow-lg overflow-hidden\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{instances.map((i) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={i.id}\n\t\t\t\t\t\t\t\tonClick={() => rss.handleGrabArticle(article.torrentURL!, articleId, i.id)}\n\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{i.label}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\ninterface Props {\n\tinstances: Instance[]\n}\n\nexport function RSSManager({ instances }: Props) {\n\tconst [tab, setTab] = useState<Tab>('feeds')\n\tconst rss = useRSSManager({ instances })\n\n\tconst selectedInstance = rss.selectedInstance\n\tif (!selectedInstance) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclassName=\"text-center py-12 rounded-xl border\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<p className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tNo instances available\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t)\n\t}\n\n\treturn (\n\t\t<div className=\"space-y-4\">\n\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t{instances.map((inst) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={inst.id}\n\t\t\t\t\t\t\tonClick={() => rss.selectInstance(inst)}\n\t\t\t\t\t\t\tclassName={`px-3 py-1.5 rounded-lg text-sm ${selectedInstance.id === inst.id ? 'font-medium' : ''}`}\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: selectedInstance.id === inst.id ? 'var(--accent)' : 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tcolor: selectedInstance.id === inst.id ? 'var(--accent-contrast)' : 'var(--text-secondary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{inst.label}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center gap-1 p-1 rounded-lg\" style={{ backgroundColor: 'var(--bg-secondary)' }}>\n\t\t\t\t\t{(['feeds', 'rules'] as Tab[]).map((t) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={t}\n\t\t\t\t\t\t\tonClick={() => setTab(t)}\n\t\t\t\t\t\t\tclassName=\"px-3 py-1 rounded-md text-xs font-medium capitalize transition-all\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: tab === t ? 'var(--bg-primary)' : 'transparent',\n\t\t\t\t\t\t\t\tcolor: tab === t ? 'var(--text-primary)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\tboxShadow: tab === t ? '0 1px 2px rgba(0,0,0,0.1)' : 'none',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{rss.error && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"px-4 py-3 rounded-lg text-sm\"\n\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 10%, transparent)', color: 'var(--error)' }}\n\t\t\t\t>\n\t\t\t\t\t{rss.error}\n\t\t\t\t\t<button onClick={rss.clearError} className=\"ml-2 opacity-70 hover:opacity-100\">\n\t\t\t\t\t\t×\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{rss.loading ? (\n\t\t\t\t<div className=\"text-center py-12\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tLoading...\n\t\t\t\t</div>\n\t\t\t) : tab === 'feeds' ? (\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => rss.setShowAddFeed(true)}\n\t\t\t\t\t\t\tclassName=\"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Plus className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t\t\t\tAdd Feed\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => rss.setShowAddFolder(true)}\n\t\t\t\t\t\t\tclassName=\"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs border\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Plus className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t\t\t\tAdd Folder\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{rss.showAddFeed && (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<form onSubmit={rss.handleAddFeed} className=\"space-y-3\">\n\t\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-1 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tFeed URL\n\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\ttype=\"url\"\n\t\t\t\t\t\t\t\t\t\t\tvalue={rss.feedUrl}\n\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => rss.setFeedUrl(e.target.value)}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"https://example.com/rss.xml\"\n\t\t\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-1 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tFolder (optional)\n\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\tvalue={rss.feedPath}\n\t\t\t\t\t\t\t\t\t\t\tonChange={rss.setFeedPath}\n\t\t\t\t\t\t\t\t\t\t\toptions={[\n\t\t\t\t\t\t\t\t\t\t\t\t{ value: '', label: 'None' },\n\t\t\t\t\t\t\t\t\t\t\t\t...rss.feeds.filter((f) => f.isFolder).map((f) => ({ value: f.path, label: f.path })),\n\t\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t\t\tminWidth=\"100%\"\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-[38px] [&>button]:h-full [&>button]:rounded-lg [&>button]:px-3 [&>button]:text-sm\"\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\t\t\tdisabled={rss.submitting}\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-lg text-xs font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{rss.submitting ? 'Adding...' : 'Add'}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={rss.cancelAddFeed}\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-lg text-xs border\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{rss.showAddFolder && (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<form onSubmit={rss.handleAddFolder} className=\"space-y-3\">\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-1 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tFolder Name\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={rss.folderName}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => rss.setFolderName(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Movies\"\n\t\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\t\t\tdisabled={rss.submitting}\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-lg text-xs font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{rss.submitting ? 'Creating...' : 'Create'}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={rss.cancelAddFolder}\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-lg text-xs border\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<div className=\"grid grid-cols-[280px_1fr] gap-4\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"rounded-xl border overflow-hidden\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 border-b text-[10px] font-semibold uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tFeeds\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"max-h-[400px] overflow-y-auto\">\n\t\t\t\t\t\t\t\t{rss.visibleFeeds.length === 0 ? (\n\t\t\t\t\t\t\t\t\t<div className=\"px-3 py-6 text-center text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tNo feeds\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\trss.visibleFeeds.map((feed) => (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={feed.path}\n\t\t\t\t\t\t\t\t\t\t\tclassName={`flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors group ${rss.selectedFeed?.path === feed.path ? 'bg-[var(--bg-tertiary)]' : 'hover:bg-[var(--bg-tertiary)]'}`}\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ paddingLeft: `${12 + feed.depth * 16}px` }}\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => (feed.isFolder ? rss.toggleFolder(feed.path) : rss.setSelectedFeed(feed))}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{feed.isFolder ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<ChevronRight\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={`w-4 h-4 shrink-0 transition-transform ${rss.expandedFolders.has(feed.path) ? 'rotate-90' : ''}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t<Rss className=\"w-4 h-4 shrink-0\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs truncate flex-1\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: rss.selectedFeed?.path === feed.path ? 'var(--text-primary)' : 'var(--text-secondary)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{feed.name}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t{!feed.isFolder && (\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trss.handleRefresh(feed)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={rss.refreshing === feed.path}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded hover:bg-[var(--bg-primary)]\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<RefreshCw\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={`w-3 h-3 ${rss.refreshing === feed.path ? 'animate-spin' : ''}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trss.setDeleteConfirm(feed)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded hover:bg-[var(--bg-primary)]\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<X className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t{feed.isFolder && (\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\t\t\trss.setDeleteConfirm(feed)\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--bg-primary)]\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<X className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"rounded-xl border overflow-hidden\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 border-b text-[10px] font-semibold uppercase tracking-wider flex items-center justify-between\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span>Articles {rss.selectedFeed && `(${rss.feedArticles.length})`}</span>\n\t\t\t\t\t\t\t\t{rss.selectedFeed && (\n\t\t\t\t\t\t\t\t\t<span className=\"font-normal normal-case truncate max-w-[200px]\" title={rss.selectedFeed.url}>\n\t\t\t\t\t\t\t\t\t\t{rss.selectedFeed.name}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"max-h-[400px] overflow-y-auto\">\n\t\t\t\t\t\t\t\t{!rss.selectedFeed ? (\n\t\t\t\t\t\t\t\t\t<div className=\"px-3 py-12 text-center text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tSelect a feed to view articles\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t) : rss.feedArticles.length > 0 ? (\n\t\t\t\t\t\t\t\t\trss.feedArticles.map((article, idx) => (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={article.id || idx}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-3 px-3 py-2 border-b last:border-b-0 hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs truncate\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: article.isRead ? 'var(--text-muted)' : 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\ttitle={article.title}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{article.title}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t{article.date && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-[10px] mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{new Date(article.date).toLocaleDateString()}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t{article.torrentURL && (\n\t\t\t\t\t\t\t\t\t\t\t\t<ArticleDownload article={article} idx={idx} instances={instances} rss={rss} />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t) : rss.selectedFeed.data?.isLoading ? (\n\t\t\t\t\t\t\t\t\t<div className=\"px-3 py-12 text-center text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tLoading feed...\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t) : rss.selectedFeed.data?.hasError ? (\n\t\t\t\t\t\t\t\t\t<div className=\"px-3 py-12 text-center text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t\t\t\tFailed to load feed\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<div className=\"px-3 py-12 text-center text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tNo articles - try refreshing\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t) : (\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => rss.setShowNewRule(true)}\n\t\t\t\t\t\t\tclassName=\"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Plus className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t\t\t\tNew Rule\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{rss.showNewRule && (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<form onSubmit={rss.handleCreateRule} className=\"space-y-3\">\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-1 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tRule Name\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={rss.newRuleName}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => rss.setNewRuleName(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"TV Shows 1080p\"\n\t\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\t\t\tdisabled={rss.submitting}\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-lg text-xs font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{rss.submitting ? 'Creating...' : 'Create'}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={rss.cancelNewRule}\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-lg text-xs border\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<div className=\"grid grid-cols-[280px_1fr] gap-4\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"rounded-xl border overflow-hidden\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 border-b text-[10px] font-semibold uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tRules ({Object.keys(rss.rules).length})\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"max-h-[500px] overflow-y-auto\">\n\t\t\t\t\t\t\t\t{Object.keys(rss.rules).length === 0 ? (\n\t\t\t\t\t\t\t\t\t<div className=\"px-3 py-6 text-center text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tNo rules\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\tObject.entries(rss.rules).map(([name, rule]) => (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={name}\n\t\t\t\t\t\t\t\t\t\t\tclassName={`flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors group ${rss.selectedRule === name ? 'bg-[var(--bg-tertiary)]' : 'hover:bg-[var(--bg-tertiary)]'}`}\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => rss.selectRule(name)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-2 h-2 rounded-full shrink-0\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: rule.enabled ? '#a6e3a1' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs truncate flex-1\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: rss.selectedRule === name ? 'var(--text-primary)' : 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{name}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\t\trss.setRuleDeleteConfirm(name)\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--bg-primary)]\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<X className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"rounded-xl border overflow-hidden\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 border-b text-[10px] font-semibold uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{rss.selectedRule ? `Edit: ${rss.selectedRule}` : 'Rule Editor'}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{!rss.selectedRule || !rss.editingRule ? (\n\t\t\t\t\t\t\t\t<div className=\"px-3 py-12 text-center text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tSelect a rule to edit\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<div className=\"p-4 space-y-4 max-h-[500px] overflow-y-auto\">\n\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\tlabel=\"Enabled\"\n\t\t\t\t\t\t\t\t\t\tchecked={rss.editingRule.enabled}\n\t\t\t\t\t\t\t\t\t\tonChange={(v) => rss.setEditingRule({ ...rss.editingRule!, enabled: v })}\n\t\t\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-4\">\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-1 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tMust Contain\n\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={rss.editingRule.mustContain}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => rss.setEditingRule({ ...rss.editingRule!, mustContain: e.target.value })}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"1080p|720p\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-1 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tMust NOT Contain\n\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={rss.editingRule.mustNotContain}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => rss.setEditingRule({ ...rss.editingRule!, mustNotContain: e.target.value })}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"CAM|TS\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\t\tlabel=\"Use Regex\"\n\t\t\t\t\t\t\t\t\t\t\tchecked={rss.editingRule.useRegex}\n\t\t\t\t\t\t\t\t\t\t\tonChange={(v) => rss.setEditingRule({ ...rss.editingRule!, useRegex: v })}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\t\tlabel=\"Smart Episode Filter\"\n\t\t\t\t\t\t\t\t\t\t\tchecked={rss.editingRule.smartFilter}\n\t\t\t\t\t\t\t\t\t\t\tonChange={(v) => rss.setEditingRule({ ...rss.editingRule!, smartFilter: v })}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-4\">\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-1 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tEpisode Filter\n\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={rss.editingRule.episodeFilter}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => rss.setEditingRule({ ...rss.editingRule!, episodeFilter: e.target.value })}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"S01E01-S01E10\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-1 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tIgnore Days\n\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={rss.editingRule.ignoreDays}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\trss.setEditingRule({ ...rss.editingRule!, ignoreDays: parseInt(e.target.value) || 0 })\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tmin={0}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-4\">\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-1 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tCategory\n\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={rss.editingRule.assignedCategory}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => rss.setEditingRule({ ...rss.editingRule!, assignedCategory: e.target.value })}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<option value=\"\">None</option>\n\t\t\t\t\t\t\t\t\t\t\t\t{Object.keys(rss.categories).map((cat) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<option key={cat} value={cat}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{cat}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-1 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tAdd Paused\n\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={\n\t\t\t\t\t\t\t\t\t\t\t\t\trss.editingRule.addPaused === null\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'default'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: rss.editingRule.addPaused\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'always'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'never'\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\trss.setEditingRule({\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t...rss.editingRule!,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\taddPaused: e.target.value === 'default' ? null : e.target.value === 'always',\n\t\t\t\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<option value=\"default\">Use global setting</option>\n\t\t\t\t\t\t\t\t\t\t\t\t<option value=\"always\">Always</option>\n\t\t\t\t\t\t\t\t\t\t\t\t<option value=\"never\">Never</option>\n\t\t\t\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-1 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tSave Path\n\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\t\tvalue={rss.editingRule.savePath}\n\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => rss.setEditingRule({ ...rss.editingRule!, savePath: e.target.value })}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"/downloads/tv\"\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"block text-[10px] font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tApply to Feeds\n\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"max-h-32 overflow-y-auto rounded border p-2 space-y-1\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{rss.feedUrls.length === 0 ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs py-2 text-center\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\tNo feeds available\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\trss.feedUrls.map((url) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={url}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tlabel={url}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tchecked={rss.editingRule!.affectedFeeds.includes(url)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={(checked) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tconst newFeeds = checked\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? [...rss.editingRule!.affectedFeeds, url]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: rss.editingRule!.affectedFeeds.filter((f) => f !== url)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trss.setEditingRule({ ...rss.editingRule!, affectedFeeds: newFeeds })\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 pt-2\">\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tonClick={rss.handleSaveRule}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={rss.savingRule}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-xs font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: rss.ruleSaved ? '#a6e3a1' : 'var(--accent)',\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: rss.ruleSaved ? '#1e1e2e' : 'var(--accent-contrast)',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{rss.savingRule ? 'Saving...' : rss.ruleSaved ? 'Saved!' : 'Save'}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tonClick={rss.handleCancelEdit}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-xs border\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tonClick={rss.handlePreviewMatches}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={rss.loadingMatches}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-xs border disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{rss.loadingMatches ? 'Loading...' : 'Preview Matches'}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t{rss.matchingArticles && (\n\t\t\t\t\t\t\t\t\t\t<div className=\"mt-4 pt-4 border-t\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-[10px] font-semibold uppercase tracking-wider mb-2\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tMatching Articles\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"max-h-40 overflow-y-auto rounded border p-2\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{Object.keys(rss.matchingArticles).length === 0 ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs py-2 text-center\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tNo matches\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\tObject.entries(rss.matchingArticles).map(([feedName, matchedTitles]) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div key={feedName} className=\"mb-2 last:mb-0\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-[10px] font-medium truncate\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{feedName}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{matchedTitles.map((title, i) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs pl-2 truncate\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttitle={title}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{title}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{rss.deleteConfirm && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-50 flex items-center justify-center p-4\"\n\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.5)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full max-w-sm rounded-xl border p-6\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-medium mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tDelete {rss.deleteConfirm.isFolder ? 'Folder' : 'Feed'}\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mb-6\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tAre you sure you want to delete{' '}\n\t\t\t\t\t\t\t<strong style={{ color: 'var(--text-primary)' }}>{rss.deleteConfirm.name}</strong>?\n\t\t\t\t\t\t\t{rss.deleteConfirm.isFolder && ' This will also delete all feeds inside.'}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<div className=\"flex gap-3 justify-end\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => rss.setDeleteConfirm(null)}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm border\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={rss.handleDeleteItem}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{rss.ruleDeleteConfirm && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-50 flex items-center justify-center p-4\"\n\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.5)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full max-w-sm rounded-xl border p-6\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-medium mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tDelete Rule\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mb-6\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tAre you sure you want to delete{' '}\n\t\t\t\t\t\t\t<strong style={{ color: 'var(--text-primary)' }}>{rss.ruleDeleteConfirm}</strong>?\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<div className=\"flex gap-3 justify-end\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => rss.setRuleDeleteConfirm(null)}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm border\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={rss.handleDeleteRule}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/RatioThresholdPopup.tsx",
    "content": "import { useState, useEffect, useRef } from 'react'\n\ninterface Props {\n\tanchor: HTMLElement\n\tthreshold: number\n\tonSave: (threshold: number) => void\n\tonClose: () => void\n}\n\nexport function RatioThresholdPopup({ anchor, threshold, onSave, onClose }: Props) {\n\tconst [value, setValue] = useState(threshold.toString())\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\tuseEffect(() => {\n\t\tfunction handleClick(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) onClose()\n\t\t}\n\t\tfunction handleKey(e: KeyboardEvent) {\n\t\t\tif (e.key === 'Escape') onClose()\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClick)\n\t\tdocument.addEventListener('keydown', handleKey)\n\t\treturn () => {\n\t\t\tdocument.removeEventListener('mousedown', handleClick)\n\t\t\tdocument.removeEventListener('keydown', handleKey)\n\t\t}\n\t}, [onClose])\n\n\tfunction handleSave() {\n\t\tconst parsed = parseFloat(value)\n\t\tif (!isNaN(parsed) && parsed >= 0) {\n\t\t\tonSave(parsed)\n\t\t}\n\t\tonClose()\n\t}\n\n\tconst rect = anchor.getBoundingClientRect()\n\n\treturn (\n\t\t<div\n\t\t\tref={ref}\n\t\t\tclassName=\"rounded-lg border shadow-xl p-3\"\n\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\tstyle={{\n\t\t\t\tposition: 'fixed',\n\t\t\t\tleft: Math.min(rect.left, window.innerWidth - 200),\n\t\t\t\ttop: rect.bottom + 4,\n\t\t\t\tzIndex: 100,\n\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\tborderColor: 'var(--border)',\n\t\t\t}}\n\t\t>\n\t\t\t<div className=\"text-[10px] uppercase tracking-wider font-medium mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\tRatio Threshold\n\t\t\t</div>\n\t\t\t<div className=\"flex items-center justify-center gap-2 mb-3\">\n\t\t\t\t<span className=\"w-2.5 h-2.5 rounded-full\" style={{ backgroundColor: '#a6e3a1' }} />\n\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t≥\n\t\t\t\t</span>\n\t\t\t\t<input\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t\tinputMode=\"decimal\"\n\t\t\t\t\tvalue={value}\n\t\t\t\t\tonChange={(e) => setValue(e.target.value)}\n\t\t\t\t\tclassName=\"w-16 px-2 py-1.5 rounded text-xs font-mono border outline-none text-center\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t/>\n\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t&lt;\n\t\t\t\t</span>\n\t\t\t\t<span className=\"w-2.5 h-2.5 rounded-full\" style={{ backgroundColor: '#f38ba8' }} />\n\t\t\t</div>\n\t\t\t<button\n\t\t\t\tonClick={handleSave}\n\t\t\t\tclassName=\"w-full py-1.5 rounded text-xs font-medium\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'white' }}\n\t\t\t>\n\t\t\t\tSave\n\t\t\t</button>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/SearchPanel.tsx",
    "content": "import { useState, useEffect, Fragment } from 'react'\nimport { Plus, Trash2, ChevronDown, Filter, X } from 'lucide-react'\nimport {\n\tgetIntegrations,\n\tcreateIntegration,\n\tdeleteIntegration,\n\ttestIntegrationConnection,\n\tgetIndexers,\n\tgetProwlarrCategories,\n\tsearch,\n\tgrabRelease,\n\ttype Integration,\n\ttype Indexer,\n\ttype ProwlarrCategory,\n\ttype SearchResult,\n} from '../api/integrations'\nimport { getInstances, type Instance } from '../api/instances'\nimport { getCategories, type Category } from '../api/qbittorrent'\nimport { formatSize } from '../utils/format'\nimport { extractTags, sortResults, filterResults, type SortKey } from '../utils/search'\n\nfunction formatAge(dateStr: string): string {\n\tconst date = new Date(dateStr)\n\tconst now = new Date()\n\tconst diff = now.getTime() - date.getTime()\n\tconst days = Math.floor(diff / (1000 * 60 * 60 * 24))\n\tif (days === 0) return 'Today'\n\tif (days === 1) return '1 day'\n\tif (days < 30) return `${days} days`\n\tif (days < 365) return `${Math.floor(days / 30)} months`\n\treturn `${Math.floor(days / 365)} years`\n}\n\nexport function SearchPanel() {\n\tconst [integrations, setIntegrations] = useState<Integration[]>([])\n\tconst [instances, setInstances] = useState<Instance[]>([])\n\tconst [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null)\n\tconst [indexers, setIndexers] = useState<Indexer[]>([])\n\tconst [prowlarrCategories, setProwlarrCategories] = useState<ProwlarrCategory[]>([])\n\tconst [selectedIndexer, setSelectedIndexer] = useState<string>('-2')\n\tconst [selectedCategory, setSelectedCategory] = useState<string>('')\n\tconst [prowlarrCategoryDropdownOpen, setCategoryDropdownOpenSearch] = useState(false)\n\tconst [query, setQuery] = useState('')\n\tconst [results, setResults] = useState<SearchResult[]>([])\n\tconst [searching, setSearching] = useState(false)\n\tconst [error, setError] = useState('')\n\tconst [showAddForm, setShowAddForm] = useState(false)\n\tconst [formData, setFormData] = useState({ label: '', url: '', api_key: '' })\n\tconst [submitting, setSubmitting] = useState(false)\n\tconst [grabbing, setGrabbing] = useState<string | null>(null)\n\tconst [grabResult, setGrabResult] = useState<{ guid: string; success: boolean; message?: string } | null>(null)\n\tconst [grabModal, setGrabModal] = useState<SearchResult | null>(null)\n\tconst [grabInstance, setGrabInstance] = useState<number | null>(null)\n\tconst [grabCategories, setGrabCategories] = useState<Record<string, Category>>({})\n\tconst [grabCategory, setGrabCategory] = useState('')\n\tconst [grabSavepath, setGrabSavepath] = useState('')\n\tconst [grabDownloadPath, setGrabDownloadPath] = useState('')\n\tconst [loadingCategories, setLoadingCategories] = useState(false)\n\tconst [sortKey, setSortKey] = useState<SortKey>('seeders')\n\tconst [sortAsc, setSortAsc] = useState(false)\n\tconst [testing, setTesting] = useState(false)\n\tconst [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)\n\tconst [deleteConfirm, setDeleteConfirm] = useState<Integration | null>(null)\n\tconst [page, setPage] = useState(1)\n\tconst itemsPerPage = 25\n\tconst [indexerDropdownOpen, setIndexerDropdownOpen] = useState(false)\n\tconst [filter, setFilter] = useState('')\n\tconst [filterDropdownOpen, setFilterDropdownOpen] = useState(false)\n\tconst [instanceDropdownOpen, setInstanceDropdownOpen] = useState(false)\n\tconst [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false)\n\n\tconst availableTags = extractTags(results.map((r) => r.title))\n\n\tuseEffect(() => {\n\t\tPromise.all([getIntegrations().catch(() => []), getInstances().catch(() => [])]).then(\n\t\t\t([integrationsData, instancesData]) => {\n\t\t\t\tsetIntegrations(integrationsData)\n\t\t\t\tsetInstances(instancesData)\n\t\t\t\tif (integrationsData.length > 0) {\n\t\t\t\t\tsetSelectedIntegration((prev) => prev ?? integrationsData[0])\n\t\t\t\t}\n\t\t\t}\n\t\t)\n\t}, [])\n\n\tuseEffect(() => {\n\t\tif (selectedIntegration) {\n\t\t\tgetIndexers(selectedIntegration.id)\n\t\t\t\t.then(setIndexers)\n\t\t\t\t.catch(() => setIndexers([]))\n\t\t\tgetProwlarrCategories(selectedIntegration.id)\n\t\t\t\t.then(setProwlarrCategories)\n\t\t\t\t.catch(() => setProwlarrCategories([]))\n\t\t}\n\t}, [selectedIntegration])\n\n\tuseEffect(() => {\n\t\tif (grabInstance) {\n\t\t\tsetLoadingCategories(true)\n\t\t\tgetCategories(grabInstance)\n\t\t\t\t.then(setGrabCategories)\n\t\t\t\t.catch(() => setGrabCategories({}))\n\t\t\t\t.finally(() => setLoadingCategories(false))\n\t\t} else {\n\t\t\tsetGrabCategories({})\n\t\t}\n\t}, [grabInstance])\n\n\tasync function handleSearch(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tif (!selectedIntegration || !query.trim()) return\n\n\t\tsetSearching(true)\n\t\tsetError('')\n\t\tsetResults([])\n\t\tsetPage(1)\n\t\tsetFilter('')\n\n\t\ttry {\n\t\t\tconst data = await search(selectedIntegration.id, query, {\n\t\t\t\tindexerIds: selectedIndexer,\n\t\t\t\tcategories: selectedCategory || undefined,\n\t\t\t})\n\t\t\tsetResults(data)\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : 'Search failed')\n\t\t} finally {\n\t\t\tsetSearching(false)\n\t\t}\n\t}\n\n\tasync function handleAddIntegration(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tsetSubmitting(true)\n\t\tsetError('')\n\n\t\ttry {\n\t\t\tconst integration = await createIntegration({\n\t\t\t\ttype: 'prowlarr',\n\t\t\t\t...formData,\n\t\t\t})\n\t\t\tsetIntegrations([...integrations, integration])\n\t\t\tsetSelectedIntegration(integration)\n\t\t\tsetShowAddForm(false)\n\t\t\tsetFormData({ label: '', url: '', api_key: '' })\n\t\t\tsetTestResult(null)\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to add integration')\n\t\t} finally {\n\t\t\tsetSubmitting(false)\n\t\t}\n\t}\n\n\tasync function handleDeleteIntegration() {\n\t\tif (!deleteConfirm) return\n\t\ttry {\n\t\t\tawait deleteIntegration(deleteConfirm.id)\n\t\t\tconst updated = integrations.filter((i) => i.id !== deleteConfirm.id)\n\t\t\tsetIntegrations(updated)\n\t\t\tif (selectedIntegration?.id === deleteConfirm.id) {\n\t\t\t\tsetSelectedIntegration(updated[0] || null)\n\t\t\t}\n\t\t\tsetDeleteConfirm(null)\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to delete')\n\t\t}\n\t}\n\n\tasync function handleTestConnection() {\n\t\tsetTesting(true)\n\t\tsetTestResult(null)\n\t\ttry {\n\t\t\tconst result = await testIntegrationConnection(formData.url, formData.api_key)\n\t\t\tif (result.success) {\n\t\t\t\tsetTestResult({ success: true, message: `Connected! Prowlarr ${result.version}` })\n\t\t\t} else {\n\t\t\t\tsetTestResult({ success: false, message: result.error || 'Connection failed' })\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tsetTestResult({ success: false, message: err instanceof Error ? err.message : 'Connection failed' })\n\t\t} finally {\n\t\t\tsetTesting(false)\n\t\t}\n\t}\n\n\tfunction openGrabModal(result: SearchResult) {\n\t\tsetGrabModal(result)\n\t\tsetGrabCategory('')\n\t\tsetGrabSavepath('')\n\t\tsetGrabDownloadPath('')\n\t\tif (instances.length === 1) {\n\t\t\tsetGrabInstance(instances[0].id)\n\t\t} else {\n\t\t\tsetGrabInstance(null)\n\t\t}\n\t}\n\n\tfunction closeGrabModal() {\n\t\tsetGrabModal(null)\n\t\tsetGrabInstance(null)\n\t\tsetGrabCategories({})\n\t\tsetGrabCategory('')\n\t\tsetGrabSavepath('')\n\t\tsetGrabDownloadPath('')\n\t\tsetInstanceDropdownOpen(false)\n\t\tsetCategoryDropdownOpen(false)\n\t}\n\n\tasync function handleGrab() {\n\t\tif (!selectedIntegration || !grabModal || !grabInstance) return\n\t\tsetGrabbing(grabModal.guid)\n\t\tsetGrabResult(null)\n\t\tconst options: { category?: string; savepath?: string; downloadPath?: string } = {}\n\t\tif (grabCategory) options.category = grabCategory\n\t\tif (grabSavepath.trim()) options.savepath = grabSavepath.trim()\n\t\tif (grabDownloadPath.trim()) options.downloadPath = grabDownloadPath.trim()\n\t\ttry {\n\t\t\tawait grabRelease(\n\t\t\t\tselectedIntegration.id,\n\t\t\t\t{\n\t\t\t\t\tguid: grabModal.guid,\n\t\t\t\t\tindexerId: grabModal.indexerId,\n\t\t\t\t\tdownloadUrl: grabModal.downloadUrl,\n\t\t\t\t\tmagnetUrl: grabModal.magnetUrl,\n\t\t\t\t},\n\t\t\t\tgrabInstance,\n\t\t\t\tObject.keys(options).length > 0 ? options : undefined\n\t\t\t)\n\t\t\tsetGrabResult({ guid: grabModal.guid, success: true })\n\t\t\tcloseGrabModal()\n\t\t\tsetTimeout(() => setGrabResult(null), 3000)\n\t\t} catch (err) {\n\t\t\tsetGrabResult({ guid: grabModal.guid, success: false, message: err instanceof Error ? err.message : 'Failed' })\n\t\t} finally {\n\t\t\tsetGrabbing(null)\n\t\t}\n\t}\n\n\tfunction handleSort(key: SortKey) {\n\t\tif (sortKey === key) {\n\t\t\tsetSortAsc(!sortAsc)\n\t\t} else {\n\t\t\tsetSortKey(key)\n\t\t\tsetSortAsc(false)\n\t\t}\n\t}\n\n\tconst filteredResults = filterResults(results, filter)\n\tconst sortedResults = sortResults(filteredResults, sortKey, sortAsc)\n\n\tconst totalPages = Math.ceil(sortedResults.length / itemsPerPage)\n\tconst paginatedResults = sortedResults.slice((page - 1) * itemsPerPage, page * itemsPerPage)\n\n\tfunction handleFilterChange(newFilter: string) {\n\t\tsetFilter(newFilter)\n\t\tsetPage(1)\n\t}\n\n\tif (integrations.length === 0 && !showAddForm) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclassName=\"text-center py-12 rounded-xl border\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<p className=\"text-sm mb-4\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tNo integrations configured\n\t\t\t\t</p>\n\t\t\t\t<button\n\t\t\t\t\tonClick={() => setShowAddForm(true)}\n\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t>\n\t\t\t\t\tAdd Integration\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t)\n\t}\n\n\treturn (\n\t\t<div className=\"space-y-6\">\n\t\t\t{showAddForm && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"p-6 rounded-xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<h2 className=\"text-lg font-medium mb-4\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tAdd Integration\n\t\t\t\t\t</h2>\n\t\t\t\t\t<form onSubmit={handleAddIntegration} className=\"space-y-4\">\n\t\t\t\t\t\t<div className=\"grid grid-cols-3 gap-4\">\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tLabel\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tvalue={formData.label}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setFormData({ ...formData, label: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tplaceholder=\"My Prowlarr\"\n\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tURL\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"url\"\n\t\t\t\t\t\t\t\t\tvalue={formData.url}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setFormData({ ...formData, url: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tplaceholder=\"http://localhost:9696\"\n\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tAPI Key\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\t\t\tvalue={formData.api_key}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setFormData({ ...formData, api_key: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tplaceholder=\"••••••••\"\n\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{testResult && (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-lg text-sm\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: testResult.success\n\t\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, #a6e3a1 10%, transparent)'\n\t\t\t\t\t\t\t\t\t\t: 'color-mix(in srgb, var(--error) 10%, transparent)',\n\t\t\t\t\t\t\t\t\tcolor: testResult.success ? '#a6e3a1' : 'var(--error)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{testResult.message}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<div className=\"flex gap-3\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\tdisabled={submitting}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{submitting ? 'Adding...' : 'Add'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={handleTestConnection}\n\t\t\t\t\t\t\t\tdisabled={testing || !formData.url || !formData.api_key}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm border disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{testing ? 'Testing...' : 'Test Connection'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetShowAddForm(false)\n\t\t\t\t\t\t\t\t\tsetTestResult(null)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm border\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</form>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{error && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"px-4 py-3 rounded-lg text-sm\"\n\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 10%, transparent)', color: 'var(--error)' }}\n\t\t\t\t>\n\t\t\t\t\t{error}\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{!showAddForm && (\n\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t{integrations.map((integration) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={integration.id}\n\t\t\t\t\t\t\t\tonClick={() => setSelectedIntegration(integration)}\n\t\t\t\t\t\t\t\tclassName={`px-3 py-1.5 rounded-lg text-sm ${selectedIntegration?.id === integration.id ? 'font-medium' : ''}`}\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: selectedIntegration?.id === integration.id ? 'var(--accent)' : 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tcolor:\n\t\t\t\t\t\t\t\t\t\tselectedIntegration?.id === integration.id ? 'var(--accent-contrast)' : 'var(--text-secondary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{integration.label}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setShowAddForm(true)}\n\t\t\t\t\t\t\tclassName=\"p-1.5 rounded-lg border\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Plus className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t\t{selectedIntegration && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setDeleteConfirm(selectedIntegration)}\n\t\t\t\t\t\t\tclassName=\"p-1.5 rounded-lg border transition-colors hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--error)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Trash2 className=\"w-4 h-4\" strokeWidth={1.5} />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{selectedIntegration && !showAddForm && (\n\t\t\t\t<form onSubmit={handleSearch} className=\"flex gap-3\">\n\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={query}\n\t\t\t\t\t\t\tonChange={(e) => setQuery(e.target.value)}\n\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tplaceholder=\"Search torrents...\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => setIndexerDropdownOpen(!indexerDropdownOpen)}\n\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t{selectedIndexer === '-2'\n\t\t\t\t\t\t\t\t\t? 'All Indexers'\n\t\t\t\t\t\t\t\t\t: indexers.find((i) => String(i.id) === selectedIndexer)?.name || 'All Indexers'}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<ChevronDown\n\t\t\t\t\t\t\t\tclassName={`w-4 h-4 transition-transform ${indexerDropdownOpen ? 'rotate-180' : ''}`}\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{indexerDropdownOpen && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<div className=\"fixed inset-0 z-10\" onClick={() => setIndexerDropdownOpen(false)} />\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"absolute right-0 top-full mt-1 z-20 min-w-[200px] max-h-64 overflow-y-auto rounded-lg border shadow-lg\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tsetSelectedIndexer('-2')\n\t\t\t\t\t\t\t\t\t\t\tsetIndexerDropdownOpen(false)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-4 py-2 text-sm hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: selectedIndexer === '-2' ? 'var(--accent)' : 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tAll Indexers\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t{indexers\n\t\t\t\t\t\t\t\t\t\t.filter((i) => i.enable && i.protocol === 'torrent')\n\t\t\t\t\t\t\t\t\t\t.map((indexer) => (\n\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\tkey={indexer.id}\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetSelectedIndexer(String(indexer.id))\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetIndexerDropdownOpen(false)\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-4 py-2 text-sm hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: String(indexer.id) === selectedIndexer ? 'var(--accent)' : 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{indexer.name}\n\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => setCategoryDropdownOpenSearch(!prowlarrCategoryDropdownOpen)}\n\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 px-4 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t{selectedCategory\n\t\t\t\t\t\t\t\t\t? prowlarrCategories.find((c) => String(c.id) === selectedCategory)?.name || 'All Categories'\n\t\t\t\t\t\t\t\t\t: 'All Categories'}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\t\tclassName={`w-4 h-4 transition-transform ${prowlarrCategoryDropdownOpen ? 'rotate-180' : ''}`}\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M19 9l-7 7-7-7\" />\n\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{prowlarrCategoryDropdownOpen && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<div className=\"fixed inset-0 z-10\" onClick={() => setCategoryDropdownOpenSearch(false)} />\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"absolute right-0 top-full mt-1 z-20 min-w-[200px] max-h-64 overflow-y-auto rounded-lg border shadow-lg\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tsetSelectedCategory('')\n\t\t\t\t\t\t\t\t\t\t\tsetCategoryDropdownOpenSearch(false)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-4 py-2 text-sm hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: selectedCategory === '' ? 'var(--accent)' : 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tAll Categories\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t{prowlarrCategories.map((category) => (\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tkey={category.id}\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSelectedCategory(String(category.id))\n\t\t\t\t\t\t\t\t\t\t\t\tsetCategoryDropdownOpenSearch(false)\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-4 py-2 text-sm hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: String(category.id) === selectedCategory ? 'var(--accent)' : 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{category.name}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\tdisabled={searching || !query.trim()}\n\t\t\t\t\t\tclassName=\"px-6 py-2.5 rounded-lg text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{searching ? 'Searching...' : 'Search'}\n\t\t\t\t\t</button>\n\t\t\t\t</form>\n\t\t\t)}\n\n\t\t\t{results.length > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"flex items-center gap-2 flex-wrap\">\n\t\t\t\t\t\t{availableTags.length > 0 && (\n\t\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonClick={() => setFilterDropdownOpen(!filterDropdownOpen)}\n\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Filter className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t<span>Filter</span>\n\t\t\t\t\t\t\t\t\t<ChevronDown\n\t\t\t\t\t\t\t\t\t\tclassName={`w-3 h-3 transition-transform ${filterDropdownOpen ? 'rotate-180' : ''}`}\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t{filterDropdownOpen && (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<div className=\"fixed inset-0 z-10\" onClick={() => setFilterDropdownOpen(false)} />\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"absolute left-0 top-full mt-1 z-20 min-w-[200px] max-h-72 overflow-y-auto rounded-lg border shadow-lg\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"p-2 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"Type to filter...\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={filter}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => handleFilterChange(e.target.value)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-2.5 py-1.5 rounded border text-sm\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"p-1\">\n\t\t\t\t\t\t\t\t\t\t\t\t{availableTags.slice(0, 20).map(({ tag, count }) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleFilterChange(filter === tag ? '' : tag)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetFilterDropdownOpen(false)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-3 py-1.5 text-sm rounded hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: filter === tag ? 'var(--accent)' : 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span>{tag}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{count}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{filter && (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => handleFilterChange('')}\n\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--accent) 15%, transparent)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--accent)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{filter}\n\t\t\t\t\t\t\t\t<X className=\"w-3 h-3\" strokeWidth={2.5} />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<span className=\"text-xs ml-auto\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t{filter ? `${sortedResults.length} of ${results.length}` : `${results.length} results`}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{sortedResults.length > 0 && (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"rounded-xl border overflow-hidden\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<table className=\"w-full text-sm table-fixed\">\n\t\t\t\t\t\t\t\t<thead>\n\t\t\t\t\t\t\t\t\t<tr style={{ borderBottom: '1px solid var(--border)' }}>\n\t\t\t\t\t\t\t\t\t\t<th className=\"text-left px-4 py-3 font-medium w-[45%]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tName\n\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t<th className=\"text-left px-4 py-3 font-medium w-[12%]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tIndexer\n\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-right px-4 py-3 font-medium cursor-pointer hover:text-[var(--text-primary)] w-[10%]\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: sortKey === 'size' ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleSort('size')}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tSize {sortKey === 'size' && (sortAsc ? '↑' : '↓')}\n\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-right px-4 py-3 font-medium cursor-pointer hover:text-[var(--text-primary)] w-[10%]\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: sortKey === 'seeders' ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleSort('seeders')}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tS/L {sortKey === 'seeders' && (sortAsc ? '↑' : '↓')}\n\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-right px-4 py-3 font-medium cursor-pointer hover:text-[var(--text-primary)] w-[10%]\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: sortKey === 'age' ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleSort('age')}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tAge {sortKey === 'age' && (sortAsc ? '↑' : '↓')}\n\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-3 w-[13%]\"></th>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t</thead>\n\t\t\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t\t\t{paginatedResults.map((result) => (\n\t\t\t\t\t\t\t\t\t\t<tr\n\t\t\t\t\t\t\t\t\t\t\tkey={result.guid}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderBottom: '1px solid var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"truncate flex items-center gap-2\" title={result.title}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{result.indexerFlags?.some((f) => /free\\s*leech|^free$/i.test(f)) && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttitle=\"Freeleech\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"shrink-0 px-1.5 py-0.5 rounded text-[10px] font-bold cursor-help\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, #a6e3a1 20%, transparent)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: '#a6e3a1',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tFL\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"truncate\">{result.title}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 truncate\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{result.indexer}\n\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right whitespace-nowrap\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{formatSize(result.size)}\n\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right whitespace-nowrap\">\n\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: '#a6e3a1' }}>{result.seeders ?? '-'}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>/</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--error)' }}>{result.leechers ?? '-'}</span>\n\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right whitespace-nowrap\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{formatAge(result.publishDate)}\n\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right\">\n\t\t\t\t\t\t\t\t\t\t\t\t{grabResult?.guid === result.guid ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1 rounded text-xs font-medium\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: grabResult.success\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, #a6e3a1 20%, transparent)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'color-mix(in srgb, var(--error) 20%, transparent)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: grabResult.success ? '#a6e3a1' : 'var(--error)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{grabResult.success ? 'Added!' : grabResult.message}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t) : instances.length === 0 ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tNo instances\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => openGrabModal(result)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={grabbing === result.guid}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1 rounded text-xs font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{grabbing === result.guid ? '...' : 'Grab'}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t\t</table>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t{sortedResults.length === 0 && filter && (\n\t\t\t\t\t\t<div className=\"text-center py-8\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tNo results match \"{filter}\"\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t{totalPages > 1 && (\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t{sortedResults.length} results\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => setPage((p) => Math.max(1, p - 1))}\n\t\t\t\t\t\t\t\t\tdisabled={page === 1}\n\t\t\t\t\t\t\t\t\tclassName=\"px-2 py-1 rounded text-xs disabled:opacity-30\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t←\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t{Array.from({ length: totalPages }, (_, i) => i + 1)\n\t\t\t\t\t\t\t\t\t.filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 2)\n\t\t\t\t\t\t\t\t\t.map((p, idx, arr) => (\n\t\t\t\t\t\t\t\t\t\t<Fragment key={p}>\n\t\t\t\t\t\t\t\t\t\t\t{idx > 0 && arr[idx - 1] !== p - 1 && (\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"px-1 text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t...\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => setPage(p)}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"min-w-[28px] px-2 py-1 rounded text-xs font-medium\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: page === p ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: page === p ? 'var(--accent-contrast)' : 'var(--text-secondary)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{p}\n\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t</Fragment>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => setPage((p) => Math.min(totalPages, p + 1))}\n\t\t\t\t\t\t\t\t\tdisabled={page === totalPages}\n\t\t\t\t\t\t\t\t\tclassName=\"px-2 py-1 rounded text-xs disabled:opacity-30\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t→\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{results.length === 0 && query && !searching && (\n\t\t\t\t<div className=\"text-center py-12\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tNo results found\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{deleteConfirm && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-50 flex items-center justify-center p-4\"\n\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.5)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full max-w-sm rounded-xl border p-6\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-medium mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tDelete Integration\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mb-6\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tAre you sure you want to delete{' '}\n\t\t\t\t\t\t\t<strong style={{ color: 'var(--text-primary)' }}>{deleteConfirm.label}</strong>? This action cannot be\n\t\t\t\t\t\t\tundone.\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<div className=\"flex gap-3 justify-end\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setDeleteConfirm(null)}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm border\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleDeleteIntegration}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{grabModal && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-50 flex items-center justify-center p-4\"\n\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.5)' }}\n\t\t\t\t\tonClick={closeGrabModal}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full max-w-md rounded-xl border p-6\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-medium mb-1\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tGrab Torrent\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mb-4 line-clamp-2\" style={{ color: 'var(--text-muted)' }} title={grabModal.title}>\n\t\t\t\t\t\t\t{grabModal.title}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<div className=\"space-y-4\">\n\t\t\t\t\t\t\t{instances.length > 1 && (\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tInstance\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => setInstanceDropdownOpen(!instanceDropdownOpen)}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-3 py-2 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: grabInstance ? 'var(--text-primary)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t\t{grabInstance ? instances.find((i) => i.id === grabInstance)?.label : 'Select instance...'}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t<ChevronDown\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={`w-4 h-4 transition-transform ${instanceDropdownOpen ? 'rotate-180' : ''}`}\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t{instanceDropdownOpen && (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"fixed inset-0 z-10\" onClick={() => setInstanceDropdownOpen(false)} />\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"absolute left-0 right-0 top-full mt-1 z-20 max-h-48 overflow-y-auto rounded-lg border shadow-lg\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{instances.map((i) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={i.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetGrabInstance(i.id)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetInstanceDropdownOpen(false)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-3 py-2 text-sm hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: grabInstance === i.id ? 'var(--accent)' : 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tgrabInstance === i.id\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, var(--accent) 10%, transparent)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'transparent',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{i.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tCategory\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => grabInstance && !loadingCategories && setCategoryDropdownOpen(!categoryDropdownOpen)}\n\t\t\t\t\t\t\t\t\t\tdisabled={!grabInstance || loadingCategories}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-3 py-2 rounded-lg border text-sm disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: grabCategory ? 'var(--text-primary)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<span>{loadingCategories ? 'Loading...' : grabCategory || 'None'}</span>\n\t\t\t\t\t\t\t\t\t\t<ChevronDown\n\t\t\t\t\t\t\t\t\t\t\tclassName={`w-4 h-4 transition-transform ${categoryDropdownOpen ? 'rotate-180' : ''}`}\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t{categoryDropdownOpen && (\n\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"fixed inset-0 z-10\" onClick={() => setCategoryDropdownOpen(false)} />\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"absolute left-0 right-0 top-full mt-1 z-20 max-h-48 overflow-y-auto rounded-lg border shadow-lg\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetGrabCategory('')\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetCategoryDropdownOpen(false)\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-3 py-2 text-sm hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: !grabCategory ? 'var(--accent)' : 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: !grabCategory\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, var(--accent) 10%, transparent)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'transparent',\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\tNone\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t{Object.keys(grabCategories).map((cat) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={cat}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetGrabCategory(cat)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetCategoryDropdownOpen(false)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-3 py-2 text-sm hover:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: grabCategory === cat ? 'var(--accent)' : 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tgrabCategory === cat\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, var(--accent) 10%, transparent)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'transparent',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{cat}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tSave Path\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tvalue={grabSavepath}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setGrabSavepath(e.target.value)}\n\t\t\t\t\t\t\t\t\tdisabled={!grabInstance}\n\t\t\t\t\t\t\t\t\tplaceholder=\"Default\"\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tDownload Path\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tvalue={grabDownloadPath}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setGrabDownloadPath(e.target.value)}\n\t\t\t\t\t\t\t\t\tdisabled={!grabInstance}\n\t\t\t\t\t\t\t\t\tplaceholder=\"Default\"\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded-lg border text-sm disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex gap-3 justify-end mt-6\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={closeGrabModal}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm border\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleGrab}\n\t\t\t\t\t\t\t\tdisabled={!grabInstance || grabbing === grabModal.guid}\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{grabbing === grabModal.guid ? 'Grabbing...' : 'Grab'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n"
  },
  {
    "path": "src/components/SettingsPanel.tsx",
    "content": "import { useState, useEffect, useMemo, type ReactNode } from 'react'\nimport { Settings, X } from 'lucide-react'\nimport type { Instance } from '../api/instances'\nimport type { QBittorrentPreferences } from '../types/preferences'\nimport { getPreferences, setPreferences } from '../api/qbittorrent'\nimport { BehaviorTab } from './settings/BehaviorTab'\nimport { DownloadsTab } from './settings/DownloadsTab'\nimport { ConnectionTab } from './settings/ConnectionTab'\nimport { SpeedTab } from './settings/SpeedTab'\nimport { BitTorrentTab } from './settings/BitTorrentTab'\nimport { RSSTab } from './settings/RSSTab'\nimport { WebUITab } from './settings/WebUITab'\nimport { AdvancedTab } from './settings/AdvancedTab'\n\ntype SettingsTab = 'behavior' | 'downloads' | 'connection' | 'speed' | 'bittorrent' | 'rss' | 'webui' | 'advanced'\n\nconst TABS: { id: SettingsTab; label: string; icon: ReactNode }[] = [\n\t{\n\t\tid: 'behavior',\n\t\tlabel: 'Behavior',\n\t\ticon: (\n\t\t\t<path\n\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\td=\"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z\"\n\t\t\t/>\n\t\t),\n\t},\n\t{\n\t\tid: 'downloads',\n\t\tlabel: 'Downloads',\n\t\ticon: (\n\t\t\t<path\n\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\td=\"M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3\"\n\t\t\t/>\n\t\t),\n\t},\n\t{\n\t\tid: 'connection',\n\t\tlabel: 'Connection',\n\t\ticon: (\n\t\t\t<path\n\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\td=\"M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418\"\n\t\t\t/>\n\t\t),\n\t},\n\t{\n\t\tid: 'speed',\n\t\tlabel: 'Speed',\n\t\ticon: (\n\t\t\t<path\n\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\td=\"M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z\"\n\t\t\t/>\n\t\t),\n\t},\n\t{\n\t\tid: 'bittorrent',\n\t\tlabel: 'BitTorrent',\n\t\ticon: (\n\t\t\t<path\n\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\td=\"M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5\"\n\t\t\t/>\n\t\t),\n\t},\n\t{\n\t\tid: 'rss',\n\t\tlabel: 'RSS',\n\t\ticon: (\n\t\t\t<path\n\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\td=\"M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z\"\n\t\t\t/>\n\t\t),\n\t},\n\t{\n\t\tid: 'webui',\n\t\tlabel: 'WebUI',\n\t\ticon: (\n\t\t\t<path\n\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\td=\"M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25\"\n\t\t\t/>\n\t\t),\n\t},\n\t{\n\t\tid: 'advanced',\n\t\tlabel: 'Advanced',\n\t\ticon: (\n\t\t\t<path\n\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\td=\"M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z\"\n\t\t\t/>\n\t\t),\n\t},\n]\n\ninterface Props {\n\tinstance: Instance\n\tonClose: () => void\n}\n\nexport function SettingsPanel({ instance, onClose }: Props) {\n\tconst [activeTab, setActiveTab] = useState<SettingsTab>('behavior')\n\tconst [loading, setLoading] = useState(true)\n\tconst [saving, setSaving] = useState(false)\n\tconst [error, setError] = useState<string | null>(null)\n\tconst [preferences, setPreferencesState] = useState<Partial<QBittorrentPreferences>>({})\n\tconst [originalPreferences, setOriginalPreferences] = useState<Partial<QBittorrentPreferences>>({})\n\n\tuseEffect(() => {\n\t\tasync function load() {\n\t\t\tsetLoading(true)\n\t\t\tsetError(null)\n\t\t\ttry {\n\t\t\t\tconst prefs = await getPreferences(instance.id)\n\t\t\t\tsetPreferencesState(prefs)\n\t\t\t\tsetOriginalPreferences(prefs)\n\t\t\t} catch {\n\t\t\t\tsetError('Failed to load preferences')\n\t\t\t} finally {\n\t\t\t\tsetLoading(false)\n\t\t\t}\n\t\t}\n\t\tload()\n\t}, [instance.id])\n\n\tfunction handleChange(updates: Partial<QBittorrentPreferences>) {\n\t\tsetPreferencesState((prev) => ({ ...prev, ...updates }))\n\t}\n\n\tasync function handleSave() {\n\t\tsetSaving(true)\n\t\tsetError(null)\n\t\ttry {\n\t\t\tconst changed: Partial<QBittorrentPreferences> = {}\n\t\t\tfor (const key of Object.keys(preferences) as (keyof QBittorrentPreferences)[]) {\n\t\t\t\tif (preferences[key] !== originalPreferences[key]) {\n\t\t\t\t\t;(changed as Record<string, unknown>)[key] = preferences[key]\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (Object.keys(changed).length > 0) {\n\t\t\t\tawait setPreferences(instance.id, changed)\n\t\t\t\tsetOriginalPreferences(preferences)\n\t\t\t}\n\t\t\tonClose()\n\t\t} catch {\n\t\t\tsetError('Failed to save preferences')\n\t\t} finally {\n\t\t\tsetSaving(false)\n\t\t}\n\t}\n\n\tconst hasChanges = useMemo(\n\t\t() => JSON.stringify(preferences) !== JSON.stringify(originalPreferences),\n\t\t[preferences, originalPreferences]\n\t)\n\n\tfunction handleClose() {\n\t\tif (hasChanges && !confirm('You have unsaved changes. Discard them?')) return\n\t\tonClose()\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tclassName=\"mb-4 rounded-lg border overflow-hidden\"\n\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName=\"flex items-center justify-between px-4 py-3 border-b\"\n\t\t\t\tstyle={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t<Settings className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tSettings\n\t\t\t\t\t</span>\n\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t— {instance.label}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t{hasChanges && (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName=\"text-[10px] px-1.5 py-0.5 rounded\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--warning) 20%, transparent)',\n\t\t\t\t\t\t\t\tcolor: 'var(--warning)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tunsaved\n\t\t\t\t\t\t</span>\n\t\t\t\t\t)}\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handleClose}\n\t\t\t\t\t\tclassName=\"p-1 rounded hover:bg-[var(--bg-primary)]\"\n\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<X className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div\n\t\t\t\tclassName=\"flex border-b overflow-x-auto\"\n\t\t\t\tstyle={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-primary)' }}\n\t\t\t>\n\t\t\t\t{TABS.map((tab) => (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tkey={tab.id}\n\t\t\t\t\t\tonClick={() => setActiveTab(tab.id)}\n\t\t\t\t\t\tclassName=\"flex items-center gap-1.5 px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tborderColor: activeTab === tab.id ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\tcolor: activeTab === tab.id ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\tbackgroundColor: activeTab === tab.id ? 'var(--bg-secondary)' : 'transparent',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<svg className=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={1.5}>\n\t\t\t\t\t\t\t{tab.icon}\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t<span className=\"hidden sm:inline\">{tab.label}</span>\n\t\t\t\t\t</button>\n\t\t\t\t))}\n\t\t\t</div>\n\n\t\t\t{loading ? (\n\t\t\t\t<div className=\"flex items-center justify-center py-12\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-5 h-5 border-2 rounded-full animate-spin\"\n\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t) : error && !Object.keys(preferences).length ? (\n\t\t\t\t<div className=\"flex items-center justify-center py-12 text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t{error}\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"p-4 max-h-[60vh] overflow-y-auto\">\n\t\t\t\t\t\t{activeTab === 'behavior' && <BehaviorTab preferences={preferences} onChange={handleChange} />}\n\t\t\t\t\t\t{activeTab === 'downloads' && <DownloadsTab preferences={preferences} onChange={handleChange} />}\n\t\t\t\t\t\t{activeTab === 'connection' && <ConnectionTab preferences={preferences} onChange={handleChange} />}\n\t\t\t\t\t\t{activeTab === 'speed' && <SpeedTab preferences={preferences} onChange={handleChange} />}\n\t\t\t\t\t\t{activeTab === 'bittorrent' && <BitTorrentTab preferences={preferences} onChange={handleChange} />}\n\t\t\t\t\t\t{activeTab === 'rss' && <RSSTab preferences={preferences} onChange={handleChange} />}\n\t\t\t\t\t\t{activeTab === 'webui' && <WebUITab preferences={preferences} onChange={handleChange} />}\n\t\t\t\t\t\t{activeTab === 'advanced' && <AdvancedTab preferences={preferences} onChange={handleChange} />}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"flex items-center gap-2 px-4 py-3 border-t\"\n\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{error && (\n\t\t\t\t\t\t\t<span className=\"text-xs mr-auto\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t\t{error}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={handleSave}\n\t\t\t\t\t\t\tdisabled={saving || !hasChanges}\n\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded text-xs font-medium disabled:opacity-50 transition-colors\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{saving ? 'Saving...' : 'Save'}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={handleClose}\n\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded text-xs border transition-colors hover:bg-[var(--bg-primary)]\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/Statistics.tsx",
    "content": "import { ArrowDown, ArrowUp, AlertCircle } from 'lucide-react'\nimport { formatSize } from '../utils/format'\nimport { Select } from './ui'\nimport { useStats } from '../hooks/useStats'\n\nexport function Statistics() {\n\tconst { periodData, instances, selectedInstance, setSelectedInstance, isLoading, hasAnyData } = useStats()\n\n\tconst instanceOptions = [\n\t\t{ value: 'all', label: 'All instances' },\n\t\t...instances.map((i) => ({ value: String(i.id), label: i.label })),\n\t]\n\n\treturn (\n\t\t<div className=\"space-y-6\">\n\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t<h1 className=\"text-xl font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\tTransfer Statistics\n\t\t\t\t</h1>\n\t\t\t\t{instances.length > 1 && (\n\t\t\t\t\t<div className=\"w-48\">\n\t\t\t\t\t\t<Select value={selectedInstance} onChange={setSelectedInstance} options={instanceOptions} />\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{isLoading ? (\n\t\t\t\t<div className=\"text-center py-12\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tLoading statistics...\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<>\n\t\t\t\t\t{!hasAnyData && (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"flex items-center gap-3 px-4 py-3 rounded-lg text-sm\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--warning) 10%, transparent)',\n\t\t\t\t\t\t\t\tcolor: 'var(--warning)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<AlertCircle className=\"w-5 h-5 flex-shrink-0\" />\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<div className=\"font-medium\">No data available yet</div>\n\t\t\t\t\t\t\t\t<div className=\"text-xs opacity-80 mt-0.5\">\n\t\t\t\t\t\t\t\t\tStatistics are recorded every 5 minutes. Data will appear once enough time has passed.\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t\t\t{periodData!.map((data) => (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={data.period}\n\t\t\t\t\t\t\t\tclassName={`px-4 py-3 rounded-xl border ${data.period === 'all' ? 'col-span-2' : ''}`}\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs font-medium uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{data.label}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t<ArrowDown className=\"w-3.5 h-3.5\" style={{ color: 'var(--accent)' }} />\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-sm font-semibold tabular-nums\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: data.hasData ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{data.hasData ? formatSize(data.downloaded) : 'N/A'}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t<ArrowUp className=\"w-3.5 h-3.5\" style={{ color: '#a6e3a1' }} />\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-sm font-semibold tabular-nums\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: data.hasData ? '#a6e3a1' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{data.hasData ? formatSize(data.uploaded) : 'N/A'}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<ul className=\"text-xs space-y-1 list-disc list-inside\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t<li>Statistics are recorded every 5 minutes while the server is running</li>\n\t\t\t\t\t\t<li>All time values are fetched directly from qBittorrent</li>\n\t\t\t\t\t\t<li>Other periods show the difference between current and historical snapshots</li>\n\t\t\t\t\t\t<li>Download stats include protocol traffic (DHT, PEX, tracker responses), not just file data</li>\n\t\t\t\t\t</ul>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/StatusBar.tsx",
    "content": "import { useState, useRef, useEffect, useCallback } from 'react'\nimport {\n\tChevronDown,\n\tArrowDown,\n\tArrowUp,\n\tZap,\n\tChevronsLeft,\n\tChevronsRight,\n\tChevronLeft,\n\tChevronRight,\n} from 'lucide-react'\nimport { useTransferInfo } from '../hooks/useTransferInfo'\nimport { useSyncMaindata } from '../hooks/useSyncMaindata'\nimport { usePagination } from '../hooks/usePagination'\nimport { useInstance } from '../hooks/useInstance'\nimport { PER_PAGE_OPTIONS } from '../utils/pagination'\nimport { formatSpeed, formatSize } from '../utils/format'\nimport { getSpeedLimitsMode, toggleSpeedLimitsMode } from '../api/qbittorrent'\n\nfunction formatLimit(bytes: number): string {\n\tif (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)}M`\n\treturn `${(bytes / 1024).toFixed(0)}K`\n}\n\nfunction PerPageDropdown({ value, onChange }: { value: number; onChange: (v: number) => void }) {\n\tconst [open, setOpen] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\tuseEffect(() => {\n\t\tfunction handleClickOutside(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClickOutside)\n\t\treturn () => document.removeEventListener('mousedown', handleClickOutside)\n\t}, [])\n\n\treturn (\n\t\t<div ref={ref} className=\"relative\">\n\t\t\t<button\n\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\tclassName=\"flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium transition-all duration-200\"\n\t\t\t\tstyle={{ color: 'var(--text-muted)', backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t>\n\t\t\t\t<span>{value}</span>\n\t\t\t\t<ChevronDown className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} strokeWidth={2} />\n\t\t\t</button>\n\t\t\t{open && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute bottom-full left-0 mb-1 min-w-[60px] rounded-lg border shadow-xl z-[100]\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t{PER_PAGE_OPTIONS.map((n) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={n}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tonChange(n)\n\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"w-full px-3 py-1.5 text-xs text-left transition-colors\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tcolor: value === n ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\tbackgroundColor: value === n ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{n}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\nfunction useAltSpeedMode(instanceId: number) {\n\tconst [enabled, setEnabled] = useState(false)\n\tconst [toggling, setToggling] = useState(false)\n\n\tuseEffect(() => {\n\t\tlet mounted = true\n\t\tconst checkMode = async () => {\n\t\t\tconst mode = await getSpeedLimitsMode(instanceId).catch(() => 0)\n\t\t\tif (mounted) setEnabled(mode === 1)\n\t\t}\n\t\tcheckMode()\n\t\tconst interval = setInterval(checkMode, 2000)\n\t\treturn () => {\n\t\t\tmounted = false\n\t\t\tclearInterval(interval)\n\t\t}\n\t}, [instanceId])\n\n\tconst toggle = useCallback(async () => {\n\t\tif (toggling) return\n\t\tsetToggling(true)\n\t\tconst ok = await toggleSpeedLimitsMode(instanceId)\n\t\t\t.then(() => true)\n\t\t\t.catch(() => false)\n\t\tif (ok) setEnabled((prev) => !prev)\n\t\tsetToggling(false)\n\t}, [instanceId, toggling])\n\n\treturn { enabled, toggling, toggle }\n}\n\nexport function StatusBar() {\n\tconst instance = useInstance()\n\tconst { data } = useTransferInfo()\n\tconst { data: syncData } = useSyncMaindata()\n\tconst { page, perPage, totalItems, totalPages, setPage, setPerPage } = usePagination()\n\tconst altSpeed = useAltSpeedMode(instance.id)\n\n\tconst statusConfig = {\n\t\tconnected: { label: 'Connected', type: 'success' as const },\n\t\tfirewalled: { label: 'Firewalled', type: 'warning' as const },\n\t\tdisconnected: { label: 'Disconnected', type: 'error' as const },\n\t}[data?.connection_status ?? 'disconnected']\n\n\tconst statusColors = {\n\t\tsuccess: 'var(--accent)',\n\t\twarning: 'var(--warning)',\n\t\terror: 'var(--error)',\n\t}\n\n\tconst startItem = totalItems === 0 ? 0 : (page - 1) * perPage + 1\n\tconst endItem = Math.min(page * perPage, totalItems)\n\n\treturn (\n\t\t<div\n\t\t\tclassName=\"relative grid grid-cols-3 items-center px-6 py-3 backdrop-blur-md border-t\"\n\t\t\tstyle={{\n\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--bg-secondary) 80%, transparent)',\n\t\t\t\tborderColor: 'var(--border)',\n\t\t\t}}\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName=\"absolute inset-0\"\n\t\t\t\tstyle={{\n\t\t\t\t\tbackground:\n\t\t\t\t\t\t'linear-gradient(to right, transparent, color-mix(in srgb, var(--accent) 1%, transparent), transparent)',\n\t\t\t\t}}\n\t\t\t/>\n\n\t\t\t<div className=\"relative flex items-center gap-6\">\n\t\t\t\t<div className=\"flex items-center gap-2.5\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-2 h-2 rounded-full shadow-lg\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: statusColors[statusConfig.type],\n\t\t\t\t\t\t\tboxShadow: `0 0 10px ${statusColors[statusConfig.type]}50`,\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t\t<span className=\"text-xs font-medium\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t{statusConfig.label}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"h-4 w-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<ArrowDown className=\"w-3.5 h-3.5\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t<span className=\"text-xs font-mono font-medium whitespace-nowrap\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t{formatSpeed(data?.dl_info_speed ?? 0)}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{(data?.dl_rate_limit ?? 0) > 0 && (\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName=\"px-1.5 py-0.5 rounded text-[10px] font-medium\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--accent) 20%, transparent)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--accent)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\ttitle={`Download limit: ${formatSpeed(data?.dl_rate_limit ?? 0)}`}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{formatLimit(data?.dl_rate_limit ?? 0)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<ArrowUp className=\"w-3.5 h-3.5\" style={{ color: 'var(--warning)' }} strokeWidth={2} />\n\t\t\t\t\t\t<span className=\"text-xs font-mono font-medium whitespace-nowrap\" style={{ color: 'var(--warning)' }}>\n\t\t\t\t\t\t\t{formatSpeed(data?.up_info_speed ?? 0)}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{(data?.up_rate_limit ?? 0) > 0 && (\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName=\"px-1.5 py-0.5 rounded text-[10px] font-medium\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--warning) 20%, transparent)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--warning)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\ttitle={`Upload limit: ${formatSpeed(data?.up_rate_limit ?? 0)}`}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{formatLimit(data?.up_rate_limit ?? 0)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"h-4 w-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t\t<button\n\t\t\t\t\tonClick={altSpeed.toggle}\n\t\t\t\t\tdisabled={altSpeed.toggling}\n\t\t\t\t\tclassName=\"flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium transition-all\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundColor: altSpeed.enabled\n\t\t\t\t\t\t\t? 'color-mix(in srgb, var(--accent) 20%, transparent)'\n\t\t\t\t\t\t\t: 'var(--bg-tertiary)',\n\t\t\t\t\t\tcolor: altSpeed.enabled ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\topacity: altSpeed.toggling ? 0.5 : 1,\n\t\t\t\t\t}}\n\t\t\t\t\ttitle={\n\t\t\t\t\t\taltSpeed.enabled\n\t\t\t\t\t\t\t? 'Alternative speed limits active (click to disable)'\n\t\t\t\t\t\t\t: 'Click to enable alternative speed limits'\n\t\t\t\t\t}\n\t\t\t\t>\n\t\t\t\t\t<Zap className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t\t<span>Alt</span>\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t<div className=\"relative flex items-center justify-center gap-3\">\n\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setPage(1)}\n\t\t\t\t\t\tdisabled={page === 1}\n\t\t\t\t\t\tclassName=\"p-1 rounded transition-colors disabled:opacity-30\"\n\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<ChevronsLeft className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setPage(page - 1)}\n\t\t\t\t\t\tdisabled={page === 1}\n\t\t\t\t\t\tclassName=\"p-1 rounded transition-colors disabled:opacity-30\"\n\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<ChevronLeft className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t\t<span className=\"text-xs tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{startItem}-{endItem} of {totalItems}\n\t\t\t\t</span>\n\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setPage(page + 1)}\n\t\t\t\t\t\tdisabled={page === totalPages}\n\t\t\t\t\t\tclassName=\"p-1 rounded transition-colors disabled:opacity-30\"\n\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<ChevronRight className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setPage(totalPages)}\n\t\t\t\t\t\tdisabled={page === totalPages}\n\t\t\t\t\t\tclassName=\"p-1 rounded transition-colors disabled:opacity-30\"\n\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<ChevronsRight className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t\t<PerPageDropdown value={perPage} onChange={setPerPage} />\n\t\t\t</div>\n\n\t\t\t<div className=\"relative flex items-center justify-end gap-4\">\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-1.5 rounded-lg border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<span className=\"text-[10px] font-medium uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tTotal\n\t\t\t\t\t</span>\n\t\t\t\t\t<span className=\"text-xs font-mono\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t{formatSize(syncData?.server_state.alltime_dl ?? 0)}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t/\n\t\t\t\t\t</span>\n\t\t\t\t\t<span className=\"text-xs font-mono\" style={{ color: 'var(--warning)' }}>\n\t\t\t\t\t\t{formatSize(syncData?.server_state.alltime_ul ?? 0)}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-1.5 rounded-lg border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<span className=\"text-[10px] font-medium uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tDHT\n\t\t\t\t\t</span>\n\t\t\t\t\t<span className=\"text-xs font-mono font-medium\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t{data?.dht_nodes ?? 0}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/ThemeManager.tsx",
    "content": "import { useState, useMemo, useRef, useEffect } from 'react'\nimport { createPortal } from 'react-dom'\nimport { ChevronLeft, Download, Pencil, Plus, Trash2, Upload, X } from 'lucide-react'\nimport { HexColorPicker } from 'react-colorful'\nimport { generateThemeColors, isValidHex } from '../utils/colorUtils'\nimport { useTheme } from '../hooks/useTheme'\nimport type { Theme } from '../themes'\n\ntype View = 'list' | 'editor'\n\ninterface ThemeManagerProps {\n\tonClose: () => void\n}\n\nexport function ThemeManager({ onClose }: ThemeManagerProps) {\n\tconst { themes, customThemes, addTheme, updateTheme, deleteTheme } = useTheme()\n\tconst [view, setView] = useState<View>('list')\n\tconst [editingTheme, setEditingTheme] = useState<Theme | null>(null)\n\tconst fileInputRef = useRef<HTMLInputElement>(null)\n\n\tconst allNames = useMemo(\n\t\t() => [...themes.map((t) => t.name), ...customThemes.map((t) => t.name)],\n\t\t[themes, customThemes]\n\t)\n\n\tfunction handleNewTheme() {\n\t\tsetEditingTheme(null)\n\t\tsetView('editor')\n\t}\n\n\tfunction handleEditTheme(theme: Theme) {\n\t\tsetEditingTheme(theme)\n\t\tsetView('editor')\n\t}\n\n\tfunction handleSaveTheme(theme: Theme) {\n\t\tif (editingTheme) {\n\t\t\tupdateTheme(theme)\n\t\t} else {\n\t\t\taddTheme(theme)\n\t\t}\n\t\tsetView('list')\n\t\tsetEditingTheme(null)\n\t}\n\n\tfunction handleExport() {\n\t\tconst blob = new Blob([JSON.stringify(customThemes, null, 2)], { type: 'application/json' })\n\t\tconst url = URL.createObjectURL(blob)\n\t\tconst a = document.createElement('a')\n\t\ta.href = url\n\t\ta.download = 'qbitwebui-themes.json'\n\t\ta.click()\n\t\tURL.revokeObjectURL(url)\n\t}\n\n\tfunction handleImport(e: React.ChangeEvent<HTMLInputElement>) {\n\t\tconst file = e.target.files?.[0]\n\t\tif (!file) return\n\n\t\tconst reader = new FileReader()\n\t\treader.onload = (event) => {\n\t\t\ttry {\n\t\t\t\tconst imported = JSON.parse(event.target?.result as string) as Theme[]\n\t\t\t\tif (!Array.isArray(imported)) throw new Error('Invalid format')\n\n\t\t\t\t// Add each theme with new ID to avoid collisions\n\t\t\t\timported.forEach((t) => {\n\t\t\t\t\tif (t.name && t.colors) {\n\t\t\t\t\t\taddTheme({\n\t\t\t\t\t\t\t...t,\n\t\t\t\t\t\t\tid: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\talert('Failed to import themes. Invalid file format.')\n\t\t\t}\n\t\t}\n\t\treader.readAsText(file)\n\t\te.target.value = '' // Reset for re-import\n\t}\n\n\treturn createPortal(\n\t\t<div className=\"fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200\">\n\t\t\t<div className=\"w-full max-w-lg bg-[var(--bg-secondary)] border border-[var(--border)] rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200\">\n\t\t\t\t{view === 'list' ? (\n\t\t\t\t\t<ListView\n\t\t\t\t\t\tcustomThemes={customThemes}\n\t\t\t\t\t\tonClose={onClose}\n\t\t\t\t\t\tonNew={handleNewTheme}\n\t\t\t\t\t\tonEdit={handleEditTheme}\n\t\t\t\t\t\tonDelete={deleteTheme}\n\t\t\t\t\t\tonExport={handleExport}\n\t\t\t\t\t\tonImportClick={() => fileInputRef.current?.click()}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t<EditorView\n\t\t\t\t\t\tinitialTheme={editingTheme}\n\t\t\t\t\t\texistingNames={allNames}\n\t\t\t\t\t\tonSave={handleSaveTheme}\n\t\t\t\t\t\tonBack={() => {\n\t\t\t\t\t\t\tsetView('list')\n\t\t\t\t\t\t\tsetEditingTheme(null)\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t\t<input ref={fileInputRef} type=\"file\" accept=\".json\" className=\"hidden\" onChange={handleImport} />\n\t\t</div>,\n\t\tdocument.body\n\t)\n}\n\n// ─────────────────────────────────────────────────────────────\n// List View\n// ─────────────────────────────────────────────────────────────\n\ninterface ListViewProps {\n\tcustomThemes: Theme[]\n\tonClose: () => void\n\tonNew: () => void\n\tonEdit: (theme: Theme) => void\n\tonDelete: (id: string) => void\n\tonExport: () => void\n\tonImportClick: () => void\n}\n\nfunction ListView({ customThemes, onClose, onNew, onEdit, onDelete, onExport, onImportClick }: ListViewProps) {\n\treturn (\n\t\t<>\n\t\t\t{/* Header */}\n\t\t\t<div className=\"p-4 border-b border-[var(--border)] flex justify-between items-center bg-[var(--bg-tertiary)]\">\n\t\t\t\t<h2 className=\"text-lg font-semibold text-[var(--text-primary)]\">Manage Themes</h2>\n\t\t\t\t<button onClick={onClose} className=\"text-[var(--text-muted)] hover:text-[var(--text-primary)]\">\n\t\t\t\t\t<X className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t{/* Action Bar */}\n\t\t\t<div className=\"p-3 border-b border-[var(--border)] flex items-center gap-2\">\n\t\t\t\t<button\n\t\t\t\t\tonClick={onNew}\n\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium bg-[var(--accent)] text-[var(--accent-contrast)] hover:opacity-90 transition-opacity\"\n\t\t\t\t>\n\t\t\t\t\t<Plus className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\tNew Theme\n\t\t\t\t</button>\n\t\t\t\t<div className=\"flex-1\" />\n\t\t\t\t<button\n\t\t\t\t\tonClick={onImportClick}\n\t\t\t\t\tclassName=\"p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors\"\n\t\t\t\t\ttitle=\"Import themes\"\n\t\t\t\t>\n\t\t\t\t\t<Upload className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\tonClick={onExport}\n\t\t\t\t\tdisabled={customThemes.length === 0}\n\t\t\t\t\tclassName=\"p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed\"\n\t\t\t\t\ttitle=\"Export themes\"\n\t\t\t\t>\n\t\t\t\t\t<Download className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t{/* Theme List */}\n\t\t\t<div className=\"flex-1 overflow-y-auto p-3 space-y-2\">\n\t\t\t\t{customThemes.length === 0 ? (\n\t\t\t\t\t<div className=\"py-8 text-center text-sm text-[var(--text-muted)]\">No custom themes yet. Create one!</div>\n\t\t\t\t) : (\n\t\t\t\t\tcustomThemes.map((t) => (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tkey={t.id}\n\t\t\t\t\t\t\tclassName=\"flex items-center gap-3 p-3 rounded-xl border border-[var(--border)] bg-[var(--bg-primary)]\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"flex gap-1 shrink-0\">\n\t\t\t\t\t\t\t\t<div className=\"w-3 h-3 rounded-full\" style={{ backgroundColor: t.colors.bgPrimary }} />\n\t\t\t\t\t\t\t\t<div className=\"w-3 h-3 rounded-full\" style={{ backgroundColor: t.colors.accent }} />\n\t\t\t\t\t\t\t\t<div className=\"w-3 h-3 rounded-full\" style={{ backgroundColor: t.colors.warning }} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<span className=\"flex-1 text-sm font-medium text-[var(--text-primary)] truncate\">{t.name}</span>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => onEdit(t)}\n\t\t\t\t\t\t\t\tclassName=\"p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--accent)] hover:bg-[var(--bg-secondary)] transition-colors\"\n\t\t\t\t\t\t\t\ttitle=\"Edit\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Pencil className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => onDelete(t.id)}\n\t\t\t\t\t\t\t\tclassName=\"p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--error)] hover:bg-[var(--bg-secondary)] transition-colors\"\n\t\t\t\t\t\t\t\ttitle=\"Delete\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Trash2 className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</>\n\t)\n}\n\n// ─────────────────────────────────────────────────────────────\n// Editor View\n// ─────────────────────────────────────────────────────────────\n\ninterface EditorViewProps {\n\tinitialTheme: Theme | null\n\texistingNames: string[]\n\tonSave: (theme: Theme) => void\n\tonBack: () => void\n}\n\nfunction EditorView({ initialTheme, existingNames, onSave, onBack }: EditorViewProps) {\n\tconst [name, setName] = useState(initialTheme?.name ?? 'My Custom Theme')\n\tconst [bgPrimary, setBgPrimary] = useState(initialTheme?.colors.bgPrimary ?? '#1e1e2e')\n\tconst [accent, setAccent] = useState(initialTheme?.colors.accent ?? '#cba6f7')\n\tconst [textPrimary, setTextPrimary] = useState(initialTheme?.colors.textPrimary ?? '#cdd6f4')\n\tconst [warning, setWarning] = useState(initialTheme?.colors.warning ?? '#f7b731')\n\n\tconst previewColors = useMemo(() => {\n\t\tif (isValidHex(bgPrimary) && isValidHex(accent) && isValidHex(textPrimary) && isValidHex(warning)) {\n\t\t\treturn generateThemeColors(bgPrimary, accent, textPrimary, warning)\n\t\t}\n\t\treturn null\n\t}, [bgPrimary, accent, textPrimary, warning])\n\n\tconst isNameTaken = useMemo(() => {\n\t\tconst trimmed = name.trim().toLowerCase()\n\t\treturn existingNames.some((n) => {\n\t\t\tif (initialTheme?.name.toLowerCase() === trimmed) return false\n\t\t\treturn n.toLowerCase() === trimmed\n\t\t})\n\t}, [name, existingNames, initialTheme?.name])\n\n\tconst handleSave = () => {\n\t\tif (!previewColors || !name.trim() || isNameTaken) return\n\t\tonSave({\n\t\t\tid: initialTheme?.id ?? `custom-${Date.now()}`,\n\t\t\tname: name.trim(),\n\t\t\tcolors: previewColors,\n\t\t})\n\t}\n\n\tconst colorFields = [\n\t\t{ label: 'Background', val: bgPrimary, set: setBgPrimary },\n\t\t{ label: 'Accent', val: accent, set: setAccent },\n\t\t{ label: 'Text', val: textPrimary, set: setTextPrimary },\n\t\t{ label: 'Warning', val: warning, set: setWarning },\n\t]\n\n\treturn (\n\t\t<>\n\t\t\t{/* Header */}\n\t\t\t<div className=\"p-4 border-b border-[var(--border)] flex items-center gap-3 bg-[var(--bg-tertiary)]\">\n\t\t\t\t<button onClick={onBack} className=\"text-[var(--text-muted)] hover:text-[var(--text-primary)]\">\n\t\t\t\t\t<ChevronLeft className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t\t<h2 className=\"text-lg font-semibold text-[var(--text-primary)]\">\n\t\t\t\t\t{initialTheme ? 'Edit Theme' : 'New Theme'}\n\t\t\t\t</h2>\n\t\t\t</div>\n\n\t\t\t{/* Content */}\n\t\t\t<div className=\"flex-1 overflow-y-auto p-4 space-y-4\">\n\t\t\t\t{/* Theme Name */}\n\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t<label className=\"text-xs font-medium text-[var(--text-secondary)] flex justify-between\">\n\t\t\t\t\t\t<span>Theme Name</span>\n\t\t\t\t\t\t<span className=\"text-[var(--text-muted)]\">{name.length}/20</span>\n\t\t\t\t\t</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tvalue={name}\n\t\t\t\t\t\tonChange={(e) => {\n\t\t\t\t\t\t\tconst sanitized = e.target.value.replace(/[^a-zA-Z0-9\\s\\-_]/g, '')\n\t\t\t\t\t\t\tsetName(sanitized.slice(0, 20))\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tmaxLength={20}\n\t\t\t\t\t\tclassName=\"w-full px-3 py-2 bg-[var(--bg-primary)] border rounded-lg text-sm text-[var(--text-primary)] focus:outline-none\"\n\t\t\t\t\t\tstyle={{ borderColor: isNameTaken ? 'var(--error)' : 'var(--border)' }}\n\t\t\t\t\t/>\n\t\t\t\t\t{isNameTaken && <p className=\"text-xs text-[var(--error)]\">Name already exists</p>}\n\t\t\t\t</div>\n\n\t\t\t\t{/* Color Inputs */}\n\t\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t\t{colorFields.map((field) => (\n\t\t\t\t\t\t<ColorInput key={field.label} label={field.label} value={field.val} onChange={field.set} />\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\n\t\t\t\t{/* Preview */}\n\t\t\t\t{previewColors && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"rounded-xl p-4 border space-y-3\"\n\t\t\t\t\t\tstyle={{ backgroundColor: previewColors.bgPrimary, borderColor: previewColors.border }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-between items-center\">\n\t\t\t\t\t\t\t<div className=\"text-sm font-bold\" style={{ color: previewColors.textPrimary }}>\n\t\t\t\t\t\t\t\t{name || 'Theme Name'}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"px-2 py-0.5 rounded-full text-xs font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: previewColors.accent, color: previewColors.accentContrast }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tBadge\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"p-3 rounded-lg border\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: previewColors.bgSecondary, borderColor: previewColors.border }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"text-xs mb-2\" style={{ color: previewColors.textSecondary }}>\n\t\t\t\t\t\t\t\tPreview Card\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tclassName=\"w-full py-1.5 rounded-md text-xs font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: previewColors.accent, color: previewColors.accentContrast }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tAction\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{/* Footer */}\n\t\t\t<div className=\"p-4 border-t border-[var(--border)] flex justify-end gap-3 bg-[var(--bg-tertiary)]\">\n\t\t\t\t<button\n\t\t\t\t\tonClick={onBack}\n\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] transition-colors\"\n\t\t\t\t>\n\t\t\t\t\tCancel\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\tonClick={handleSave}\n\t\t\t\t\tdisabled={!previewColors || !name.trim() || isNameTaken}\n\t\t\t\t\tclassName=\"px-4 py-2 rounded-lg text-sm font-medium bg-[var(--accent)] text-[var(--accent-contrast)] hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed\"\n\t\t\t\t>\n\t\t\t\t\tSave Theme\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</>\n\t)\n}\n\nfunction ColorInput({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {\n\tconst [open, setOpen] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\tuseEffect(() => {\n\t\tif (!open) return\n\t\tfunction handleClick(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClick)\n\t\treturn () => document.removeEventListener('mousedown', handleClick)\n\t}, [open])\n\n\treturn (\n\t\t<div className=\"space-y-1\">\n\t\t\t<label className=\"text-xs font-medium text-[var(--text-secondary)]\">{label}</label>\n\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t<div ref={ref} className=\"relative\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\t\t\tclassName=\"w-8 h-8 rounded-lg border border-[var(--border)] shrink-0 cursor-pointer hover:opacity-80 transition-opacity\"\n\t\t\t\t\t\tstyle={{ backgroundColor: isValidHex(value) ? value : 'transparent' }}\n\t\t\t\t\t/>\n\t\t\t\t\t{open && (\n\t\t\t\t\t\t<div className=\"absolute top-full left-0 mt-2 z-10 p-3 rounded-xl border border-[var(--border)] bg-[var(--bg-secondary)] shadow-xl\">\n\t\t\t\t\t\t\t<HexColorPicker color={isValidHex(value) ? value : '#000000'} onChange={onChange} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t\t<input\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t\tvalue={value}\n\t\t\t\t\tonChange={(e) => onChange(e.target.value)}\n\t\t\t\t\tclassName=\"flex-1 px-2 py-1.5 font-mono text-xs bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]\"\n\t\t\t\t\tplaceholder=\"#000000\"\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/ThemeSwitcher.tsx",
    "content": "import { useState, useRef, useEffect } from 'react'\nimport { ChevronDown, Settings, Check } from 'lucide-react'\nimport { useTheme } from '../hooks/useTheme'\nimport { ThemeManager } from './ThemeManager'\n\nexport function ThemeSwitcher() {\n\tconst { theme, setTheme, themes, customThemes } = useTheme()\n\tconst [open, setOpen] = useState(false)\n\tconst [showManager, setShowManager] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\tuseEffect(() => {\n\t\tfunction handleClickOutside(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClickOutside)\n\t\treturn () => document.removeEventListener('mousedown', handleClickOutside)\n\t}, [])\n\n\treturn (\n\t\t<>\n\t\t\t<div ref={ref} className=\"relative\">\n\t\t\t\t<button\n\t\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-colors\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"w-3 h-3 rounded-full ring-1 ring-white/20\" style={{ backgroundColor: theme.colors.accent }} />\n\t\t\t\t\t<span className=\"text-xs font-medium\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t{theme.name}\n\t\t\t\t\t</span>\n\t\t\t\t\t<ChevronDown\n\t\t\t\t\t\tclassName={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`}\n\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t/>\n\t\t\t\t</button>\n\n\t\t\t\t{open && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"absolute top-full right-0 mt-2 w-48 py-1 rounded-lg border shadow-xl z-[100] max-h-[80vh] overflow-y-auto\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{/* Official Themes */}\n\t\t\t\t\t\t<div className=\"px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider opacity-50 select-none text-[var(--text-muted)]\">\n\t\t\t\t\t\t\tOfficial\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{themes.map((t) => (\n\t\t\t\t\t\t\t<ThemeRow\n\t\t\t\t\t\t\t\tkey={t.id}\n\t\t\t\t\t\t\t\tt={t}\n\t\t\t\t\t\t\t\tisActive={theme.id === t.id}\n\t\t\t\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\t\t\t\tsetTheme(t.id)\n\t\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\n\t\t\t\t\t\t{/* Custom Themes */}\n\t\t\t\t\t\t{customThemes.length > 0 && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<div className=\"my-1 border-t border-[var(--border)]\" />\n\t\t\t\t\t\t\t\t<div className=\"px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider opacity-50 select-none text-[var(--text-muted)]\">\n\t\t\t\t\t\t\t\t\tCustom\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{customThemes.map((t) => (\n\t\t\t\t\t\t\t\t\t<ThemeRow\n\t\t\t\t\t\t\t\t\t\tkey={t.id}\n\t\t\t\t\t\t\t\t\t\tt={t}\n\t\t\t\t\t\t\t\t\t\tisActive={theme.id === t.id}\n\t\t\t\t\t\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\t\t\t\t\t\tsetTheme(t.id)\n\t\t\t\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Manage Themes */}\n\t\t\t\t\t\t<div className=\"my-1 border-t border-[var(--border)]\" />\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t\tsetShowManager(true)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"w-full flex items-center gap-2 px-3 py-2 text-left text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] transition-colors\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Settings className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\tManage Themes\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{showManager && <ThemeManager onClose={() => setShowManager(false)} />}\n\t\t</>\n\t)\n}\n\n// Simple theme row component\nfunction ThemeRow({\n\tt,\n\tisActive,\n\tonSelect,\n}: {\n\tt: { id: string; name: string; colors: { bgPrimary: string; accent: string; warning: string } }\n\tisActive: boolean\n\tonSelect: () => void\n}) {\n\treturn (\n\t\t<button\n\t\t\tonClick={onSelect}\n\t\t\tclassName=\"w-full flex items-center gap-3 px-3 py-2 text-left transition-colors\"\n\t\t\tstyle={{\n\t\t\t\tbackgroundColor: isActive ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\tcolor: isActive ? 'var(--accent)' : 'var(--text-secondary)',\n\t\t\t}}\n\t\t>\n\t\t\t<div className=\"flex gap-1 shrink-0\">\n\t\t\t\t<div className=\"w-2.5 h-2.5 rounded-full\" style={{ backgroundColor: t.colors.bgPrimary }} />\n\t\t\t\t<div className=\"w-2.5 h-2.5 rounded-full\" style={{ backgroundColor: t.colors.accent }} />\n\t\t\t\t<div className=\"w-2.5 h-2.5 rounded-full\" style={{ backgroundColor: t.colors.warning }} />\n\t\t\t</div>\n\t\t\t<span className=\"text-xs font-medium truncate\">{t.name}</span>\n\t\t\t{isActive && <Check className=\"w-3 h-3 ml-auto shrink-0\" strokeWidth={3} />}\n\t\t</button>\n\t)\n}\n"
  },
  {
    "path": "src/components/TorrentDetailsPanel.tsx",
    "content": "import { useState, useEffect, useCallback, useRef, useMemo } from 'react'\nimport {\n\tSettings,\n\tArrowRightLeft,\n\tUsers,\n\tLink,\n\tFolder,\n\tPlus,\n\tX,\n\tChevronRight,\n\tChevronUp,\n\tMousePointer,\n\tFileText,\n} from 'lucide-react'\nimport type { TorrentFile } from '../types/torrentDetails'\nimport {\n\tuseTorrentProperties,\n\tuseTorrentTrackers,\n\tuseTorrentPeers,\n\tuseTorrentFiles,\n\tuseTorrentWebSeeds,\n\tuseSetFilePriority,\n\tuseAddTrackers,\n\tuseRemoveTrackers,\n} from '../hooks/useTorrentDetails'\nimport { useSetTorrentDownloadPath, useSetTorrentLocation } from '../hooks/useTorrents'\nimport { formatSize, formatSpeed, formatDate, formatDuration, formatEta } from '../utils/format'\nimport type { Tracker, Peer } from '../types/torrentDetails'\nimport { buildFileTree, flattenVisibleNodes, getInitialExpanded } from '../utils/fileTree'\n\ninterface Props {\n\thash: string | null\n\tname: string\n\tcategory: string\n\ttags: string\n\texpanded: boolean\n\tonToggle: () => void\n\theight: number\n\tonHeightChange: (h: number) => void\n}\n\ntype Tab = 'general' | 'trackers' | 'peers' | 'http' | 'content'\n\nconst TABS: { id: Tab; label: string; Icon: React.ComponentType<{ className?: string; strokeWidth?: number }> }[] = [\n\t{ id: 'general', label: 'General', Icon: Settings },\n\t{ id: 'trackers', label: 'Trackers', Icon: ArrowRightLeft },\n\t{ id: 'peers', label: 'Peers', Icon: Users },\n\t{ id: 'http', label: 'HTTP', Icon: Link },\n\t{ id: 'content', label: 'Files', Icon: Folder },\n]\n\nconst MIN_HEIGHT = 120\nconst MAX_HEIGHT_PERCENT = 0.55\nconst COLLAPSED_HEIGHT = 36\n\nconst TRACKER_STATUSES: Record<number, { label: string; colorVar: string }> = {\n\t0: { label: 'Disabled', colorVar: 'var(--text-muted)' },\n\t1: { label: 'Not contacted', colorVar: 'var(--text-muted)' },\n\t2: { label: 'Working', colorVar: 'var(--accent)' },\n\t3: { label: 'Updating', colorVar: 'var(--warning)' },\n\t4: { label: 'Error', colorVar: 'var(--error)' },\n}\n\nfunction StatusBadge({ status }: { status: number }) {\n\tconst { label, colorVar } = TRACKER_STATUSES[status] ?? { label: 'Unknown', colorVar: 'var(--text-muted)' }\n\treturn (\n\t\t<span\n\t\t\tclassName=\"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium\"\n\t\t\tstyle={{ color: colorVar, backgroundColor: `color-mix(in srgb, ${colorVar} 10%, transparent)` }}\n\t\t>\n\t\t\t<span className=\"w-1 h-1 rounded-full\" style={{ backgroundColor: colorVar }} />\n\t\t\t{label}\n\t\t</span>\n\t)\n}\n\nfunction LoadingSkeleton() {\n\treturn (\n\t\t<div className=\"p-4 space-y-3 animate-pulse\">\n\t\t\t<div className=\"grid grid-cols-4 gap-3\">\n\t\t\t\t{[...Array(8)].map((_, i) => (\n\t\t\t\t\t<div\n\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\tclassName=\"h-10 rounded\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, white 3%, transparent)' }}\n\t\t\t\t\t/>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nfunction EmptyState({ message }: { message: string }) {\n\treturn (\n\t\t<div\n\t\t\tclassName=\"flex items-center justify-center h-full text-xs tracking-wide uppercase\"\n\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t>\n\t\t\t{message}\n\t\t</div>\n\t)\n}\n\nfunction formatLimit(limit: number): string {\n\treturn limit <= 0 ? '∞' : formatSpeed(limit)\n}\n\nconst cellBase = { backgroundColor: 'color-mix(in srgb, white 2.5%, transparent)', borderColor: 'var(--border)' }\n\nfunction InfoCell({\n\tlabel,\n\tvalue,\n\taccent,\n\tmuted,\n\tspan,\n\twide,\n}: {\n\tlabel: string\n\tvalue: string\n\taccent?: boolean\n\tmuted?: boolean\n\tspan?: number\n\twide?: boolean\n}) {\n\tconst color = muted ? 'var(--text-muted)' : accent ? 'var(--accent)' : 'var(--text-primary)'\n\treturn (\n\t\t<div\n\t\t\tclassName={`px-3 py-2 rounded border ${wide ? '' : 'min-w-0 overflow-hidden'}`}\n\t\t\tstyle={{ ...cellBase, gridColumn: span ? `span ${span}` : undefined }}\n\t\t>\n\t\t\t<div className=\"text-[9px] uppercase tracking-widest\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t{label}\n\t\t\t</div>\n\t\t\t<div\n\t\t\t\tclassName={`text-xs font-mono mt-0.5 ${wide ? 'break-all' : 'truncate'}`}\n\t\t\t\tstyle={{ color }}\n\t\t\t\ttitle={wide ? undefined : value}\n\t\t\t>\n\t\t\t\t{value}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nfunction GeneralTab({ hash, category, tags }: { hash: string; category: string; tags: string }) {\n\tconst [editorMode, setEditorMode] = useState<'savePath' | 'downloadPath' | null>(null)\n\tconst [inputValue, setInputValue] = useState('')\n\tconst { data: p, isLoading } = useTorrentProperties(hash)\n\tconst setLocationMutation = useSetTorrentLocation()\n\tconst setDownloadPathMutation = useSetTorrentDownloadPath()\n\tif (isLoading) return <LoadingSkeleton />\n\tif (!p) return <EmptyState message=\"Failed to load\" />\n\tconst properties = p\n\n\tconst ratio =\n\t\tproperties.total_downloaded === 0 && properties.pieces_have === properties.pieces_num && properties.total_size > 0\n\t\t\t? '∞'\n\t\t\t: properties.share_ratio.toFixed(2)\n\n\tconst timeActive =\n\t\tproperties.seeding_time > 0\n\t\t\t? `${formatDuration(properties.time_elapsed)} (seeded ${formatDuration(properties.seeding_time)})`\n\t\t\t: formatDuration(properties.time_elapsed)\n\tconst pathMutationPending = setLocationMutation.isPending || setDownloadPathMutation.isPending\n\n\tfunction openEditor(mode: 'savePath' | 'downloadPath') {\n\t\tsetInputValue(mode === 'downloadPath' ? properties.download_path : properties.save_path)\n\t\tsetEditorMode(mode)\n\t}\n\n\tfunction handlePathSave() {\n\t\tconst trimmed = inputValue.trim()\n\t\tif (!trimmed) return\n\n\t\tif (editorMode === 'savePath') {\n\t\t\tsetLocationMutation.mutate({ hashes: [hash], location: trimmed })\n\t\t\tsetEditorMode(null)\n\t\t\treturn\n\t\t}\n\n\t\tif (editorMode === 'downloadPath') {\n\t\t\tsetDownloadPathMutation.mutate({ hashes: [hash], downloadPath: trimmed })\n\t\t\tsetEditorMode(null)\n\t\t}\n\t}\n\n\treturn (\n\t\t<div className=\"p-3 overflow-auto h-full space-y-3\">\n\t\t\t<fieldset className=\"border rounded p-2\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t<legend\n\t\t\t\t\tclassName=\"px-2 text-[9px] uppercase tracking-widest font-medium\"\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t>\n\t\t\t\t\tTransfer\n\t\t\t\t</legend>\n\t\t\t\t<div className=\"grid grid-cols-12 gap-1.5\">\n\t\t\t\t\t<InfoCell label=\"Time Active\" value={timeActive} span={2} />\n\t\t\t\t\t<InfoCell label=\"ETA\" value={formatEta(properties.eta)} span={2} />\n\t\t\t\t\t<InfoCell label=\"Connections\" value={`${properties.nb_connections} (${properties.nb_connections_limit} max)`} span={2} />\n\t\t\t\t\t<InfoCell label=\"Seeds\" value={`${properties.seeds} (${properties.seeds_total} total)`} span={2} />\n\t\t\t\t\t<InfoCell label=\"Peers\" value={`${properties.peers} (${properties.peers_total} total)`} span={2} />\n\t\t\t\t\t<InfoCell label=\"Wasted\" value={formatSize(properties.total_wasted)} span={2} />\n\t\t\t\t\t<InfoCell\n\t\t\t\t\t\tlabel=\"Downloaded\"\n\t\t\t\t\t\tvalue={`${formatSize(properties.total_downloaded)} (${formatSize(properties.total_downloaded_session)} session)`}\n\t\t\t\t\t\tspan={2}\n\t\t\t\t\t/>\n\t\t\t\t\t<InfoCell\n\t\t\t\t\t\tlabel=\"Uploaded\"\n\t\t\t\t\t\tvalue={`${formatSize(properties.total_uploaded)} (${formatSize(properties.total_uploaded_session)} session)`}\n\t\t\t\t\t\tspan={2}\n\t\t\t\t\t/>\n\t\t\t\t\t<InfoCell\n\t\t\t\t\t\tlabel=\"DL Speed\"\n\t\t\t\t\t\tvalue={`${formatSpeed(properties.dl_speed)} (${formatSpeed(properties.dl_speed_avg)} avg)`}\n\t\t\t\t\t\tspan={2}\n\t\t\t\t\t/>\n\t\t\t\t\t<InfoCell\n\t\t\t\t\t\tlabel=\"UP Speed\"\n\t\t\t\t\t\tvalue={`${formatSpeed(properties.up_speed)} (${formatSpeed(properties.up_speed_avg)} avg)`}\n\t\t\t\t\t\tspan={2}\n\t\t\t\t\t/>\n\t\t\t\t\t<InfoCell label=\"DL Limit\" value={formatLimit(properties.dl_limit)} span={2} />\n\t\t\t\t\t<InfoCell label=\"UP Limit\" value={formatLimit(properties.up_limit)} span={2} />\n\t\t\t\t\t<InfoCell label=\"Ratio\" value={ratio} span={3} />\n\t\t\t\t\t<InfoCell label=\"Reannounce\" value={properties.reannounce > 0 ? formatDuration(properties.reannounce) : '0'} span={3} />\n\t\t\t\t\t<InfoCell label=\"Last Seen Complete\" value={properties.last_seen > 0 ? formatDate(properties.last_seen) : 'Never'} span={3} />\n\t\t\t\t\t<InfoCell label=\"Popularity\" value={properties.popularity !== undefined ? properties.popularity.toFixed(2) : '—'} span={3} />\n\t\t\t\t</div>\n\t\t\t</fieldset>\n\n\t\t\t<fieldset className=\"border rounded p-2\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t<legend\n\t\t\t\t\tclassName=\"px-2 text-[9px] uppercase tracking-widest font-medium\"\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t>\n\t\t\t\t\tInformation\n\t\t\t\t</legend>\n\t\t\t\t<div className=\"grid grid-cols-6 gap-1.5\">\n\t\t\t\t\t<InfoCell label=\"Total Size\" value={formatSize(properties.total_size)} />\n\t\t\t\t\t<InfoCell label=\"Pieces\" value={`${properties.pieces_num} × ${formatSize(properties.piece_size)} (have ${properties.pieces_have})`} />\n\t\t\t\t\t<InfoCell label=\"Created By\" value={properties.created_by || '—'} />\n\t\t\t\t\t<InfoCell label=\"Added On\" value={formatDate(properties.addition_date)} />\n\t\t\t\t\t<InfoCell label=\"Completed On\" value={properties.completion_date > 0 ? formatDate(properties.completion_date) : '—'} />\n\t\t\t\t\t<InfoCell label=\"Created On\" value={properties.creation_date > 0 ? formatDate(properties.creation_date) : '—'} />\n\t\t\t\t\t<InfoCell label=\"Private\" value={properties.is_private ? 'Yes' : 'No'} accent={properties.is_private} span={2} />\n\t\t\t\t\t<InfoCell label=\"Category\" value={category || '—'} span={2} />\n\t\t\t\t\t<InfoCell label=\"Tags\" value={tags || '—'} span={2} />\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid grid-cols-2 gap-1.5 mt-1.5\">\n\t\t\t\t\t<InfoCell label=\"Info Hash v1\" value={properties.infohash_v1 || hash} wide />\n\t\t\t\t\t<InfoCell label=\"Info Hash v2\" value={properties.infohash_v2 || 'N/A'} muted={!properties.infohash_v2} wide />\n\t\t\t\t</div>\n\t\t\t\t<div className=\"mt-1.5 px-3 py-2 rounded border flex items-start gap-3\" style={cellBase}>\n\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t<div className=\"text-[9px] uppercase tracking-widest\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tSave Path\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"text-xs font-mono mt-0.5 break-all\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t{properties.save_path}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => openEditor('savePath')}\n\t\t\t\t\t\tdisabled={pathMutationPending}\n\t\t\t\t\t\tclassName=\"shrink-0 text-[9px] uppercase tracking-widest font-medium hover:opacity-80 disabled:opacity-50\"\n\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\tEdit\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t\t{properties.download_path && properties.download_path !== properties.save_path && (\n\t\t\t\t\t<div className=\"mt-1.5 px-3 py-2 rounded border flex items-start gap-3\" style={cellBase}>\n\t\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t\t<div className=\"text-[9px] uppercase tracking-widest\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tDownload Path\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"text-xs font-mono mt-0.5 break-all\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t{properties.download_path}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{properties.pieces_have < properties.pieces_num && (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => openEditor('downloadPath')}\n\t\t\t\t\t\t\t\tdisabled={pathMutationPending}\n\t\t\t\t\t\t\t\tclassName=\"shrink-0 text-[9px] uppercase tracking-widest font-medium hover:opacity-80 disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tEdit\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{editorMode && (\n\t\t\t\t\t<div className=\"mt-1.5 rounded border p-2 space-y-2\" style={{ ...cellBase }}>\n\t\t\t\t\t\t<div className=\"text-[9px] uppercase tracking-widest\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t{editorMode === 'savePath' ? 'Change Save Path' : 'Change Download Path'}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={inputValue}\n\t\t\t\t\t\t\tonChange={(e) => setInputValue(e.target.value)}\n\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\tif (e.key === 'Enter') handlePathSave()\n\t\t\t\t\t\t\t\tif (e.key === 'Escape') setEditorMode(null)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2 rounded border text-xs\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setEditorMode(null)}\n\t\t\t\t\t\t\t\tclassName=\"px-2.5 py-1.5 rounded text-[10px] font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handlePathSave}\n\t\t\t\t\t\t\t\tdisabled={!inputValue.trim() || pathMutationPending}\n\t\t\t\t\t\t\t\tclassName=\"px-2.5 py-1.5 rounded text-[10px] font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{pathMutationPending ? 'Saving...' : 'Save'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{properties.comment && (\n\t\t\t\t\t<div className=\"mt-1.5\">\n\t\t\t\t\t\t<InfoCell label=\"Comment\" value={properties.comment} wide />\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</fieldset>\n\t\t</div>\n\t)\n}\n\nfunction TrackersTab({ hash }: { hash: string }) {\n\tconst [adding, setAdding] = useState(false)\n\tconst [newUrl, setNewUrl] = useState('')\n\tconst { data: trackers, isLoading } = useTorrentTrackers(hash)\n\tconst addMutation = useAddTrackers()\n\tconst removeMutation = useRemoveTrackers()\n\n\tfunction handleAdd() {\n\t\tif (newUrl.trim()) {\n\t\t\taddMutation.mutate({ hash, urls: newUrl.trim().split('\\n').filter(Boolean) })\n\t\t\tsetNewUrl('')\n\t\t\tsetAdding(false)\n\t\t}\n\t}\n\n\tfunction handleRemove(url: string) {\n\t\tremoveMutation.mutate({ hash, urls: [url] })\n\t}\n\n\tif (isLoading) return <LoadingSkeleton />\n\tconst allTrackers = trackers ?? []\n\tconst dhtPexLsd = allTrackers.filter((t: Tracker) => t.url.startsWith('** ['))\n\tconst regularTrackers = allTrackers.filter((t: Tracker) => !t.url.startsWith('** ['))\n\n\treturn (\n\t\t<div className=\"flex flex-col h-full\">\n\t\t\t<div className=\"flex items-center gap-2 px-3 py-2 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t{adding ? (\n\t\t\t\t\t<div className=\"flex-1 flex items-center gap-2\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={newUrl}\n\t\t\t\t\t\t\tonChange={(e) => setNewUrl(e.target.value)}\n\t\t\t\t\t\t\tplaceholder=\"Tracker URL (one per line)\"\n\t\t\t\t\t\t\tclassName=\"flex-1 px-2 py-1 rounded text-xs border\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\tif (e.key === 'Enter') handleAdd()\n\t\t\t\t\t\t\t\tif (e.key === 'Escape') setAdding(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={handleAdd}\n\t\t\t\t\t\t\tclassName=\"px-2 py-1 rounded text-[10px] font-medium\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tAdd\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setAdding(false)}\n\t\t\t\t\t\t\tclassName=\"px-2 py-1 rounded text-[10px] font-medium\"\n\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setAdding(true)}\n\t\t\t\t\t\tclassName=\"flex items-center gap-1 px-2 py-1 rounded text-[10px] font-medium\"\n\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Plus className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\tAdd Tracker\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t\t{allTrackers.length === 0 ? (\n\t\t\t\t<EmptyState message=\"No trackers\" />\n\t\t\t) : (\n\t\t\t\t<div className=\"overflow-auto flex-1\">\n\t\t\t\t\t<table className=\"w-full text-xs\">\n\t\t\t\t\t\t<thead\n\t\t\t\t\t\t\tclassName=\"sticky top-0 backdrop-blur-sm\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--bg-secondary) 95%, transparent)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<tr className=\"text-left border-b\" style={{ color: 'var(--text-muted)', borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest\">Tier</th>\n\t\t\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest\">URL</th>\n\t\t\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest\">Status</th>\n\t\t\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest text-right\">Seeds</th>\n\t\t\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest text-right\">Leeches</th>\n\t\t\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest text-right\">Peers</th>\n\t\t\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest text-right\">Downloaded</th>\n\t\t\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest\"></th>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t</thead>\n\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t{dhtPexLsd.map((t: Tracker, i: number) => (\n\t\t\t\t\t\t\t\t<tr key={`dht-${i}`} className=\"border-t transition-colors\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 font-mono\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t—\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 font-medium\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t\t\t{t.url.replace('** [', '').replace('] **', '')}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5\">\n\t\t\t\t\t\t\t\t\t\t<StatusBadge status={t.status} />\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right font-mono\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t\t\t{t.num_seeds}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right font-mono\" style={{ color: 'var(--warning)' }}>\n\t\t\t\t\t\t\t\t\t\t{t.num_leeches}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right font-mono\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{t.num_peers}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right font-mono\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{t.num_downloaded}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5\"></td>\n\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t{regularTrackers.map((t: Tracker, i: number) => (\n\t\t\t\t\t\t\t\t<tr key={i} className=\"border-t transition-colors group\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 font-mono\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{t.tier}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 font-mono truncate max-w-[200px]\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t\ttitle={t.url}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{t.url}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5\">\n\t\t\t\t\t\t\t\t\t\t<StatusBadge status={t.status} />\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right font-mono\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t\t\t{t.num_seeds}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right font-mono\" style={{ color: 'var(--warning)' }}>\n\t\t\t\t\t\t\t\t\t\t{t.num_leeches}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right font-mono\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{t.num_peers}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right font-mono\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{t.num_downloaded}\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right\">\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleRemove(t.url)}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"opacity-0 group-hover:opacity-100 p-1 rounded transition-opacity\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t\ttitle=\"Remove tracker\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<X className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</tbody>\n\t\t\t\t\t</table>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\nfunction PeersTab({ hash }: { hash: string }) {\n\tconst { data, isLoading } = useTorrentPeers(hash)\n\tif (isLoading) return <LoadingSkeleton />\n\tconst peers = Object.values(data?.peers || {}) as Peer[]\n\tif (peers.length === 0) return <EmptyState message=\"No peers\" />\n\treturn (\n\t\t<div className=\"overflow-auto h-full\">\n\t\t\t<table className=\"w-full text-xs\">\n\t\t\t\t<thead\n\t\t\t\t\tclassName=\"sticky top-0 backdrop-blur-sm\"\n\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--bg-secondary) 95%, transparent)' }}\n\t\t\t\t>\n\t\t\t\t\t<tr className=\"text-left border-b\" style={{ color: 'var(--text-muted)', borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest\">IP</th>\n\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest\">Client</th>\n\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest\">Flags</th>\n\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest text-right\">Progress</th>\n\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest text-right\">DL</th>\n\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest text-right\">UP</th>\n\t\t\t\t\t</tr>\n\t\t\t\t</thead>\n\t\t\t\t<tbody>\n\t\t\t\t\t{peers.map((p: Peer, i: number) => (\n\t\t\t\t\t\t<tr key={i} className=\"border-t transition-colors\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 font-mono\">\n\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-primary)' }}>{p.ip}</span>\n\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>:{p.port}</span>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 truncate max-w-[100px]\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\ttitle={p.client}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{p.client}\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 font-mono\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t{p.flags || '—'}\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right\">\n\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-end gap-1.5\">\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-10 h-1 rounded-full overflow-hidden\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-full rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ width: `${p.progress * 100}%`, backgroundColor: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<span className=\"font-mono w-7 text-right\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{(p.progress * 100).toFixed(0)}%\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right font-mono\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t{formatSpeed(p.dl_speed, false)}\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right font-mono\" style={{ color: 'var(--warning)' }}>\n\t\t\t\t\t\t\t\t{formatSpeed(p.up_speed, false)}\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t))}\n\t\t\t\t</tbody>\n\t\t\t</table>\n\t\t</div>\n\t)\n}\n\nfunction HttpSourcesTab({ hash }: { hash: string }) {\n\tconst { data: seeds, isLoading } = useTorrentWebSeeds(hash)\n\tif (isLoading) return <LoadingSkeleton />\n\tif (!seeds || seeds.length === 0) return <EmptyState message=\"No HTTP sources\" />\n\treturn (\n\t\t<div className=\"p-3 space-y-1.5 overflow-auto h-full\">\n\t\t\t{seeds.map((s, i) => (\n\t\t\t\t<div\n\t\t\t\t\tkey={i}\n\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-2 rounded border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, white 2.5%, transparent)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<Link className=\"w-3 h-3 shrink-0\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t<span className=\"text-xs font-mono break-all\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t{s.url}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n\nconst PRIORITY_OPTIONS = [\n\t{ value: 0, label: 'Skip', color: 'var(--text-muted)' },\n\t{ value: 1, label: 'Normal', color: 'var(--text-primary)' },\n\t{ value: 6, label: 'High', color: 'var(--warning)' },\n\t{ value: 7, label: 'Max', color: 'var(--accent)' },\n]\n\nconst PRIORITY_TO_VALUE: Record<string, number> = { skip: 0, normal: 1, high: 6, max: 7 }\n\nconst PRIORITY_COLORS: Record<string, string> = {\n\tskip: 'var(--error)',\n\thigh: 'var(--warning)',\n\tmax: 'var(--warning)',\n\tmixed: 'var(--text-muted)',\n\tnormal: 'var(--text-primary)',\n}\n\nfunction ContentTabInner({ hash, files }: { hash: string; files: TorrentFile[] }) {\n\tconst setPriorityMutation = useSetFilePriority()\n\tconst tree = useMemo(() => buildFileTree(files), [files])\n\tconst [expanded, setExpanded] = useState<Set<string>>(() => getInitialExpanded(tree))\n\tconst flatNodes = useMemo(() => flattenVisibleNodes(tree, expanded), [tree, expanded])\n\n\tfunction toggleExpanded(path: string) {\n\t\tsetExpanded((prev) => {\n\t\t\tconst next = new Set(prev)\n\t\t\tif (next.has(path)) next.delete(path)\n\t\t\telse next.add(path)\n\t\t\treturn next\n\t\t})\n\t}\n\n\tfunction handlePriorityChange(fileIndices: number[], priority: number) {\n\t\tsetPriorityMutation.mutate({ hash, ids: fileIndices, priority })\n\t}\n\n\treturn (\n\t\t<div className=\"overflow-auto h-full\">\n\t\t\t<table className=\"w-full text-xs\">\n\t\t\t\t<thead\n\t\t\t\t\tclassName=\"sticky top-0 backdrop-blur-sm\"\n\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--bg-secondary) 95%, transparent)' }}\n\t\t\t\t>\n\t\t\t\t\t<tr className=\"text-left border-b\" style={{ color: 'var(--text-muted)', borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest\">Name</th>\n\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest text-right w-20\">Size</th>\n\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest text-right w-24\">Progress</th>\n\t\t\t\t\t\t<th className=\"px-3 py-2 font-medium text-[9px] uppercase tracking-widest text-right w-20\">Priority</th>\n\t\t\t\t\t</tr>\n\t\t\t\t</thead>\n\t\t\t\t<tbody>\n\t\t\t\t\t{flatNodes.map(({ node, depth }) => {\n\t\t\t\t\t\tconst progress = node.progress * 100\n\t\t\t\t\t\tconst done = progress >= 100\n\t\t\t\t\t\tconst isSkipped = node.priority === 'skip'\n\t\t\t\t\t\tconst isMixed = node.priority === 'mixed'\n\t\t\t\t\t\tconst prioColor = PRIORITY_COLORS[node.priority] ?? 'var(--text-primary)'\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<tr\n\t\t\t\t\t\t\t\tkey={node.path}\n\t\t\t\t\t\t\t\tclassName={`border-t transition-colors ${node.isFolder ? 'cursor-pointer hover:bg-white/[0.02]' : ''}`}\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\tonClick={node.isFolder ? () => toggleExpanded(node.path) : undefined}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5\">\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\" style={{ paddingLeft: `${depth * 16}px` }}>\n\t\t\t\t\t\t\t\t\t\t{node.isFolder ? (\n\t\t\t\t\t\t\t\t\t\t\t<ChevronRight\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-3 h-3 shrink-0 transition-transform\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t\t\t\ttransform: expanded.has(node.path) ? 'rotate(90deg)' : 'rotate(0deg)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"w-3\" />\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{node.isFolder ? (\n\t\t\t\t\t\t\t\t\t\t\t<Folder className=\"w-3.5 h-3.5 shrink-0\" style={{ color: '#f59e0b' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<FileText\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-3 h-3 shrink-0\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: isSkipped ? 'var(--error)' : done ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"truncate\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: isSkipped ? 'var(--text-muted)' : 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t\ttextDecoration: isSkipped ? 'line-through' : 'none',\n\t\t\t\t\t\t\t\t\t\t\t\tfontWeight: node.isFolder ? 500 : 400,\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\ttitle={node.name}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{node.name}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{node.isFolder && (\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[9px] ml-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t({node.fileIndices.length})\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t<td\n\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 text-right font-mono whitespace-nowrap\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{formatSize(node.size)}\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right\">\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-end gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-14 h-1 rounded-full overflow-hidden\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-full rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ width: `${progress}%`, backgroundColor: done ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"font-mono w-9 text-right\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: done ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{progress.toFixed(0)}%\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t<td className=\"px-3 py-1.5 text-right\" onClick={(e) => e.stopPropagation()}>\n\t\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\t\tvalue={isMixed ? '' : (PRIORITY_TO_VALUE[node.priority] ?? 1)}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => handlePriorityChange(node.fileIndices, parseInt(e.target.value))}\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-2 py-1 rounded text-[10px] font-medium border cursor-pointer\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)', color: prioColor }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{isMixed && (\n\t\t\t\t\t\t\t\t\t\t\t<option value=\"\" disabled>\n\t\t\t\t\t\t\t\t\t\t\t\tMixed\n\t\t\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{PRIORITY_OPTIONS.map((p) => (\n\t\t\t\t\t\t\t\t\t\t\t<option\n\t\t\t\t\t\t\t\t\t\t\t\tkey={p.value}\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={p.value}\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{p.label}\n\t\t\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</tbody>\n\t\t\t</table>\n\t\t</div>\n\t)\n}\n\nfunction ContentTab({ hash }: { hash: string }) {\n\tconst { data: files, isLoading } = useTorrentFiles(hash)\n\tif (isLoading) return <LoadingSkeleton />\n\tif (!files || files.length === 0) return <EmptyState message=\"No files\" />\n\treturn <ContentTabInner key={hash} hash={hash} files={files} />\n}\n\nexport function TorrentDetailsPanel({ hash, name, category, tags, expanded, onToggle, height, onHeightChange }: Props) {\n\tconst [tab, setTab] = useState<Tab>('general')\n\tconst [dragging, setDragging] = useState(false)\n\tconst dragStartY = useRef(0)\n\tconst dragStartHeight = useRef(0)\n\n\tconst handleMouseDown = useCallback(\n\t\t(e: React.MouseEvent) => {\n\t\t\tif (!expanded) return\n\t\t\te.preventDefault()\n\t\t\tsetDragging(true)\n\t\t\tdragStartY.current = e.clientY\n\t\t\tdragStartHeight.current = height\n\t\t},\n\t\t[height, expanded]\n\t)\n\n\tuseEffect(() => {\n\t\tif (!dragging) return\n\t\tconst handleMouseMove = (e: MouseEvent) => {\n\t\t\tconst delta = dragStartY.current - e.clientY\n\t\t\tconst maxHeight = window.innerHeight * MAX_HEIGHT_PERCENT\n\t\t\tconst newHeight = Math.min(maxHeight, Math.max(MIN_HEIGHT, dragStartHeight.current + delta))\n\t\t\tonHeightChange(newHeight)\n\t\t}\n\t\tconst handleMouseUp = () => {\n\t\t\tsetDragging(false)\n\t\t\tlocalStorage.setItem('detailsPanelHeight', height.toString())\n\t\t}\n\t\tdocument.addEventListener('mousemove', handleMouseMove)\n\t\tdocument.addEventListener('mouseup', handleMouseUp)\n\t\treturn () => {\n\t\t\tdocument.removeEventListener('mousemove', handleMouseMove)\n\t\t\tdocument.removeEventListener('mouseup', handleMouseUp)\n\t\t}\n\t}, [dragging, height, onHeightChange])\n\n\tconst panelHeight = expanded ? height : COLLAPSED_HEIGHT\n\n\treturn (\n\t\t<div\n\t\t\tclassName=\"flex flex-col transition-[height] duration-300 ease-out\"\n\t\t\tstyle={{ height: panelHeight, backgroundColor: 'var(--bg-secondary)' }}\n\t\t>\n\t\t\t<div\n\t\t\t\tonMouseDown={expanded ? handleMouseDown : undefined}\n\t\t\t\tclassName={`h-[3px] shrink-0 ${expanded ? `cursor-ns-resize ${dragging ? 'opacity-100' : 'opacity-70 hover:opacity-100'}` : ''} transition-opacity`}\n\t\t\t\tstyle={{\n\t\t\t\t\tbackground:\n\t\t\t\t\t\t'linear-gradient(to right, color-mix(in srgb, var(--accent) 60%, transparent), color-mix(in srgb, var(--accent) 30%, transparent), color-mix(in srgb, var(--accent) 60%, transparent))',\n\t\t\t\t}}\n\t\t\t/>\n\t\t\t<div\n\t\t\t\tclassName=\"flex items-center gap-2 px-3 shrink-0 cursor-pointer select-none border-b\"\n\t\t\t\tstyle={{ height: COLLAPSED_HEIGHT - 3, backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\tonClick={onToggle}\n\t\t\t>\n\t\t\t\t<button\n\t\t\t\t\tclassName=\"p-1 rounded transition-colors\"\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\tonToggle()\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<ChevronUp\n\t\t\t\t\t\tclassName={`w-4 h-4 transition-transform duration-300 ${expanded ? 'rotate-180' : ''}`}\n\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t/>\n\t\t\t\t</button>\n\n\t\t\t\t<div className=\"w-px h-4\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t\t<span className=\"text-[10px] uppercase tracking-widest font-medium\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tDetails\n\t\t\t\t</span>\n\n\t\t\t\t{hash && name && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<div className=\"w-px h-4\" style={{ backgroundColor: 'var(--border)' }} />\n\t\t\t\t\t\t<span className=\"text-xs truncate max-w-[300px]\" style={{ color: 'var(--text-muted)' }} title={name}>\n\t\t\t\t\t\t\t{name}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\n\t\t\t\t<div className=\"flex-1\" />\n\n\t\t\t\t{expanded && hash && (\n\t\t\t\t\t<div className=\"flex items-center gap-0.5\">\n\t\t\t\t\t\t{TABS.map((t) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={t.id}\n\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\tsetTab(t.id)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium uppercase tracking-wide transition-all border\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: tab === t.id ? 'color-mix(in srgb, white 8%, transparent)' : 'transparent',\n\t\t\t\t\t\t\t\t\tcolor: tab === t.id ? 'var(--text-secondary)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\tborderColor: tab === t.id ? 'color-mix(in srgb, white 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<t.Icon className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t<span className=\"hidden lg:inline\">{t.label}</span>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{expanded && (\n\t\t\t\t<div className=\"flex-1 overflow-hidden border-t\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t{hash ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t{tab === 'general' && <GeneralTab hash={hash} category={category} tags={tags} />}\n\t\t\t\t\t\t\t{tab === 'trackers' && <TrackersTab hash={hash} />}\n\t\t\t\t\t\t\t{tab === 'peers' && <PeersTab hash={hash} />}\n\t\t\t\t\t\t\t{tab === 'http' && <HttpSourcesTab hash={hash} />}\n\t\t\t\t\t\t\t{tab === 'content' && <ContentTab hash={hash} />}\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<div className=\"flex items-center justify-center h-full\">\n\t\t\t\t\t\t\t<div className=\"text-center\">\n\t\t\t\t\t\t\t\t<MousePointer className=\"w-8 h-8 mx-auto mb-2\" style={{ color: 'var(--border)' }} strokeWidth={1} />\n\t\t\t\t\t\t\t\t<p className=\"text-xs uppercase tracking-widest\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tSelect a torrent\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n\n"
  },
  {
    "path": "src/components/TorrentList.tsx",
    "content": "import { useState, useMemo, useEffect, useCallback, lazy, Suspense } from 'react'\nimport {\n\tChevronUp,\n\tChevronDown,\n\tPlus,\n\tPlay,\n\tSquare,\n\tTrash2,\n\tAlertTriangle,\n\tSettings,\n\tMaximize2,\n\tArchive,\n} from 'lucide-react'\nimport type { TorrentFilter, Torrent } from '../types/qbittorrent'\nimport type { CustomView, CustomViewsStorage } from '../types/views'\nimport {\n\tuseTorrents,\n\tuseStopTorrents,\n\tuseStartTorrents,\n\tuseDeleteTorrents,\n\tuseCategories,\n\tuseTags,\n} from '../hooks/useTorrents'\nimport { TorrentRow } from './TorrentRow'\nimport {\n\tFilterBar,\n\tSearchInput,\n\tCategoryDropdown,\n\tTagDropdown,\n\tTrackerDropdown,\n\tColumnSelector,\n\tManageButton,\n} from './FilterBar'\nimport { ViewSelector } from './ViewSelector'\nimport { ContextMenu } from './ContextMenu'\nimport { RatioThresholdPopup } from './RatioThresholdPopup'\nimport { DateSettingsPopup } from './DateSettingsPopup'\nimport { loadRatioThreshold, saveRatioThreshold } from '../utils/ratioThresholds'\nimport { loadHideAddedTime, saveHideAddedTime } from '../utils/dateSettings'\nimport { loadCustomViews, saveCustomViews, createView, viewsAreEqual } from '../utils/customViews'\nimport { normalizeSearch } from '../utils/format'\nimport { COLUMNS, DEFAULT_VISIBLE_COLUMNS, DEFAULT_COLUMN_ORDER, type SortKey } from './columns'\nimport { usePagination } from '../hooks/usePagination'\n\nconst AddTorrentModal = lazy(() => import('./AddTorrentModal').then((m) => ({ default: m.AddTorrentModal })))\nconst CategoryTagManager = lazy(() => import('./CategoryTagManager').then((m) => ({ default: m.CategoryTagManager })))\nconst TorrentDetailsPanel = lazy(() =>\n\timport('./TorrentDetailsPanel').then((m) => ({ default: m.TorrentDetailsPanel }))\n)\n\nconst DEFAULT_PANEL_HEIGHT = 220\n\nfunction SortIcon({ active, asc }: { active: boolean; asc: boolean }) {\n\tconst Icon = asc ? ChevronUp : ChevronDown\n\treturn (\n\t\t<Icon\n\t\t\tclassName=\"w-3 h-3 transition-colors\"\n\t\t\tstyle={{ color: active ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\tstrokeWidth={2}\n\t\t/>\n\t)\n}\n\nfunction ActionButton({\n\tonClick,\n\tdisabled,\n\ticon: Icon,\n\tlabel,\n\tcolorVar,\n}: {\n\tonClick: () => void\n\tdisabled: boolean\n\ticon: React.ComponentType<{ className?: string; strokeWidth?: number }>\n\tlabel: string\n\tcolorVar: string\n}) {\n\treturn (\n\t\t<button\n\t\t\tonClick={onClick}\n\t\t\tdisabled={disabled}\n\t\t\ttitle={label}\n\t\t\tclassName=\"flex items-center justify-center w-7 h-7 rounded transition-all duration-150 active:scale-95 disabled:cursor-not-allowed\"\n\t\t\tstyle={{ color: disabled ? 'var(--text-muted)' : colorVar, opacity: disabled ? 0.5 : 1 }}\n\t\t>\n\t\t\t<Icon className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t</button>\n\t)\n}\n\nexport function TorrentList() {\n\tconst [filter, setFilter] = useState<TorrentFilter>('all')\n\tconst [categoryFilter, setCategoryFilter] = useState<string | null>(null)\n\tconst [tagFilter, setTagFilter] = useState<string | null>(null)\n\tconst [trackerFilter, setTrackerFilter] = useState<string | null>(null)\n\tconst [search, setSearch] = useState('')\n\tconst [selected, setSelected] = useState<Set<string>>(new Set())\n\tconst [lastSelected, setLastSelected] = useState<string | null>(null)\n\tconst [sortKey, setSortKey] = useState<SortKey>(() => {\n\t\tconst stored = localStorage.getItem('sortKey')\n\t\tif (stored && COLUMNS.some((c) => c.sortKey === stored || stored === 'name')) return stored as SortKey\n\t\treturn 'name'\n\t})\n\tconst [sortAsc, setSortAsc] = useState(() => {\n\t\tconst stored = localStorage.getItem('sortAsc')\n\t\treturn stored !== null ? stored === 'true' : true\n\t})\n\tconst [deleteModal, setDeleteModal] = useState(false)\n\tconst [addModal, setAddModal] = useState(false)\n\tconst [panelExpanded, setPanelExpanded] = useState(false)\n\tconst [panelHeight, setPanelHeight] = useState(() => {\n\t\tconst stored = localStorage.getItem('detailsPanelHeight')\n\t\treturn stored ? parseInt(stored, 10) : DEFAULT_PANEL_HEIGHT\n\t})\n\tconst [contextMenu, setContextMenu] = useState<{ x: number; y: number; torrents: Torrent[] } | null>(null)\n\tconst [ratioThreshold, setRatioThreshold] = useState(loadRatioThreshold)\n\tconst [ratioPopupAnchor, setRatioPopupAnchor] = useState<HTMLElement | null>(null)\n\tconst [hideAddedTime, setHideAddedTime] = useState(loadHideAddedTime)\n\tconst [datePopupAnchor, setDatePopupAnchor] = useState<HTMLElement | null>(null)\n\tconst [managerModal, setManagerModal] = useState(false)\n\tconst [customViews, setCustomViews] = useState<CustomViewsStorage>(loadCustomViews)\n\n\tconst [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {\n\t\tconst stored = localStorage.getItem('visibleColumns')\n\t\tif (stored) return new Set(JSON.parse(stored))\n\t\treturn new Set(DEFAULT_VISIBLE_COLUMNS)\n\t})\n\n\tconst [columnOrder, setColumnOrder] = useState<string[]>(() => {\n\t\tconst stored = localStorage.getItem('columnOrder')\n\t\tif (stored) {\n\t\t\tconst parsed = JSON.parse(stored)\n\t\t\tif (Array.isArray(parsed)) {\n\t\t\t\tconst known = new Set(COLUMNS.map((c) => c.id))\n\t\t\t\tconst cleaned = parsed.filter((id) => known.has(id))\n\t\t\t\tconst missing = DEFAULT_COLUMN_ORDER.filter((id) => !cleaned.includes(id))\n\t\t\t\tconst merged = [...cleaned, ...missing]\n\t\t\t\tif (missing.length > 0 || cleaned.length !== parsed.length) {\n\t\t\t\t\tlocalStorage.setItem('columnOrder', JSON.stringify(merged))\n\t\t\t\t}\n\t\t\t\treturn merged\n\t\t\t}\n\t\t}\n\t\treturn DEFAULT_COLUMN_ORDER\n\t})\n\n\tconst [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {\n\t\tconst stored = localStorage.getItem('columnWidths')\n\t\tif (stored) return JSON.parse(stored)\n\t\treturn {}\n\t})\n\n\tconst [resizing, setResizing] = useState<{ id: string; startX: number; startWidth: number } | null>(null)\n\n\tfunction handleColumnChange(next: Set<string>) {\n\t\tsetVisibleColumns(next)\n\t\tlocalStorage.setItem('visibleColumns', JSON.stringify([...next]))\n\t}\n\n\tfunction handleColumnReorder(next: string[]) {\n\t\tsetColumnOrder(next)\n\t\tlocalStorage.setItem('columnOrder', JSON.stringify(next))\n\t}\n\n\tfunction handleColumnsReset() {\n\t\tsetVisibleColumns(new Set(DEFAULT_VISIBLE_COLUMNS))\n\t\tsetColumnOrder(DEFAULT_COLUMN_ORDER)\n\t\tlocalStorage.setItem('visibleColumns', JSON.stringify([...DEFAULT_VISIBLE_COLUMNS]))\n\t\tlocalStorage.setItem('columnOrder', JSON.stringify(DEFAULT_COLUMN_ORDER))\n\t}\n\n\tfunction handleResetWidths() {\n\t\tsetColumnWidths({})\n\t\tlocalStorage.removeItem('columnWidths')\n\t}\n\n\tconst getCurrentViewState = useCallback(\n\t\t() => ({\n\t\t\tsortKey,\n\t\t\tsortAsc,\n\t\t\tvisibleColumns,\n\t\t\tcolumnOrder,\n\t\t\tcolumnWidths,\n\t\t\tfilter,\n\t\t\tcategoryFilter,\n\t\t\ttagFilter,\n\t\t\ttrackerFilter,\n\t\t\tsearch,\n\t\t}),\n\t\t[\n\t\t\tsortKey,\n\t\t\tsortAsc,\n\t\t\tvisibleColumns,\n\t\t\tcolumnOrder,\n\t\t\tcolumnWidths,\n\t\t\tfilter,\n\t\t\tcategoryFilter,\n\t\t\ttagFilter,\n\t\t\ttrackerFilter,\n\t\t\tsearch,\n\t\t]\n\t)\n\n\tconst isViewModified = useMemo(() => {\n\t\tconst activeView = customViews.activeViewId\n\t\t\t? customViews.views.find((v) => v.id === customViews.activeViewId)\n\t\t\t: null\n\t\tif (!activeView) return false\n\t\treturn !viewsAreEqual(activeView, getCurrentViewState())\n\t}, [customViews, getCurrentViewState])\n\n\tfunction applyView(view: CustomView | null) {\n\t\tif (!view) {\n\t\t\tsetSortKey('name')\n\t\t\tsetSortAsc(true)\n\t\t\tsetVisibleColumns(new Set(DEFAULT_VISIBLE_COLUMNS))\n\t\t\tsetColumnOrder(DEFAULT_COLUMN_ORDER)\n\t\t\tsetColumnWidths({})\n\t\t\tsetFilter('all')\n\t\t\tsetCategoryFilter(null)\n\t\t\tsetTagFilter(null)\n\t\t\tsetTrackerFilter(null)\n\t\t\tsetSearch('')\n\t\t\tlocalStorage.setItem('sortKey', 'name')\n\t\t\tlocalStorage.setItem('sortAsc', 'true')\n\t\t\tlocalStorage.setItem('visibleColumns', JSON.stringify([...DEFAULT_VISIBLE_COLUMNS]))\n\t\t\tlocalStorage.setItem('columnOrder', JSON.stringify(DEFAULT_COLUMN_ORDER))\n\t\t\tlocalStorage.removeItem('columnWidths')\n\t\t\treturn\n\t\t}\n\t\tsetSortKey(view.sortKey)\n\t\tsetSortAsc(view.sortAsc)\n\t\tsetVisibleColumns(new Set(view.visibleColumns))\n\t\tsetColumnOrder(view.columnOrder)\n\t\tsetColumnWidths(view.columnWidths)\n\t\tsetFilter(view.filter)\n\t\tsetCategoryFilter(view.categoryFilter)\n\t\tsetTagFilter(view.tagFilter)\n\t\tsetTrackerFilter(view.trackerFilter)\n\t\tsetSearch(view.search)\n\t}\n\n\tfunction handleViewSelect(viewId: string | null) {\n\t\tconst view = viewId ? customViews.views.find((v) => v.id === viewId) : null\n\t\tapplyView(view ?? null)\n\t\tconst updated = { ...customViews, activeViewId: viewId }\n\t\tsetCustomViews(updated)\n\t\tsaveCustomViews(updated)\n\t}\n\n\tfunction handleSaveView() {\n\t\tif (!customViews.activeViewId) return\n\t\tconst updated = {\n\t\t\t...customViews,\n\t\t\tviews: customViews.views.map((v) =>\n\t\t\t\tv.id === customViews.activeViewId\n\t\t\t\t\t? { ...createView(v.name, getCurrentViewState()), id: v.id, createdAt: v.createdAt }\n\t\t\t\t\t: v\n\t\t\t),\n\t\t}\n\t\tsetCustomViews(updated)\n\t\tsaveCustomViews(updated)\n\t}\n\n\tfunction handleSaveViewAs(name: string) {\n\t\tconst newView = createView(name, getCurrentViewState())\n\t\tconst updated = {\n\t\t\tviews: [...customViews.views, newView],\n\t\t\tactiveViewId: newView.id,\n\t\t}\n\t\tsetCustomViews(updated)\n\t\tsaveCustomViews(updated)\n\t}\n\n\tfunction handleRenameView(viewId: string, name: string) {\n\t\tconst updated = {\n\t\t\t...customViews,\n\t\t\tviews: customViews.views.map((v) => (v.id === viewId ? { ...v, name, updatedAt: Date.now() } : v)),\n\t\t}\n\t\tsetCustomViews(updated)\n\t\tsaveCustomViews(updated)\n\t}\n\n\tfunction handleDeleteView(viewId: string) {\n\t\tconst updated = {\n\t\t\tviews: customViews.views.filter((v) => v.id !== viewId),\n\t\t\tactiveViewId: customViews.activeViewId === viewId ? null : customViews.activeViewId,\n\t\t}\n\t\tif (customViews.activeViewId === viewId) {\n\t\t\tapplyView(null)\n\t\t}\n\t\tsetCustomViews(updated)\n\t\tsaveCustomViews(updated)\n\t}\n\n\tfunction handleResizeStart(e: React.MouseEvent, columnId: string) {\n\t\te.preventDefault()\n\t\tconst th = (e.target as HTMLElement).closest('th')\n\t\tconst startWidth = columnWidths[columnId] || th?.offsetWidth || 100\n\t\tsetResizing({ id: columnId, startX: e.clientX, startWidth })\n\t}\n\n\tfunction handleResizeMove(e: React.MouseEvent) {\n\t\tif (!resizing) return\n\t\tconst delta = e.clientX - resizing.startX\n\t\tconst newWidth = Math.max(60, resizing.startWidth + delta)\n\t\tsetColumnWidths((prev) => ({ ...prev, [resizing.id]: newWidth }))\n\t}\n\n\tfunction handleResizeEnd() {\n\t\tif (resizing) {\n\t\t\tlocalStorage.setItem('columnWidths', JSON.stringify(columnWidths))\n\t\t}\n\t\tsetResizing(null)\n\t}\n\n\tconst orderedColumns = columnOrder\n\t\t.map((id) => COLUMNS.find((c) => c.id === id))\n\t\t.filter((c): c is (typeof COLUMNS)[number] => c !== undefined)\n\n\tconst { data: categories = {} } = useCategories()\n\tconst { data: tags = [] } = useTags()\n\tconst { data: torrents = [], isLoading } = useTorrents({\n\t\tfilter,\n\t\tcategory: categoryFilter ?? undefined,\n\t})\n\n\tconst uniqueTrackers = useMemo(() => {\n\t\tconst trackers = new Set<string>()\n\t\ttorrents.forEach((t) => {\n\t\t\tif (t.tracker) trackers.add(t.tracker)\n\t\t})\n\t\treturn [...trackers].sort()\n\t}, [torrents])\n\tconst stopMutation = useStopTorrents()\n\tconst startMutation = useStartTorrents()\n\tconst deleteMutation = useDeleteTorrents()\n\n\tconst { page, perPage, setTotalItems, setPage } = usePagination()\n\n\tconst filtered = useMemo(() => {\n\t\tlet result = torrents\n\t\tif (tagFilter) {\n\t\t\tresult = result.filter((t) =>\n\t\t\t\tt.tags\n\t\t\t\t\t.split(',')\n\t\t\t\t\t.map((tag) => tag.trim())\n\t\t\t\t\t.includes(tagFilter)\n\t\t\t)\n\t\t}\n\t\tif (trackerFilter) {\n\t\t\tresult = result.filter((t) => t.tracker === trackerFilter)\n\t\t}\n\t\tif (search) {\n\t\t\tconst q = normalizeSearch(search)\n\t\t\tresult = result.filter((t) => normalizeSearch(t.name).includes(q))\n\t\t}\n\t\tresult = [...result].sort((a, b) => {\n\t\t\tconst mul = sortAsc ? 1 : -1\n\t\t\tconst valA = a[sortKey]\n\t\t\tconst valB = b[sortKey]\n\n\t\t\tif (typeof valA === 'string' && typeof valB === 'string') {\n\t\t\t\treturn mul * valA.localeCompare(valB)\n\t\t\t}\n\t\t\tif (typeof valA === 'number' && typeof valB === 'number') {\n\t\t\t\treturn mul * (valA - valB)\n\t\t\t}\n\t\t\treturn 0\n\t\t})\n\t\treturn result\n\t}, [torrents, tagFilter, trackerFilter, search, sortKey, sortAsc])\n\n\tuseEffect(() => {\n\t\tsetTotalItems(filtered.length)\n\t\tconst maxPage = Math.max(1, Math.ceil(filtered.length / perPage))\n\t\tif (page > maxPage) setPage(maxPage)\n\t}, [filtered.length, perPage, page, setPage, setTotalItems])\n\n\tconst paginatedTorrents = useMemo(() => {\n\t\tconst start = (page - 1) * perPage\n\t\treturn filtered.slice(start, start + perPage)\n\t}, [filtered, page, perPage])\n\n\tfunction handleSelect(hash: string, multi: boolean, range: boolean) {\n\t\tif (range && lastSelected && filtered.some((t) => t.hash === lastSelected)) {\n\t\t\tconst idx1 = filtered.findIndex((t) => t.hash === lastSelected)\n\t\t\tconst idx2 = filtered.findIndex((t) => t.hash === hash)\n\t\t\tif (idx1 !== -1 && idx2 !== -1) {\n\t\t\t\tconst start = Math.min(idx1, idx2)\n\t\t\t\tconst end = Math.max(idx1, idx2)\n\t\t\t\tconst rangeHashes = filtered.slice(start, end + 1).map((t) => t.hash)\n\t\t\t\tsetSelected((prev) => {\n\t\t\t\t\tconst next = new Set(prev)\n\t\t\t\t\trangeHashes.forEach((h) => next.add(h))\n\t\t\t\t\treturn next\n\t\t\t\t})\n\t\t\t}\n\t\t} else {\n\t\t\tsetSelected((prev) => {\n\t\t\t\tif (multi) {\n\t\t\t\t\tconst next = new Set(prev)\n\t\t\t\t\tif (next.has(hash)) next.delete(hash)\n\t\t\t\t\telse next.add(hash)\n\t\t\t\t\treturn next\n\t\t\t\t}\n\t\t\t\tif (prev.has(hash) && prev.size === 1) return new Set()\n\t\t\t\treturn new Set([hash])\n\t\t\t})\n\t\t\tsetLastSelected(hash)\n\t\t}\n\t}\n\n\tfunction handleSelectAll() {\n\t\tif (selected.size === filtered.length && filtered.length > 0) {\n\t\t\tsetSelected(new Set())\n\t\t} else {\n\t\t\tsetSelected(new Set(filtered.map((t) => t.hash)))\n\t\t}\n\t}\n\n\tuseEffect(() => {\n\t\tfunction handleKeyDown(e: KeyboardEvent) {\n\t\t\tif (e.key === 'Escape') {\n\t\t\t\tsetSelected(new Set())\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (e.key === 'a' && (e.ctrlKey || e.metaKey)) {\n\t\t\t\te.preventDefault()\n\t\t\t\tsetSelected(new Set(filtered.map((t) => t.hash)))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (e.key === 'ArrowDown' || e.key === 'ArrowUp') {\n\t\t\t\tif (filtered.length === 0) return\n\t\t\t\te.preventDefault()\n\t\t\t\tconst currentHash = selected.size === 1 ? [...selected][0] : null\n\t\t\t\tconst currentIndex = currentHash ? filtered.findIndex((t) => t.hash === currentHash) : -1\n\t\t\t\tlet nextIndex: number\n\t\t\t\tif (e.key === 'ArrowDown') {\n\t\t\t\t\tnextIndex = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, filtered.length - 1)\n\t\t\t\t} else {\n\t\t\t\t\tnextIndex = currentIndex < 0 ? filtered.length - 1 : Math.max(currentIndex - 1, 0)\n\t\t\t\t}\n\t\t\t\tsetSelected(new Set([filtered[nextIndex].hash]))\n\t\t\t}\n\t\t}\n\t\twindow.addEventListener('keydown', handleKeyDown)\n\t\treturn () => window.removeEventListener('keydown', handleKeyDown)\n\t}, [filtered, selected])\n\n\tfunction handleSort(key: SortKey) {\n\t\tif (sortKey === key) {\n\t\t\tconst newAsc = !sortAsc\n\t\t\tsetSortAsc(newAsc)\n\t\t\tlocalStorage.setItem('sortAsc', String(newAsc))\n\t\t} else {\n\t\t\tsetSortKey(key)\n\t\t\tsetSortAsc(true)\n\t\t\tlocalStorage.setItem('sortKey', key)\n\t\t\tlocalStorage.setItem('sortAsc', 'true')\n\t\t}\n\t}\n\n\tfunction handleStop() {\n\t\tif (selected.size) stopMutation.mutate([...selected])\n\t}\n\n\tfunction handleStart() {\n\t\tif (selected.size) startMutation.mutate([...selected])\n\t}\n\n\tfunction handleDelete(deleteFiles: boolean) {\n\t\tif (selected.size) {\n\t\t\tdeleteMutation.mutate({ hashes: [...selected], deleteFiles })\n\t\t\tsetSelected(new Set())\n\t\t}\n\t\tsetDeleteModal(false)\n\t}\n\n\tfunction handleContextMenu(e: React.MouseEvent, torrent: Torrent) {\n\t\te.preventDefault()\n\t\tconst contextTorrents = selected.has(torrent.hash) ? torrents.filter((t) => selected.has(t.hash)) : [torrent]\n\t\tif (!selected.has(torrent.hash)) {\n\t\t\tsetSelected(new Set([torrent.hash]))\n\t\t}\n\t\tsetContextMenu({ x: e.clientX, y: e.clientY, torrents: contextTorrents })\n\t}\n\n\tconst hasSelection = selected.size > 0\n\tconst selectedHash = selected.size === 1 ? [...selected][0] : null\n\tconst selectedTorrent = selectedHash ? torrents.find((t) => t.hash === selectedHash) : null\n\n\treturn (\n\t\t<div className=\"flex flex-col flex-1 overflow-hidden\" style={{ backgroundColor: 'var(--bg-primary)' }}>\n\t\t\t<div\n\t\t\t\tclassName=\"flex items-center gap-1 px-2 py-1.5 border-b overflow-x-auto\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-center shrink-0\">\n\t\t\t\t\t<ActionButton\n\t\t\t\t\t\tonClick={() => setAddModal(true)}\n\t\t\t\t\t\tdisabled={false}\n\t\t\t\t\t\tlabel=\"Add Torrent\"\n\t\t\t\t\t\tcolorVar=\"var(--accent)\"\n\t\t\t\t\t\ticon={Plus}\n\t\t\t\t\t/>\n\t\t\t\t\t<ActionButton\n\t\t\t\t\t\tonClick={handleStart}\n\t\t\t\t\t\tdisabled={!hasSelection}\n\t\t\t\t\t\tlabel=\"Start\"\n\t\t\t\t\t\tcolorVar=\"var(--accent)\"\n\t\t\t\t\t\ticon={Play}\n\t\t\t\t\t/>\n\t\t\t\t\t<ActionButton\n\t\t\t\t\t\tonClick={handleStop}\n\t\t\t\t\t\tdisabled={!hasSelection}\n\t\t\t\t\t\tlabel=\"Stop\"\n\t\t\t\t\t\tcolorVar=\"var(--warning)\"\n\t\t\t\t\t\ticon={Square}\n\t\t\t\t\t/>\n\t\t\t\t\t<ActionButton\n\t\t\t\t\t\tonClick={() => setDeleteModal(true)}\n\t\t\t\t\t\tdisabled={!hasSelection}\n\t\t\t\t\t\tlabel=\"Delete\"\n\t\t\t\t\t\tcolorVar=\"var(--error)\"\n\t\t\t\t\t\ticon={Trash2}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"w-px h-5 shrink-0\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t\t<div className=\"flex items-center shrink-0\">\n\t\t\t\t\t<FilterBar filter={filter} onFilterChange={setFilter} />\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"w-px h-5 shrink-0\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t\t<div className=\"flex items-center shrink-0\">\n\t\t\t\t\t<CategoryDropdown value={categoryFilter} onChange={setCategoryFilter} categories={categories} />\n\t\t\t\t\t<TagDropdown value={tagFilter} onChange={setTagFilter} tags={tags} />\n\t\t\t\t\t<TrackerDropdown value={trackerFilter} onChange={setTrackerFilter} trackers={uniqueTrackers} />\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"w-px h-5 shrink-0\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t\t<div className=\"flex items-center shrink-0\">\n\t\t\t\t\t<ViewSelector\n\t\t\t\t\t\tviews={customViews}\n\t\t\t\t\t\tisModified={isViewModified}\n\t\t\t\t\t\tonViewSelect={handleViewSelect}\n\t\t\t\t\t\tonSave={handleSaveView}\n\t\t\t\t\t\tonSaveAs={handleSaveViewAs}\n\t\t\t\t\t\tonRename={handleRenameView}\n\t\t\t\t\t\tonDelete={handleDeleteView}\n\t\t\t\t\t/>\n\t\t\t\t\t<ManageButton onClick={() => setManagerModal(true)} />\n\t\t\t\t\t<ColumnSelector\n\t\t\t\t\t\tcolumns={COLUMNS}\n\t\t\t\t\t\tvisible={visibleColumns}\n\t\t\t\t\t\tonChange={handleColumnChange}\n\t\t\t\t\t\tcolumnOrder={columnOrder}\n\t\t\t\t\t\tonReorder={handleColumnReorder}\n\t\t\t\t\t\tonReset={handleColumnsReset}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"flex-1 min-w-4\" />\n\n\t\t\t\t<div className=\"shrink-0\">\n\t\t\t\t\t<SearchInput value={search} onChange={setSearch} />\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div\n\t\t\t\tclassName=\"flex-1 overflow-auto\"\n\t\t\t\tonMouseMove={handleResizeMove}\n\t\t\t\tonMouseUp={handleResizeEnd}\n\t\t\t\tonMouseLeave={handleResizeEnd}\n\t\t\t\tstyle={{ cursor: resizing ? 'col-resize' : undefined }}\n\t\t\t>\n\t\t\t\t{isLoading ? (\n\t\t\t\t\t<div className=\"flex flex-col items-center justify-center h-48 gap-3\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-6 h-6 border-2 rounded-full animate-spin\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tborderColor: 'color-mix(in srgb, var(--accent) 20%, transparent)',\n\t\t\t\t\t\t\t\tborderTopColor: 'var(--accent)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<span className=\"text-xs uppercase tracking-widest\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tLoading\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t) : filtered.length === 0 ? (\n\t\t\t\t\t<div className=\"flex flex-col items-center justify-center h-48 gap-2\">\n\t\t\t\t\t\t<Archive className=\"w-10 h-10\" style={{ color: 'var(--border)' }} strokeWidth={1} />\n\t\t\t\t\t\t<span className=\"text-xs uppercase tracking-widest\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tNo torrents\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\t<table className=\"w-full table-auto\">\n\t\t\t\t\t\t<thead className=\"sticky top-0 z-10\">\n\t\t\t\t\t\t\t<tr\n\t\t\t\t\t\t\t\tclassName=\"backdrop-blur-sm border-b\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--bg-secondary) 95%, transparent)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-1.5 text-left relative\"\n\t\t\t\t\t\t\t\t\tstyle={columnWidths.name ? { width: columnWidths.name } : undefined}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tonClick={handleSelectAll}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center justify-center w-4 h-4 rounded border transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\t\t\t\t\tselected.size > 0 ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{selected.size === filtered.length && filtered.length > 0 && (\n\t\t\t\t\t\t\t\t\t\t\t\t<Square className=\"w-2.5 h-2.5 fill-current\" style={{ color: 'var(--accent)' }} />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t{selected.size > 0 &&\n\t\t\t\t\t\t\t\t\t\t\t\tselected.size < filtered.length &&\n\t\t\t\t\t\t\t\t\t\t\t\t// Indeterminate state icon (minus/dash)\n\t\t\t\t\t\t\t\t\t\t\t\t(\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-2 h-0.5 rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleSort('name')}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 text-[9px] font-medium uppercase tracking-widest transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tName\n\t\t\t\t\t\t\t\t\t\t\t<SortIcon active={sortKey === 'name'} asc={sortAsc} />\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"resize-handle\" onMouseDown={(e) => handleResizeStart(e, 'name')} />\n\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t{orderedColumns\n\t\t\t\t\t\t\t\t\t.filter((col) => visibleColumns.has(col.id))\n\t\t\t\t\t\t\t\t\t.map((col) => (\n\t\t\t\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\t\t\t\tkey={col.id}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 text-left whitespace-nowrap relative\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={columnWidths[col.id] ? { width: columnWidths[col.id] } : undefined}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{col.id === 'ratio' ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleSort('ratio')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 text-[9px] font-medium uppercase tracking-widest transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tRatio\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SortIcon active={sortKey === 'ratio'} asc={sortAsc} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => setRatioPopupAnchor(e.currentTarget)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-0.5 rounded opacity-50 hover:opacity-100 transition-opacity\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttitle=\"Configure ratio colors\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Settings className=\"w-3 h-3\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t) : col.id === 'added_on' ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleSort('added_on')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 text-[9px] font-medium uppercase tracking-widest transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{col.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SortIcon active={sortKey === 'added_on'} asc={sortAsc} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => setDatePopupAnchor(e.currentTarget)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-0.5 rounded opacity-50 hover:opacity-100 transition-opacity\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttitle=\"Date display settings\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Settings className=\"w-3 h-3\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t) : col.sortKey ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleSort(col.sortKey!)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 text-[9px] font-medium uppercase tracking-widest transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{col.id === 'dlspeed' && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-2 h-2 rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--accent) 40%, transparent)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t{col.id === 'upspeed' && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-2 h-2 rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--warning) 40%, transparent)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t{col.id === 'dlspeed' || col.id === 'upspeed' ? 'Speed' : col.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SortIcon active={sortKey === col.sortKey} asc={sortAsc} />\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-[9px] font-medium uppercase tracking-widest\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{col.label}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"resize-handle\" onMouseDown={(e) => handleResizeStart(e, col.id)} />\n\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t{Object.keys(columnWidths).length > 0 && (\n\t\t\t\t\t\t\t\t\t<th className=\"px-2 py-1.5 w-8\">\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tonClick={handleResetWidths}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded opacity-50 hover:opacity-100 transition-opacity\"\n\t\t\t\t\t\t\t\t\t\t\ttitle=\"Reset column widths\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<Maximize2 className=\"w-3 h-3\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t</thead>\n\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t{paginatedTorrents.map((t) => (\n\t\t\t\t\t\t\t\t<TorrentRow\n\t\t\t\t\t\t\t\t\tkey={t.hash}\n\t\t\t\t\t\t\t\t\ttorrent={t}\n\t\t\t\t\t\t\t\t\tselected={selected.has(t.hash)}\n\t\t\t\t\t\t\t\t\tonSelect={handleSelect}\n\t\t\t\t\t\t\t\t\tonContextMenu={(e) => handleContextMenu(e, t)}\n\t\t\t\t\t\t\t\t\tratioThreshold={ratioThreshold}\n\t\t\t\t\t\t\t\t\thideAddedTime={hideAddedTime}\n\t\t\t\t\t\t\t\t\tvisibleColumns={visibleColumns}\n\t\t\t\t\t\t\t\t\tcolumnOrder={columnOrder}\n\t\t\t\t\t\t\t\t\tcolumnWidths={columnWidths}\n\t\t\t\t\t\t\t\t\thasCustomWidths={Object.keys(columnWidths).length > 0}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</tbody>\n\t\t\t\t\t</table>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{deleteModal && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 backdrop-blur-sm flex items-center justify-center z-50\"\n\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.7)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"relative w-full max-w-xs mx-4\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"rounded-xl p-5 border shadow-2xl\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-3 mb-4\">\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"w-9 h-9 rounded-lg flex items-center justify-center\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 10%, transparent)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<AlertTriangle className=\"w-4 h-4\" style={{ color: 'var(--error)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<h3 className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t\t\t<p className=\"text-[10px] uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{selected.size} torrent{selected.size > 1 ? 's' : ''}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => handleDelete(false)}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full py-2.5 rounded-lg border text-xs font-medium transition-colors\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--error) 10%, transparent)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'color-mix(in srgb, var(--error) 20%, transparent)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--error)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tRemove from list\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => handleDelete(true)}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full py-2.5 rounded-lg text-xs font-medium transition-colors text-white\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tDelete with files\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => setDeleteModal(false)}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full py-2.5 rounded-lg border text-xs font-medium transition-colors\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{addModal && (\n\t\t\t\t<Suspense fallback={null}>\n\t\t\t\t\t<AddTorrentModal open={addModal} onClose={() => setAddModal(false)} />\n\t\t\t\t</Suspense>\n\t\t\t)}\n\t\t\t{managerModal && (\n\t\t\t\t<Suspense fallback={null}>\n\t\t\t\t\t<CategoryTagManager open={managerModal} onClose={() => setManagerModal(false)} />\n\t\t\t\t</Suspense>\n\t\t\t)}\n\n\t\t\t<Suspense fallback={null}>\n\t\t\t\t<TorrentDetailsPanel\n\t\t\t\t\thash={selectedHash}\n\t\t\t\t\tname={selectedTorrent?.name ?? ''}\n\t\t\t\t\tcategory={selectedTorrent?.category ?? ''}\n\t\t\t\t\ttags={selectedTorrent?.tags ?? ''}\n\t\t\t\t\texpanded={panelExpanded}\n\t\t\t\t\tonToggle={() => setPanelExpanded(!panelExpanded)}\n\t\t\t\t\theight={panelHeight}\n\t\t\t\t\tonHeightChange={setPanelHeight}\n\t\t\t\t/>\n\t\t\t</Suspense>\n\n\t\t\t{contextMenu && (\n\t\t\t\t<ContextMenu\n\t\t\t\t\tx={contextMenu.x}\n\t\t\t\t\ty={contextMenu.y}\n\t\t\t\t\ttorrents={contextMenu.torrents}\n\t\t\t\t\tonClose={() => setContextMenu(null)}\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t{ratioPopupAnchor && (\n\t\t\t\t<RatioThresholdPopup\n\t\t\t\t\tanchor={ratioPopupAnchor}\n\t\t\t\t\tthreshold={ratioThreshold}\n\t\t\t\t\tonSave={(t) => {\n\t\t\t\t\t\tsaveRatioThreshold(t)\n\t\t\t\t\t\tsetRatioThreshold(t)\n\t\t\t\t\t}}\n\t\t\t\t\tonClose={() => setRatioPopupAnchor(null)}\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t{datePopupAnchor && (\n\t\t\t\t<DateSettingsPopup\n\t\t\t\t\tanchor={datePopupAnchor}\n\t\t\t\t\thideTime={hideAddedTime}\n\t\t\t\t\tonSave={(hide) => {\n\t\t\t\t\t\tsaveHideAddedTime(hide)\n\t\t\t\t\t\tsetHideAddedTime(hide)\n\t\t\t\t\t}}\n\t\t\t\t\tonClose={() => setDatePopupAnchor(null)}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n"
  },
  {
    "path": "src/components/TorrentRow.tsx",
    "content": "import type { ReactNode } from 'react'\nimport { Check } from 'lucide-react'\nimport type { Torrent, TorrentState } from '../types/qbittorrent'\nimport { formatSpeed, formatSize, formatEta, formatDate, formatRelativeTime, formatDuration } from '../utils/format'\n\ntype StateType = 'accent' | 'warning' | 'muted' | 'info' | 'error'\n\nfunction getStateInfo(state: TorrentState): { label: string; type: StateType; isDownloading: boolean } {\n\tconst map: Record<TorrentState, { label: string; type: StateType; isDownloading: boolean }> = {\n\t\tdownloading: { label: 'Downloading', type: 'accent', isDownloading: true },\n\t\tuploading: { label: 'Seeding', type: 'warning', isDownloading: false },\n\t\tpausedDL: { label: 'Stopped', type: 'muted', isDownloading: false },\n\t\tpausedUP: { label: 'Stopped', type: 'muted', isDownloading: false },\n\t\tstoppedDL: { label: 'Stopped', type: 'muted', isDownloading: false },\n\t\tstoppedUP: { label: 'Stopped', type: 'muted', isDownloading: false },\n\t\tstalledDL: { label: 'Stalled', type: 'warning', isDownloading: false },\n\t\tstalledUP: { label: 'Seeding', type: 'warning', isDownloading: false },\n\t\tqueuedDL: { label: 'Queued', type: 'muted', isDownloading: false },\n\t\tqueuedUP: { label: 'Queued', type: 'muted', isDownloading: false },\n\t\tcheckingDL: { label: 'Checking', type: 'info', isDownloading: false },\n\t\tcheckingUP: { label: 'Checking', type: 'info', isDownloading: false },\n\t\tcheckingResumeData: { label: 'Checking', type: 'info', isDownloading: false },\n\t\tforcedDL: { label: 'Forced', type: 'accent', isDownloading: true },\n\t\tforcedUP: { label: 'Forced', type: 'warning', isDownloading: false },\n\t\tmetaDL: { label: 'Metadata', type: 'info', isDownloading: false },\n\t\tallocating: { label: 'Allocating', type: 'info', isDownloading: false },\n\t\tmoving: { label: 'Moving', type: 'info', isDownloading: false },\n\t\terror: { label: 'Error', type: 'error', isDownloading: false },\n\t\tmissingFiles: { label: 'Missing', type: 'error', isDownloading: false },\n\t\tunknown: { label: 'Unknown', type: 'muted', isDownloading: false },\n\t}\n\treturn map[state] ?? { label: state, type: 'muted', isDownloading: false }\n}\n\nfunction getColor(type: StateType): string {\n\tconst colors: Record<StateType, string> = {\n\t\taccent: 'var(--accent)',\n\t\twarning: 'var(--warning)',\n\t\terror: 'var(--error)',\n\t\tmuted: 'var(--text-muted)',\n\t\tinfo: '#8b5cf6',\n\t}\n\treturn colors[type]\n}\n\nconst TWO_PART_TLD_MARKERS = new Set(['co', 'com', 'net', 'org', 'gov', 'edu'])\n\nfunction getTrackerName(tracker: string): string {\n\tif (!tracker) return '—'\n\tlet host = ''\n\ttry {\n\t\thost = new URL(tracker).hostname\n\t} catch {\n\t\tconst withoutScheme = tracker.replace(/^[a-z]+:\\/\\//i, '')\n\t\tconst hostPort = withoutScheme.split('/')[0] ?? ''\n\t\thost = hostPort.split(':')[0] ?? ''\n\t}\n\tif (!host || host.startsWith('**')) return '—'\n\tconst normalized = host.replace(/^www\\./i, '')\n\tconst parts = normalized.split('.').filter(Boolean)\n\tif (parts.length === 0) return '—'\n\tif (parts.length === 1) return parts[0]\n\tconst tld = parts[parts.length - 1]\n\tconst sld = parts[parts.length - 2]\n\tif (tld.length === 2 && TWO_PART_TLD_MARKERS.has(sld) && parts.length >= 3) {\n\t\treturn parts[parts.length - 3]\n\t}\n\treturn sld\n}\n\ninterface CellContext {\n\tstateColor: string\n\tstateLabel: string\n\tisComplete: boolean\n\tprogress: number\n\tratioColor: string\n\thideAddedTime: boolean\n\tisCrossSeed: boolean\n}\n\nfunction renderCell(columnId: string, torrent: Torrent, ctx: CellContext): ReactNode {\n\tswitch (columnId) {\n\t\tcase 'progress':\n\t\t\treturn ctx.isComplete ? (\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-5 h-5 rounded-full flex items-center justify-center\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--accent) 20%, transparent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Check className=\"w-3 h-3\" style={{ color: 'var(--accent)' }} strokeWidth={3} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<span className=\"text-xs font-medium\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\tComplete\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<div className=\"group/progress relative flex items-center gap-2\">\n\t\t\t\t\t<div className=\"w-20 h-1.5 rounded-full overflow-hidden\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"h-full rounded-full\"\n\t\t\t\t\t\t\tstyle={{ width: `${ctx.progress}%`, backgroundColor: 'var(--progress)' }}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<span className=\"text-xs font-mono\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t{ctx.progress}%\n\t\t\t\t\t</span>\n\t\t\t\t\t{torrent.eta > 0 && torrent.eta < 8640000 && (\n\t\t\t\t\t\t<div className=\"absolute left-0 -top-8 opacity-0 group-hover/progress:opacity-100 transition-opacity pointer-events-none z-50\">\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"px-2 py-1 rounded text-xs font-mono whitespace-nowrap\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborder: '1px solid var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tETA: {formatEta(torrent.eta)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)\n\t\tcase 'eta':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono whitespace-nowrap\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{torrent.eta > 0 && torrent.eta < 8640000 ? formatEta(torrent.eta) : '—'}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'status':\n\t\t\treturn (\n\t\t\t\t<span\n\t\t\t\t\tclassName=\"inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs font-medium\"\n\t\t\t\t\tstyle={{ color: ctx.stateColor, backgroundColor: `color-mix(in srgb, ${ctx.stateColor} 10%, transparent)` }}\n\t\t\t\t>\n\t\t\t\t\t<span className=\"w-1.5 h-1.5 rounded-full\" style={{ backgroundColor: ctx.stateColor }} />\n\t\t\t\t\t{ctx.stateLabel}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'size':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono whitespace-nowrap\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{formatSize(torrent.size)}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'downloaded':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono whitespace-nowrap\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{formatSize(torrent.downloaded)}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'uploaded':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono whitespace-nowrap\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{formatSize(torrent.uploaded)}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'dlspeed':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono font-medium whitespace-nowrap\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t{formatSpeed(torrent.dlspeed, false)}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'upspeed':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono font-medium whitespace-nowrap\" style={{ color: 'var(--warning)' }}>\n\t\t\t\t\t{formatSpeed(torrent.upspeed, false)}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'ratio':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono font-medium whitespace-nowrap\" style={{ color: ctx.ratioColor }}>\n\t\t\t\t\t{ctx.isCrossSeed ? '∞' : torrent.ratio.toFixed(2)}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'seeding_time':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono whitespace-nowrap\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{formatDuration(torrent.seeding_time)}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'added_on': {\n\t\t\tconst dateStr = formatDate(torrent.added_on)\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono whitespace-nowrap\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{ctx.hideAddedTime ? dateStr.split(',')[0] : dateStr}\n\t\t\t\t</span>\n\t\t\t)\n\t\t}\n\t\tcase 'completion_on':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono whitespace-nowrap\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{formatDate(torrent.completion_on)}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'category':\n\t\t\treturn torrent.category ? (\n\t\t\t\t<span className=\"px-1.5 py-0.5 rounded text-[10px] bg-white/5 text-white/70 border border-white/10 whitespace-nowrap\">\n\t\t\t\t\t{torrent.category}\n\t\t\t\t</span>\n\t\t\t) : (\n\t\t\t\t<span className=\"text-xs text-gray-500\">—</span>\n\t\t\t)\n\t\tcase 'tags':\n\t\t\treturn torrent.tags ? (\n\t\t\t\t<div className=\"flex gap-1\">\n\t\t\t\t\t{torrent.tags.split(',').map((tag, i) => (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\tclassName=\"px-1.5 py-0.5 rounded text-[10px] bg-white/5 text-white/70 border border-white/10 whitespace-nowrap\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tag.trim()}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<span className=\"text-xs text-gray-500\">—</span>\n\t\t\t)\n\t\tcase 'num_seeds':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{torrent.num_seeds}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'num_leechs':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{torrent.num_leechs}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'last_activity':\n\t\t\treturn (\n\t\t\t\t<span className=\"text-xs font-mono whitespace-nowrap\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{formatRelativeTime(torrent.last_activity)}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'save_path':\n\t\t\treturn (\n\t\t\t\t<span\n\t\t\t\t\tclassName=\"text-xs font-mono truncate max-w-[150px] block\"\n\t\t\t\t\ttitle={torrent.save_path}\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t>\n\t\t\t\t\t{torrent.save_path}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'tracker':\n\t\t\treturn (\n\t\t\t\t<span\n\t\t\t\t\tclassName=\"text-xs font-mono truncate max-w-[150px] block\"\n\t\t\t\t\ttitle={torrent.tracker}\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t>\n\t\t\t\t\t{torrent.tracker}\n\t\t\t\t</span>\n\t\t\t)\n\t\tcase 'tracker_name': {\n\t\t\tconst trackerName = getTrackerName(torrent.tracker)\n\t\t\treturn (\n\t\t\t\t<span\n\t\t\t\t\tclassName=\"text-xs font-mono truncate max-w-[140px] block\"\n\t\t\t\t\ttitle={torrent.tracker || trackerName}\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t>\n\t\t\t\t\t{trackerName}\n\t\t\t\t</span>\n\t\t\t)\n\t\t}\n\t\tdefault:\n\t\t\treturn null\n\t}\n}\n\ninterface Props {\n\ttorrent: Torrent\n\tselected: boolean\n\tonSelect: (hash: string, multi: boolean, range: boolean) => void\n\tonContextMenu: (e: React.MouseEvent) => void\n\tratioThreshold: number\n\thideAddedTime: boolean\n\tvisibleColumns: Set<string>\n\tcolumnOrder: string[]\n\tcolumnWidths: Record<string, number>\n\thasCustomWidths: boolean\n}\n\nexport function TorrentRow({\n\ttorrent,\n\tselected,\n\tonSelect,\n\tonContextMenu,\n\tratioThreshold,\n\thideAddedTime,\n\tvisibleColumns,\n\tcolumnOrder,\n\tcolumnWidths,\n\thasCustomWidths,\n}: Props) {\n\tconst { label, type, isDownloading } = getStateInfo(torrent.state)\n\tconst progress = Math.round(torrent.progress * 100)\n\tconst isComplete = progress >= 100\n\tconst stateColor = getColor(type)\n\tconst isCrossSeed = torrent.downloaded === 0 && isComplete && torrent.size > 0\n\tconst ratioRounded = Math.round(torrent.ratio * 100) / 100\n\tconst ratioColor = isCrossSeed || ratioRounded >= ratioThreshold ? '#a6e3a1' : '#f38ba8'\n\n\tconst cellContext = { stateColor, stateLabel: label, isComplete, progress, ratioColor, hideAddedTime, isCrossSeed }\n\n\treturn (\n\t\t<tr\n\t\t\tonMouseDown={(e) => { if (e.shiftKey) e.preventDefault() }}\n\t\t\tonClick={(e) => onSelect(torrent.hash, e.ctrlKey || e.metaKey, e.shiftKey)}\n\t\t\tonContextMenu={onContextMenu}\n\t\t\tclassName={`group cursor-pointer transition-colors duration-150 ${isDownloading ? 'downloading' : ''}`}\n\t\t\tstyle={{\n\t\t\t\tbackgroundColor: selected ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent',\n\t\t\t}}\n\t\t>\n\t\t\t<td\n\t\t\t\tclassName=\"px-4 py-3 max-w-xs xl:max-w-sm 2xl:max-w-md\"\n\t\t\t\tstyle={columnWidths.name ? { width: columnWidths.name, maxWidth: columnWidths.name } : undefined}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"shrink-0 w-4 h-4 rounded border transition-colors duration-150 flex items-center justify-center\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tborderColor: selected ? 'var(--text-muted)' : 'var(--border)',\n\t\t\t\t\t\t\tbackgroundColor: selected ? 'color-mix(in srgb, white 3%, transparent)' : 'transparent',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{selected && <Check className=\"w-2.5 h-2.5\" style={{ color: 'var(--text-muted)' }} strokeWidth={3} />}\n\t\t\t\t\t</div>\n\t\t\t\t\t<span\n\t\t\t\t\t\tclassName=\"truncate font-medium text-sm\"\n\t\t\t\t\t\tstyle={{ color: 'var(--text-secondary)' }}\n\t\t\t\t\t\ttitle={`${torrent.name}\\nLast active: ${formatRelativeTime(torrent.last_activity)}`}\n\t\t\t\t\t>\n\t\t\t\t\t\t{torrent.name}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t\t{columnOrder\n\t\t\t\t.filter((id) => visibleColumns.has(id))\n\t\t\t\t.map((id) => (\n\t\t\t\t\t<td\n\t\t\t\t\t\tkey={id}\n\t\t\t\t\t\tclassName=\"px-3 py-3\"\n\t\t\t\t\t\tstyle={columnWidths[id] ? { width: columnWidths[id], maxWidth: columnWidths[id] } : undefined}\n\t\t\t\t\t>\n\t\t\t\t\t\t{renderCell(id, torrent, cellContext)}\n\t\t\t\t\t</td>\n\t\t\t\t))}\n\t\t\t{hasCustomWidths && <td className=\"w-8\" />}\n\t\t</tr>\n\t)\n}\n"
  },
  {
    "path": "src/components/ViewSelector.tsx",
    "content": "import { useState, useRef, useCallback } from 'react'\nimport { Save, Trash2, Pencil, Check, X, Eye } from 'lucide-react'\nimport type { CustomView, CustomViewsStorage } from '../types/views'\nimport { useClickOutside } from '../hooks/useClickOutside'\n\ninterface ViewSelectorProps {\n\tviews: CustomViewsStorage\n\tisModified: boolean\n\tonViewSelect: (viewId: string | null) => void\n\tonSave: () => void\n\tonSaveAs: (name: string) => void\n\tonRename: (viewId: string, name: string) => void\n\tonDelete: (viewId: string) => void\n}\n\nexport function ViewSelector({\n\tviews,\n\tisModified,\n\tonViewSelect,\n\tonSave,\n\tonSaveAs,\n\tonRename,\n\tonDelete,\n}: ViewSelectorProps) {\n\tconst [open, setOpen] = useState(false)\n\tconst [saveAsMode, setSaveAsMode] = useState(false)\n\tconst [newName, setNewName] = useState('')\n\tconst [editingId, setEditingId] = useState<string | null>(null)\n\tconst [editName, setEditName] = useState('')\n\tconst ref = useRef<HTMLDivElement>(null)\n\tconst closeDropdown = useCallback(() => {\n\t\tsetOpen(false)\n\t\tsetSaveAsMode(false)\n\t\tsetEditingId(null)\n\t}, [])\n\tuseClickOutside(ref, closeDropdown)\n\n\tconst activeView = views.activeViewId ? views.views.find((v) => v.id === views.activeViewId) : null\n\n\tfunction handleSaveAs() {\n\t\tconst trimmed = newName.trim()\n\t\tif (!trimmed) return\n\t\tonSaveAs(trimmed)\n\t\tsetNewName('')\n\t\tsetSaveAsMode(false)\n\t\tsetOpen(false)\n\t}\n\n\tfunction handleRename(view: CustomView) {\n\t\tconst trimmed = editName.trim()\n\t\tif (!trimmed) return\n\t\tonRename(view.id, trimmed)\n\t\tsetEditingId(null)\n\t}\n\n\tfunction startEdit(view: CustomView, e: React.MouseEvent) {\n\t\te.stopPropagation()\n\t\tsetEditingId(view.id)\n\t\tsetEditName(view.name)\n\t}\n\n\tconst displayName = activeView?.name ?? 'View'\n\n\treturn (\n\t\t<div ref={ref} className=\"relative flex items-center\">\n\t\t\t<button\n\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\ttitle={activeView?.name ?? 'Default View'}\n\t\t\t\tclassName=\"flex items-center gap-1.5 px-2 py-1 rounded transition-all duration-150\"\n\t\t\t\tstyle={{\n\t\t\t\t\tcolor: views.activeViewId ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\tbackgroundColor: views.activeViewId ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<Eye className=\"w-3.5 h-3.5\" strokeWidth={2} />\n\t\t\t\t<span className=\"text-[10px] font-medium max-w-[60px] truncate\">\n\t\t\t\t\t{displayName}\n\t\t\t\t\t{isModified && views.activeViewId ? '*' : ''}\n\t\t\t\t</span>\n\t\t\t</button>\n\n\t\t\t{views.activeViewId && isModified && (\n\t\t\t\t<button\n\t\t\t\t\tonClick={onSave}\n\t\t\t\t\tclassName=\"flex items-center justify-center w-6 h-6 rounded transition-all duration-150 hover:opacity-80\"\n\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\ttitle=\"Save view\"\n\t\t\t\t>\n\t\t\t\t\t<Save className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t)}\n\n\t\t\t{open && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute top-full left-0 mt-1 min-w-[200px] max-h-[400px] overflow-auto rounded border shadow-xl z-[100]\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tonViewSelect(null)\n\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"w-full flex items-center px-2.5 py-1.5 text-xs text-left transition-colors\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tcolor: !views.activeViewId ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\tbackgroundColor: !views.activeViewId\n\t\t\t\t\t\t\t\t? 'color-mix(in srgb, var(--accent) 10%, transparent)'\n\t\t\t\t\t\t\t\t: 'transparent',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\tDefault View\n\t\t\t\t\t</button>\n\n\t\t\t\t\t{views.views.length > 0 && <div className=\"border-t\" style={{ borderColor: 'var(--border)' }} />}\n\n\t\t\t\t\t{views.views.map((view) => (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tkey={view.id}\n\t\t\t\t\t\t\tclassName=\"flex items-center group\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\tviews.activeViewId === view.id ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{editingId === view.id ? (\n\t\t\t\t\t\t\t\t<div className=\"flex-1 flex items-center gap-1 px-2 py-1\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={editName}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setEditName(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\t\t\t\tif (e.key === 'Enter') handleRename(view)\n\t\t\t\t\t\t\t\t\t\t\tif (e.key === 'Escape') setEditingId(null)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 px-2 py-1 rounded text-xs border\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tonClick={() => handleRename(view)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded transition-colors hover:opacity-80\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Check className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tonClick={() => setEditingId(null)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded transition-colors hover:opacity-80\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<X className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tonViewSelect(view.id)\n\t\t\t\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 px-2.5 py-1.5 text-xs text-left transition-colors truncate\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tcolor: views.activeViewId === view.id ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{view.name}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tonClick={(e) => startEdit(view, e)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"p-1.5 opacity-0 group-hover:opacity-100 transition-opacity\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\ttitle=\"Rename\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Pencil className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\tonDelete(view.id)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName=\"p-1.5 opacity-0 group-hover:opacity-100 transition-opacity\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\ttitle=\"Delete\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Trash2 className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\n\t\t\t\t\t<div className=\"border-t\" style={{ borderColor: 'var(--border)' }} />\n\n\t\t\t\t\t{saveAsMode ? (\n\t\t\t\t\t\t<div className=\"flex items-center gap-1 px-2 py-1.5\">\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={newName}\n\t\t\t\t\t\t\t\tonChange={(e) => setNewName(e.target.value)}\n\t\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\t\tif (e.key === 'Enter') handleSaveAs()\n\t\t\t\t\t\t\t\t\tif (e.key === 'Escape') setSaveAsMode(false)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tplaceholder=\"View name...\"\n\t\t\t\t\t\t\t\tclassName=\"flex-1 px-2 py-1 rounded text-xs border\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleSaveAs}\n\t\t\t\t\t\t\t\tdisabled={!newName.trim()}\n\t\t\t\t\t\t\t\tclassName=\"p-1 rounded transition-colors hover:opacity-80 disabled:opacity-40\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Check className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setSaveAsMode(false)}\n\t\t\t\t\t\t\t\tclassName=\"p-1 rounded transition-colors hover:opacity-80\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<X className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setSaveAsMode(true)}\n\t\t\t\t\t\t\tclassName=\"w-full flex items-center gap-2 px-2.5 py-1.5 text-xs transition-colors hover:opacity-80\"\n\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Save className=\"w-3 h-3\" strokeWidth={2} />\n\t\t\t\t\t\t\tSave current as...\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/columns.ts",
    "content": "export type SortKey =\n\t| 'name'\n\t| 'size'\n\t| 'progress'\n\t| 'downloaded'\n\t| 'uploaded'\n\t| 'dlspeed'\n\t| 'upspeed'\n\t| 'ratio'\n\t| 'state'\n\t| 'category'\n\t| 'tags'\n\t| 'num_seeds'\n\t| 'num_leechs'\n\t| 'last_activity'\n\t| 'save_path'\n\t| 'tracker'\n\t| 'seeding_time'\n\t| 'added_on'\n\t| 'completion_on'\n\t| 'eta'\n\nexport interface ColumnDef {\n\tid: string\n\tlabel: string\n\tsortKey: SortKey | null\n}\n\nexport const COLUMNS: ColumnDef[] = [\n\t{ id: 'progress', label: 'Progress', sortKey: 'progress' },\n\t{ id: 'eta', label: 'ETA', sortKey: 'eta' },\n\t{ id: 'status', label: 'Status', sortKey: null },\n\t{ id: 'size', label: 'Size', sortKey: 'size' },\n\t{ id: 'downloaded', label: 'Down', sortKey: 'downloaded' },\n\t{ id: 'uploaded', label: 'Up', sortKey: 'uploaded' },\n\t{ id: 'dlspeed', label: 'DL Speed', sortKey: 'dlspeed' },\n\t{ id: 'upspeed', label: 'UP Speed', sortKey: 'upspeed' },\n\t{ id: 'ratio', label: 'Ratio', sortKey: 'ratio' },\n\t{ id: 'seeding_time', label: 'Seed Time', sortKey: 'seeding_time' },\n\t{ id: 'added_on', label: 'Added', sortKey: 'added_on' },\n\t{ id: 'completion_on', label: 'Completed', sortKey: 'completion_on' },\n\t{ id: 'category', label: 'Category', sortKey: 'category' },\n\t{ id: 'tags', label: 'Tags', sortKey: 'tags' },\n\t{ id: 'num_seeds', label: 'Seeds', sortKey: 'num_seeds' },\n\t{ id: 'num_leechs', label: 'Peers', sortKey: 'num_leechs' },\n\t{ id: 'last_activity', label: 'Last Active', sortKey: 'last_activity' },\n\t{ id: 'save_path', label: 'Save Path', sortKey: 'save_path' },\n\t{ id: 'tracker_name', label: 'Tracker', sortKey: 'tracker' },\n\t{ id: 'tracker', label: 'Tracker (URL)', sortKey: 'tracker' },\n]\n\nexport const DEFAULT_VISIBLE_COLUMNS = new Set([\n\t'progress',\n\t'status',\n\t'downloaded',\n\t'uploaded',\n\t'dlspeed',\n\t'upspeed',\n\t'ratio',\n\t'seeding_time',\n\t'added_on',\n])\n\nexport const DEFAULT_COLUMN_ORDER = COLUMNS.map((c) => c.id)\n"
  },
  {
    "path": "src/components/settings/AdvancedTab.tsx",
    "content": "import { AlertTriangle } from 'lucide-react'\nimport type { QBittorrentPreferences } from '../../types/preferences'\nimport { Toggle, Select } from '../ui'\n\ninterface Props {\n\tpreferences: Partial<QBittorrentPreferences>\n\tonChange: (updates: Partial<QBittorrentPreferences>) => void\n}\n\nconst RESUME_DATA_STORAGE_TYPES = [\n\t{ value: 'Legacy', label: 'Fastresume files' },\n\t{ value: 'SQLite', label: 'SQLite database' },\n]\n\nconst TORRENT_CONTENT_REMOVE_OPTIONS = [\n\t{ value: 'Delete', label: 'Delete permanently' },\n\t{ value: 'MoveToTrash', label: 'Move to trash' },\n]\n\nconst DISK_IO_TYPES = [\n\t{ value: 0, label: 'Default' },\n\t{ value: 1, label: 'Memory mapped' },\n\t{ value: 2, label: 'POSIX-compliant' },\n]\n\nconst DISK_IO_MODES = [\n\t{ value: 0, label: 'Disable OS cache' },\n\t{ value: 1, label: 'Enable OS cache' },\n]\n\nconst UTP_TCP_MIXED_MODES = [\n\t{ value: 0, label: 'Prefer TCP' },\n\t{ value: 1, label: 'Peer proportional' },\n]\n\nconst UPLOAD_SLOTS_BEHAVIORS = [\n\t{ value: 0, label: 'Fixed slots' },\n\t{ value: 1, label: 'Upload rate based' },\n]\n\nconst UPLOAD_CHOKING_ALGORITHMS = [\n\t{ value: 0, label: 'Round robin' },\n\t{ value: 1, label: 'Fastest upload' },\n\t{ value: 2, label: 'Anti-leech' },\n]\n\nexport function AdvancedTab({ preferences, onChange }: Props) {\n\treturn (\n\t\t<div className=\"space-y-4\">\n\t\t\t<div\n\t\t\t\tclassName=\"px-3 py-2 rounded flex items-start gap-2\"\n\t\t\t\tstyle={{\n\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--warning) 15%, transparent)',\n\t\t\t\t\tborder: '1px solid var(--warning)',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<AlertTriangle className=\"w-4 h-4 shrink-0 mt-0.5\" style={{ color: 'var(--warning)' }} strokeWidth={1.5} />\n\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\tIncorrect values may affect performance or stability.\n\t\t\t\t</p>\n\t\t\t</div>\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tqBittorrent\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tResume data storage\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.resume_data_storage_type ?? 'SQLite'}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ resume_data_storage_type: v })}\n\t\t\t\t\t\t\t\toptions={RESUME_DATA_STORAGE_TYPES}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tContent removing mode\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.torrent_content_remove_option ?? 'Delete'}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ torrent_content_remove_option: v })}\n\t\t\t\t\t\t\t\toptions={TORRENT_CONTENT_REMOVE_OPTIONS}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-3 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tMemory limit (MiB)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.memory_working_set_limit ?? 512}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ memory_working_set_limit: parseInt(e.target.value) || 512 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tNetwork interface\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.current_network_interface ?? ''}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ current_network_interface: e.target.value })}\n\t\t\t\t\t\t\t\tplaceholder=\"Any\"\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tIP to bind\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.current_interface_address ?? ''}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ current_interface_address: e.target.value })}\n\t\t\t\t\t\t\t\tplaceholder=\"All\"\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-4 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tResume interval (min)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.save_resume_data_interval ?? 60}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ save_resume_data_interval: parseInt(e.target.value) || 60 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tStats interval (min)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.save_statistics_interval ?? 15}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ save_statistics_interval: parseInt(e.target.value) || 15 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t.torrent size (MiB)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={Math.round((preferences.torrent_file_size_limit ?? 104857600) / 1024 / 1024)}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ torrent_file_size_limit: (parseInt(e.target.value) || 100) * 1024 * 1024 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tRefresh (ms)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.refresh_interval ?? 1500}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ refresh_interval: parseInt(e.target.value) || 1500 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tInstance name\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={preferences.app_instance_name ?? ''}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ app_instance_name: e.target.value })}\n\t\t\t\t\t\t\tplaceholder=\"qBittorrent\"\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-x-4 gap-y-1\">\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tConfirm recheck\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.confirm_torrent_recheck ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ confirm_torrent_recheck: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tRecheck on completion\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.recheck_completed_torrents ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ recheck_completed_torrents: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tResolve peer countries\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.resolve_peer_countries ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ resolve_peer_countries: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tReannounce on IP change\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.reannounce_when_address_changed ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ reannounce_when_address_changed: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"p-2 rounded space-y-2\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tEmbedded tracker\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.enable_embedded_tracker ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ enable_embedded_tracker: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{preferences.enable_embedded_tracker && (\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-4 pl-4\">\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t<label className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tPort\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\tvalue={preferences.embedded_tracker_port ?? 9000}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ embedded_tracker_port: parseInt(e.target.value) || 9000 })}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-16 px-2 py-1 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between flex-1\">\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tPort forwarding\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\t\t\tchecked={preferences.embedded_tracker_port_forwarding ?? false}\n\t\t\t\t\t\t\t\t\t\tonChange={(v) => onChange({ embedded_tracker_port_forwarding: v })}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tIgnore SSL errors\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\tchecked={preferences.ignore_ssl_errors ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ ignore_ssl_errors: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tPython executable path\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={preferences.python_executable_path ?? ''}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ python_executable_path: e.target.value })}\n\t\t\t\t\t\t\tplaceholder=\"Auto detect\"\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tlibtorrent\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t<div className=\"grid grid-cols-4 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tBdecode depth\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.bdecode_depth_limit ?? 100}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ bdecode_depth_limit: parseInt(e.target.value) || 100 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tBdecode tokens\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.bdecode_token_limit ?? 10000000}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ bdecode_token_limit: parseInt(e.target.value) || 10000000 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tAsync I/O threads\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.async_io_threads ?? 10}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ async_io_threads: parseInt(e.target.value) || 10 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tHashing threads\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.hashing_threads ?? 1}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ hashing_threads: parseInt(e.target.value) || 1 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-4 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tFile pool size\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.file_pool_size ?? 100}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ file_pool_size: parseInt(e.target.value) || 100 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tCheck mem (MiB)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.checking_memory_use ?? 32}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ checking_memory_use: parseInt(e.target.value) || 32 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tDisk queue (KiB)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.disk_queue_size ?? 1024}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ disk_queue_size: parseInt(e.target.value) || 1024 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tDisk IO type\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.disk_io_type ?? 0}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ disk_io_type: v })}\n\t\t\t\t\t\t\t\toptions={DISK_IO_TYPES}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tDisk IO read mode\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.disk_io_read_mode ?? 0}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ disk_io_read_mode: v })}\n\t\t\t\t\t\t\t\toptions={DISK_IO_MODES}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tDisk IO write mode\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.disk_io_write_mode ?? 0}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ disk_io_write_mode: v })}\n\t\t\t\t\t\t\t\toptions={DISK_IO_MODES}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-x-4 gap-y-1\">\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tPiece extent affinity\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.enable_piece_extent_affinity ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ enable_piece_extent_affinity: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tUpload piece suggestions\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.enable_upload_suggestions ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ enable_upload_suggestions: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-3 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tSend buffer (KiB)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.send_buffer_watermark ?? 500}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ send_buffer_watermark: parseInt(e.target.value) || 500 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tLow watermark (KiB)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.send_buffer_low_watermark ?? 10}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ send_buffer_low_watermark: parseInt(e.target.value) || 10 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tWatermark factor (%)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.send_buffer_watermark_factor ?? 50}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ send_buffer_watermark_factor: parseInt(e.target.value) || 50 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-3 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tConnections/sec\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.connection_speed ?? 30}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ connection_speed: parseInt(e.target.value) || 30 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tSend buffer (KiB)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.socket_send_buffer_size ?? 0}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ socket_send_buffer_size: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tRecv buffer (KiB)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.socket_receive_buffer_size ?? 0}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ socket_receive_buffer_size: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-4 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tSocket backlog\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.socket_backlog_size ?? 30}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ socket_backlog_size: parseInt(e.target.value) || 30 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tUPnP lease\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.upnp_lease_duration ?? 0}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ upnp_lease_duration: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tOut ports min\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.outgoing_ports_min ?? 0}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ outgoing_ports_min: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tOut ports max\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.outgoing_ports_max ?? 0}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ outgoing_ports_max: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tPeer ToS\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.peer_tos ?? 4}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ peer_tos: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tµTP-TCP mixed mode\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.utp_tcp_mixed_mode ?? 0}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ utp_tcp_mixed_mode: v })}\n\t\t\t\t\t\t\t\toptions={UTP_TCP_MIXED_MODES}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-x-4 gap-y-1\">\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tIDN support\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.idn_support_enabled ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ idn_support_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tMulti connections same IP\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.enable_multi_connections_from_same_ip ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ enable_multi_connections_from_same_ip: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tValidate HTTPS tracker cert\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.validate_https_tracker_certificate ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ validate_https_tracker_certificate: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tSSRF mitigation\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.ssrf_mitigation ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ ssrf_mitigation: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tBlock privileged ports\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.block_peers_on_privileged_ports ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ block_peers_on_privileged_ports: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tUpload slots behavior\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.upload_slots_behavior ?? 0}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ upload_slots_behavior: v })}\n\t\t\t\t\t\t\t\toptions={UPLOAD_SLOTS_BEHAVIORS}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tUpload choking algorithm\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.upload_choking_algorithm ?? 1}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ upload_choking_algorithm: v })}\n\t\t\t\t\t\t\t\toptions={UPLOAD_CHOKING_ALGORITHMS}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-x-4 gap-y-1\">\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tAnnounce to all trackers in tier\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.announce_to_all_tiers ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ announce_to_all_tiers: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tAnnounce to all tiers\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.announce_to_all_trackers ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ announce_to_all_trackers: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tAnnounce IP\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.announce_ip ?? ''}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ announce_ip: e.target.value })}\n\t\t\t\t\t\t\t\tplaceholder=\"(restart req)\"\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tAnnounce port (0=listen)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.announce_port ?? 0}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ announce_port: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-3 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tMax HTTP announces\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.max_concurrent_http_announces ?? 50}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ max_concurrent_http_announces: parseInt(e.target.value) || 50 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tStop tracker timeout\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.stop_tracker_timeout ?? 2}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ stop_tracker_timeout: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tRequest queue\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.request_queue_size ?? 500}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ request_queue_size: parseInt(e.target.value) || 500 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-3 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tPeer turnover (%)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.peer_turnover ?? 4}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ peer_turnover: parseInt(e.target.value) || 4 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tTurnover cutoff (%)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.peer_turnover_cutoff ?? 90}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ peer_turnover_cutoff: parseInt(e.target.value) || 90 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tTurnover interval (s)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.peer_turnover_interval ?? 300}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ peer_turnover_interval: parseInt(e.target.value) || 300 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tDHT bootstrap nodes\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={\n\t\t\t\t\t\t\t\tpreferences.dht_bootstrap_nodes ??\n\t\t\t\t\t\t\t\t'dht.libtorrent.org:25401, dht.transmissionbt.com:6881, router.bittorrent.com:6881'\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ dht_bootstrap_nodes: e.target.value })}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tI2P\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid grid-cols-4 gap-2\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tInbound qty\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.i2p_inbound_quantity ?? 3}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ i2p_inbound_quantity: parseInt(e.target.value) || 3 })}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tOutbound qty\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.i2p_outbound_quantity ?? 3}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ i2p_outbound_quantity: parseInt(e.target.value) || 3 })}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tInbound len\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.i2p_inbound_length ?? 3}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ i2p_inbound_length: parseInt(e.target.value) || 3 })}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tOutbound len\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.i2p_outbound_length ?? 3}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ i2p_outbound_length: parseInt(e.target.value) || 3 })}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/settings/BehaviorTab.tsx",
    "content": "import { FileText } from 'lucide-react'\nimport type { QBittorrentPreferences } from '../../types/preferences'\nimport { Toggle, Select, Checkbox } from '../ui'\n\ninterface Props {\n\tpreferences: Partial<QBittorrentPreferences>\n\tonChange: (updates: Partial<QBittorrentPreferences>) => void\n}\n\nconst LOCALES = [\n\t{ value: 'en', label: 'English' },\n\t{ value: 'en_AU', label: 'English (Australia)' },\n\t{ value: 'en_GB', label: 'English (United Kingdom)' },\n\t{ value: 'de', label: 'Deutsch' },\n\t{ value: 'es', label: 'Español' },\n\t{ value: 'fr', label: 'Français' },\n\t{ value: 'it', label: 'Italiano' },\n\t{ value: 'ja', label: '日本語' },\n\t{ value: 'ko', label: '한국어' },\n\t{ value: 'nl', label: 'Nederlands' },\n\t{ value: 'pl', label: 'Polski' },\n\t{ value: 'pt_BR', label: 'Português (Brasil)' },\n\t{ value: 'pt_PT', label: 'Português (Portugal)' },\n\t{ value: 'ru', label: 'Русский' },\n\t{ value: 'tr', label: 'Türkçe' },\n\t{ value: 'uk', label: 'Українська' },\n\t{ value: 'zh', label: '中文 (简体)' },\n\t{ value: 'zh_TW', label: '中文 (繁體)' },\n]\n\nconst FILE_LOG_AGE_TYPES = [\n\t{ value: 0, label: 'Days' },\n\t{ value: 1, label: 'Months' },\n\t{ value: 2, label: 'Years' },\n]\n\nexport function BehaviorTab({ preferences, onChange }: Props) {\n\treturn (\n\t\t<div className=\"space-y-4\">\n\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t<label className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tLanguage\n\t\t\t\t</label>\n\t\t\t\t<Select\n\t\t\t\t\tvalue={preferences.locale ?? 'en'}\n\t\t\t\t\tonChange={(v) => onChange({ locale: v })}\n\t\t\t\t\toptions={LOCALES}\n\t\t\t\t\tminWidth=\"180px\"\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tTransfer List\n\t\t\t\t</div>\n\t\t\t\t<Checkbox\n\t\t\t\t\tlabel=\"Confirm when deleting torrents\"\n\t\t\t\t\tchecked={preferences.confirm_torrent_deletion ?? true}\n\t\t\t\t\tonChange={(v) => onChange({ confirm_torrent_deletion: v })}\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tInterface\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid grid-cols-2 gap-x-4 gap-y-1\">\n\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tShow external IP in status bar\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\tchecked={preferences.status_bar_external_ip ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ status_bar_external_ip: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tPerformance warning\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\tchecked={preferences.performance_warning ?? true}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ performance_warning: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"flex items-center justify-between mb-2\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<FileText\n\t\t\t\t\t\t\tclassName=\"w-4 h-4\"\n\t\t\t\t\t\t\tstyle={{ color: preferences.file_log_enabled ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\tstrokeWidth={1.5}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<span className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tFile Log\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<Toggle checked={preferences.file_log_enabled ?? false} onChange={(v) => onChange({ file_log_enabled: v })} />\n\t\t\t\t</div>\n\n\t\t\t\t{preferences.file_log_enabled && (\n\t\t\t\t\t<div className=\"space-y-2 pl-6\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tLog path\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.file_log_path ?? ''}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ file_log_path: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"grid grid-cols-3 gap-2\">\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tMax size (KiB)\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\tvalue={preferences.file_log_max_size ?? 65}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ file_log_max_size: parseInt(e.target.value) || 65 })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tDelete after\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\tvalue={preferences.file_log_age ?? 1}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ file_log_age: parseInt(e.target.value) || 1 })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t&nbsp;\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\tvalue={preferences.file_log_age_type ?? 1}\n\t\t\t\t\t\t\t\t\tonChange={(v) => onChange({ file_log_age_type: v })}\n\t\t\t\t\t\t\t\t\toptions={FILE_LOG_AGE_TYPES}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-x-4 gap-y-1\">\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tBackup log file\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\t\tchecked={preferences.file_log_backup_enabled ?? true}\n\t\t\t\t\t\t\t\t\tonChange={(v) => onChange({ file_log_backup_enabled: v })}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tDelete old logs\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\t\tchecked={preferences.file_log_delete_old ?? true}\n\t\t\t\t\t\t\t\t\tonChange={(v) => onChange({ file_log_delete_old: v })}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/settings/BitTorrentTab.tsx",
    "content": "import type { QBittorrentPreferences } from '../../types/preferences'\nimport { Select, Checkbox } from '../ui'\n\ninterface Props {\n\tpreferences: Partial<QBittorrentPreferences>\n\tonChange: (updates: Partial<QBittorrentPreferences>) => void\n}\n\nconst ENCRYPTION_OPTIONS = [\n\t{ value: 0, label: 'Allow encryption' },\n\t{ value: 1, label: 'Require encryption' },\n\t{ value: 2, label: 'Disable encryption' },\n]\n\nconst RATIO_ACTION_OPTIONS = [\n\t{ value: 0, label: 'Stop torrent' },\n\t{ value: 1, label: 'Remove torrent' },\n\t{ value: 2, label: 'Remove torrent and files' },\n\t{ value: 3, label: 'Enable super seeding' },\n]\n\nexport function BitTorrentTab({ preferences, onChange }: Props) {\n\treturn (\n\t\t<div className=\"space-y-4\">\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tPrivacy\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t<Checkbox label=\"Enable DHT\" checked={preferences.dht ?? true} onChange={(v) => onChange({ dht: v })} />\n\t\t\t\t\t<Checkbox label=\"Enable PeX\" checked={preferences.pex ?? true} onChange={(v) => onChange({ pex: v })} />\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"Enable Local Peer Discovery\"\n\t\t\t\t\t\tchecked={preferences.lsd ?? true}\n\t\t\t\t\t\tonChange={(v) => onChange({ lsd: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"Anonymous mode\"\n\t\t\t\t\t\tchecked={preferences.anonymous_mode ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ anonymous_mode: v })}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center gap-3 mt-2\">\n\t\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tEncryption\n\t\t\t\t\t</label>\n\t\t\t\t\t<Select\n\t\t\t\t\t\tvalue={preferences.encryption ?? 0}\n\t\t\t\t\t\tonChange={(v) => onChange({ encryption: v as number })}\n\t\t\t\t\t\toptions={ENCRYPTION_OPTIONS}\n\t\t\t\t\t\tminWidth=\"140px\"\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tMax active checking torrents\n\t\t\t\t</label>\n\t\t\t\t<input\n\t\t\t\t\ttype=\"number\"\n\t\t\t\t\tvalue={preferences.max_active_checking_torrents ?? 1}\n\t\t\t\t\tonChange={(e) => onChange({ max_active_checking_torrents: parseInt(e.target.value) || 1 })}\n\t\t\t\t\tclassName=\"w-16 px-2 py-1 rounded border text-xs font-mono\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"flex items-center gap-2 mb-2\">\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"\"\n\t\t\t\t\t\tchecked={preferences.queueing_enabled ?? true}\n\t\t\t\t\t\tonChange={(v) => onChange({ queueing_enabled: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<span className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tTorrent Queueing\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\n\t\t\t\t{preferences.queueing_enabled && (\n\t\t\t\t\t<div className=\"pl-6 space-y-2\">\n\t\t\t\t\t\t<div className=\"grid grid-cols-3 gap-3\">\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tMax active DL\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\tvalue={preferences.max_active_downloads ?? 3}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ max_active_downloads: parseInt(e.target.value) || 3 })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-14 px-2 py-1 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tMax active UL\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\tvalue={preferences.max_active_uploads ?? 3}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ max_active_uploads: parseInt(e.target.value) || 3 })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-14 px-2 py-1 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tMax total\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\tvalue={preferences.max_active_torrents ?? 5}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ max_active_torrents: parseInt(e.target.value) || 5 })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-14 px-2 py-1 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"p-2 rounded space-y-2\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tlabel=\"Do not count slow torrents\"\n\t\t\t\t\t\t\t\tchecked={preferences.dont_count_slow_torrents ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ dont_count_slow_torrents: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t{preferences.dont_count_slow_torrents && (\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-4 pl-6\">\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t<label className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tDL\n\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\t\tvalue={preferences.slow_torrent_dl_rate_threshold ?? 2}\n\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ slow_torrent_dl_rate_threshold: parseInt(e.target.value) || 2 })}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-12 px-1 py-0.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tKiB/s\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t<label className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tUL\n\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\t\tvalue={preferences.slow_torrent_ul_rate_threshold ?? 2}\n\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ slow_torrent_ul_rate_threshold: parseInt(e.target.value) || 2 })}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-12 px-1 py-0.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tKiB/s\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t<label className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tInactive\n\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\t\tvalue={preferences.slow_torrent_inactive_timer ?? 60}\n\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ slow_torrent_inactive_timer: parseInt(e.target.value) || 60 })}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-14 px-1 py-0.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tsec\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tSeeding Limits\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"When ratio reaches\"\n\t\t\t\t\t\t\tchecked={preferences.max_ratio_enabled ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ max_ratio_enabled: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tstep=\"0.1\"\n\t\t\t\t\t\t\tvalue={preferences.max_ratio ?? 1}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ max_ratio: parseFloat(e.target.value) || 1 })}\n\t\t\t\t\t\t\tdisabled={!preferences.max_ratio_enabled}\n\t\t\t\t\t\t\tclassName=\"w-16 px-2 py-1 rounded border text-xs font-mono disabled:opacity-50\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"When seeding time reaches\"\n\t\t\t\t\t\t\tchecked={preferences.max_seeding_time_enabled ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ max_seeding_time_enabled: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.max_seeding_time ?? 1440}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ max_seeding_time: parseInt(e.target.value) || 1440 })}\n\t\t\t\t\t\t\tdisabled={!preferences.max_seeding_time_enabled}\n\t\t\t\t\t\t\tclassName=\"w-20 px-2 py-1 rounded border text-xs font-mono disabled:opacity-50\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tmin\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"When inactive seeding time reaches\"\n\t\t\t\t\t\t\tchecked={preferences.max_inactive_seeding_time_enabled ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ max_inactive_seeding_time_enabled: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.max_inactive_seeding_time ?? 1440}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ max_inactive_seeding_time: parseInt(e.target.value) || 1440 })}\n\t\t\t\t\t\t\tdisabled={!preferences.max_inactive_seeding_time_enabled}\n\t\t\t\t\t\t\tclassName=\"w-20 px-2 py-1 rounded border text-xs font-mono disabled:opacity-50\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tmin\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{(preferences.max_ratio_enabled ||\n\t\t\t\t\t\tpreferences.max_seeding_time_enabled ||\n\t\t\t\t\t\tpreferences.max_inactive_seeding_time_enabled) && (\n\t\t\t\t\t\t<div className=\"flex items-center gap-2 pl-6\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tthen\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.max_ratio_act ?? 0}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ max_ratio_act: v as number })}\n\t\t\t\t\t\t\t\toptions={RATIO_ACTION_OPTIONS}\n\t\t\t\t\t\t\t\tminWidth=\"180px\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"flex items-center gap-2 mb-2\">\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"\"\n\t\t\t\t\t\tchecked={preferences.add_trackers_enabled ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ add_trackers_enabled: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<span className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tAuto-append trackers\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t{preferences.add_trackers_enabled && (\n\t\t\t\t\t<textarea\n\t\t\t\t\t\tvalue={preferences.add_trackers ?? ''}\n\t\t\t\t\t\tonChange={(e) => onChange({ add_trackers: e.target.value })}\n\t\t\t\t\t\trows={3}\n\t\t\t\t\t\tplaceholder=\"One tracker URL per line\"\n\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono resize-none\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div>\n\t\t\t\t<div className=\"flex items-center gap-2 mb-2\">\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"\"\n\t\t\t\t\t\tchecked={preferences.add_trackers_from_url_enabled ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ add_trackers_from_url_enabled: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<span className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tAuto-append trackers from URL\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t{preferences.add_trackers_from_url_enabled && (\n\t\t\t\t\t<div className=\"space-y-2 pl-6\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"url\"\n\t\t\t\t\t\t\tvalue={preferences.add_trackers_url ?? ''}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ add_trackers_url: e.target.value })}\n\t\t\t\t\t\t\tplaceholder=\"https://...\"\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"text-[10px] mb-1 block\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tFetched trackers (read-only)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\t\tvalue={preferences.add_trackers_url_list ?? ''}\n\t\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\t\trows={2}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono resize-none\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-muted)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/settings/ConnectionTab.tsx",
    "content": "import type { QBittorrentPreferences } from '../../types/preferences'\nimport { Select, Checkbox } from '../ui'\n\ninterface Props {\n\tpreferences: Partial<QBittorrentPreferences>\n\tonChange: (updates: Partial<QBittorrentPreferences>) => void\n}\n\nconst PROTOCOL_OPTIONS = [\n\t{ value: 0, label: 'TCP and µTP' },\n\t{ value: 1, label: 'TCP' },\n\t{ value: 2, label: 'µTP' },\n]\n\nconst PROXY_TYPES = [\n\t{ value: 0, label: '(None)' },\n\t{ value: 1, label: 'HTTP' },\n\t{ value: 2, label: 'SOCKS5' },\n\t{ value: 3, label: 'HTTP with auth' },\n\t{ value: 4, label: 'SOCKS5 with auth' },\n\t{ value: 5, label: 'SOCKS4' },\n]\n\nexport function ConnectionTab({ preferences, onChange }: Props) {\n\tconst proxyEnabled = (preferences.proxy_type ?? 0) > 0\n\n\treturn (\n\t\t<div className=\"space-y-4\">\n\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t<label className=\"text-xs w-36 shrink-0\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tPeer protocol\n\t\t\t\t</label>\n\t\t\t\t<Select\n\t\t\t\t\tvalue={preferences.bittorrent_protocol ?? 0}\n\t\t\t\t\tonChange={(v) => onChange({ bittorrent_protocol: v as number })}\n\t\t\t\t\toptions={PROTOCOL_OPTIONS}\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tListening Port\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<label className=\"text-xs shrink-0\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tPort\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.listen_port ?? 6881}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ listen_port: parseInt(e.target.value) || 6881 })}\n\t\t\t\t\t\t\tclassName=\"w-24 px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() =>\n\t\t\t\t\t\t\t\tonChange({ random_port: true, listen_port: Math.floor(Math.random() * (65535 - 49152) + 49152) })\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tclassName=\"px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-muted)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tRandom\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"Use UPnP / NAT-PMP\"\n\t\t\t\t\t\tchecked={preferences.upnp ?? true}\n\t\t\t\t\t\tonChange={(v) => onChange({ upnp: v })}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tConnection Limits\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid grid-cols-2 gap-x-6 gap-y-2\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Global max connections\"\n\t\t\t\t\t\t\tchecked={(preferences.max_connec ?? 500) > 0}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ max_connec: v ? 500 : 0 })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.max_connec ?? 500}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ max_connec: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\tclassName=\"w-16 px-2 py-1 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Per torrent\"\n\t\t\t\t\t\t\tchecked={(preferences.max_connec_per_torrent ?? 100) > 0}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ max_connec_per_torrent: v ? 100 : 0 })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.max_connec_per_torrent ?? 100}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ max_connec_per_torrent: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\tclassName=\"w-16 px-2 py-1 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Global upload slots\"\n\t\t\t\t\t\t\tchecked={(preferences.max_uploads ?? 8) > 0}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ max_uploads: v ? 8 : 0 })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.max_uploads ?? 8}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ max_uploads: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\tclassName=\"w-16 px-2 py-1 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Per torrent\"\n\t\t\t\t\t\t\tchecked={(preferences.max_uploads_per_torrent ?? 4) > 0}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ max_uploads_per_torrent: v ? 4 : 0 })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.max_uploads_per_torrent ?? 4}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ max_uploads_per_torrent: parseInt(e.target.value) || 0 })}\n\t\t\t\t\t\t\tclassName=\"w-16 px-2 py-1 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"flex items-center gap-2 mb-2\">\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"\"\n\t\t\t\t\t\tchecked={preferences.i2p_enabled ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ i2p_enabled: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<span className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tI2P (Experimental)\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t{preferences.i2p_enabled && (\n\t\t\t\t\t<div className=\"flex items-center gap-4 pl-6\">\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tHost\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.i2p_address ?? '127.0.0.1'}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ i2p_address: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"w-28 px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tPort\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.i2p_port ?? 7656}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ i2p_port: parseInt(e.target.value) || 7656 })}\n\t\t\t\t\t\t\t\tclassName=\"w-20 px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Mixed mode\"\n\t\t\t\t\t\t\tchecked={preferences.i2p_mixed_mode ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ i2p_mixed_mode: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tProxy Server\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center gap-3 mb-3\">\n\t\t\t\t\t<Select\n\t\t\t\t\t\tvalue={preferences.proxy_type ?? 0}\n\t\t\t\t\t\tonChange={(v) => onChange({ proxy_type: v as number })}\n\t\t\t\t\t\toptions={PROXY_TYPES}\n\t\t\t\t\t\tminWidth=\"140px\"\n\t\t\t\t\t/>\n\t\t\t\t\t{proxyEnabled && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.proxy_ip ?? ''}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ proxy_ip: e.target.value })}\n\t\t\t\t\t\t\t\tplaceholder=\"Host\"\n\t\t\t\t\t\t\t\tclassName=\"flex-1 px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.proxy_port ?? 8080}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ proxy_port: parseInt(e.target.value) || 8080 })}\n\t\t\t\t\t\t\t\tplaceholder=\"Port\"\n\t\t\t\t\t\t\t\tclassName=\"w-20 px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t{proxyEnabled && (\n\t\t\t\t\t<div className=\"space-y-2 pl-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Hostname lookup via proxy\"\n\t\t\t\t\t\t\tchecked={preferences.proxy_hostname_lookup ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ proxy_hostname_lookup: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div className=\"p-2 rounded space-y-2\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tlabel=\"Authentication\"\n\t\t\t\t\t\t\t\tchecked={preferences.proxy_auth_enabled ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ proxy_auth_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t{preferences.proxy_auth_enabled && (\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3 pl-6\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={preferences.proxy_username ?? ''}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ proxy_username: e.target.value })}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Username\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-32 px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\t\t\t\tvalue={preferences.proxy_password ?? ''}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ proxy_password: e.target.value })}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Password\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-32 px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tlabel=\"Use for BitTorrent\"\n\t\t\t\t\t\t\t\tchecked={preferences.proxy_bittorrent ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ proxy_bittorrent: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t{preferences.proxy_bittorrent && (\n\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\tlabel=\"Use for peer connections\"\n\t\t\t\t\t\t\t\t\tchecked={preferences.proxy_peer_connections ?? false}\n\t\t\t\t\t\t\t\t\tonChange={(v) => onChange({ proxy_peer_connections: v })}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tlabel=\"Use for RSS\"\n\t\t\t\t\t\t\t\tchecked={preferences.proxy_rss ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ proxy_rss: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tlabel=\"Use for general\"\n\t\t\t\t\t\t\t\tchecked={preferences.proxy_misc ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ proxy_misc: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tIP Filtering\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Filter path\"\n\t\t\t\t\t\t\tchecked={preferences.ip_filter_enabled ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ ip_filter_enabled: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={preferences.ip_filter_path ?? ''}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ ip_filter_path: e.target.value })}\n\t\t\t\t\t\t\tdisabled={!preferences.ip_filter_enabled}\n\t\t\t\t\t\t\tplaceholder=\".dat, .p2p, .p2b\"\n\t\t\t\t\t\t\tclassName=\"flex-1 px-2 py-1.5 rounded border text-xs disabled:opacity-50\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"Apply to trackers\"\n\t\t\t\t\t\tchecked={preferences.ip_filter_trackers ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ ip_filter_trackers: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"text-xs mb-1 block\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tBanned IPs\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\tvalue={preferences.banned_IPs ?? ''}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ banned_IPs: e.target.value })}\n\t\t\t\t\t\t\trows={3}\n\t\t\t\t\t\t\tplaceholder=\"One per line\"\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono resize-none\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/settings/DownloadsTab.tsx",
    "content": "import type { QBittorrentPreferences } from '../../types/preferences'\nimport { Toggle, Select, Checkbox } from '../ui'\n\ninterface Props {\n\tpreferences: Partial<QBittorrentPreferences>\n\tonChange: (updates: Partial<QBittorrentPreferences>) => void\n}\n\nconst CONTENT_LAYOUT_OPTIONS = [\n\t{ value: 'Original', label: 'Original' },\n\t{ value: 'Subfolder', label: 'Create subfolder' },\n\t{ value: 'NoSubfolder', label: \"Don't create subfolder\" },\n]\n\nconst STOP_CONDITION_OPTIONS = [\n\t{ value: 'None', label: 'None' },\n\t{ value: 'MetadataReceived', label: 'Metadata received' },\n\t{ value: 'FilesChecked', label: 'Files checked' },\n]\n\nexport function DownloadsTab({ preferences, onChange }: Props) {\n\treturn (\n\t\t<div className=\"space-y-4\">\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tWhen adding a torrent\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tContent layout\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\tvalue={preferences.torrent_content_layout ?? 'Original'}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ torrent_content_layout: v as 'Original' | 'Subfolder' | 'NoSubfolder' })}\n\t\t\t\t\t\t\toptions={CONTENT_LAYOUT_OPTIONS}\n\t\t\t\t\t\t\tminWidth=\"160px\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tStop condition\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\tvalue={preferences.torrent_stop_condition ?? 'None'}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ torrent_stop_condition: v as 'None' | 'MetadataReceived' | 'FilesChecked' })}\n\t\t\t\t\t\t\toptions={STOP_CONDITION_OPTIONS}\n\t\t\t\t\t\t\tminWidth=\"160px\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Add to top of queue\"\n\t\t\t\t\t\t\tchecked={preferences.add_to_top_of_queue ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ add_to_top_of_queue: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Don't start automatically\"\n\t\t\t\t\t\t\tchecked={preferences.add_stopped_enabled ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ add_stopped_enabled: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Merge trackers on duplicate\"\n\t\t\t\t\t\t\tchecked={preferences.merge_trackers ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ merge_trackers: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Delete .torrent files\"\n\t\t\t\t\t\t\tchecked={(preferences.auto_delete_mode ?? 0) > 0}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ auto_delete_mode: v ? 1 : 0 })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tPre-allocation & Extensions\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"Pre-allocate disk space\"\n\t\t\t\t\t\tchecked={preferences.preallocate_all ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ preallocate_all: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"Append .!qB to incomplete\"\n\t\t\t\t\t\tchecked={preferences.incomplete_files_ext ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ incomplete_files_ext: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel='Keep unselected in \".unwanted\"'\n\t\t\t\t\t\tchecked={preferences.use_unwanted_folder ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ use_unwanted_folder: v })}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tSaving Management\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tDefault Torrent Management Mode\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName=\"text-xs\"\n\t\t\t\t\t\t\t\tstyle={{ color: preferences.auto_tmm_enabled ? 'var(--text-muted)' : 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tManual\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.auto_tmm_enabled ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ auto_tmm_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName=\"text-xs\"\n\t\t\t\t\t\t\t\tstyle={{ color: preferences.auto_tmm_enabled ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tAuto\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-x-4 gap-y-1\">\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tRelocate on category change\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.torrent_changed_tmm_enabled ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ torrent_changed_tmm_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tRelocate on default path change\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.save_path_changed_tmm_enabled ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ save_path_changed_tmm_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tRelocate on category path change\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.category_changed_tmm_enabled ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ category_changed_tmm_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Use Subcategories\"\n\t\t\t\t\t\t\tchecked={preferences.use_subcategories ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ use_subcategories: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Category paths in Manual Mode\"\n\t\t\t\t\t\t\tchecked={preferences.use_category_paths_in_manual_mode ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ use_category_paths_in_manual_mode: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tDefault Save Path\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={preferences.save_path ?? ''}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ save_path: e.target.value })}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Keep incomplete in:\"\n\t\t\t\t\t\t\tchecked={preferences.temp_path_enabled ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ temp_path_enabled: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{preferences.temp_path_enabled && (\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.temp_path ?? ''}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ temp_path: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Copy .torrent to:\"\n\t\t\t\t\t\t\tchecked={preferences.export_dir !== undefined && preferences.export_dir !== ''}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ export_dir: v ? preferences.export_dir || '' : '' })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{preferences.export_dir !== undefined && preferences.export_dir !== '' && (\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.export_dir}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ export_dir: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Copy finished .torrent to:\"\n\t\t\t\t\t\t\tchecked={preferences.export_dir_fin !== undefined && preferences.export_dir_fin !== ''}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ export_dir_fin: v ? preferences.export_dir_fin || '' : '' })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{preferences.export_dir_fin !== undefined && preferences.export_dir_fin !== '' && (\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.export_dir_fin}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ export_dir_fin: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"flex items-center gap-2 mb-2\">\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"\"\n\t\t\t\t\t\tchecked={preferences.excluded_file_names_enabled ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ excluded_file_names_enabled: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<span className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tExcluded file names\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t{preferences.excluded_file_names_enabled && (\n\t\t\t\t\t<textarea\n\t\t\t\t\t\tvalue={preferences.excluded_file_names ?? ''}\n\t\t\t\t\t\tonChange={(e) => onChange({ excluded_file_names: e.target.value })}\n\t\t\t\t\t\trows={3}\n\t\t\t\t\t\tplaceholder=\"*.exe&#10;*.scr\"\n\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono resize-none\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"flex items-center gap-2 mb-2\">\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"\"\n\t\t\t\t\t\tchecked={preferences.mail_notification_enabled ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ mail_notification_enabled: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<span className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tEmail notification\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t{preferences.mail_notification_enabled && (\n\t\t\t\t\t<div className=\"space-y-2 pl-6\">\n\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tFrom\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tvalue={preferences.mail_notification_sender ?? ''}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ mail_notification_sender: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tTo\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"email\"\n\t\t\t\t\t\t\t\t\tvalue={preferences.mail_notification_email ?? ''}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ mail_notification_email: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tSMTP server\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tvalue={preferences.mail_notification_smtp ?? ''}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ mail_notification_smtp: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tlabel=\"SSL\"\n\t\t\t\t\t\t\t\tchecked={preferences.mail_notification_ssl_enabled ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ mail_notification_ssl_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"p-2 rounded space-y-2\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tlabel=\"Authentication\"\n\t\t\t\t\t\t\t\tchecked={preferences.mail_notification_auth_enabled ?? false}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ mail_notification_auth_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t{preferences.mail_notification_auth_enabled && (\n\t\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2 pl-6\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={preferences.mail_notification_username ?? ''}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ mail_notification_username: e.target.value })}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Username\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\t\t\t\tvalue={preferences.mail_notification_password ?? ''}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ mail_notification_password: e.target.value })}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Password\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tRun external program\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"On torrent added:\"\n\t\t\t\t\t\t\tchecked={preferences.autorun_on_torrent_added_enabled ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ autorun_on_torrent_added_enabled: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{preferences.autorun_on_torrent_added_enabled && (\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.autorun_on_torrent_added_program ?? ''}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ autorun_on_torrent_added_program: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"On torrent finished:\"\n\t\t\t\t\t\t\tchecked={preferences.autorun_enabled ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ autorun_enabled: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{preferences.autorun_enabled && (\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.autorun_program ?? ''}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ autorun_program: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<p className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tParams: %N (name), %L (category), %G (tags), %F (content path), %R (root path), %D (save path), %C (files),\n\t\t\t\t\t\t%Z (size), %T (tracker), %I (hash v1), %J (hash v2), %K (ID)\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/settings/RSSTab.tsx",
    "content": "import type { QBittorrentPreferences } from '../../types/preferences'\nimport { Checkbox } from '../ui'\n\ninterface Props {\n\tpreferences: Partial<QBittorrentPreferences>\n\tonChange: (updates: Partial<QBittorrentPreferences>) => void\n}\n\nexport function RSSTab({ preferences, onChange }: Props) {\n\treturn (\n\t\t<div className=\"space-y-4\">\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tRSS Reader\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"Enable fetching RSS feeds\"\n\t\t\t\t\t\tchecked={preferences.rss_processing_enabled ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ rss_processing_enabled: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<div className=\"grid grid-cols-3 gap-2\">\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tRefresh\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.rss_refresh_interval ?? 30}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ rss_refresh_interval: parseInt(e.target.value) || 30 })}\n\t\t\t\t\t\t\t\tmin={1}\n\t\t\t\t\t\t\t\tclassName=\"w-14 px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tmin\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tDelay\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.rss_fetch_delay ?? 2}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ rss_fetch_delay: parseInt(e.target.value) || 2 })}\n\t\t\t\t\t\t\t\tmin={0}\n\t\t\t\t\t\t\t\tclassName=\"w-14 px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tsec\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<label className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tMax articles\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.rss_max_articles_per_feed ?? 50}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ rss_max_articles_per_feed: parseInt(e.target.value) || 50 })}\n\t\t\t\t\t\t\t\tmin={1}\n\t\t\t\t\t\t\t\tclassName=\"w-14 px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tRSS Torrent Auto Downloader\n\t\t\t\t</div>\n\t\t\t\t<Checkbox\n\t\t\t\t\tlabel=\"Enable auto downloading of RSS torrents\"\n\t\t\t\t\tchecked={preferences.rss_auto_downloading_enabled ?? false}\n\t\t\t\t\tonChange={(v) => onChange({ rss_auto_downloading_enabled: v })}\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tRSS Smart Episode Filter\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tlabel=\"Download REPACK/PROPER episodes\"\n\t\t\t\t\t\tchecked={preferences.rss_download_repack_proper_episodes ?? true}\n\t\t\t\t\t\tonChange={(v) => onChange({ rss_download_repack_proper_episodes: v })}\n\t\t\t\t\t/>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tFilters\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\tvalue={preferences.rss_smart_episode_filters ?? ''}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ rss_smart_episode_filters: e.target.value })}\n\t\t\t\t\t\t\tplaceholder=\"s(\\d+)e(\\d+)&#10;(\\d+)x(\\d+)\"\n\t\t\t\t\t\t\trows={4}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono resize-none\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/settings/SpeedTab.tsx",
    "content": "import { ArrowDown, ArrowUp, Clock } from 'lucide-react'\nimport type { QBittorrentPreferences } from '../../types/preferences'\nimport { Toggle, Checkbox, Select } from '../ui'\n\nconst SCHEDULER_DAYS = [\n\t{ value: 0, label: 'Every day' },\n\t{ value: 1, label: 'Weekdays' },\n\t{ value: 2, label: 'Weekend' },\n\t{ value: 3, label: 'Monday' },\n\t{ value: 4, label: 'Tuesday' },\n\t{ value: 5, label: 'Wednesday' },\n\t{ value: 6, label: 'Thursday' },\n\t{ value: 7, label: 'Friday' },\n\t{ value: 8, label: 'Saturday' },\n\t{ value: 9, label: 'Sunday' },\n]\n\ninterface Props {\n\tpreferences: Partial<QBittorrentPreferences>\n\tonChange: (updates: Partial<QBittorrentPreferences>) => void\n}\n\nfunction bytesToKB(bytes: number | undefined): string {\n\tif (bytes === undefined) return '0'\n\treturn bytes === 0 ? '0' : Math.round(bytes / 1024).toString()\n}\n\nfunction kbToBytes(kb: string): number {\n\tconst val = parseInt(kb, 10)\n\treturn isNaN(val) || val < 0 ? 0 : val * 1024\n}\n\nexport function SpeedTab({ preferences, onChange }: Props) {\n\treturn (\n\t\t<div className=\"space-y-4\">\n\t\t\t<div className=\"flex justify-end\">\n\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t0 = unlimited\n\t\t\t\t</span>\n\t\t\t</div>\n\n\t\t\t<div className=\"grid grid-cols-2 gap-4\">\n\t\t\t\t<div>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"text-[10px] font-semibold uppercase tracking-wider mb-2\"\n\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\tGlobal Limits\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<ArrowDown className=\"w-3 h-3 shrink-0\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tinputMode=\"numeric\"\n\t\t\t\t\t\t\t\tvalue={bytesToKB(preferences.dl_limit)}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ dl_limit: kbToBytes(e.target.value) })}\n\t\t\t\t\t\t\t\tclassName=\"w-24 px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tKiB/s\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<ArrowUp className=\"w-3 h-3 shrink-0\" style={{ color: '#a6e3a1' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tinputMode=\"numeric\"\n\t\t\t\t\t\t\t\tvalue={bytesToKB(preferences.up_limit)}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ up_limit: kbToBytes(e.target.value) })}\n\t\t\t\t\t\t\t\tclassName=\"w-24 px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tKiB/s\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"text-[10px] font-semibold uppercase tracking-wider mb-2\"\n\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\tAlternative Limits\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<ArrowDown className=\"w-3 h-3 shrink-0\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tinputMode=\"numeric\"\n\t\t\t\t\t\t\t\tvalue={bytesToKB(preferences.alt_dl_limit)}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ alt_dl_limit: kbToBytes(e.target.value) })}\n\t\t\t\t\t\t\t\tclassName=\"w-24 px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tKiB/s\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<ArrowUp className=\"w-3 h-3 shrink-0\" style={{ color: '#a6e3a1' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tinputMode=\"numeric\"\n\t\t\t\t\t\t\t\tvalue={bytesToKB(preferences.alt_up_limit)}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ alt_up_limit: kbToBytes(e.target.value) })}\n\t\t\t\t\t\t\t\tclassName=\"w-24 px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"text-[10px]\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tKiB/s\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"flex items-center justify-between mb-2\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Clock\n\t\t\t\t\t\t\tclassName=\"w-3.5 h-3.5\"\n\t\t\t\t\t\t\tstyle={{ color: preferences.scheduler_enabled ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\tstrokeWidth={1.5}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<span className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tSchedule alternative limits\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<Toggle\n\t\t\t\t\t\tchecked={preferences.scheduler_enabled ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ scheduler_enabled: v })}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t{preferences.scheduler_enabled && (\n\t\t\t\t\t<div className=\"flex items-center gap-4 pl-5\">\n\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tFrom\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.schedule_from_hour ?? 8}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ schedule_from_hour: v })}\n\t\t\t\t\t\t\t\toptions={Array.from({ length: 24 }, (_, i) => ({ value: i, label: i.toString().padStart(2, '0') }))}\n\t\t\t\t\t\t\t\tminWidth=\"50px\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>:</span>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.schedule_from_min ?? 0}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ schedule_from_min: v })}\n\t\t\t\t\t\t\t\toptions={[0, 15, 30, 45].map((m) => ({ value: m, label: m.toString().padStart(2, '0') }))}\n\t\t\t\t\t\t\t\tminWidth=\"50px\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tTo\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.schedule_to_hour ?? 20}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ schedule_to_hour: v })}\n\t\t\t\t\t\t\t\toptions={Array.from({ length: 24 }, (_, i) => ({ value: i, label: i.toString().padStart(2, '0') }))}\n\t\t\t\t\t\t\t\tminWidth=\"50px\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>:</span>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={preferences.schedule_to_min ?? 0}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ schedule_to_min: v })}\n\t\t\t\t\t\t\t\toptions={[0, 15, 30, 45].map((m) => ({ value: m, label: m.toString().padStart(2, '0') }))}\n\t\t\t\t\t\t\t\tminWidth=\"50px\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\tvalue={preferences.scheduler_days ?? 0}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ scheduler_days: v })}\n\t\t\t\t\t\t\toptions={SCHEDULER_DAYS}\n\t\t\t\t\t\t\tminWidth=\"100px\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tRate Limit Settings\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tchecked={preferences.limit_utp_rate ?? true}\n\t\t\t\t\t\tonChange={(v) => onChange({ limit_utp_rate: v })}\n\t\t\t\t\t\tlabel=\"Apply to µTP protocol\"\n\t\t\t\t\t/>\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tchecked={preferences.limit_tcp_overhead ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ limit_tcp_overhead: v })}\n\t\t\t\t\t\tlabel=\"Apply to transport overhead\"\n\t\t\t\t\t/>\n\t\t\t\t\t<Checkbox\n\t\t\t\t\t\tchecked={preferences.limit_lan_peers ?? true}\n\t\t\t\t\t\tonChange={(v) => onChange({ limit_lan_peers: v })}\n\t\t\t\t\t\tlabel=\"Apply to LAN peers\"\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/settings/WebUITab.tsx",
    "content": "import { AlertTriangle } from 'lucide-react'\nimport type { QBittorrentPreferences } from '../../types/preferences'\nimport { Toggle, Select } from '../ui'\n\ninterface Props {\n\tpreferences: Partial<QBittorrentPreferences>\n\tonChange: (updates: Partial<QBittorrentPreferences>) => void\n}\n\nconst DYNDNS_SERVICES = [\n\t{ value: 0, label: 'DynDNS' },\n\t{ value: 1, label: 'NO-IP' },\n]\n\nexport function WebUITab({ preferences, onChange }: Props) {\n\treturn (\n\t\t<div className=\"space-y-4\">\n\t\t\t<div\n\t\t\t\tclassName=\"px-3 py-2 rounded flex items-start gap-2\"\n\t\t\t\tstyle={{\n\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--warning) 15%, transparent)',\n\t\t\t\t\tborder: '1px solid var(--warning)',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<AlertTriangle className=\"w-4 h-4 shrink-0 mt-0.5\" style={{ color: 'var(--warning)' }} strokeWidth={1.5} />\n\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\tChanging port, address, or auth settings may lock you out.\n\t\t\t\t</p>\n\t\t\t</div>\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tWeb User Interface\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid grid-cols-3 gap-2 mb-2\">\n\t\t\t\t\t<div className=\"col-span-2\">\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tIP address\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={preferences.web_ui_address ?? '*'}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ web_ui_address: e.target.value })}\n\t\t\t\t\t\t\tplaceholder=\"* = all\"\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tPort\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tvalue={preferences.web_ui_port ?? 8080}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ web_ui_port: parseInt(e.target.value) || 8080 })}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tUse UPnP / NAT-PMP for WebUI port\n\t\t\t\t\t</span>\n\t\t\t\t\t<Toggle checked={preferences.web_ui_upnp ?? false} onChange={(v) => onChange({ web_ui_upnp: v })} />\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"flex items-center justify-between mb-2\">\n\t\t\t\t\t<span className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tHTTPS\n\t\t\t\t\t</span>\n\t\t\t\t\t<Toggle checked={preferences.use_https ?? false} onChange={(v) => onChange({ use_https: v })} />\n\t\t\t\t</div>\n\t\t\t\t{preferences.use_https && (\n\t\t\t\t\t<div className=\"space-y-2 pl-4\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tCertificate path\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.web_ui_https_cert_path ?? ''}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ web_ui_https_cert_path: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tPrivate key path\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={preferences.web_ui_https_key_path ?? ''}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ web_ui_https_key_path: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tAuthentication\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tUsername\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={preferences.web_ui_username ?? ''}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ web_ui_username: e.target.value })}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tBypass auth for localhost\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\tchecked={preferences.bypass_local_auth ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ bypass_local_auth: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tBypass for whitelisted subnets\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\tchecked={preferences.bypass_auth_subnet_whitelist_enabled ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ bypass_auth_subnet_whitelist_enabled: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t{preferences.bypass_auth_subnet_whitelist_enabled && (\n\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\tvalue={preferences.bypass_auth_subnet_whitelist ?? ''}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ bypass_auth_subnet_whitelist: e.target.value })}\n\t\t\t\t\t\t\tplaceholder=\"One subnet per line (e.g., 192.168.1.0/24)\"\n\t\t\t\t\t\t\trows={2}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono resize-none\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t<div className=\"grid grid-cols-3 gap-2\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tMax auth fails\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.web_ui_max_auth_fail_count ?? 5}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ web_ui_max_auth_fail_count: parseInt(e.target.value) || 5 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tBan duration (s)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.web_ui_ban_duration ?? 3600}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ web_ui_ban_duration: parseInt(e.target.value) || 3600 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tSession timeout (s)\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tvalue={preferences.web_ui_session_timeout ?? 3600}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ web_ui_session_timeout: parseInt(e.target.value) || 3600 })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"text-[10px] font-semibold uppercase tracking-wider mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tSecurity\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t<div className=\"grid grid-cols-2 gap-x-4 gap-y-1\">\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tClickjacking protection\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.web_ui_clickjacking_protection_enabled ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ web_ui_clickjacking_protection_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tCSRF protection\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.web_ui_csrf_protection_enabled ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ web_ui_csrf_protection_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tSecure cookie\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.web_ui_secure_cookie_enabled ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ web_ui_secure_cookie_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tHost header validation\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\tchecked={preferences.web_ui_host_header_validation_enabled ?? true}\n\t\t\t\t\t\t\t\tonChange={(v) => onChange({ web_ui_host_header_validation_enabled: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t{preferences.web_ui_host_header_validation_enabled && (\n\t\t\t\t\t\t<div className=\"pl-4\">\n\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tServer domains\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\t\tvalue={preferences.web_ui_domain_list ?? ''}\n\t\t\t\t\t\t\t\tonChange={(e) => onChange({ web_ui_domain_list: e.target.value })}\n\t\t\t\t\t\t\t\tplaceholder=\"One domain per line\"\n\t\t\t\t\t\t\t\trows={2}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono resize-none\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tAdd custom HTTP headers\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\tchecked={preferences.web_ui_use_custom_http_headers_enabled ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ web_ui_use_custom_http_headers_enabled: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t{preferences.web_ui_use_custom_http_headers_enabled && (\n\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\tvalue={preferences.web_ui_custom_http_headers ?? ''}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ web_ui_custom_http_headers: e.target.value })}\n\t\t\t\t\t\t\tplaceholder=\"Header: value (one per line)\"\n\t\t\t\t\t\t\trows={2}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono resize-none\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tEnable reverse proxy support\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\tchecked={preferences.web_ui_reverse_proxy_enabled ?? false}\n\t\t\t\t\t\t\tonChange={(v) => onChange({ web_ui_reverse_proxy_enabled: v })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t{preferences.web_ui_reverse_proxy_enabled && (\n\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\tvalue={preferences.web_ui_reverse_proxies_list ?? ''}\n\t\t\t\t\t\t\tonChange={(e) => onChange({ web_ui_reverse_proxies_list: e.target.value })}\n\t\t\t\t\t\t\tplaceholder=\"Trusted proxies (one per line)\"\n\t\t\t\t\t\t\trows={2}\n\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs font-mono resize-none\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"flex items-center justify-between mb-2\">\n\t\t\t\t\t<span className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tAlternative WebUI\n\t\t\t\t\t</span>\n\t\t\t\t\t<Toggle\n\t\t\t\t\t\tchecked={preferences.alternative_webui_enabled ?? false}\n\t\t\t\t\t\tonChange={(v) => onChange({ alternative_webui_enabled: v })}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t{preferences.alternative_webui_enabled && (\n\t\t\t\t\t<input\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tvalue={preferences.alternative_webui_path ?? ''}\n\t\t\t\t\t\tonChange={(e) => onChange({ alternative_webui_path: e.target.value })}\n\t\t\t\t\t\tplaceholder=\"Files location\"\n\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"h-px\" style={{ backgroundColor: 'var(--border)' }} />\n\n\t\t\t<div>\n\t\t\t\t<div className=\"flex items-center justify-between mb-2\">\n\t\t\t\t\t<span className=\"text-[10px] font-semibold uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tDynamic DNS\n\t\t\t\t\t</span>\n\t\t\t\t\t<Toggle checked={preferences.dyndns_enabled ?? false} onChange={(v) => onChange({ dyndns_enabled: v })} />\n\t\t\t\t</div>\n\t\t\t\t{preferences.dyndns_enabled && (\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tService\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\tvalue={preferences.dyndns_service ?? 0}\n\t\t\t\t\t\t\t\t\tonChange={(v) => onChange({ dyndns_service: v })}\n\t\t\t\t\t\t\t\t\toptions={DYNDNS_SERVICES}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tDomain\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tvalue={preferences.dyndns_domain ?? ''}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ dyndns_domain: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2\">\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tUsername\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tvalue={preferences.dyndns_username ?? ''}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ dyndns_username: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-[10px] mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tPassword\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\t\t\tvalue={preferences.dyndns_password ?? ''}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onChange({ dyndns_password: e.target.value })}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-2 py-1.5 rounded border text-xs\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/settings/index.ts",
    "content": "export { BehaviorTab } from './BehaviorTab'\nexport { DownloadsTab } from './DownloadsTab'\nexport { ConnectionTab } from './ConnectionTab'\nexport { SpeedTab } from './SpeedTab'\nexport { BitTorrentTab } from './BitTorrentTab'\nexport { RSSTab } from './RSSTab'\nexport { WebUITab } from './WebUITab'\nexport { AdvancedTab } from './AdvancedTab'\n"
  },
  {
    "path": "src/components/ui/Checkbox.tsx",
    "content": "import type { ReactNode } from 'react'\nimport { Check } from 'lucide-react'\n\nexport function Checkbox({\n\tchecked,\n\tonChange,\n\tlabel,\n\tdisabled,\n}: {\n\tchecked: boolean\n\tonChange: (v: boolean) => void\n\tlabel: ReactNode\n\tdisabled?: boolean\n}) {\n\treturn (\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tdisabled={disabled}\n\t\t\tonClick={() => onChange(!checked)}\n\t\t\tclassName=\"flex items-center gap-2 text-left disabled:opacity-50 disabled:cursor-wait\"\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName=\"w-4 h-4 rounded flex items-center justify-center border transition-colors shrink-0\"\n\t\t\t\tstyle={{\n\t\t\t\t\tbackgroundColor: checked ? 'var(--accent)' : 'transparent',\n\t\t\t\t\tborderColor: checked ? 'var(--accent)' : 'var(--border)',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{checked && <Check className=\"w-2.5 h-2.5\" style={{ color: 'var(--accent-contrast)' }} strokeWidth={3} />}\n\t\t\t</div>\n\t\t\t{label && (\n\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t{label}\n\t\t\t\t</span>\n\t\t\t)}\n\t\t</button>\n\t)\n}\n"
  },
  {
    "path": "src/components/ui/MultiSelect.tsx",
    "content": "import { useState, useEffect, useRef } from 'react'\nimport { ChevronDown } from 'lucide-react'\nimport { Checkbox } from './Checkbox'\n\ninterface Option {\n\tvalue: number\n\tlabel: string\n}\n\ninterface MultiSelectProps {\n\toptions: Option[]\n\tselected: number[]\n\tonChange: (selected: number[]) => void\n\tplaceholder?: string\n}\n\nexport function MultiSelect({ options, selected, onChange, placeholder = 'Select...' }: MultiSelectProps) {\n\tconst [open, setOpen] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\tuseEffect(() => {\n\t\tfunction handleClickOutside(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClickOutside)\n\t\treturn () => document.removeEventListener('mousedown', handleClickOutside)\n\t}, [])\n\n\tconst sorted = [...options].sort((a, b) => a.label.localeCompare(b.label))\n\tconst allSelected = selected.length === options.length && options.length > 0\n\n\tfunction handleSelectAll() {\n\t\tif (allSelected) {\n\t\t\tonChange([])\n\t\t} else {\n\t\t\tonChange(options.map((o) => o.value))\n\t\t}\n\t}\n\n\tfunction handleToggle(value: number) {\n\t\tif (selected.includes(value)) {\n\t\t\tonChange(selected.filter((v) => v !== value))\n\t\t} else {\n\t\t\tonChange([...selected, value])\n\t\t}\n\t}\n\n\tconst displayText = selected.length === 0 ? placeholder : `${selected.length} selected`\n\n\treturn (\n\t\t<div ref={ref} className=\"relative\">\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tdata-dropdown\n\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\tclassName=\"w-full flex items-center justify-between gap-2 px-3 py-2 rounded-lg border text-sm\"\n\t\t\t\tstyle={{\n\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\tcolor: selected.length ? 'var(--text-primary)' : 'var(--text-muted)',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<span className=\"truncate\">{displayText}</span>\n\t\t\t\t<ChevronDown\n\t\t\t\t\tclassName={`w-3 h-3 shrink-0 ${open ? 'rotate-180' : ''}`}\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t/>\n\t\t\t</button>\n\t\t\t{open && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute top-full left-0 mt-1 w-full max-h-64 overflow-auto rounded-lg border shadow-lg z-50\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"px-3 py-2 border-b cursor-pointer hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t\t\t\tonClick={handleSelectAll}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tchecked={allSelected}\n\t\t\t\t\t\t\tonChange={handleSelectAll}\n\t\t\t\t\t\t\tlabel={allSelected ? 'Deselect All' : 'Select All'}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t{sorted.map((option) => (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tkey={option.value}\n\t\t\t\t\t\t\tclassName=\"px-3 py-2 cursor-pointer hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\tonClick={() => handleToggle(option.value)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tchecked={selected.includes(option.value)}\n\t\t\t\t\t\t\t\tonChange={() => handleToggle(option.value)}\n\t\t\t\t\t\t\t\tlabel={option.label}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/ui/Select.tsx",
    "content": "import { useState, useEffect, useRef } from 'react'\nimport { ChevronDown } from 'lucide-react'\n\ninterface SelectProps<T extends string | number> {\n\tvalue: T\n\toptions: { value: T; label: string }[]\n\tonChange: (v: T) => void\n\tclassName?: string\n\tminWidth?: string\n}\n\nexport function Select<T extends string | number>({\n\tvalue,\n\toptions,\n\tonChange,\n\tclassName,\n\tminWidth = '120px',\n}: SelectProps<T>) {\n\tconst [open, setOpen] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\tuseEffect(() => {\n\t\tfunction handleClickOutside(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClickOutside)\n\t\treturn () => document.removeEventListener('mousedown', handleClickOutside)\n\t}, [])\n\n\tconst selected = options.find((o) => o.value === value)\n\n\treturn (\n\t\t<div ref={ref} className={`relative ${className || ''}`} style={{ minWidth }}>\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tdata-dropdown\n\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\tclassName=\"w-full flex items-center justify-between gap-2 px-2 py-1.5 rounded border text-xs\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t>\n\t\t\t\t<span className=\"truncate\">{selected?.label}</span>\n\t\t\t\t<ChevronDown\n\t\t\t\t\tclassName={`w-3 h-3 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`}\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t/>\n\t\t\t</button>\n\t\t\t{open && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute top-full left-0 mt-1 min-w-full max-h-48 overflow-auto rounded border shadow-lg z-50\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t{options.map((o) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={o.value}\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tonChange(o.value)\n\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"w-full px-2.5 py-1.5 text-left text-xs whitespace-nowrap transition-colors hover:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tcolor: value === o.value ? 'var(--accent)' : 'var(--text-primary)',\n\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\tvalue === o.value ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{o.label}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/components/ui/Toggle.tsx",
    "content": "export function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {\n\treturn (\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tonClick={() => onChange(!checked)}\n\t\t\tclassName=\"relative w-8 h-[18px] rounded-full transition-all duration-200 border shrink-0\"\n\t\t\tstyle={{\n\t\t\t\tbackgroundColor: checked ? 'var(--accent)' : 'var(--bg-primary)',\n\t\t\t\tborderColor: checked ? 'var(--accent)' : 'var(--border)',\n\t\t\t}}\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName=\"absolute top-[2px] w-3 h-3 rounded-full transition-all duration-200\"\n\t\t\t\tstyle={{\n\t\t\t\t\tleft: checked ? '14px' : '2px',\n\t\t\t\t\tbackgroundColor: checked ? 'white' : 'var(--text-muted)',\n\t\t\t\t}}\n\t\t\t/>\n\t\t</button>\n\t)\n}\n"
  },
  {
    "path": "src/components/ui/index.ts",
    "content": "export { Toggle } from './Toggle'\nexport { Checkbox } from './Checkbox'\nexport { Select } from './Select'\nexport { MultiSelect } from './MultiSelect'\n"
  },
  {
    "path": "src/contexts/InstanceProvider.tsx",
    "content": "import { useMemo, type ReactNode } from 'react'\nimport type { Instance } from '../api/instances'\nimport { InstanceContext } from './instanceContext'\n\ninterface Props {\n\tinstance: Instance\n\tchildren: ReactNode\n}\n\nexport function InstanceProvider({ instance, children }: Props) {\n\tconst value = useMemo(() => ({ instance }), [instance])\n\treturn <InstanceContext.Provider value={value}>{children}</InstanceContext.Provider>\n}\n"
  },
  {
    "path": "src/contexts/PaginationProvider.tsx",
    "content": "import { useState, type ReactNode } from 'react'\nimport { PaginationContext } from './paginationContext'\n\nexport function PaginationProvider({ children }: { children: ReactNode }) {\n\tconst [page, setPage] = useState(1)\n\tconst [perPage, setPerPage] = useState(() => {\n\t\tconst stored = localStorage.getItem('torrentsPerPage')\n\t\treturn stored ? parseInt(stored, 10) : 50\n\t})\n\tconst [totalItems, setTotalItems] = useState(0)\n\n\tconst totalPages = Math.max(1, Math.ceil(totalItems / perPage))\n\n\tfunction handleSetPerPage(value: number) {\n\t\tsetPerPage(value)\n\t\tsetPage(1)\n\t\tlocalStorage.setItem('torrentsPerPage', value.toString())\n\t}\n\n\treturn (\n\t\t<PaginationContext.Provider\n\t\t\tvalue={{\n\t\t\t\tpage,\n\t\t\t\tperPage,\n\t\t\t\ttotalItems,\n\t\t\t\tsetPage,\n\t\t\t\tsetPerPage: handleSetPerPage,\n\t\t\t\tsetTotalItems,\n\t\t\t\ttotalPages,\n\t\t\t}}\n\t\t>\n\t\t\t{children}\n\t\t</PaginationContext.Provider>\n\t)\n}\n"
  },
  {
    "path": "src/contexts/ThemeContext.ts",
    "content": "import { createContext } from 'react'\nimport type { Theme } from '../themes'\n\nexport interface ThemeContextValue {\n\ttheme: Theme\n\tsetTheme: (id: string) => void\n\tthemes: Theme[]\n\tcustomThemes: Theme[]\n\taddTheme: (theme: Theme) => void\n\tupdateTheme: (theme: Theme) => void\n\tdeleteTheme: (id: string) => void\n}\n\nexport const ThemeContext = createContext<ThemeContextValue | null>(null)\n"
  },
  {
    "path": "src/contexts/ThemeProvider.tsx",
    "content": "import { useEffect, useState, type ReactNode } from 'react'\nimport { themes, type Theme } from '../themes'\nimport { ThemeContext } from './ThemeContext'\n\nconst STORAGE_KEY = 'qbitwebui-theme'\nconst CUSTOM_THEMES_KEY = 'qbitwebui-custom-themes'\n\nfunction applyTheme(colors: (typeof themes)[0]['colors']) {\n\tconst root = document.documentElement\n\troot.style.setProperty('--bg-primary', colors.bgPrimary)\n\troot.style.setProperty('--bg-secondary', colors.bgSecondary)\n\troot.style.setProperty('--bg-tertiary', colors.bgTertiary)\n\troot.style.setProperty('--text-primary', colors.textPrimary)\n\troot.style.setProperty('--text-secondary', colors.textSecondary)\n\troot.style.setProperty('--text-muted', colors.textMuted)\n\troot.style.setProperty('--accent', colors.accent)\n\troot.style.setProperty('--accent-contrast', colors.accentContrast)\n\troot.style.setProperty('--warning', colors.warning)\n\troot.style.setProperty('--error', colors.error)\n\troot.style.setProperty('--border', colors.border)\n\troot.style.setProperty('--progress', colors.progress)\n}\n\nexport function ThemeProvider({ children }: { children: ReactNode }) {\n\tconst [customThemes, setCustomThemes] = useState<Theme[]>(() => {\n\t\tconst stored = localStorage.getItem(CUSTOM_THEMES_KEY)\n\t\treturn stored ? JSON.parse(stored) : []\n\t})\n\n\tconst [theme, setThemeState] = useState(() => {\n\t\tconst stored = localStorage.getItem(STORAGE_KEY)\n\t\t// Check standard themes first\n\t\tlet found = themes.find((t) => t.id === stored)\n\t\t// Then check custom themes\n\t\tif (!found && stored) {\n\t\t\tconst storedCustom = localStorage.getItem(CUSTOM_THEMES_KEY)\n\t\t\tif (storedCustom) {\n\t\t\t\tconst customs = JSON.parse(storedCustom) as Theme[]\n\t\t\t\tfound = customs.find((t) => t.id === stored)\n\t\t\t}\n\t\t}\n\t\treturn found ?? themes[0]\n\t})\n\n\tuseEffect(() => {\n\t\tapplyTheme(theme.colors)\n\t}, [theme])\n\n\tfunction setTheme(id: string) {\n\t\tconst st = themes.find((t) => t.id === id) ?? customThemes.find((t) => t.id === id)\n\t\tif (st) {\n\t\t\tsetThemeState(st)\n\t\t\tlocalStorage.setItem(STORAGE_KEY, id)\n\t\t}\n\t}\n\n\tfunction addTheme(newTheme: Theme) {\n\t\tsetCustomThemes((prev) => {\n\t\t\tconst next = [...prev, newTheme]\n\t\t\tlocalStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(next))\n\t\t\treturn next\n\t\t})\n\t\tsetTheme(newTheme.id)\n\t}\n\n\tfunction deleteTheme(id: string) {\n\t\tsetCustomThemes((prev) => {\n\t\t\tconst next = prev.filter((t) => t.id !== id)\n\t\t\tlocalStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(next))\n\t\t\treturn next\n\t\t})\n\t\tif (theme.id === id) {\n\t\t\tsetTheme(themes[0].id)\n\t\t}\n\t}\n\n\tfunction updateTheme(updatedTheme: Theme) {\n\t\tsetCustomThemes((prev) => {\n\t\t\tconst next = prev.map((t) => (t.id === updatedTheme.id ? updatedTheme : t))\n\t\t\tlocalStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(next))\n\t\t\treturn next\n\t\t})\n\t\t// If the updated theme is currently active, re-apply it\n\t\tif (theme.id === updatedTheme.id) {\n\t\t\tsetThemeState(updatedTheme)\n\t\t}\n\t}\n\n\treturn (\n\t\t<ThemeContext.Provider\n\t\t\tvalue={{\n\t\t\t\ttheme,\n\t\t\t\tsetTheme,\n\t\t\t\tthemes,\n\t\t\t\tcustomThemes,\n\t\t\t\taddTheme,\n\t\t\t\tupdateTheme,\n\t\t\t\tdeleteTheme,\n\t\t\t}}\n\t\t>\n\t\t\t{children}\n\t\t</ThemeContext.Provider>\n\t)\n}\n"
  },
  {
    "path": "src/contexts/instanceContext.ts",
    "content": "import { createContext } from 'react'\nimport type { Instance } from '../api/instances'\n\ninterface InstanceContextValue {\n\tinstance: Instance\n}\n\nexport const InstanceContext = createContext<InstanceContextValue | null>(null)\n"
  },
  {
    "path": "src/contexts/paginationContext.ts",
    "content": "import { createContext } from 'react'\n\nexport interface PaginationContextValue {\n\tpage: number\n\tperPage: number\n\ttotalItems: number\n\tsetPage: (page: number) => void\n\tsetPerPage: (perPage: number) => void\n\tsetTotalItems: (total: number) => void\n\ttotalPages: number\n}\n\nexport const PaginationContext = createContext<PaginationContextValue | null>(null)\n"
  },
  {
    "path": "src/hooks/useClickOutside.ts",
    "content": "import { useEffect, type RefObject } from 'react'\n\nexport function useClickOutside(ref: RefObject<HTMLElement | null>, handler: () => void): void {\n\tuseEffect(() => {\n\t\tfunction handleClickOutside(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) handler()\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClickOutside)\n\t\treturn () => document.removeEventListener('mousedown', handleClickOutside)\n\t}, [ref, handler])\n}\n"
  },
  {
    "path": "src/hooks/useCrossSeed.ts",
    "content": "import { useState, useEffect, useRef } from 'react'\nimport { type Instance } from '../api/instances'\nimport { getIntegrations, type Integration } from '../api/integrations'\nimport {\n\tgetCrossSeedConfig,\n\tupdateCrossSeedConfig,\n\ttriggerScan,\n\tgetInstanceStatus,\n\tclearCache,\n\tgetCacheStats,\n\tstopScan,\n\tgetLogs,\n\tgetIndexers,\n\ttype CrossSeedConfig,\n\ttype SchedulerStatus,\n\ttype CacheStats,\n\ttype LogEntry,\n\ttype TorznabIndexer,\n} from '../api/crossSeed'\n\nexport function useCrossSeed(instances: Instance[]) {\n\tconst [selectedInstance, setSelectedInstance] = useState<number | null>(instances[0]?.id ?? null)\n\tconst [config, setConfig] = useState<CrossSeedConfig | null>(null)\n\tconst [status, setStatus] = useState<SchedulerStatus | null>(null)\n\tconst [cacheStats, setCacheStats] = useState<CacheStats | null>(null)\n\tconst [integrations, setIntegrations] = useState<Integration[]>([])\n\tconst [availableIndexers, setAvailableIndexers] = useState<TorznabIndexer[]>([])\n\tconst [logs, setLogs] = useState<LogEntry[]>([])\n\tconst [loading, setLoading] = useState(false)\n\tconst [scanning, setScanning] = useState(false)\n\tconst [stopping, setStopping] = useState(false)\n\tconst [error, setError] = useState('')\n\tconst [success, setSuccess] = useState('')\n\tconst [saving, setSaving] = useState(false)\n\tconst [autoScroll, setAutoScroll] = useState(true)\n\tconst logsContainerRef = useRef<HTMLDivElement>(null)\n\tconst lastLogCountRef = useRef(0)\n\n\tuseEffect(() => {\n\t\tgetIntegrations()\n\t\t\t.then(setIntegrations)\n\t\t\t.catch(() => {})\n\t}, [])\n\n\tuseEffect(() => {\n\t\tif (!selectedInstance) return\n\t\tsetLoading(true)\n\t\tsetError('')\n\t\tPromise.all([\n\t\t\tgetCrossSeedConfig(selectedInstance),\n\t\t\tgetInstanceStatus(selectedInstance),\n\t\t\tgetCacheStats(selectedInstance),\n\t\t])\n\t\t\t.then(([cfg, st, cs]) => {\n\t\t\t\tsetConfig(cfg)\n\t\t\t\tsetStatus(st)\n\t\t\t\tsetCacheStats(cs)\n\t\t\t})\n\t\t\t.catch((e) => setError(e.message))\n\t\t\t.finally(() => setLoading(false))\n\t}, [selectedInstance])\n\n\tuseEffect(() => {\n\t\tif (!selectedInstance || !config?.integration_id) {\n\t\t\tsetAvailableIndexers([])\n\t\t\treturn\n\t\t}\n\t\tgetIndexers(selectedInstance, config.integration_id)\n\t\t\t.then((indexers) => {\n\t\t\t\tsetAvailableIndexers(indexers)\n\t\t\t\tconst validIds = new Set(indexers.map((i) => i.id))\n\t\t\t\tsetConfig((c) => {\n\t\t\t\t\tif (!c) return c\n\t\t\t\t\tconst cleanedIds = c.indexer_ids.filter((id) => validIds.has(id))\n\t\t\t\t\tif (cleanedIds.length !== c.indexer_ids.length) {\n\t\t\t\t\t\treturn { ...c, indexer_ids: cleanedIds }\n\t\t\t\t\t}\n\t\t\t\t\treturn c\n\t\t\t\t})\n\t\t\t})\n\t\t\t.catch(() => setAvailableIndexers([]))\n\t}, [selectedInstance, config?.integration_id])\n\n\tuseEffect(() => {\n\t\tconst fetchLogs = () =>\n\t\t\tgetLogs(200)\n\t\t\t\t.then(setLogs)\n\t\t\t\t.catch(() => {})\n\t\tfetchLogs()\n\t\tconst interval = setInterval(fetchLogs, 2000)\n\t\treturn () => clearInterval(interval)\n\t}, [])\n\n\tuseEffect(() => {\n\t\tif (!selectedInstance) return\n\t\tconst fetchStatus = () =>\n\t\t\tgetInstanceStatus(selectedInstance)\n\t\t\t\t.then(setStatus)\n\t\t\t\t.catch(() => {})\n\t\tconst interval = setInterval(fetchStatus, 2000)\n\t\treturn () => clearInterval(interval)\n\t}, [selectedInstance])\n\n\tuseEffect(() => {\n\t\tif (!status?.running) setStopping(false)\n\t}, [status?.running])\n\n\tuseEffect(() => {\n\t\tconst container = logsContainerRef.current\n\t\tif (!container || !autoScroll) return\n\t\tif (logs.length > lastLogCountRef.current) {\n\t\t\tcontainer.scrollTop = container.scrollHeight\n\t\t}\n\t\tlastLogCountRef.current = logs.length\n\t}, [logs, autoScroll])\n\n\tasync function handleSave() {\n\t\tif (!selectedInstance || !config) return\n\t\tsetSaving(true)\n\t\tsetError('')\n\t\tsetSuccess('')\n\t\ttry {\n\t\t\tconst result = await updateCrossSeedConfig(selectedInstance, {\n\t\t\t\tenabled: config.enabled,\n\t\t\t\tinterval_hours: config.interval_hours,\n\t\t\t\tdelay_seconds: config.delay_seconds,\n\t\t\t\tdry_run: config.dry_run,\n\t\t\t\tcategory_suffix: config.category_suffix,\n\t\t\t\ttag: config.tag,\n\t\t\t\tskip_recheck: config.skip_recheck,\n\t\t\t\tintegration_id: config.integration_id,\n\t\t\t\tindexer_ids: config.indexer_ids,\n\t\t\t\tmatch_mode: config.match_mode,\n\t\t\t\tlink_dir: config.link_dir,\n\t\t\t\tblocklist: config.blocklist,\n\t\t\t\tinclude_single_episodes: config.include_single_episodes,\n\t\t\t})\n\t\t\tif (result.linkDirValid === false) {\n\t\t\t\tsetError('Link directory not writable')\n\t\t\t} else {\n\t\t\t\tsetSuccess('Saved')\n\t\t\t\tsetTimeout(() => setSuccess(''), 2000)\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : 'Failed')\n\t\t} finally {\n\t\t\tsetSaving(false)\n\t\t}\n\t}\n\n\tasync function handleScan(force: boolean) {\n\t\tif (!selectedInstance) return\n\t\tsetScanning(true)\n\t\tsetError('')\n\t\tsetSuccess('')\n\t\ttry {\n\t\t\tconst result = await triggerScan(selectedInstance, force)\n\t\t\tsetSuccess(`Done: ${result.matchesFound} matches, ${result.torrentsAdded} added`)\n\t\t\tgetInstanceStatus(selectedInstance)\n\t\t\t\t.then(setStatus)\n\t\t\t\t.catch(() => {})\n\t\t\tgetCacheStats(selectedInstance)\n\t\t\t\t.then(setCacheStats)\n\t\t\t\t.catch(() => {})\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : 'Failed')\n\t\t} finally {\n\t\t\tsetScanning(false)\n\t\t}\n\t}\n\n\tasync function handleStop() {\n\t\tif (!selectedInstance) return\n\t\ttry {\n\t\t\tsetStopping(true)\n\t\t\tawait stopScan(selectedInstance)\n\t\t\tsetSuccess('Stop requested')\n\t\t\tsetTimeout(() => setSuccess(''), 2000)\n\t\t} catch (e) {\n\t\t\tsetStopping(false)\n\t\t\tsetError(e instanceof Error ? e.message : 'Failed')\n\t\t}\n\t}\n\n\tasync function handleClearCache() {\n\t\tif (!selectedInstance) return\n\t\ttry {\n\t\t\tconst result = await clearCache(selectedInstance)\n\t\t\tsetSuccess(`Cleared ${result.cacheCleared + result.outputCleared} files`)\n\t\t\tgetCacheStats(selectedInstance)\n\t\t\t\t.then(setCacheStats)\n\t\t\t\t.catch(() => {})\n\t\t\tsetTimeout(() => setSuccess(''), 2000)\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : 'Failed')\n\t\t}\n\t}\n\n\tconst prowlarrIntegrations = integrations.filter((i) => i.type === 'prowlarr')\n\tconst isRunning = scanning || status?.running\n\n\treturn {\n\t\tselectedInstance,\n\t\tsetSelectedInstance,\n\t\tconfig,\n\t\tsetConfig,\n\t\tstatus,\n\t\tcacheStats,\n\t\tavailableIndexers,\n\t\tlogs,\n\t\tloading,\n\t\tscanning,\n\t\tstopping,\n\t\terror,\n\t\tsuccess,\n\t\tsaving,\n\t\tautoScroll,\n\t\tsetAutoScroll,\n\t\tlogsContainerRef,\n\t\tprowlarrIntegrations,\n\t\tisRunning,\n\t\thandleSave,\n\t\thandleScan,\n\t\thandleStop,\n\t\thandleClearCache,\n\t}\n}\n\nexport function formatTimestamp(ts: number | null): string {\n\tif (!ts) return '—'\n\treturn new Date(ts * 1000).toLocaleString(undefined, {\n\t\tmonth: 'short',\n\t\tday: 'numeric',\n\t\thour: '2-digit',\n\t\tminute: '2-digit',\n\t})\n}\n\nexport const LOG_LEVEL_COLORS: Record<string, string> = {\n\tERROR: 'var(--error)',\n\tWARN: 'var(--warning)',\n}\n"
  },
  {
    "path": "src/hooks/useInstance.ts",
    "content": "import { useContext } from 'react'\nimport { InstanceContext } from '../contexts/instanceContext'\nimport type { Instance } from '../api/instances'\n\nexport function useInstance(): Instance {\n\tconst context = useContext(InstanceContext)\n\tif (!context) {\n\t\tthrow new Error('useInstance must be used within InstanceProvider')\n\t}\n\treturn context.instance\n}\n"
  },
  {
    "path": "src/hooks/usePagination.ts",
    "content": "import { useContext } from 'react'\nimport { PaginationContext } from '../contexts/paginationContext'\n\nexport function usePagination() {\n\tconst ctx = useContext(PaginationContext)\n\tif (!ctx) throw new Error('usePagination must be used within PaginationProvider')\n\treturn ctx\n}\n"
  },
  {
    "path": "src/hooks/useRSSManager.ts",
    "content": "import { useState, useEffect, useCallback, useRef } from 'react'\nimport { type Instance } from '../api/instances'\nimport {\n\tgetRSSItems,\n\taddRSSFeed,\n\taddRSSFolder,\n\tremoveRSSItem,\n\trefreshRSSItem,\n\tgetRSSRules,\n\tsetRSSRule,\n\tremoveRSSRule,\n\tgetMatchingArticles,\n\tgetCategories,\n\taddTorrent,\n\ttype Category,\n} from '../api/qbittorrent'\nimport type { RSSItems, RSSFeedData, RSSRule, RSSRules, MatchingArticles } from '../types/rss'\n\nconst FEED_REFRESH_DELAY = 500\n\nexport interface FlatFeed {\n\tpath: string\n\tname: string\n\turl: string\n\tisFolder: boolean\n\tdepth: number\n\tdata?: RSSFeedData\n}\n\nexport function flattenFeeds(items: RSSItems, parentPath = '', depth = 0): FlatFeed[] {\n\tconst result: FlatFeed[] = []\n\tfor (const [name, value] of Object.entries(items)) {\n\t\tconst path = parentPath ? `${parentPath}\\\\${name}` : name\n\t\tif (typeof value === 'string') {\n\t\t\tresult.push({ path, name, url: value, isFolder: false, depth })\n\t\t} else if (value && typeof value === 'object' && 'uid' in value) {\n\t\t\tresult.push({ path, name, url: (value as RSSFeedData).url, isFolder: false, depth, data: value as RSSFeedData })\n\t\t} else {\n\t\t\tresult.push({ path, name, url: '', isFolder: true, depth })\n\t\t\tresult.push(...flattenFeeds(value as RSSItems, path, depth + 1))\n\t\t}\n\t}\n\treturn result\n}\n\nexport const defaultRule: RSSRule = {\n\tenabled: true,\n\tmustContain: '',\n\tmustNotContain: '',\n\tuseRegex: false,\n\tepisodeFilter: '',\n\tsmartFilter: false,\n\tpreviouslyMatchedEpisodes: [],\n\taffectedFeeds: [],\n\tignoreDays: 0,\n\tlastMatch: '',\n\taddPaused: null,\n\tassignedCategory: '',\n\tsavePath: '',\n}\n\ninterface UseRSSManagerOptions {\n\tinstances: Instance[]\n\tonViewChange?: (view: 'list' | 'articles' | 'editor') => void\n}\n\nexport function useRSSManager({ instances, onViewChange }: UseRSSManagerOptions) {\n\tconst [selectedInstance, setSelectedInstance] = useState<Instance | null>(instances[0] || null)\n\tconst [loading, setLoading] = useState(true)\n\tconst [error, setError] = useState('')\n\n\tconst [feeds, setFeeds] = useState<FlatFeed[]>([])\n\tconst [selectedFeed, setSelectedFeed] = useState<FlatFeed | null>(null)\n\tconst [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())\n\tconst [showAddFeed, setShowAddFeed] = useState(false)\n\tconst [showAddFolder, setShowAddFolder] = useState(false)\n\tconst [feedUrl, setFeedUrl] = useState('')\n\tconst [feedPath, setFeedPath] = useState('')\n\tconst [folderName, setFolderName] = useState('')\n\tconst [submitting, setSubmitting] = useState(false)\n\tconst [refreshing, setRefreshing] = useState<string | null>(null)\n\tconst [deleteConfirm, setDeleteConfirm] = useState<FlatFeed | null>(null)\n\n\tconst [rules, setRules] = useState<RSSRules>({})\n\tconst [selectedRule, setSelectedRule] = useState<string | null>(null)\n\tconst [editingRule, setEditingRule] = useState<RSSRule | null>(null)\n\tconst [newRuleName, setNewRuleName] = useState('')\n\tconst [showNewRule, setShowNewRule] = useState(false)\n\tconst [ruleDeleteConfirm, setRuleDeleteConfirm] = useState<string | null>(null)\n\tconst [matchingArticles, setMatchingArticles] = useState<MatchingArticles | null>(null)\n\tconst [loadingMatches, setLoadingMatches] = useState(false)\n\tconst [savingRule, setSavingRule] = useState(false)\n\tconst [ruleSaved, setRuleSaved] = useState(false)\n\n\tconst [categories, setCategories] = useState<Record<string, Category>>({})\n\tconst [grabbing, setGrabbing] = useState<string | null>(null)\n\tconst [grabResult, setGrabResult] = useState<{ id: string; success: boolean } | null>(null)\n\tconst [instanceDropdown, setInstanceDropdown] = useState<string | null>(null)\n\n\tconst mountedRef = useRef(true)\n\tconst ruleSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\tconst grabResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n\tuseEffect(() => {\n\t\tmountedRef.current = true\n\t\treturn () => {\n\t\t\tmountedRef.current = false\n\t\t\tif (ruleSavedTimerRef.current) clearTimeout(ruleSavedTimerRef.current)\n\t\t\tif (grabResultTimerRef.current) clearTimeout(grabResultTimerRef.current)\n\t\t}\n\t}, [])\n\n\tconst loadFeeds = useCallback(async () => {\n\t\tif (!selectedInstance) return\n\t\ttry {\n\t\t\tconst items = await getRSSItems(selectedInstance.id, true)\n\t\t\tif (!mountedRef.current) return\n\t\t\tconst flat = flattenFeeds(items)\n\t\t\tsetFeeds(flat)\n\t\t\tsetExpandedFolders(new Set(flat.filter((f) => f.isFolder).map((f) => f.path)))\n\t\t\tsetSelectedFeed((prev) => (prev ? flat.find((f) => f.path === prev.path) || null : null))\n\t\t} catch (err) {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to load feeds')\n\t\t}\n\t}, [selectedInstance])\n\n\tconst loadRules = useCallback(async () => {\n\t\tif (!selectedInstance) return\n\t\ttry {\n\t\t\tconst data = await getRSSRules(selectedInstance.id)\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetRules(data)\n\t\t} catch (err) {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to load rules')\n\t\t}\n\t}, [selectedInstance])\n\n\tconst loadCategories = useCallback(async () => {\n\t\tif (!selectedInstance) return\n\t\ttry {\n\t\t\tconst data = await getCategories(selectedInstance.id)\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetCategories(data)\n\t\t} catch {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetCategories({})\n\t\t}\n\t}, [selectedInstance])\n\n\tuseEffect(() => {\n\t\tif (selectedInstance) {\n\t\t\tsetLoading(true)\n\t\t\tsetError('')\n\t\t\tPromise.all([loadFeeds(), loadRules(), loadCategories()]).finally(() => {\n\t\t\t\tif (mountedRef.current) setLoading(false)\n\t\t\t})\n\t\t}\n\t}, [selectedInstance, loadFeeds, loadRules, loadCategories])\n\n\tfunction selectRule(ruleName: string | null) {\n\t\tsetSelectedRule(ruleName)\n\t\tif (ruleName && rules[ruleName]) {\n\t\t\tsetEditingRule({ ...rules[ruleName] })\n\t\t\tsetMatchingArticles(null)\n\t\t} else {\n\t\t\tsetEditingRule(null)\n\t\t\tsetMatchingArticles(null)\n\t\t}\n\t}\n\n\tfunction extractFeedName(url: string): string {\n\t\ttry {\n\t\t\tconst urlObj = new URL(url)\n\t\t\tconst pathParts = urlObj.pathname.split('/').filter(Boolean)\n\t\t\tif (pathParts.length > 0) {\n\t\t\t\tconst lastPart = pathParts[pathParts.length - 1]\n\t\t\t\treturn lastPart.replace(/\\.(xml|rss|atom)$/i, '') || urlObj.hostname\n\t\t\t}\n\t\t\treturn urlObj.hostname\n\t\t} catch {\n\t\t\treturn url.slice(0, 30)\n\t\t}\n\t}\n\n\tasync function handleAddFeed(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tif (!selectedInstance) return\n\t\tsetSubmitting(true)\n\t\tsetError('')\n\t\tconst url = feedUrl\n\t\tconst folder = feedPath\n\t\ttry {\n\t\t\tconst feedName = extractFeedName(url)\n\t\t\tconst path = folder ? `${folder}\\\\${feedName}` : undefined\n\t\t\tawait addRSSFeed(selectedInstance.id, url, path)\n\t\t\tsetShowAddFeed(false)\n\t\t\tsetFeedUrl('')\n\t\t\tsetFeedPath('')\n\t\t\tawait new Promise((r) => setTimeout(r, FEED_REFRESH_DELAY))\n\t\t\tif (!mountedRef.current) return\n\t\t\tconst items = await getRSSItems(selectedInstance.id, true)\n\t\t\tif (!mountedRef.current) return\n\t\t\tconst flat = flattenFeeds(items)\n\t\t\tconst newFeed = flat.find((f) => !f.isFolder && f.url === url)\n\t\t\tif (newFeed) {\n\t\t\t\tawait refreshRSSItem(selectedInstance.id, newFeed.path)\n\t\t\t\tawait new Promise((r) => setTimeout(r, FEED_REFRESH_DELAY))\n\t\t\t\tif (!mountedRef.current) return\n\t\t\t\tconst updatedItems = await getRSSItems(selectedInstance.id, true)\n\t\t\t\tif (!mountedRef.current) return\n\t\t\t\tconst updatedFlat = flattenFeeds(updatedItems)\n\t\t\t\tsetFeeds(updatedFlat)\n\t\t\t\tsetExpandedFolders(new Set(updatedFlat.filter((f) => f.isFolder).map((f) => f.path)))\n\t\t\t\tsetSelectedFeed(updatedFlat.find((f) => f.path === newFeed.path) || null)\n\t\t\t} else {\n\t\t\t\tsetFeeds(flat)\n\t\t\t\tsetExpandedFolders(new Set(flat.filter((f) => f.isFolder).map((f) => f.path)))\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to add feed')\n\t\t} finally {\n\t\t\tif (mountedRef.current) setSubmitting(false)\n\t\t}\n\t}\n\n\tasync function handleAddFolder(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tif (!selectedInstance) return\n\t\tsetSubmitting(true)\n\t\tsetError('')\n\t\ttry {\n\t\t\tawait addRSSFolder(selectedInstance.id, folderName)\n\t\t\tsetShowAddFolder(false)\n\t\t\tsetFolderName('')\n\t\t\tawait loadFeeds()\n\t\t} catch (err) {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to add folder')\n\t\t} finally {\n\t\t\tif (mountedRef.current) setSubmitting(false)\n\t\t}\n\t}\n\n\tasync function handleDeleteItem() {\n\t\tif (!selectedInstance || !deleteConfirm) return\n\t\tconst deletingPath = deleteConfirm.path\n\t\ttry {\n\t\t\tawait removeRSSItem(selectedInstance.id, deletingPath)\n\t\t\tsetDeleteConfirm(null)\n\t\t\tif (selectedFeed?.path === deletingPath) {\n\t\t\t\tsetSelectedFeed(null)\n\t\t\t\tonViewChange?.('list')\n\t\t\t}\n\t\t\tawait loadFeeds()\n\t\t} catch (err) {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to delete')\n\t\t}\n\t}\n\n\tasync function handleRefresh(feed: FlatFeed) {\n\t\tif (!selectedInstance) return\n\t\tsetRefreshing(feed.path)\n\t\ttry {\n\t\t\tawait refreshRSSItem(selectedInstance.id, feed.path)\n\t\t\tawait loadFeeds()\n\t\t} catch (err) {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to refresh')\n\t\t} finally {\n\t\t\tif (mountedRef.current) setRefreshing(null)\n\t\t}\n\t}\n\n\tasync function handleSaveRule() {\n\t\tif (!selectedInstance || !selectedRule || !editingRule) return\n\t\tsetSavingRule(true)\n\t\tsetError('')\n\t\tsetRuleSaved(false)\n\t\ttry {\n\t\t\tawait setRSSRule(selectedInstance.id, selectedRule, editingRule)\n\t\t\tawait loadRules()\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetRuleSaved(true)\n\t\t\tif (ruleSavedTimerRef.current) clearTimeout(ruleSavedTimerRef.current)\n\t\t\truleSavedTimerRef.current = setTimeout(() => {\n\t\t\t\tif (mountedRef.current) setRuleSaved(false)\n\t\t\t}, 2000)\n\t\t} catch (err) {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to save rule')\n\t\t} finally {\n\t\t\tif (mountedRef.current) setSavingRule(false)\n\t\t}\n\t}\n\n\tfunction handleCancelEdit() {\n\t\tif (selectedRule && rules[selectedRule]) {\n\t\t\tsetEditingRule({ ...rules[selectedRule] })\n\t\t}\n\t\tsetMatchingArticles(null)\n\t}\n\n\tasync function handleCreateRule(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tif (!selectedInstance || !newRuleName.trim()) return\n\t\tconst ruleName = newRuleName.trim()\n\t\tsetSubmitting(true)\n\t\tsetError('')\n\t\ttry {\n\t\t\tawait setRSSRule(selectedInstance.id, ruleName, defaultRule)\n\t\t\tsetShowNewRule(false)\n\t\t\tsetNewRuleName('')\n\t\t\tawait loadRules()\n\t\t\tif (!mountedRef.current) return\n\t\t\tselectRule(ruleName)\n\t\t\tonViewChange?.('editor')\n\t\t} catch (err) {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to create rule')\n\t\t} finally {\n\t\t\tif (mountedRef.current) setSubmitting(false)\n\t\t}\n\t}\n\n\tasync function handleDeleteRule() {\n\t\tif (!selectedInstance || !ruleDeleteConfirm) return\n\t\tconst ruleName = ruleDeleteConfirm\n\t\ttry {\n\t\t\tawait removeRSSRule(selectedInstance.id, ruleName)\n\t\t\tsetRuleDeleteConfirm(null)\n\t\t\tif (selectedRule === ruleName) {\n\t\t\t\tselectRule(null)\n\t\t\t\tonViewChange?.('list')\n\t\t\t}\n\t\t\tawait loadRules()\n\t\t} catch (err) {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to delete rule')\n\t\t}\n\t}\n\n\tasync function handlePreviewMatches() {\n\t\tif (!selectedInstance || !selectedRule) return\n\t\tsetLoadingMatches(true)\n\t\ttry {\n\t\t\tconst data = await getMatchingArticles(selectedInstance.id, selectedRule)\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetMatchingArticles(data)\n\t\t} catch (err) {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to load matches')\n\t\t} finally {\n\t\t\tif (mountedRef.current) setLoadingMatches(false)\n\t\t}\n\t}\n\n\tasync function handleGrabArticle(url: string, articleId: string, instanceId: number) {\n\t\tsetGrabbing(articleId)\n\t\tsetGrabResult(null)\n\t\ttry {\n\t\t\tawait addTorrent(instanceId, { urls: url })\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetGrabResult({ id: articleId, success: true })\n\t\t\tif (grabResultTimerRef.current) clearTimeout(grabResultTimerRef.current)\n\t\t\tgrabResultTimerRef.current = setTimeout(() => {\n\t\t\t\tif (mountedRef.current) setGrabResult(null)\n\t\t\t}, 3000)\n\t\t} catch {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetGrabResult({ id: articleId, success: false })\n\t\t} finally {\n\t\t\tif (mountedRef.current) {\n\t\t\t\tsetGrabbing(null)\n\t\t\t\tsetInstanceDropdown(null)\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction toggleFolder(path: string) {\n\t\tsetExpandedFolders((prev) => {\n\t\t\tconst next = new Set(prev)\n\t\t\tif (next.has(path)) next.delete(path)\n\t\t\telse next.add(path)\n\t\t\treturn next\n\t\t})\n\t}\n\n\tfunction selectInstance(instance: Instance) {\n\t\tsetSelectedInstance(instance)\n\t\tsetSelectedFeed(null)\n\t\tselectRule(null)\n\t}\n\n\tfunction clearError() {\n\t\tsetError('')\n\t}\n\n\tfunction cancelAddFeed() {\n\t\tsetShowAddFeed(false)\n\t\tsetFeedUrl('')\n\t\tsetFeedPath('')\n\t}\n\n\tfunction cancelAddFolder() {\n\t\tsetShowAddFolder(false)\n\t\tsetFolderName('')\n\t}\n\n\tfunction cancelNewRule() {\n\t\tsetShowNewRule(false)\n\t\tsetNewRuleName('')\n\t}\n\n\tconst visibleFeeds = feeds.filter((f) => {\n\t\tif (f.depth === 0) return true\n\t\tconst parts = f.path.split('\\\\')\n\t\tfor (let i = 1; i < parts.length; i++) {\n\t\t\tconst parentPath = parts.slice(0, i).join('\\\\')\n\t\t\tif (!expandedFolders.has(parentPath)) return false\n\t\t}\n\t\treturn true\n\t})\n\n\tconst feedUrls = feeds.filter((f) => !f.isFolder).map((f) => f.url)\n\tconst feedArticles = selectedFeed?.data?.articles || []\n\n\treturn {\n\t\tselectedInstance,\n\t\tloading,\n\t\terror,\n\t\tfeeds,\n\t\tvisibleFeeds,\n\t\tselectedFeed,\n\t\texpandedFolders,\n\t\tshowAddFeed,\n\t\tshowAddFolder,\n\t\tfeedUrl,\n\t\tfeedPath,\n\t\tfolderName,\n\t\tsubmitting,\n\t\trefreshing,\n\t\tdeleteConfirm,\n\t\trules,\n\t\tselectedRule,\n\t\teditingRule,\n\t\tnewRuleName,\n\t\tshowNewRule,\n\t\truleDeleteConfirm,\n\t\tmatchingArticles,\n\t\tloadingMatches,\n\t\tsavingRule,\n\t\truleSaved,\n\t\tcategories,\n\t\tgrabbing,\n\t\tgrabResult,\n\t\tinstanceDropdown,\n\t\tfeedUrls,\n\t\tfeedArticles,\n\t\tsetSelectedFeed,\n\t\tsetShowAddFeed,\n\t\tsetShowAddFolder,\n\t\tsetFeedUrl,\n\t\tsetFeedPath,\n\t\tsetFolderName,\n\t\tsetDeleteConfirm,\n\t\tselectRule,\n\t\tsetEditingRule,\n\t\tsetNewRuleName,\n\t\tsetShowNewRule,\n\t\tsetRuleDeleteConfirm,\n\t\tsetInstanceDropdown,\n\t\tselectInstance,\n\t\tclearError,\n\t\tcancelAddFeed,\n\t\tcancelAddFolder,\n\t\tcancelNewRule,\n\t\ttoggleFolder,\n\t\thandleAddFeed,\n\t\thandleAddFolder,\n\t\thandleDeleteItem,\n\t\thandleRefresh,\n\t\thandleSaveRule,\n\t\thandleCancelEdit,\n\t\thandleCreateRule,\n\t\thandleDeleteRule,\n\t\thandlePreviewMatches,\n\t\thandleGrabArticle,\n\t}\n}\n"
  },
  {
    "path": "src/hooks/useStats.ts",
    "content": "import { useState, useEffect } from 'react'\nimport { getStats, type PeriodStats } from '../api/stats'\n\nexport const PERIODS = [\n\t{ value: '15m', label: '15 min' },\n\t{ value: '30m', label: '30 min' },\n\t{ value: '1h', label: '1 hour' },\n\t{ value: '4h', label: '4 hours' },\n\t{ value: '12h', label: '12 hours' },\n\t{ value: '1d', label: '1 day' },\n\t{ value: '1w', label: '1 week' },\n\t{ value: '1mo', label: '1 month' },\n\t{ value: '6mo', label: '6 months' },\n\t{ value: '1y', label: '1 year' },\n\t{ value: 'all', label: 'All time' },\n] as const\n\nexport interface PeriodData {\n\tperiod: string\n\tlabel: string\n\tuploaded: number\n\tdownloaded: number\n\thasData: boolean\n}\n\nexport interface InstanceOption {\n\tid: number\n\tlabel: string\n}\n\ninterface UseStatsResult {\n\tperiodData: PeriodData[] | null\n\tinstances: InstanceOption[]\n\tselectedInstance: string\n\tsetSelectedInstance: (value: string) => void\n\tisLoading: boolean\n\thasAnyData: boolean\n}\n\nexport function useStats(): UseStatsResult {\n\tconst [periodData, setPeriodData] = useState<PeriodData[] | null>(null)\n\tconst [instances, setInstances] = useState<InstanceOption[]>([])\n\tconst [selectedInstance, setSelectedInstance] = useState<string>('all')\n\n\tuseEffect(() => {\n\t\tlet cancelled = false\n\n\t\tasync function fetchAllPeriods(): Promise<void> {\n\t\t\tconst results = await Promise.all(\n\t\t\t\tPERIODS.map(async (p) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\treturn { period: p.value, stats: await getStats(p.value) }\n\t\t\t\t\t} catch {\n\t\t\t\t\t\treturn { period: p.value, stats: [] as PeriodStats[] }\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t)\n\n\t\t\tif (cancelled) return\n\n\t\t\tconst firstWithData = results.find((r) => r.stats.length > 0)\n\t\t\tif (firstWithData) {\n\t\t\t\tconst uniqueInstances: InstanceOption[] = []\n\t\t\t\tfor (const s of firstWithData.stats) {\n\t\t\t\t\tif (!uniqueInstances.some((i) => i.id === s.instanceId)) {\n\t\t\t\t\t\tuniqueInstances.push({ id: s.instanceId, label: s.instanceLabel })\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tsetInstances(uniqueInstances)\n\t\t\t}\n\n\t\t\tconst processed = results.map(({ period, stats }) => {\n\t\t\t\tconst periodConfig = PERIODS.find((p) => p.value === period)!\n\t\t\t\tconst filtered =\n\t\t\t\t\tselectedInstance === 'all' ? stats : stats.filter((s) => s.instanceId === Number(selectedInstance))\n\t\t\t\treturn {\n\t\t\t\t\tperiod,\n\t\t\t\t\tlabel: periodConfig.label,\n\t\t\t\t\tuploaded: filtered.reduce((sum, s) => sum + s.uploaded, 0),\n\t\t\t\t\tdownloaded: filtered.reduce((sum, s) => sum + s.downloaded, 0),\n\t\t\t\t\thasData: filtered.some((s) => s.hasData) || period === 'all',\n\t\t\t\t}\n\t\t\t})\n\t\t\tsetPeriodData(processed)\n\t\t}\n\n\t\tfetchAllPeriods()\n\t\treturn () => {\n\t\t\tcancelled = true\n\t\t}\n\t}, [selectedInstance])\n\n\treturn {\n\t\tperiodData,\n\t\tinstances,\n\t\tselectedInstance,\n\t\tsetSelectedInstance,\n\t\tisLoading: periodData === null,\n\t\thasAnyData: periodData?.some((p) => p.hasData) ?? false,\n\t}\n}\n"
  },
  {
    "path": "src/hooks/useSyncMaindata.ts",
    "content": "import { useQuery } from '@tanstack/react-query'\nimport { getSyncMaindata } from '../api/qbittorrent'\nimport { useInstance } from './useInstance'\n\nexport function useSyncMaindata() {\n\tconst instance = useInstance()\n\treturn useQuery({\n\t\tqueryKey: ['syncMaindata', instance.id],\n\t\tqueryFn: () => getSyncMaindata(instance.id),\n\t\trefetchInterval: 2000,\n\t})\n}\n"
  },
  {
    "path": "src/hooks/useTheme.ts",
    "content": "import { useContext } from 'react'\nimport { ThemeContext } from '../contexts/ThemeContext'\n\nexport function useTheme() {\n\tconst ctx = useContext(ThemeContext)\n\tif (!ctx) throw new Error('useTheme must be used within ThemeProvider')\n\treturn ctx\n}\n\nexport type { ThemeContextValue } from '../contexts/ThemeContext'\n"
  },
  {
    "path": "src/hooks/useTorrentDetails.ts",
    "content": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport * as api from '../api/qbittorrent'\nimport { useInstance } from './useInstance'\n\nexport function useTorrentProperties(hash: string | null) {\n\tconst instance = useInstance()\n\treturn useQuery({\n\t\tqueryKey: ['torrent-properties', instance.id, hash],\n\t\tqueryFn: () => api.getTorrentProperties(instance.id, hash!),\n\t\tenabled: !!hash,\n\t\trefetchInterval: 2000,\n\t})\n}\n\nexport function useTorrentTrackers(hash: string | null) {\n\tconst instance = useInstance()\n\treturn useQuery({\n\t\tqueryKey: ['torrent-trackers', instance.id, hash],\n\t\tqueryFn: () => api.getTorrentTrackers(instance.id, hash!),\n\t\tenabled: !!hash,\n\t\trefetchInterval: 5000,\n\t})\n}\n\nexport function useTorrentPeers(hash: string | null) {\n\tconst instance = useInstance()\n\treturn useQuery({\n\t\tqueryKey: ['torrent-peers', instance.id, hash],\n\t\tqueryFn: () => api.getTorrentPeers(instance.id, hash!),\n\t\tenabled: !!hash,\n\t\trefetchInterval: 2000,\n\t})\n}\n\nexport function useTorrentFiles(hash: string | null) {\n\tconst instance = useInstance()\n\treturn useQuery({\n\t\tqueryKey: ['torrent-files', instance.id, hash],\n\t\tqueryFn: () => api.getTorrentFiles(instance.id, hash!),\n\t\tenabled: !!hash,\n\t})\n}\n\nexport function useTorrentWebSeeds(hash: string | null) {\n\tconst instance = useInstance()\n\treturn useQuery({\n\t\tqueryKey: ['torrent-webseeds', instance.id, hash],\n\t\tqueryFn: () => api.getTorrentWebSeeds(instance.id, hash!),\n\t\tenabled: !!hash,\n\t})\n}\n\nexport function useSetFilePriority() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ hash, ids, priority }: { hash: string; ids: number[]; priority: number }) =>\n\t\t\tapi.setFilePriority(instance.id, hash, ids, priority),\n\t\tonMutate: async ({ hash, ids, priority }) => {\n\t\t\tawait queryClient.cancelQueries({ queryKey: ['torrent-files', instance.id, hash] })\n\t\t\tconst previous = queryClient.getQueryData(['torrent-files', instance.id, hash])\n\t\t\tqueryClient.setQueryData(['torrent-files', instance.id, hash], (old: unknown) => {\n\t\t\t\tif (!Array.isArray(old)) return old\n\t\t\t\treturn old.map((file, idx) => (ids.includes(idx) ? { ...file, priority } : file))\n\t\t\t})\n\t\t\treturn { previous, hash }\n\t\t},\n\t\tonError: (_, __, context) => {\n\t\t\tif (context?.previous) {\n\t\t\t\tqueryClient.setQueryData(['torrent-files', instance.id, context.hash], context.previous)\n\t\t\t}\n\t\t},\n\t\tonSettled: (_, __, { hash }) => queryClient.invalidateQueries({ queryKey: ['torrent-files', instance.id, hash] }),\n\t})\n}\n\nexport function useAddTrackers() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ hash, urls }: { hash: string; urls: string[] }) => api.addTrackers(instance.id, hash, urls),\n\t\tonSuccess: (_, { hash }) => queryClient.invalidateQueries({ queryKey: ['torrent-trackers', instance.id, hash] }),\n\t})\n}\n\nexport function useRemoveTrackers() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ hash, urls }: { hash: string; urls: string[] }) => api.removeTrackers(instance.id, hash, urls),\n\t\tonSuccess: (_, { hash }) => queryClient.invalidateQueries({ queryKey: ['torrent-trackers', instance.id, hash] }),\n\t})\n}\n"
  },
  {
    "path": "src/hooks/useTorrents.ts",
    "content": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport * as api from '../api/qbittorrent'\nimport type { AddTorrentOptions, TorrentFilterOptions } from '../api/qbittorrent'\nimport { useInstance } from './useInstance'\n\nexport function useTorrents(options: TorrentFilterOptions = {}) {\n\tconst instance = useInstance()\n\treturn useQuery({\n\t\tqueryKey: ['torrents', instance.id, options],\n\t\tqueryFn: () => api.getTorrents(instance.id, options),\n\t\trefetchInterval: 2000,\n\t})\n}\n\nexport function useStopTorrents() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: (hashes: string[]) => api.stopTorrents(instance.id, hashes),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['torrents', instance.id] }),\n\t})\n}\n\nexport function useStartTorrents() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: (hashes: string[]) => api.startTorrents(instance.id, hashes),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['torrents', instance.id] }),\n\t})\n}\n\nexport function useRecheckTorrents() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: (hashes: string[]) => api.recheckTorrents(instance.id, hashes),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['torrents', instance.id] }),\n\t})\n}\n\nexport function useReannounceTorrents() {\n\tconst instance = useInstance()\n\treturn useMutation({\n\t\tmutationFn: (hashes: string[]) => api.reannounceTorrents(instance.id, hashes),\n\t})\n}\n\nexport function useDeleteTorrents() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ hashes, deleteFiles }: { hashes: string[]; deleteFiles?: boolean }) =>\n\t\t\tapi.deleteTorrents(instance.id, hashes, deleteFiles),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['torrents', instance.id] }),\n\t})\n}\n\nexport function useAddTorrent() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ options, files }: { options: AddTorrentOptions; files?: File[] }) =>\n\t\t\tapi.addTorrent(instance.id, options, files),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['torrents', instance.id] }),\n\t})\n}\n\nexport function useCategories() {\n\tconst instance = useInstance()\n\treturn useQuery({\n\t\tqueryKey: ['categories', instance.id],\n\t\tqueryFn: () => api.getCategories(instance.id),\n\t})\n}\n\nexport function useTags() {\n\tconst instance = useInstance()\n\treturn useQuery({\n\t\tqueryKey: ['tags', instance.id],\n\t\tqueryFn: () => api.getTags(instance.id),\n\t})\n}\n\nexport function useSetCategory() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ hashes, category }: { hashes: string[]; category: string }) =>\n\t\t\tapi.setCategory(instance.id, hashes, category),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['torrents', instance.id] }),\n\t})\n}\n\nexport function useAddTags() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ hashes, tags }: { hashes: string[]; tags: string }) => api.addTags(instance.id, hashes, tags),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrents', instance.id] })\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['tags', instance.id] })\n\t\t},\n\t})\n}\n\nexport function useRemoveTags() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ hashes, tags }: { hashes: string[]; tags: string }) => api.removeTags(instance.id, hashes, tags),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrents', instance.id] })\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['tags', instance.id] })\n\t\t},\n\t})\n}\n\nexport function useRenameTorrent() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ hash, name }: { hash: string; name: string }) => api.renameTorrent(instance.id, hash, name),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['torrents', instance.id] }),\n\t})\n}\n\nexport function useSetTorrentLocation() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ hashes, location }: { hashes: string[]; location: string }) =>\n\t\t\tapi.setTorrentLocation(instance.id, hashes, location),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrents', instance.id] })\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrent-properties', instance.id] })\n\t\t},\n\t})\n}\n\nexport function useSetTorrentDownloadPath() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ hashes, downloadPath }: { hashes: string[]; downloadPath: string }) =>\n\t\t\tapi.setTorrentDownloadPath(instance.id, hashes, downloadPath),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrents', instance.id] })\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrent-properties', instance.id] })\n\t\t},\n\t})\n}\n\nexport function useCreateTag() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: (tag: string) => api.createTags(instance.id, tag),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['tags', instance.id] }),\n\t})\n}\n\nexport function useDeleteTag() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: (tag: string) => api.deleteTags(instance.id, tag),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['tags', instance.id] })\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrents', instance.id] })\n\t\t},\n\t})\n}\n\nexport function useCreateCategory() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ name, savePath }: { name: string; savePath?: string }) =>\n\t\t\tapi.createCategory(instance.id, name, savePath),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['categories', instance.id] }),\n\t})\n}\n\nexport function useEditCategory() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: ({ name, savePath }: { name: string; savePath: string }) =>\n\t\t\tapi.editCategory(instance.id, name, savePath),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['categories', instance.id] }),\n\t})\n}\n\nexport function useDeleteCategory() {\n\tconst instance = useInstance()\n\tconst queryClient = useQueryClient()\n\treturn useMutation({\n\t\tmutationFn: (name: string) => api.removeCategories(instance.id, [name]),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['categories', instance.id] })\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrents', instance.id] })\n\t\t},\n\t})\n}\n\nexport function useExportTorrents() {\n\tconst instance = useInstance()\n\treturn useMutation({\n\t\tmutationFn: (torrents: { hash: string; name: string }[]) => api.exportTorrents(instance.id, torrents),\n\t})\n}\n"
  },
  {
    "path": "src/hooks/useTransferInfo.ts",
    "content": "import { useQuery } from '@tanstack/react-query'\nimport { getTransferInfo } from '../api/qbittorrent'\nimport { useInstance } from './useInstance'\n\nexport function useTransferInfo() {\n\tconst instance = useInstance()\n\treturn useQuery({\n\t\tqueryKey: ['transferInfo', instance.id],\n\t\tqueryFn: () => getTransferInfo(instance.id),\n\t\trefetchInterval: 2000,\n\t})\n}\n"
  },
  {
    "path": "src/hooks/useUpdateCheck.ts",
    "content": "import { useQuery } from '@tanstack/react-query'\n\ndeclare const __APP_VERSION__: string\n\ninterface GitHubRelease {\n\ttag_name: string\n\tbody?: string\n\thtml_url?: string\n}\n\nfunction compareVersions(current: string, latest: string): number {\n\tconst parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)\n\tconst [c, l] = [parse(current), parse(latest)]\n\tfor (let i = 0; i < Math.max(c.length, l.length); i++) {\n\t\tconst diff = (l[i] ?? 0) - (c[i] ?? 0)\n\t\tif (diff !== 0) return diff\n\t}\n\treturn 0\n}\n\nexport function useUpdateCheck() {\n\tconst { data, isLoading, error } = useQuery({\n\t\tqueryKey: ['update-check'],\n\t\tqueryFn: async (): Promise<GitHubRelease> => {\n\t\t\tconst res = await fetch('https://api.github.com/repos/Maciejonos/qbitwebui/releases/latest')\n\t\t\tif (!res.ok) throw new Error('Failed to fetch')\n\t\t\treturn res.json()\n\t\t},\n\t\tstaleTime: 1000 * 60 * 60,\n\t\trefetchOnWindowFocus: false,\n\t})\n\n\tconst latestVersion = data?.tag_name?.replace(/^v/, '')\n\tconst hasUpdate = latestVersion ? compareVersions(__APP_VERSION__, latestVersion) > 0 : false\n\n\treturn {\n\t\thasUpdate,\n\t\tlatestVersion,\n\t\treleaseNotes: data?.body ?? '',\n\t\treleaseUrl: data?.html_url ?? '',\n\t\tisLoading,\n\t\terror,\n\t}\n}\n"
  },
  {
    "path": "src/index.css",
    "content": "@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap');\n@import 'tailwindcss';\n\n:root {\n\t--bg-primary: #07070a;\n\t--bg-secondary: #0a0a0f;\n\t--bg-tertiary: #0e0e14;\n\t--text-primary: #e8e8ed;\n\t--text-secondary: #b8b8c8;\n\t--text-muted: #8a8a9e;\n\t--accent: #00d4aa;\n\t--accent-contrast: #070a09;\n\t--warning: #f7b731;\n\t--error: #f43f5e;\n\t--border: #ffffff12;\n\t--progress: #00d4aa;\n}\n\n* {\n\tscrollbar-width: thin;\n\tscrollbar-color: var(--text-muted) transparent;\n}\n\nbody {\n\tfont-family: 'Outfit', system-ui, sans-serif;\n\tbackground: var(--bg-primary);\n\tcolor: var(--text-primary);\n\tmin-height: 100vh;\n\t-webkit-font-smoothing: antialiased;\n\t-moz-osx-font-smoothing: grayscale;\n}\n\n.font-mono {\n\tfont-family: 'JetBrains Mono', monospace;\n}\n\n.progress-glow {\n\tposition: relative;\n}\n.progress-glow::after {\n\tcontent: '';\n\tposition: absolute;\n\tinset: 0;\n\tborder-radius: inherit;\n\topacity: 0.6;\n\tfilter: blur(6px);\n\tbackground: inherit;\n\tz-index: -1;\n}\n\n@keyframes pulse-glow {\n\t0%,\n\t100% {\n\t\topacity: 0.6;\n\t}\n\t50% {\n\t\topacity: 1;\n\t}\n}\n\n.downloading .progress-glow::after {\n\tanimation: pulse-glow 2s ease-in-out infinite;\n}\n\n@keyframes fadeIn {\n\tfrom {\n\t\topacity: 0;\n\t\ttransform: translateY(8px);\n\t}\n\tto {\n\t\topacity: 1;\n\t\ttransform: translateY(0);\n\t}\n}\n\n.animate-in {\n\tanimation: fadeIn 0.4s ease-out forwards;\n}\n\ninput:focus {\n\toutline: none;\n}\n\nbutton,\nselect,\n[role='button'],\ninput[type='button'],\ninput[type='submit'],\ninput[type='checkbox'],\ninput[type='radio'],\nlabel {\n\tcursor: pointer;\n}\n\nbutton:not([data-dropdown]),\n[role='button'],\ninput[type='button'],\ninput[type='submit'] {\n\ttransition:\n\t\tfilter 0.15s ease,\n\t\ttransform 0.1s ease;\n}\n\nbutton:not([data-dropdown]):hover:not(:disabled),\n[role='button']:hover:not(:disabled),\ninput[type='button']:hover:not(:disabled),\ninput[type='submit']:hover:not(:disabled) {\n\tfilter: brightness(1.15);\n}\n\nbutton:not([data-dropdown]):active:not(:disabled),\n[role='button']:active:not(:disabled),\ninput[type='button']:active:not(:disabled),\ninput[type='submit']:active:not(:disabled) {\n\tfilter: brightness(0.9);\n\ttransform: scale(0.97);\n}\n\n::selection {\n\tbackground: color-mix(in srgb, var(--accent) 30%, transparent);\n\tcolor: var(--text-primary);\n}\n\n.resize-handle {\n\tposition: absolute;\n\tright: -4px;\n\ttop: 0;\n\tbottom: 0;\n\twidth: 9px;\n\tcursor: col-resize;\n\tuser-select: none;\n\tz-index: 10;\n}\n\n.resize-handle::before {\n\tcontent: '';\n\tposition: absolute;\n\tleft: 50%;\n\ttop: 50%;\n\ttransform: translate(-50%, -50%);\n\twidth: 1px;\n\theight: 40%;\n\tbackground: transparent;\n\tborder-radius: 1px;\n\ttransition: all 0.2s ease;\n}\n\n.resize-handle:hover::before {\n\theight: 60%;\n\twidth: 2px;\n\tbackground: var(--accent);\n\tbox-shadow:\n\t\t0 0 8px var(--accent),\n\t\t0 0 4px var(--accent);\n}\n"
  },
  {
    "path": "src/main.tsx",
    "content": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport './index.css'\nimport App from './App.tsx'\n\ncreateRoot(document.getElementById('root')!).render(\n\t<StrictMode>\n\t\t<App />\n\t</StrictMode>\n)\n"
  },
  {
    "path": "src/mobile/MobileApp.tsx",
    "content": "import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'\nimport { Download, Plus, Wrench, Zap, User, LogOut, Search, X, Server } from 'lucide-react'\nimport { QueryClientProvider, QueryClient } from '@tanstack/react-query'\nimport { getInstances, type Instance } from '../api/instances'\nimport { logout } from '../api/auth'\nimport { getSpeedLimitsMode, toggleSpeedLimitsMode } from '../api/qbittorrent'\nimport { MobileInstancePicker } from './MobileInstancePicker'\nimport { MobileStats } from './MobileStats'\nimport { MobileTorrentList } from './MobileTorrentList'\nimport { MobileThemeSwitcher } from './MobileThemeSwitcher'\nimport { InstanceProvider } from '../contexts/InstanceProvider'\n\nconst MobileTorrentDetail = lazy(() =>\n\timport('./MobileTorrentDetail').then((m) => ({ default: m.MobileTorrentDetail }))\n)\nconst MobileTools = lazy(() => import('./MobileTools').then((m) => ({ default: m.MobileTools })))\nconst AddTorrentModal = lazy(() =>\n\timport('../components/AddTorrentModal').then((m) => ({ default: m.AddTorrentModal }))\n)\n\ntype MainTab = 'torrents' | 'tools'\ntype Tool = 'search' | 'files' | 'orphans' | 'rss' | 'logs' | 'cross-seed' | 'statistics' | 'network' | null\n\nconst toolUrlMap: Record<string, Tool> = {\n\tindexers: 'search',\n\tsearch: 'search',\n\tfiles: 'files',\n\torphans: 'orphans',\n\trss: 'rss',\n\tlogs: 'logs',\n\t'cross-seed': 'cross-seed',\n\tstatistics: 'statistics',\n\tnetwork: 'network',\n}\n\nconst toolToUrl: Record<NonNullable<Tool>, string> = {\n\tsearch: 'indexers',\n\tfiles: 'files',\n\torphans: 'orphans',\n\trss: 'rss',\n\tlogs: 'logs',\n\t'cross-seed': 'cross-seed',\n\tstatistics: 'statistics',\n\tnetwork: 'network',\n}\n\nfunction parseHash(): { tab: MainTab; tool: Tool } {\n\tconst hash = window.location.hash.slice(1)\n\tif (hash === 'tools') return { tab: 'tools', tool: null }\n\tif (hash.startsWith('tools/')) {\n\t\tconst toolName = hash.slice(6)\n\t\tconst tool = toolUrlMap[toolName] ?? null\n\t\treturn { tab: 'tools', tool }\n\t}\n\treturn { tab: 'torrents', tool: null }\n}\n\nfunction setHash(tab: MainTab, tool: Tool) {\n\tif (tab === 'tools') {\n\t\twindow.location.hash = tool ? `tools/${toolToUrl[tool]}` : 'tools'\n\t} else {\n\t\twindow.location.hash = ''\n\t}\n}\n\nfunction useAltSpeedMode(instanceId: number | null) {\n\tconst [enabled, setEnabled] = useState(false)\n\tconst [toggling, setToggling] = useState(false)\n\n\tuseEffect(() => {\n\t\tif (instanceId === null) return\n\t\tlet mounted = true\n\t\tconst checkMode = () =>\n\t\t\tgetSpeedLimitsMode(instanceId)\n\t\t\t\t.then((mode) => mounted && setEnabled(mode === 1))\n\t\t\t\t.catch(() => {})\n\t\tcheckMode()\n\t\tconst interval = setInterval(checkMode, 2000)\n\t\treturn () => {\n\t\t\tmounted = false\n\t\t\tclearInterval(interval)\n\t\t}\n\t}, [instanceId])\n\n\tconst toggle = useCallback(async () => {\n\t\tif (instanceId === null || toggling) return\n\t\tsetToggling(true)\n\t\tawait toggleSpeedLimitsMode(instanceId)\n\t\t\t.then(() => setEnabled((prev) => !prev))\n\t\t\t.catch(() => {})\n\t\tsetToggling(false)\n\t}, [instanceId, toggling])\n\n\treturn { enabled, toggling, toggle }\n}\n\nconst queryClient = new QueryClient({\n\tdefaultOptions: {\n\t\tqueries: {\n\t\t\tretry: 1,\n\t\t\tstaleTime: 1000,\n\t\t},\n\t},\n})\n\ninterface Props {\n\tusername: string\n\tonLogout: () => void\n\tauthDisabled?: boolean\n}\n\nexport function MobileApp({ username, onLogout, authDisabled }: Props) {\n\tconst [instances, setInstances] = useState<Instance[]>([])\n\tconst [selectedInstance, setSelectedInstance] = useState<Instance | 'all'>('all')\n\tconst [loading, setLoading] = useState(true)\n\tconst [selectedTorrentHash, setSelectedTorrentHash] = useState<string | null>(null)\n\tconst [selectedTorrentInstanceId, setSelectedTorrentInstanceId] = useState<number | null>(null)\n\tconst [showUserMenu, setShowUserMenu] = useState(false)\n\tconst [search, setSearch] = useState('')\n\tconst [searchFocused, setSearchFocused] = useState(false)\n\tconst [mainTab, setMainTab] = useState<MainTab>(() => parseHash().tab)\n\tconst [activeTool, setActiveTool] = useState<Tool>(() => parseHash().tool)\n\tconst [compactMode, setCompactMode] = useState(() => localStorage.getItem('mobileCompactMode') === 'true')\n\tconst [showAddModal, setShowAddModal] = useState(false)\n\tconst searchInputRef = useRef<HTMLInputElement>(null)\n\tconst effectiveInstance = selectedInstance !== 'all' ? selectedInstance : instances.length === 1 ? instances[0] : null\n\tconst altSpeed = useAltSpeedMode(effectiveInstance?.id ?? null)\n\n\tconst handleMainTabChange = useCallback(\n\t\t(tab: MainTab) => {\n\t\t\tsetMainTab(tab)\n\t\t\tif (tab === 'torrents') {\n\t\t\t\tsetActiveTool(null)\n\t\t\t\tsetHash(tab, null)\n\t\t\t} else {\n\t\t\t\tsetHash(tab, activeTool)\n\t\t\t}\n\t\t},\n\t\t[activeTool]\n\t)\n\n\tconst handleToolChange = useCallback((tool: Tool) => {\n\t\tsetActiveTool(tool)\n\t\tsetHash('tools', tool)\n\t}, [])\n\n\tuseEffect(() => {\n\t\tfunction handleHashChange() {\n\t\t\tconst { tab, tool } = parseHash()\n\t\t\tsetMainTab(tab)\n\t\t\tsetActiveTool(tool)\n\t\t}\n\t\twindow.addEventListener('hashchange', handleHashChange)\n\t\treturn () => window.removeEventListener('hashchange', handleHashChange)\n\t}, [])\n\n\tconst toggleCompactMode = useCallback(() => {\n\t\tsetCompactMode((prev) => {\n\t\t\tconst next = !prev\n\t\t\tlocalStorage.setItem('mobileCompactMode', String(next))\n\t\t\treturn next\n\t\t})\n\t}, [])\n\n\tconst loadInstances = useCallback(async () => {\n\t\ttry {\n\t\t\tconst data = await getInstances()\n\t\t\tsetInstances(data)\n\t\t} finally {\n\t\t\tsetLoading(false)\n\t\t}\n\t}, [])\n\n\tuseEffect(() => {\n\t\tloadInstances()\n\t}, [loadInstances])\n\n\tasync function handleLogout() {\n\t\tawait logout()\n\t\tonLogout()\n\t}\n\n\tif (loading) {\n\t\treturn (\n\t\t\t<div className=\"min-h-screen flex items-center justify-center\" style={{ backgroundColor: 'var(--bg-primary)' }}>\n\t\t\t\t<div className=\"flex flex-col items-center gap-3\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-10 h-10 rounded-xl flex items-center justify-center\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackground: 'linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, black))',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Download className=\"w-5 h-5 animate-pulse\" style={{ color: 'var(--accent-contrast)' }} strokeWidth={2.5} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tLoading...\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (instances.length === 0) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclassName=\"min-h-screen flex flex-col items-center justify-center p-6\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-primary)' }}\n\t\t\t>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"w-16 h-16 rounded-2xl flex items-center justify-center mb-4\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t>\n\t\t\t\t\t<Server className=\"w-8 h-8\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t</div>\n\t\t\t\t<h2 className=\"text-lg font-semibold mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\tNo Instances\n\t\t\t\t</h2>\n\t\t\t\t<p className=\"text-sm text-center mb-6\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\tAdd a qBittorrent instance using the desktop version to get started.\n\t\t\t\t</p>\n\t\t\t\t{!authDisabled && (\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={handleLogout}\n\t\t\t\t\t\tclassName=\"px-6 py-3 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\tLogout\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t)\n\t}\n\n\treturn (\n\t\t<QueryClientProvider client={queryClient}>\n\t\t\t<div className=\"min-h-screen flex flex-col\" style={{ backgroundColor: 'var(--bg-primary)' }}>\n\t\t\t\t<header\n\t\t\t\t\tclassName=\"sticky top-0 z-40 border-b backdrop-blur-xl\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--bg-primary) 85%, transparent)',\n\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex items-center justify-between px-4 py-3\">\n\t\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t\t<img src=\"/logo.svg\" alt=\"qbitwebui\" className=\"w-8 h-8\" />\n\t\t\t\t\t\t\t{mainTab === 'torrents' && (\n\t\t\t\t\t\t\t\t<MobileInstancePicker instances={instances} current={selectedInstance} onChange={setSelectedInstance} />\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{mainTab === 'tools' && (\n\t\t\t\t\t\t\t\t<span className=\"text-base font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tTools\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t{effectiveInstance && (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={altSpeed.toggle}\n\t\t\t\t\t\t\t\t\tdisabled={altSpeed.toggling}\n\t\t\t\t\t\t\t\t\tclassName=\"w-9 h-9 rounded-full flex items-center justify-center active:scale-95 transition-all\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: altSpeed.enabled\n\t\t\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, var(--accent) 20%, transparent)'\n\t\t\t\t\t\t\t\t\t\t\t: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\topacity: altSpeed.toggling ? 0.5 : 1,\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Zap\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-5 h-5\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: altSpeed.enabled ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<MobileThemeSwitcher />\n\t\t\t\t\t\t\t{!authDisabled && (\n\t\t\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tonClick={() => setShowUserMenu(!showUserMenu)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-9 h-9 rounded-full flex items-center justify-center active:scale-95 transition-transform\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<User className=\"w-5 h-5\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t{showUserMenu && (\n\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"fixed inset-0 z-40\" onClick={() => setShowUserMenu(false)} />\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"absolute right-0 top-full mt-2 z-50 min-w-[160px] rounded-xl border shadow-xl overflow-hidden\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"px-4 py-3 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{username}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetShowUserMenu(false)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleLogout()\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-4 py-3 text-sm active:bg-[var(--bg-tertiary)] flex items-center gap-3\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<LogOut className=\"w-4 h-4\" strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t\t\t\t\tLogout\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t{mainTab === 'torrents' && (\n\t\t\t\t\t\t<div className=\"px-4 pb-3\">\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-3 px-4 py-2.5 rounded-xl border transition-all duration-200\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tborderColor: searchFocused ? 'var(--accent)' : 'var(--border)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Search\n\t\t\t\t\t\t\t\t\tclassName=\"w-4 h-4 flex-shrink-0\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: searchFocused ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\tref={searchInputRef}\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tinputMode=\"search\"\n\t\t\t\t\t\t\t\t\tenterKeyHint=\"search\"\n\t\t\t\t\t\t\t\t\tvalue={search}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setSearch(e.target.value)}\n\t\t\t\t\t\t\t\t\tonFocus={() => setSearchFocused(true)}\n\t\t\t\t\t\t\t\t\tonBlur={() => setSearchFocused(false)}\n\t\t\t\t\t\t\t\t\tplaceholder=\"Search torrents...\"\n\t\t\t\t\t\t\t\t\tclassName=\"flex-1 bg-transparent outline-none\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)', fontSize: '16px' }}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t{search && (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tonMouseDown={(e) => e.preventDefault()}\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tsetSearch('')\n\t\t\t\t\t\t\t\t\t\t\tsearchInputRef.current?.focus()\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName=\"p-1 rounded-full active:scale-90 transition-transform\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<X className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</header>\n\n\t\t\t\t<main\n\t\t\t\t\tclassName=\"flex-1 overflow-y-auto\"\n\t\t\t\t\tstyle={{ paddingBottom: 'calc(70px + env(safe-area-inset-bottom, 0px))' }}\n\t\t\t\t>\n\t\t\t\t\t{mainTab === 'torrents' && (\n\t\t\t\t\t\t<div className=\"p-4 space-y-4\">\n\t\t\t\t\t\t\t<MobileStats instances={selectedInstance === 'all' ? instances : [selectedInstance]} />\n\t\t\t\t\t\t\t<MobileTorrentList\n\t\t\t\t\t\t\t\tinstances={selectedInstance === 'all' ? instances : [selectedInstance]}\n\t\t\t\t\t\t\t\tsearch={search}\n\t\t\t\t\t\t\t\tcompact={compactMode}\n\t\t\t\t\t\t\t\tonToggleCompact={toggleCompactMode}\n\t\t\t\t\t\t\t\tonSelectTorrent={(hash, instanceId) => {\n\t\t\t\t\t\t\t\t\tsetSelectedTorrentHash(hash)\n\t\t\t\t\t\t\t\t\tsetSelectedTorrentInstanceId(instanceId)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t{mainTab === 'tools' && (\n\t\t\t\t\t\t<Suspense fallback={null}>\n\t\t\t\t\t\t\t<MobileTools instances={instances} activeTool={activeTool} onToolChange={handleToolChange} />\n\t\t\t\t\t\t</Suspense>\n\t\t\t\t\t)}\n\t\t\t\t</main>\n\n\t\t\t\t<nav\n\t\t\t\t\tclassName=\"fixed bottom-0 left-0 right-0 z-30 border-t backdrop-blur-xl\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--bg-primary) 90%, transparent)',\n\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\tpaddingBottom: 'env(safe-area-inset-bottom, 0px)',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => handleMainTabChange('torrents')}\n\t\t\t\t\t\t\tclassName=\"flex-1 flex flex-col items-center gap-1 py-3 transition-colors\"\n\t\t\t\t\t\t\tstyle={{ color: mainTab === 'torrents' ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Download className=\"w-6 h-6\" strokeWidth={mainTab === 'torrents' ? 2 : 1.5} />\n\t\t\t\t\t\t\t<span className=\"text-xs font-medium\">Torrents</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setShowAddModal(true)}\n\t\t\t\t\t\t\tclassName=\"flex-1 flex flex-col items-center gap-1 py-3 transition-colors active:scale-95\"\n\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Plus className=\"w-6 h-6\" strokeWidth={1.5} />\n\t\t\t\t\t\t\t<span className=\"text-xs font-medium\">Add</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => handleMainTabChange('tools')}\n\t\t\t\t\t\t\tclassName=\"flex-1 flex flex-col items-center gap-1 py-3 transition-colors\"\n\t\t\t\t\t\t\tstyle={{ color: mainTab === 'tools' ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Wrench className=\"w-6 h-6\" strokeWidth={mainTab === 'tools' ? 2 : 1.5} />\n\t\t\t\t\t\t\t<span className=\"text-xs font-medium\">Tools</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</nav>\n\n\t\t\t\t{selectedTorrentHash && selectedTorrentInstanceId && (\n\t\t\t\t\t<Suspense fallback={null}>\n\t\t\t\t\t\t<MobileTorrentDetail\n\t\t\t\t\t\t\ttorrentHash={selectedTorrentHash}\n\t\t\t\t\t\t\tinstanceId={selectedTorrentInstanceId}\n\t\t\t\t\t\t\tonClose={() => {\n\t\t\t\t\t\t\t\tsetSelectedTorrentHash(null)\n\t\t\t\t\t\t\t\tsetSelectedTorrentInstanceId(null)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Suspense>\n\t\t\t\t)}\n\n\t\t\t\t{showAddModal && (\n\t\t\t\t\t<Suspense fallback={null}>\n\t\t\t\t\t\t<InstanceProvider instance={selectedInstance !== 'all' ? selectedInstance : instances[0]}>\n\t\t\t\t\t\t\t<AddTorrentModal open={showAddModal} onClose={() => setShowAddModal(false)} />\n\t\t\t\t\t\t</InstanceProvider>\n\t\t\t\t\t</Suspense>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</QueryClientProvider>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileCrossSeedManager.tsx",
    "content": "import { useState, type ReactNode } from 'react'\nimport { ChevronDown, ChevronLeft } from 'lucide-react'\nimport { type Instance } from '../api/instances'\nimport { formatSize, formatCountdown } from '../utils/format'\nimport { Toggle, Select, MultiSelect } from '../components/ui'\nimport { useCrossSeed, LOG_LEVEL_COLORS } from '../hooks/useCrossSeed'\n\ninterface Props {\n\tinstances: Instance[]\n\tonBack: () => void\n}\n\nexport function MobileCrossSeedManager({ instances, onBack }: Props): ReactNode {\n\tconst [showLogs, setShowLogs] = useState(false)\n\tconst {\n\t\tselectedInstance,\n\t\tsetSelectedInstance,\n\t\tconfig,\n\t\tsetConfig,\n\t\tstatus,\n\t\tcacheStats,\n\t\tavailableIndexers,\n\t\tlogs,\n\t\tloading,\n\t\terror,\n\t\tsuccess,\n\t\tsaving,\n\t\tautoScroll,\n\t\tsetAutoScroll,\n\t\tlogsContainerRef,\n\t\tprowlarrIntegrations,\n\t\tisRunning,\n\t\thandleSave,\n\t\thandleScan,\n\t\thandleStop,\n\t\thandleClearCache,\n\t} = useCrossSeed(instances)\n\n\treturn (\n\t\t<div className=\"flex flex-col min-h-full\" style={{ backgroundColor: 'var(--bg-primary)' }}>\n\t\t\t<header\n\t\t\t\tclassName=\"sticky top-0 z-10 px-4 py-3 flex items-center gap-3 border-b\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<button onClick={onBack} className=\"p-2 -ml-2 rounded-lg active:bg-[var(--bg-tertiary)]\">\n\t\t\t\t\t<ChevronLeft className=\"w-5 h-5\" style={{ color: 'var(--text-primary)' }} strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t\t<h1 className=\"text-lg font-semibold flex-1\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\tCross-Seed\n\t\t\t\t</h1>\n\t\t\t\t<a\n\t\t\t\t\thref=\"https://github.com/Maciejonos/qbitwebui/issues\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\tclassName=\"px-2 py-1 rounded-lg border text-xs\"\n\t\t\t\t\tstyle={{ borderColor: 'var(--error)', backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)' }}\n\t\t\t\t>\n\t\t\t\t\t<span style={{ color: 'var(--error)' }}>Experimental</span>\n\t\t\t\t</a>\n\t\t\t</header>\n\n\t\t\t<div className=\"flex-1 p-4 space-y-4\">\n\t\t\t\t{instances.length > 1 && (\n\t\t\t\t\t<Select\n\t\t\t\t\t\tvalue={String(selectedInstance ?? '')}\n\t\t\t\t\t\tonChange={(v) => setSelectedInstance(Number(v))}\n\t\t\t\t\t\toptions={instances.map((i) => ({ value: String(i.id), label: i.label }))}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\n\t\t\t\t{error && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-xl text-sm\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 10%, transparent)', color: 'var(--error)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{error}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{success && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-xl text-sm\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, #a6e3a1 10%, transparent)', color: '#a6e3a1' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{success}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{loading ? (\n\t\t\t\t\t<div className=\"flex items-center justify-center p-8\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-6 h-6 border-2 rounded-full animate-spin\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t) : config ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{prowlarrIntegrations.length === 0 && (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-xl text-sm\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--warning) 10%, transparent)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--warning)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tNo Prowlarr integration configured\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider mb-3\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tStatus\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-4 text-sm\">\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<div className=\"mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tScheduler\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"font-medium\" style={{ color: status?.enabled ? '#a6e3a1' : 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{status?.enabled ? 'On' : 'Off'}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<div className=\"mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tStatus\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName=\"font-medium flex items-center gap-2\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: isRunning ? 'var(--accent)' : 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{isRunning && (\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-2 h-2 rounded-full animate-pulse\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{isRunning ? 'Running' : 'Idle'}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<div className=\"mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tNext\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t\t{status?.enabled ? formatCountdown(status?.nextRun ?? null) : '—'}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<div className=\"mb-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tCache\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t\t{cacheStats ? `${cacheStats.cache.count} (${formatSize(cacheStats.cache.totalSize)})` : '0'}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border space-y-4\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tConfiguration\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tEnabled\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Toggle checked={config.enabled} onChange={(v) => setConfig({ ...config, enabled: v })} />\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tProwlarr\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\tvalue={config.integration_id ? String(config.integration_id) : ''}\n\t\t\t\t\t\t\t\t\tonChange={(v) => setConfig({ ...config, integration_id: v ? Number(v) : null, indexer_ids: [] })}\n\t\t\t\t\t\t\t\t\toptions={[\n\t\t\t\t\t\t\t\t\t\t{ value: '', label: 'None' },\n\t\t\t\t\t\t\t\t\t\t...prowlarrIntegrations.map((i) => ({ value: String(i.id), label: i.label })),\n\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t{availableIndexers.length > 0 && (\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tIndexers ({config.indexer_ids.length}/{availableIndexers.length})\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<MultiSelect\n\t\t\t\t\t\t\t\t\t\toptions={availableIndexers.map((idx) => ({ value: idx.id, label: idx.name }))}\n\t\t\t\t\t\t\t\t\t\tselected={config.indexer_ids}\n\t\t\t\t\t\t\t\t\t\tonChange={(ids) => setConfig({ ...config, indexer_ids: ids })}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Select indexers...\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-4\">\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tInterval (hours)\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\tmin=\"1\"\n\t\t\t\t\t\t\t\t\t\tmax=\"168\"\n\t\t\t\t\t\t\t\t\t\tvalue={config.interval_hours}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setConfig({ ...config, interval_hours: parseInt(e.target.value) || 24 })}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tDelay (30-3600s)\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\tmin=\"30\"\n\t\t\t\t\t\t\t\t\t\tmax=\"3600\"\n\t\t\t\t\t\t\t\t\t\tvalue={config.delay_seconds}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\t\tsetConfig({ ...config, delay_seconds: Math.max(30, parseInt(e.target.value) || 30) })\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-4\">\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tCategory Suffix\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={config.category_suffix}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setConfig({ ...config, category_suffix: e.target.value })}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tTag\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={config.tag}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setConfig({ ...config, tag: e.target.value })}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2.5 rounded-lg border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tMatch Mode\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\tvalue={config.match_mode}\n\t\t\t\t\t\t\t\t\tonChange={(v) => setConfig({ ...config, match_mode: v as 'strict' | 'flexible' })}\n\t\t\t\t\t\t\t\t\toptions={[\n\t\t\t\t\t\t\t\t\t\t{ value: 'strict', label: 'Strict (names must match)' },\n\t\t\t\t\t\t\t\t\t\t{ value: 'flexible', label: 'Flexible (sizes only)' },\n\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\tminWidth=\"100%\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t{config.match_mode === 'flexible' && (\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tLink Directory\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={config.link_dir || ''}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setConfig({ ...config, link_dir: e.target.value || null })}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"/path/to/links\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<p className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tHardlinks created when file names differ\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className=\"block text-xs mb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tBlocklist (one per line)\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\t\t\tvalue={config.blocklist.join('\\n')}\n\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\tsetConfig({\n\t\t\t\t\t\t\t\t\t\t\t...config,\n\t\t\t\t\t\t\t\t\t\t\tblocklist: e.target.value\n\t\t\t\t\t\t\t\t\t\t\t\t.split('\\n')\n\t\t\t\t\t\t\t\t\t\t\t\t.map((s) => s.trim())\n\t\t\t\t\t\t\t\t\t\t\t\t.filter(Boolean),\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tplaceholder=\"name:YIFY&#10;nameRegex:.*-RARBG$&#10;category:movies\"\n\t\t\t\t\t\t\t\t\trows={4}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl text-sm font-mono\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\tresize: 'vertical',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<p className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tFormat: type:value\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tInclude Single Episodes\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Toggle\n\t\t\t\t\t\t\t\t\tchecked={config.include_single_episodes}\n\t\t\t\t\t\t\t\t\tonChange={(v) => setConfig({ ...config, include_single_episodes: v })}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tDry Run\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Toggle checked={config.dry_run} onChange={(v) => setConfig({ ...config, dry_run: v })} />\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tSkip Recheck\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Toggle checked={config.skip_recheck} onChange={(v) => setConfig({ ...config, skip_recheck: v })} />\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleSave}\n\t\t\t\t\t\t\t\tdisabled={saving}\n\t\t\t\t\t\t\t\tclassName=\"w-full py-3 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{saving ? 'Saving...' : 'Save Configuration'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border space-y-4\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tActions\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => handleScan(false)}\n\t\t\t\t\t\t\t\t\tdisabled={isRunning || !config.integration_id}\n\t\t\t\t\t\t\t\t\tclassName=\"py-3 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tScan\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => handleScan(true)}\n\t\t\t\t\t\t\t\t\tdisabled={isRunning || !config.integration_id}\n\t\t\t\t\t\t\t\t\tclassName=\"py-3 rounded-xl text-sm border disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tForce Scan\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={handleStop}\n\t\t\t\t\t\t\t\t\tdisabled={!isRunning}\n\t\t\t\t\t\t\t\t\tclassName=\"py-3 rounded-xl text-sm border disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tborderColor: isRunning ? 'var(--error)' : 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: isRunning ? 'var(--error)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tStop\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={handleClearCache}\n\t\t\t\t\t\t\t\t\tclassName=\"py-3 rounded-xl text-sm border\"\n\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tClear Torrents\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border overflow-hidden\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<button onClick={() => setShowLogs(!showLogs)} className=\"w-full flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<div className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tLogs ({logs.length} entries)\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<ChevronDown\n\t\t\t\t\t\t\t\t\tclassName={`w-4 h-4 transition-transform ${showLogs ? 'rotate-180' : ''}`}\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{showLogs && (\n\t\t\t\t\t\t\t\t<div className=\"mt-4 space-y-3\">\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-end\">\n\t\t\t\t\t\t\t\t\t\t<label className=\"flex items-center gap-2 cursor-pointer\">\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\tAuto-scroll\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t<Toggle checked={autoScroll} onChange={setAutoScroll} />\n\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tref={logsContainerRef}\n\t\t\t\t\t\t\t\t\t\tclassName=\"overflow-auto rounded-lg p-3 font-mono text-xs leading-relaxed max-h-[70vh]\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{logs.length === 0 ? (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-center py-6\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\tNo logs\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\tlogs.map((log, i) => {\n\t\t\t\t\t\t\t\t\t\t\t\tconst isMatch = log.message.includes('MATCH:')\n\t\t\t\t\t\t\t\t\t\t\t\tconst isAdded = log.message.includes('Added torrent:')\n\t\t\t\t\t\t\t\t\t\t\t\tconst isInjection = isMatch || isAdded\n\t\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div key={i} className=\"py-0.5 whitespace-pre-wrap break-all\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>{log.timestamp.slice(11, 19)}</span>{' '}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: LOG_LEVEL_COLORS[log.level] || 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[{log.level}]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>{' '}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: isInjection ? '#a6e3a1' : undefined,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tfontWeight: isInjection ? 500 : undefined,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{log.message}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileFileBrowser.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react'\nimport {\n\tChevronLeft,\n\tRefreshCw,\n\tFolderOpen,\n\tFolder,\n\tFile,\n\tDownload,\n\tCheck,\n\tPencil,\n\tFolderInput,\n\tCopy,\n\tTrash2,\n} from 'lucide-react'\nimport {\n\tlistFiles,\n\tgetDownloadUrl,\n\tcheckWritable,\n\tdeleteFiles,\n\tmoveFiles,\n\tcopyFiles,\n\trenameFile,\n\ttype FileEntry,\n} from '../api/files'\nimport { formatSize } from '../utils/format'\n\nfunction formatDate(timestamp: number): string {\n\treturn new Date(timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })\n}\n\ninterface Props {\n\tonBack: () => void\n}\n\nexport function MobileFileBrowser({ onBack }: Props) {\n\tconst [path, setPath] = useState('/')\n\tconst [files, setFiles] = useState<FileEntry[]>([])\n\tconst [loading, setLoading] = useState(true)\n\tconst [error, setError] = useState('')\n\tconst [writable, setWritable] = useState(false)\n\tconst [selected, setSelected] = useState<Set<string>>(new Set())\n\tconst [selectionMode, setSelectionMode] = useState(false)\n\tconst [showActionSheet, setShowActionSheet] = useState(false)\n\tconst [showFolderPicker, setShowFolderPicker] = useState<'move' | 'copy' | null>(null)\n\tconst [pickerPath, setPickerPath] = useState('/')\n\tconst [pickerFolders, setPickerFolders] = useState<FileEntry[]>([])\n\tconst [pickerLoading, setPickerLoading] = useState(false)\n\tconst [showRenameModal, setShowRenameModal] = useState(false)\n\tconst [renameValue, setRenameValue] = useState('')\n\tconst [showDeleteConfirm, setShowDeleteConfirm] = useState(false)\n\tconst [actionLoading, setActionLoading] = useState(false)\n\n\tuseEffect(() => {\n\t\tcheckWritable().then(setWritable)\n\t}, [])\n\n\tconst loadFiles = useCallback(async () => {\n\t\tsetLoading(true)\n\t\tsetError('')\n\t\ttry {\n\t\t\tconst data = await listFiles(path)\n\t\t\tsetFiles(data)\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : 'Failed to load')\n\t\t\tsetFiles([])\n\t\t} finally {\n\t\t\tsetLoading(false)\n\t\t}\n\t}, [path])\n\n\tuseEffect(() => {\n\t\tloadFiles()\n\t\tsetSelected(new Set())\n\t\tsetSelectionMode(false)\n\t}, [loadFiles])\n\n\tuseEffect(() => {\n\t\tif (!showFolderPicker) return\n\t\tsetPickerLoading(true)\n\t\tlistFiles(pickerPath)\n\t\t\t.then((files) => setPickerFolders(files.filter((f) => f.isDirectory)))\n\t\t\t.catch(() => setPickerFolders([]))\n\t\t\t.finally(() => setPickerLoading(false))\n\t}, [showFolderPicker, pickerPath])\n\n\tfunction handleNavigate(name: string) {\n\t\tif (selectionMode) return\n\t\tsetPath(path === '/' ? `/${name}` : `${path}/${name}`)\n\t}\n\n\tfunction handleBack() {\n\t\tconst parts = path.split('/').filter(Boolean)\n\t\tparts.pop()\n\t\tsetPath(parts.length ? `/${parts.join('/')}` : '/')\n\t}\n\n\tfunction getFullPath(name: string) {\n\t\treturn path === '/' ? `/${name}` : `${path}/${name}`\n\t}\n\n\tfunction toggleSelect(name: string) {\n\t\tconst next = new Set(selected)\n\t\tif (next.has(name)) next.delete(name)\n\t\telse next.add(name)\n\t\tsetSelected(next)\n\t\tif (next.size === 0) setSelectionMode(false)\n\t}\n\n\tfunction selectAll() {\n\t\tif (selected.size === files.length) {\n\t\t\tsetSelected(new Set())\n\t\t\tsetSelectionMode(false)\n\t\t} else {\n\t\t\tsetSelected(new Set(files.map((f) => f.name)))\n\t\t}\n\t}\n\n\tfunction startSelection(name: string) {\n\t\tsetSelectionMode(true)\n\t\tsetSelected(new Set([name]))\n\t}\n\n\tasync function handleDelete() {\n\t\tsetActionLoading(true)\n\t\tsetError('')\n\t\ttry {\n\t\t\tawait deleteFiles(Array.from(selected).map(getFullPath))\n\t\t\tsetSelected(new Set())\n\t\t\tsetSelectionMode(false)\n\t\t\tsetShowDeleteConfirm(false)\n\t\t\tsetShowActionSheet(false)\n\t\t\tawait loadFiles()\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : 'Delete failed')\n\t\t} finally {\n\t\t\tsetActionLoading(false)\n\t\t}\n\t}\n\n\tasync function handleMoveOrCopy(destination: string) {\n\t\tconst mode = showFolderPicker\n\t\tsetActionLoading(true)\n\t\tsetError('')\n\t\ttry {\n\t\t\tconst paths = Array.from(selected).map(getFullPath)\n\t\t\tif (mode === 'move') await moveFiles(paths, destination)\n\t\t\telse await copyFiles(paths, destination)\n\t\t\tsetSelected(new Set())\n\t\t\tsetSelectionMode(false)\n\t\t\tsetShowFolderPicker(null)\n\t\t\tsetShowActionSheet(false)\n\t\t\tsetPickerPath('/')\n\t\t\tawait loadFiles()\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : `${mode} failed`)\n\t\t} finally {\n\t\t\tsetActionLoading(false)\n\t\t}\n\t}\n\n\tasync function handleRename() {\n\t\tif (!renameValue.trim()) return\n\t\tconst name = Array.from(selected)[0]\n\t\tsetActionLoading(true)\n\t\tsetError('')\n\t\ttry {\n\t\t\tawait renameFile(getFullPath(name), renameValue.trim())\n\t\t\tsetSelected(new Set())\n\t\t\tsetSelectionMode(false)\n\t\t\tsetShowRenameModal(false)\n\t\t\tsetShowActionSheet(false)\n\t\t\tawait loadFiles()\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : 'Rename failed')\n\t\t} finally {\n\t\t\tsetActionLoading(false)\n\t\t}\n\t}\n\n\tfunction openRename() {\n\t\tconst name = Array.from(selected)[0]\n\t\tsetRenameValue(name)\n\t\tsetShowRenameModal(true)\n\t\tsetShowActionSheet(false)\n\t}\n\n\tfunction openFolderPicker(mode: 'move' | 'copy') {\n\t\tsetPickerPath('/')\n\t\tsetShowFolderPicker(mode)\n\t\tsetShowActionSheet(false)\n\t}\n\n\tconst pathParts = path.split('/').filter(Boolean)\n\tconst pickerPathParts = pickerPath.split('/').filter(Boolean)\n\n\treturn (\n\t\t<div className=\"flex flex-col h-full\">\n\t\t\t<div className=\"p-4 space-y-3\">\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t<button onClick={onBack} className=\"p-2 -ml-2 rounded-xl active:bg-[var(--bg-tertiary)]\">\n\t\t\t\t\t\t<ChevronLeft className=\"w-5 h-5\" style={{ color: 'var(--text-primary)' }} strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t\t<h2 className=\"text-lg font-semibold flex-1\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tFiles\n\t\t\t\t\t</h2>\n\t\t\t\t\t{selectionMode && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetSelectionMode(false)\n\t\t\t\t\t\t\t\tsetSelected(new Set())\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"text-sm font-medium\"\n\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"flex items-center gap-2 p-3 rounded-xl border overflow-x-auto\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={handleBack}\n\t\t\t\t\t\tdisabled={path === '/'}\n\t\t\t\t\t\tclassName=\"p-1.5 rounded-lg shrink-0 disabled:opacity-30\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<ChevronLeft className=\"w-4 h-4\" style={{ color: 'var(--text-secondary)' }} strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t\t<div className=\"flex items-center text-sm overflow-x-auto scrollbar-none\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setPath('/')}\n\t\t\t\t\t\t\tclassName=\"px-1 py-0.5 rounded shrink-0\"\n\t\t\t\t\t\t\tstyle={{ color: path === '/' ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t/\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{pathParts.map((part, i) => (\n\t\t\t\t\t\t\t<div key={i} className=\"flex items-center shrink-0\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => setPath(`/${pathParts.slice(0, i + 1).join('/')}`)}\n\t\t\t\t\t\t\t\t\tclassName=\"px-1 py-0.5 rounded truncate max-w-[120px]\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: i === pathParts.length - 1 ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{part}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t{i < pathParts.length - 1 && <span style={{ color: 'var(--text-muted)' }}>/</span>}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={loadFiles}\n\t\t\t\t\t\tclassName=\"ml-auto p-1.5 rounded-lg shrink-0\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<RefreshCw\n\t\t\t\t\t\t\tclassName={`w-4 h-4 ${loading ? 'animate-spin' : ''}`}\n\t\t\t\t\t\t\tstyle={{ color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\n\t\t\t\t{error && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-xl text-sm\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 15%, transparent)', color: 'var(--error)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{error}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"flex-1 overflow-y-auto px-4 pb-4\">\n\t\t\t\t{loading && files.length === 0 ? (\n\t\t\t\t\t<div className=\"flex items-center justify-center py-12\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-8 h-8 border-2 rounded-full animate-spin\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t) : files.length === 0 ? (\n\t\t\t\t\t<div className=\"text-center py-12\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<FolderOpen className=\"w-8 h-8\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tEmpty folder\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t{selectionMode && writable && (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={selectAll}\n\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-3 py-2 text-sm\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{selected.size === files.length ? 'Deselect All' : 'Select All'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{files.map((file) => (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={file.name}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tif (selectionMode && writable) toggleSelect(file.name)\n\t\t\t\t\t\t\t\t\telse if (file.isDirectory) handleNavigate(file.name)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonContextMenu={(e) => {\n\t\t\t\t\t\t\t\t\tif (writable) {\n\t\t\t\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\t\t\t\tstartSelection(file.name)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-3 p-3 rounded-xl active:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: selected.has(file.name) ? 'var(--bg-tertiary)' : 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{selectionMode && writable && (\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-5 h-5 rounded flex items-center justify-center shrink-0 border\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: selected.has(file.name) ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: selected.has(file.name) ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{selected.has(file.name) && <Check className=\"w-3 h-3 text-white\" strokeWidth={3} />}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{file.isDirectory ? (\n\t\t\t\t\t\t\t\t\t<Folder className=\"w-5 h-5 shrink-0\" style={{ color: 'var(--warning)' }} fill=\"currentColor\" />\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<File className=\"w-5 h-5 shrink-0\" style={{ color: 'var(--text-muted)' }} fill=\"currentColor\" />\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t\t\t\t<div className=\"text-sm font-medium truncate\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t{file.name}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{!file.isDirectory && <span>{formatSize(file.size)}</span>}\n\t\t\t\t\t\t\t\t\t\t<span>{formatDate(file.modified)}</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{!selectionMode && (\n\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\thref={getDownloadUrl(getFullPath(file.name))}\n\t\t\t\t\t\t\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\t\t\t\t\t\t\tclassName=\"p-2 rounded-lg shrink-0\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Download className=\"w-4 h-4\" style={{ color: 'var(--text-secondary)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{selectionMode && selected.size > 0 && writable && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"sticky bottom-0 p-4 border-t\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\tpaddingBottom: 'calc(70px + env(safe-area-inset-bottom, 1rem))',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setShowActionSheet(true)}\n\t\t\t\t\t\tclassName=\"w-full py-3 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\tActions ({selected.size} selected)\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{showActionSheet && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowActionSheet(false)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-0 bottom-0 z-50 rounded-t-3xl border-t\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tpaddingBottom: 'env(safe-area-inset-bottom, 0px)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-center pt-3 pb-2\">\n\t\t\t\t\t\t\t<div className=\"w-10 h-1 rounded-full\" style={{ backgroundColor: 'var(--text-muted)' }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"px-5 pb-3 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t{selected.size} item{selected.size > 1 ? 's' : ''} selected\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"p-4 space-y-2\">\n\t\t\t\t\t\t\t{selected.size === 1 && (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={openRename}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center gap-4 px-4 py-3 rounded-xl active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Pencil className=\"w-5 h-5\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-primary)' }}>Rename</span>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => openFolderPicker('move')}\n\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center gap-4 px-4 py-3 rounded-xl active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<FolderInput className=\"w-5 h-5\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-primary)' }}>Move to...</span>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => openFolderPicker('copy')}\n\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center gap-4 px-4 py-3 rounded-xl active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Copy className=\"w-5 h-5\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-primary)' }}>Copy to...</span>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetShowActionSheet(false)\n\t\t\t\t\t\t\t\t\tsetShowDeleteConfirm(true)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center gap-4 px-4 py-3 rounded-xl active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 10%, var(--bg-secondary))' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Trash2 className=\"w-5 h-5\" style={{ color: 'var(--error)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t<span style={{ color: 'var(--error)' }}>Delete</span>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{showFolderPicker && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowFolderPicker(null)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-0 bottom-0 z-50 rounded-t-3xl border-t max-h-[80vh] flex flex-col\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tpaddingBottom: 'env(safe-area-inset-bottom, 0px)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-center pt-3 pb-2 shrink-0\">\n\t\t\t\t\t\t\t<div className=\"w-10 h-1 rounded-full\" style={{ backgroundColor: 'var(--text-muted)' }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"px-5 pb-3 border-b shrink-0\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t{showFolderPicker === 'move' ? 'Move to' : 'Copy to'}\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"px-4 py-3 border-b shrink-0 overflow-x-auto\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<div className=\"flex items-center text-sm\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => setPickerPath('/')}\n\t\t\t\t\t\t\t\t\tclassName=\"px-1 py-0.5 rounded shrink-0\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: pickerPath === '/' ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t/\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t{pickerPathParts.map((part, i) => (\n\t\t\t\t\t\t\t\t\t<div key={i} className=\"flex items-center shrink-0\">\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => setPickerPath(`/${pickerPathParts.slice(0, i + 1).join('/')}`)}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-1 py-0.5 rounded\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: i === pickerPathParts.length - 1 ? 'var(--text-primary)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{part}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t{i < pickerPathParts.length - 1 && <span style={{ color: 'var(--text-muted)' }}>/</span>}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex-1 overflow-y-auto p-4 min-h-[200px]\">\n\t\t\t\t\t\t\t{pickerLoading ? (\n\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-center py-8\">\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-6 h-6 border-2 rounded-full animate-spin\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : pickerFolders.length === 0 ? (\n\t\t\t\t\t\t\t\t<div className=\"text-center py-8 text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tNo subfolders\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t\t\t\t{pickerFolders.map((folder) => (\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tkey={folder.name}\n\t\t\t\t\t\t\t\t\t\t\tonClick={() =>\n\t\t\t\t\t\t\t\t\t\t\t\tsetPickerPath(pickerPath === '/' ? `/${folder.name}` : `${pickerPath}/${folder.name}`)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center gap-3 px-3 py-2.5 rounded-xl active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<Folder className=\"w-5 h-5 shrink-0\" style={{ color: 'var(--warning)' }} fill=\"currentColor\" />\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-sm truncate\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{folder.name}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"p-4 border-t shrink-0\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => handleMoveOrCopy(pickerPath)}\n\t\t\t\t\t\t\t\tdisabled={actionLoading}\n\t\t\t\t\t\t\t\tclassName=\"w-full py-3 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{actionLoading ? 'Working...' : `${showFolderPicker === 'move' ? 'Move' : 'Copy'} here`}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{showRenameModal && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowRenameModal(false)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-4 top-1/2 -translate-y-1/2 z-50 rounded-2xl border p-5\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-semibold mb-4\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tRename\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={renameValue}\n\t\t\t\t\t\t\tonChange={(e) => setRenameValue(e.target.value)}\n\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-base\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div className=\"flex gap-3 mt-5\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setShowRenameModal(false)}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleRename}\n\t\t\t\t\t\t\t\tdisabled={!renameValue.trim() || actionLoading}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{actionLoading ? 'Renaming...' : 'Rename'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{showDeleteConfirm && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowDeleteConfirm(false)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-4 top-1/2 -translate-y-1/2 z-50 rounded-2xl border p-5\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-semibold mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tDelete Files\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tDelete {selected.size} item{selected.size > 1 ? 's' : ''}? This cannot be undone.\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t{selected.size <= 3 && (\n\t\t\t\t\t\t\t<ul className=\"mt-3 text-sm space-y-1\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t{Array.from(selected).map((name) => (\n\t\t\t\t\t\t\t\t\t<li key={name} className=\"truncate\">\n\t\t\t\t\t\t\t\t\t\t• {name}\n\t\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</ul>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<div className=\"flex gap-3 mt-5\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setShowDeleteConfirm(false)}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleDelete}\n\t\t\t\t\t\t\t\tdisabled={actionLoading}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{actionLoading ? 'Deleting...' : 'Delete'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileInstancePicker.tsx",
    "content": "import { useState } from 'react'\nimport { ChevronDown, LayoutGrid, Server, Check } from 'lucide-react'\nimport type { Instance } from '../api/instances'\n\ninterface Props {\n\tinstances: Instance[]\n\tcurrent: Instance | 'all'\n\tonChange: (instance: Instance | 'all') => void\n}\n\nexport function MobileInstancePicker({ instances, current, onChange }: Props) {\n\tconst [open, setOpen] = useState(false)\n\tconst currentLabel = current === 'all' ? 'All Instances' : current.label\n\n\tif (instances.length === 1) {\n\t\treturn (\n\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t{instances[0].label}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t)\n\t}\n\n\treturn (\n\t\t<div className=\"relative\">\n\t\t\t<button\n\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-1.5 rounded-lg active:scale-98 transition-transform\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t>\n\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t{currentLabel}\n\t\t\t\t</span>\n\t\t\t\t<ChevronDown\n\t\t\t\t\tclassName=\"w-4 h-4 transition-transform\"\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)', transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}\n\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t/>\n\t\t\t</button>\n\n\t\t\t{open && (\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"fixed inset-0 z-40\" onClick={() => setOpen(false)} />\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"absolute left-0 top-full mt-2 z-50 min-w-[200px] rounded-xl border shadow-xl overflow-hidden\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tonChange('all')\n\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"w-full text-left px-4 py-3 flex items-center justify-between active:bg-[var(--bg-tertiary)] transition-colors border-b\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"w-8 h-8 rounded-lg flex items-center justify-center\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<LayoutGrid className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\tAll Instances\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{current === 'all' && <Check className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2.5} />}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{instances.map((instance) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={instance.id}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tonChange(instance)\n\t\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-4 py-3 flex items-center justify-between active:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-8 h-8 rounded-lg flex items-center justify-center\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Server className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{instance.label}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{instance.url}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{current !== 'all' && instance.id === current.id && (\n\t\t\t\t\t\t\t\t\t<Check className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2.5} />\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileLogViewer.tsx",
    "content": "import { useState, useEffect, useRef, useCallback, useMemo } from 'react'\nimport { ChevronLeft, SlidersHorizontal, RefreshCw, Server, FileText } from 'lucide-react'\nimport { type Instance } from '../api/instances'\nimport { getLog, getPeerLog, type LogEntry, type PeerLogEntry } from '../api/qbittorrent'\n\nconst LOG_TYPES = {\n\t1: { label: 'Normal', color: 'var(--text-secondary)', bg: 'var(--bg-tertiary)' },\n\t2: { label: 'Info', color: 'var(--accent)', bg: 'color-mix(in srgb, var(--accent) 12%, transparent)' },\n\t4: { label: 'Warning', color: 'var(--warning)', bg: 'color-mix(in srgb, var(--warning) 12%, transparent)' },\n\t8: { label: 'Critical', color: 'var(--error)', bg: 'color-mix(in srgb, var(--error) 12%, transparent)' },\n} as const\n\ntype LogTab = 'main' | 'peers'\ntype SortOrder = 'newest' | 'oldest'\n\ninterface Props {\n\tinstances: Instance[]\n\tonBack: () => void\n}\n\nexport function MobileLogViewer({ instances, onBack }: Props) {\n\tconst [selectedInstance, setSelectedInstance] = useState<number>(instances[0]?.id ?? 0)\n\tconst [tab, setTab] = useState<LogTab>('main')\n\tconst [mainLogs, setMainLogs] = useState<LogEntry[]>([])\n\tconst [peerLogs, setPeerLogs] = useState<PeerLogEntry[]>([])\n\tconst [loading, setLoading] = useState(false)\n\tconst [autoRefresh, setAutoRefresh] = useState(false)\n\tconst [showFilters, setShowFilters] = useState(false)\n\tconst [sortOrder, setSortOrder] = useState<SortOrder>('newest')\n\tconst [filters, setFilters] = useState({ normal: true, info: true, warning: true, critical: true })\n\tconst lastMainIdRef = useRef(-1)\n\tconst lastPeerIdRef = useRef(-1)\n\tconst containerRef = useRef<HTMLDivElement>(null)\n\n\tconst fetchLogs = useCallback(\n\t\tasync (incremental = false) => {\n\t\t\tif (!selectedInstance) return\n\t\t\tsetLoading(true)\n\t\t\ttry {\n\t\t\t\tif (tab === 'main') {\n\t\t\t\t\tconst lastId = incremental ? lastMainIdRef.current : -1\n\t\t\t\t\tconst entries = await getLog(selectedInstance, { ...filters, lastKnownId: lastId })\n\t\t\t\t\tif (entries.length > 0) {\n\t\t\t\t\t\tlastMainIdRef.current = Math.max(...entries.map((e) => e.id))\n\t\t\t\t\t\tif (incremental) {\n\t\t\t\t\t\t\tsetMainLogs((prev) => [...prev, ...entries])\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tsetMainLogs(entries)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (!incremental) {\n\t\t\t\t\t\tsetMainLogs([])\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst lastId = incremental ? lastPeerIdRef.current : -1\n\t\t\t\t\tconst entries = await getPeerLog(selectedInstance, lastId === -1 ? undefined : lastId)\n\t\t\t\t\tif (entries.length > 0) {\n\t\t\t\t\t\tlastPeerIdRef.current = Math.max(...entries.map((e) => e.id))\n\t\t\t\t\t\tif (incremental) {\n\t\t\t\t\t\t\tsetPeerLogs((prev) => [...prev, ...entries])\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tsetPeerLogs(entries)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (!incremental) {\n\t\t\t\t\t\tsetPeerLogs([])\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\tif (!incremental) {\n\t\t\t\t\tif (tab === 'main') setMainLogs([])\n\t\t\t\t\telse setPeerLogs([])\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\tsetLoading(false)\n\t\t\t}\n\t\t},\n\t\t[selectedInstance, tab, filters]\n\t)\n\n\tuseEffect(() => {\n\t\tif (tab === 'main') {\n\t\t\tlastMainIdRef.current = -1\n\t\t\tsetMainLogs([])\n\t\t} else {\n\t\t\tlastPeerIdRef.current = -1\n\t\t\tsetPeerLogs([])\n\t\t}\n\t\tif (selectedInstance) fetchLogs()\n\t}, [selectedInstance, tab, filters, fetchLogs])\n\n\tuseEffect(() => {\n\t\tif (!autoRefresh || !selectedInstance) return\n\t\tconst interval = setInterval(() => fetchLogs(true), 5000)\n\t\treturn () => clearInterval(interval)\n\t}, [autoRefresh, selectedInstance, fetchLogs])\n\n\tuseEffect(() => {\n\t\tif (autoRefresh && containerRef.current) {\n\t\t\tcontainerRef.current.scrollTop = sortOrder === 'newest' ? 0 : containerRef.current.scrollHeight\n\t\t}\n\t}, [mainLogs, peerLogs, autoRefresh, sortOrder])\n\n\tconst sortedMainLogs = useMemo(() => {\n\t\treturn sortOrder === 'newest' ? [...mainLogs].reverse() : mainLogs\n\t}, [mainLogs, sortOrder])\n\n\tconst sortedPeerLogs = useMemo(() => {\n\t\treturn sortOrder === 'newest' ? [...peerLogs].reverse() : peerLogs\n\t}, [peerLogs, sortOrder])\n\n\tconst logCount = tab === 'main' ? sortedMainLogs.length : sortedPeerLogs.length\n\n\tfunction formatTime(ts: number) {\n\t\treturn new Date(ts * 1000).toLocaleTimeString()\n\t}\n\n\tfunction formatDate(ts: number) {\n\t\treturn new Date(ts * 1000).toLocaleDateString()\n\t}\n\n\tfunction toggleFilter(key: keyof typeof filters) {\n\t\tsetFilters((f) => ({ ...f, [key]: !f[key] }))\n\t}\n\n\tconst activeFilterCount = Object.values(filters).filter(Boolean).length\n\n\treturn (\n\t\t<div className=\"flex flex-col h-full\">\n\t\t\t<div\n\t\t\t\tclassName=\"flex items-center gap-3 px-4 py-3 border-b\"\n\t\t\t\tstyle={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-primary)' }}\n\t\t\t>\n\t\t\t\t<button\n\t\t\t\t\tonClick={onBack}\n\t\t\t\t\tclassName=\"p-2 -ml-2 rounded-xl active:scale-95 transition-transform\"\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t>\n\t\t\t\t\t<ChevronLeft className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t<h1 className=\"text-lg font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tLog Viewer\n\t\t\t\t\t</h1>\n\t\t\t\t\t{autoRefresh && (\n\t\t\t\t\t\t<span className=\"text-xs flex items-center gap-1\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t<span className=\"w-1.5 h-1.5 rounded-full animate-pulse\" style={{ backgroundColor: 'var(--accent)' }} />\n\t\t\t\t\t\t\tLive\n\t\t\t\t\t\t</span>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t\t<button\n\t\t\t\t\tonClick={() => setShowFilters(!showFilters)}\n\t\t\t\t\tclassName=\"p-2 rounded-xl active:scale-95 transition-transform relative\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tcolor: showFilters ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\tbackgroundColor: showFilters ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<SlidersHorizontal className=\"w-5 h-5\" strokeWidth={1.5} />\n\t\t\t\t\t{activeFilterCount < 4 && (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName=\"absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full text-xs flex items-center justify-center font-medium\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{activeFilterCount}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t)}\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\tonClick={() => fetchLogs()}\n\t\t\t\t\tdisabled={loading || !selectedInstance}\n\t\t\t\t\tclassName=\"p-2 rounded-xl disabled:opacity-50 active:scale-95 transition-transform\"\n\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t>\n\t\t\t\t\t<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t{showFilters && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"p-4 border-b space-y-4\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<label className=\"text-xs font-medium uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tInstance\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<select\n\t\t\t\t\t\t\tvalue={selectedInstance}\n\t\t\t\t\t\t\tonChange={(e) => setSelectedInstance(Number(e.target.value))}\n\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2.5 rounded-xl border text-sm appearance-none\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{instances.map((i) => (\n\t\t\t\t\t\t\t\t<option key={i.id} value={i.id}>\n\t\t\t\t\t\t\t\t\t{i.label}\n\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</select>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<label className=\"text-xs font-medium uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tSort Order\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t\t{[\n\t\t\t\t\t\t\t\t{ value: 'newest' as const, label: 'Newest first' },\n\t\t\t\t\t\t\t\t{ value: 'oldest' as const, label: 'Oldest first' },\n\t\t\t\t\t\t\t].map((opt) => (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tkey={opt.value}\n\t\t\t\t\t\t\t\t\tonClick={() => setSortOrder(opt.value)}\n\t\t\t\t\t\t\t\t\tclassName=\"flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all border\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\t\t\tsortOrder === opt.value ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'transparent',\n\t\t\t\t\t\t\t\t\t\tborderColor: sortOrder === opt.value ? 'var(--accent)' : 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: sortOrder === opt.value ? 'var(--accent)' : 'var(--text-secondary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{opt.label}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{tab === 'main' && (\n\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<label className=\"text-xs font-medium uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tLog Types\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t{activeFilterCount < 4 && (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tonClick={() => setFilters({ normal: true, info: true, warning: true, critical: true })}\n\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tReset\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex flex-wrap gap-2\">\n\t\t\t\t\t\t\t\t{Object.entries(LOG_TYPES).map(([type, { label, color, bg }]) => {\n\t\t\t\t\t\t\t\t\tconst key = label.toLowerCase() as keyof typeof filters\n\t\t\t\t\t\t\t\t\tconst active = filters[key]\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tkey={type}\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => toggleFilter(key)}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-full text-xs font-medium border transition-all\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: active ? bg : 'transparent',\n\t\t\t\t\t\t\t\t\t\t\t\tborderColor: active ? color : 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\tcolor: active ? color : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t\t\topacity: active ? 1 : 0.6,\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{label}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<div className=\"flex items-center justify-between pt-2\">\n\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tAuto-refresh\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-11 h-6 rounded-full p-0.5 transition-colors cursor-pointer\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: autoRefresh ? 'var(--accent)' : 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\tonClick={() => setAutoRefresh(!autoRefresh)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"w-5 h-5 rounded-full shadow-md transition-transform\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'white',\n\t\t\t\t\t\t\t\t\ttransform: autoRefresh ? 'translateX(20px)' : 'translateX(0)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t<div\n\t\t\t\tclassName=\"flex items-center gap-1 p-1.5 mx-4 my-2 rounded-lg\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t>\n\t\t\t\t{(['main', 'peers'] as const).map((t) => (\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={t}\n\t\t\t\t\t\tonClick={() => setTab(t)}\n\t\t\t\t\t\tclassName=\"flex-1 px-4 py-2 text-sm font-medium transition-all rounded-md\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: tab === t ? 'var(--bg-secondary)' : 'transparent',\n\t\t\t\t\t\t\tcolor: tab === t ? 'var(--text-primary)' : 'var(--text-muted)',\n\t\t\t\t\t\t\tboxShadow: tab === t ? '0 1px 2px rgba(0,0,0,0.15), 0 0 0 1px var(--border)' : 'none',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t === 'main' ? 'Application' : 'Peers'}\n\t\t\t\t\t</button>\n\t\t\t\t))}\n\t\t\t</div>\n\n\t\t\t<div ref={containerRef} className=\"flex-1 overflow-auto\">\n\t\t\t\t{!selectedInstance ? (\n\t\t\t\t\t<div className=\"text-center py-16\">\n\t\t\t\t\t\t<Server className=\"w-12 h-12 mx-auto mb-3\" style={{ color: 'var(--text-muted)' }} strokeWidth={1} />\n\t\t\t\t\t\t<p className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tNo instances\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t) : logCount === 0 && !loading ? (\n\t\t\t\t\t<div className=\"text-center py-16\">\n\t\t\t\t\t\t<FileText className=\"w-12 h-12 mx-auto mb-3\" style={{ color: 'var(--text-muted)' }} strokeWidth={1} />\n\t\t\t\t\t\t<p className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tNo logs\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<p className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t{tab === 'main' && activeFilterCount < 4 ? 'Adjust filters' : 'Pull to refresh'}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\t<div>\n\t\t\t\t\t\t{tab === 'main'\n\t\t\t\t\t\t\t? sortedMainLogs.map((entry, idx) => {\n\t\t\t\t\t\t\t\t\tconst typeInfo = LOG_TYPES[entry.type as keyof typeof LOG_TYPES] || LOG_TYPES[1]\n\t\t\t\t\t\t\t\t\tconst prevEntry = sortedMainLogs[idx - 1]\n\t\t\t\t\t\t\t\t\tconst showDate = !prevEntry || formatDate(entry.timestamp) !== formatDate(prevEntry.timestamp)\n\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<div key={entry.id}>\n\t\t\t\t\t\t\t\t\t\t\t{showDate && (\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 text-xs font-medium sticky top-0\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{formatDate(entry.timestamp)}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-3 border-b\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'color-mix(in srgb, var(--border) 50%, transparent)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 mb-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatTime(entry.timestamp)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-2 py-0.5 rounded text-xs font-medium\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: typeInfo.bg, color: typeInfo.color }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{typeInfo.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-sm break-words font-mono\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{entry.message}\n\t\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t: sortedPeerLogs.map((entry, idx) => {\n\t\t\t\t\t\t\t\t\tconst prevEntry = sortedPeerLogs[idx - 1]\n\t\t\t\t\t\t\t\t\tconst showDate = !prevEntry || formatDate(entry.timestamp) !== formatDate(prevEntry.timestamp)\n\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<div key={entry.id}>\n\t\t\t\t\t\t\t\t\t\t\t{showDate && (\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-2 text-xs font-medium sticky top-0\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{formatDate(entry.timestamp)}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-3 border-b\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'color-mix(in srgb, var(--border) 50%, transparent)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 mb-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatTime(entry.timestamp)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-2 py-0.5 rounded text-xs font-medium\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: entry.blocked\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, var(--error) 12%, transparent)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'color-mix(in srgb, var(--accent) 12%, transparent)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: entry.blocked ? 'var(--error)' : 'var(--accent)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{entry.blocked ? 'Blocked' : 'Connected'}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-sm font-mono\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{entry.ip}\n\t\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t\t{entry.reason && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{entry.reason}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{logCount > 0 && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"px-4 py-2 text-xs border-t flex items-center justify-between\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)', color: 'var(--text-muted)' }}\n\t\t\t\t>\n\t\t\t\t\t<span>{logCount} entries</span>\n\t\t\t\t\t<span>{instances.find((i) => i.id === selectedInstance)?.label}</span>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileNetworkTools.tsx",
    "content": "import { useState, useEffect, useRef } from 'react'\nimport {\n\tGlobe,\n\tGauge,\n\tServer,\n\tNetwork,\n\tLoader2,\n\tMapPin,\n\tBuilding2,\n\tTerminal,\n\tArrowDown,\n\tArrowUp,\n\tClock,\n\tZap,\n\tActivity,\n\tChevronLeft,\n\tChevronDown,\n\tChevronUp,\n} from 'lucide-react'\nimport type { Instance } from '../api/instances'\nimport { Select } from '../components/ui'\nimport {\n\tgetIpInfo,\n\trunSpeedtest,\n\tgetSpeedtestServers,\n\tgetDnsInfo,\n\tgetInterfaces,\n\texecCommand,\n\tcheckAgentHealth,\n\ttype IpInfo,\n\ttype SpeedtestResult,\n\ttype SpeedtestServer,\n\ttype DnsInfo,\n\ttype NetworkInterface,\n} from '../api/netAgent'\n\ninterface Props {\n\tinstances: Instance[]\n\tonBack: () => void\n}\n\ntype CardStatus = 'idle' | 'loading' | 'success' | 'error'\n\ninterface CardState<T> {\n\tstatus: CardStatus\n\tdata: T | null\n\terror: string | null\n}\n\nfunction formatBandwidth(bps: number): string {\n\tconst mbps = (bps * 8) / 1_000_000\n\treturn mbps.toFixed(1)\n}\n\nexport function MobileNetworkTools({ instances, onBack }: Props) {\n\tconst agentInstances = instances.filter((i) => i.agent_enabled)\n\tconst [selectedInstance, setSelectedInstance] = useState<Instance | null>(agentInstances[0] || null)\n\tconst [agentOnline, setAgentOnline] = useState<boolean | null>(null)\n\n\tuseEffect(() => {\n\t\tconst current = instances.filter((i) => i.agent_enabled)\n\t\tsetSelectedInstance((prev) => {\n\t\t\tif (prev && current.some((i) => i.id === prev.id)) {\n\t\t\t\treturn current.find((i) => i.id === prev.id) || null\n\t\t\t}\n\t\t\treturn current[0] || null\n\t\t})\n\t}, [instances])\n\n\tconst [ipInfo, setIpInfo] = useState<CardState<IpInfo>>({ status: 'idle', data: null, error: null })\n\tconst [speedtest, setSpeedtest] = useState<CardState<SpeedtestResult>>({ status: 'idle', data: null, error: null })\n\tconst [speedtestServers, setSpeedtestServers] = useState<SpeedtestServer[]>([])\n\tconst [selectedServer, setSelectedServer] = useState<number | null>(null)\n\tconst [loadingServers, setLoadingServers] = useState(false)\n\tconst lastLoadedInstanceId = useRef<number | null>(null)\n\tconst [dns, setDns] = useState<CardState<DnsInfo>>({ status: 'idle', data: null, error: null })\n\tconst [ifaces, setIfaces] = useState<CardState<NetworkInterface[]>>({ status: 'idle', data: null, error: null })\n\tconst [command, setCommand] = useState('')\n\tconst [commandHistory, setCommandHistory] = useState<string[]>([])\n\tconst [commandResult, setCommandResult] = useState<CardState<{ output: string; error?: string }>>({\n\t\tstatus: 'idle',\n\t\tdata: null,\n\t\terror: null,\n\t})\n\tconst [expandedCard, setExpandedCard] = useState<string | null>(null)\n\n\tuseEffect(() => {\n\t\tif (!selectedInstance) {\n\t\t\tsetAgentOnline(null)\n\t\t\treturn\n\t\t}\n\t\tcheckAgentHealth(selectedInstance.id).then(setAgentOnline)\n\t}, [selectedInstance])\n\n\tuseEffect(() => {\n\t\tif (!selectedInstance || !agentOnline || loadingServers || speedtestServers.length !== 0) return\n\t\tif (lastLoadedInstanceId.current === selectedInstance.id) return\n\t\tlastLoadedInstanceId.current = selectedInstance.id\n\n\t\tlet cancelled = false\n\t\tsetLoadingServers(true)\n\t\tvoid (async () => {\n\t\t\ttry {\n\t\t\t\tconst data = await getSpeedtestServers(selectedInstance.id)\n\t\t\t\tif (!cancelled) setSpeedtestServers(data.servers || [])\n\t\t\t} catch {\n\t\t\t\tif (!cancelled) setSpeedtestServers([])\n\t\t\t} finally {\n\t\t\t\tif (!cancelled) setLoadingServers(false)\n\t\t\t}\n\t\t})()\n\n\t\treturn () => {\n\t\t\tcancelled = true\n\t\t}\n\t}, [selectedInstance, agentOnline, loadingServers, speedtestServers.length])\n\n\tasync function handleRunIpInfo() {\n\t\tif (!selectedInstance) return\n\t\tsetIpInfo((prev) => ({ ...prev, status: 'loading', error: null }))\n\t\ttry {\n\t\t\tconst data = await getIpInfo(selectedInstance.id)\n\t\t\tsetIpInfo({ status: 'success', data, error: null })\n\t\t} catch (e) {\n\t\t\tsetIpInfo((prev) => ({ ...prev, status: 'error', error: e instanceof Error ? e.message : 'Failed' }))\n\t\t}\n\t}\n\n\tasync function handleRunSpeedtest() {\n\t\tif (!selectedInstance) return\n\t\tsetSpeedtest({ status: 'loading', data: null, error: null })\n\t\ttry {\n\t\t\tconst data = await runSpeedtest(selectedInstance.id, selectedServer || undefined)\n\t\t\tsetSpeedtest({ status: 'success', data, error: null })\n\t\t} catch (e) {\n\t\t\tsetSpeedtest({ status: 'error', data: null, error: e instanceof Error ? e.message : 'Failed' })\n\t\t}\n\t}\n\n\tasync function handleRunDns() {\n\t\tif (!selectedInstance) return\n\t\tsetDns((prev) => ({ ...prev, status: 'loading', error: null }))\n\t\ttry {\n\t\t\tconst data = await getDnsInfo(selectedInstance.id)\n\t\t\tsetDns({ status: 'success', data, error: null })\n\t\t} catch (e) {\n\t\t\tsetDns((prev) => ({ ...prev, status: 'error', error: e instanceof Error ? e.message : 'Failed' }))\n\t\t}\n\t}\n\n\tasync function handleRunInterfaces() {\n\t\tif (!selectedInstance) return\n\t\tsetIfaces((prev) => ({ ...prev, status: 'loading', error: null }))\n\t\ttry {\n\t\t\tconst data = await getInterfaces(selectedInstance.id)\n\t\t\tsetIfaces({ status: 'success', data, error: null })\n\t\t} catch (e) {\n\t\t\tsetIfaces((prev) => ({ ...prev, status: 'error', error: e instanceof Error ? e.message : 'Failed' }))\n\t\t}\n\t}\n\n\tasync function handleRunCommand() {\n\t\tif (!selectedInstance || !command.trim()) return\n\t\tconst cmd = command.trim()\n\t\tsetCommandResult({ status: 'loading', data: null, error: null })\n\t\tsetCommandHistory((h) => [cmd, ...h.filter((c) => c !== cmd)].slice(0, 10))\n\t\ttry {\n\t\t\tconst data = await execCommand(selectedInstance.id, cmd)\n\t\t\tsetCommandResult({ status: 'success', data, error: null })\n\t\t} catch (e) {\n\t\t\tsetCommandResult({ status: 'error', data: null, error: e instanceof Error ? e.message : 'Failed' })\n\t\t}\n\t}\n\n\tconst canRun = selectedInstance && agentOnline\n\tconst serverOptions = [\n\t\t{ value: 0, label: loadingServers ? 'Loading...' : 'Auto (nearest)' },\n\t\t...speedtestServers.map((s) => ({ value: s.id, label: `${s.name} - ${s.location}` })),\n\t]\n\n\tif (agentInstances.length === 0) {\n\t\treturn (\n\t\t\t<div className=\"flex flex-col h-full\">\n\t\t\t\t<div className=\"p-4\">\n\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t<button onClick={onBack} className=\"p-2 -ml-2 rounded-xl active:bg-[var(--bg-tertiary)]\">\n\t\t\t\t\t\t\t<ChevronLeft className=\"w-5 h-5\" style={{ color: 'var(--text-primary)' }} strokeWidth={2} />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t\t<h2 className=\"text-lg font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tNetwork Tools\n\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex-1 flex flex-col items-center justify-center p-6\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-16 h-16 rounded-2xl flex items-center justify-center mb-6\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Network className=\"w-8 h-8\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<h3 className=\"text-base font-medium mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tNo Agent Configured\n\t\t\t\t\t</h3>\n\t\t\t\t\t<p className=\"text-sm text-center mb-6\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tEnable net-agent on an instance to run network diagnostics\n\t\t\t\t\t</p>\n\t\t\t\t\t<a\n\t\t\t\t\t\thref=\"https://maciejonos.github.io/qbitwebui/guide/network-agent/\"\n\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\t\tclassName=\"px-5 py-2.5 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\tSetup Guide\n\t\t\t\t\t</a>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t)\n\t}\n\n\treturn (\n\t\t<div className=\"flex flex-col h-full\">\n\t\t\t<div className=\"p-4 space-y-3\">\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t<button onClick={onBack} className=\"p-2 -ml-2 rounded-xl active:bg-[var(--bg-tertiary)]\">\n\t\t\t\t\t\t<ChevronLeft className=\"w-5 h-5\" style={{ color: 'var(--text-primary)' }} strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t<h2 className=\"text-lg font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tNetwork Tools\n\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tRun tests from your instance\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t{agentOnline !== null && (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"flex items-center gap-1.5 px-2.5 py-1 rounded-lg\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"w-2 h-2 rounded-full\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: agentOnline ? '#a6e3a1' : 'var(--error)' }}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: agentOnline ? '#a6e3a1' : 'var(--error)' }}>\n\t\t\t\t\t\t\t\t{agentOnline ? 'Online' : 'Offline'}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t{agentInstances.length > 1 && (\n\t\t\t\t\t<Select\n\t\t\t\t\t\tvalue={selectedInstance?.id || 0}\n\t\t\t\t\t\toptions={agentInstances.map((i) => ({ value: i.id, label: i.label }))}\n\t\t\t\t\t\tonChange={(v) => {\n\t\t\t\t\t\t\tconst inst = agentInstances.find((i) => i.id === v)\n\t\t\t\t\t\t\tsetSelectedInstance(inst || null)\n\t\t\t\t\t\t\tsetAgentOnline(null)\n\t\t\t\t\t\t\tsetSpeedtestServers([])\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\n\t\t\t\t{agentOnline === false && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-xl text-sm flex items-center gap-3\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--error) 8%, transparent)',\n\t\t\t\t\t\t\tborder: '1px solid color-mix(in srgb, var(--error) 20%, transparent)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Activity className=\"w-4 h-4 shrink-0\" style={{ color: 'var(--error)' }} />\n\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\tAgent not reachable. Ensure net-agent is running.\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"flex-1 overflow-y-auto px-4 pb-4 space-y-3\">\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"rounded-xl overflow-hidden\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setExpandedCard(expandedCard === 'ip' ? null : 'ip')}\n\t\t\t\t\t\tclassName=\"w-full px-4 py-3 flex items-center justify-between active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2.5\">\n\t\t\t\t\t\t\t<Globe className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tExternal IP\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{ipInfo.data && (\n\t\t\t\t\t\t\t\t<span className=\"text-xs font-mono\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t\t{ipInfo.data.ip}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{expandedCard === 'ip' ? (\n\t\t\t\t\t\t\t<ChevronUp className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<ChevronDown className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t\t{expandedCard === 'ip' && (\n\t\t\t\t\t\t<div className=\"px-4 pb-4 pt-1 space-y-3\" style={{ borderTop: '1px solid var(--border)' }}>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleRunIpInfo}\n\t\t\t\t\t\t\t\tdisabled={!canRun || ipInfo.status === 'loading'}\n\t\t\t\t\t\t\t\tclassName=\"w-full py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 flex items-center justify-center gap-2\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{ipInfo.status === 'loading' ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : 'Fetch IP Info'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{ipInfo.status === 'error' && (\n\t\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t\t\t{ipInfo.error}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{ipInfo.data && (\n\t\t\t\t\t\t\t\t<div className=\"space-y-2 pt-2\">\n\t\t\t\t\t\t\t\t\t<div className=\"text-xl font-mono font-semibold tracking-tight\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t\t\t{ipInfo.data.ip}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"space-y-1.5\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t<MapPin className=\"w-3 h-3\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t\t\t\t\t\t{ipInfo.data.city}, {ipInfo.data.region}, {ipInfo.data.country}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t<Building2 className=\"w-3 h-3\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t\t\t\t\t\t{ipInfo.data.org}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t<Clock className=\"w-3 h-3\" />\n\t\t\t\t\t\t\t\t\t\t\t{ipInfo.data.timezone}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"rounded-xl overflow-hidden\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setExpandedCard(expandedCard === 'dns' ? null : 'dns')}\n\t\t\t\t\t\tclassName=\"w-full px-4 py-3 flex items-center justify-between active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2.5\">\n\t\t\t\t\t\t\t<Server className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tDNS Servers\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{dns.data && (\n\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t{dns.data.servers.length} found\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{expandedCard === 'dns' ? (\n\t\t\t\t\t\t\t<ChevronUp className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<ChevronDown className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t\t{expandedCard === 'dns' && (\n\t\t\t\t\t\t<div className=\"px-4 pb-4 pt-1 space-y-3\" style={{ borderTop: '1px solid var(--border)' }}>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleRunDns}\n\t\t\t\t\t\t\t\tdisabled={!canRun || dns.status === 'loading'}\n\t\t\t\t\t\t\t\tclassName=\"w-full py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 flex items-center justify-center gap-2\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{dns.status === 'loading' ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : 'Fetch DNS'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{dns.status === 'error' && (\n\t\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t\t\t{dns.error}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{dns.data && (\n\t\t\t\t\t\t\t\t<div className=\"space-y-2 pt-2\">\n\t\t\t\t\t\t\t\t\t{dns.data.servers.map((server, i) => (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-2 rounded-lg\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"w-1.5 h-1.5 rounded-full\" style={{ backgroundColor: 'var(--accent)' }} />\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"font-mono text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{server}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t{dns.data.servers.length === 0 && (\n\t\t\t\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tNo DNS servers found\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"rounded-xl overflow-hidden\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setExpandedCard(expandedCard === 'ifaces' ? null : 'ifaces')}\n\t\t\t\t\t\tclassName=\"w-full px-4 py-3 flex items-center justify-between active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2.5\">\n\t\t\t\t\t\t\t<Network className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tInterfaces\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{ifaces.data && (\n\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t{ifaces.data.filter((i) => i.addr_info?.some((a) => a.family === 'inet')).length} active\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{expandedCard === 'ifaces' ? (\n\t\t\t\t\t\t\t<ChevronUp className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<ChevronDown className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t\t{expandedCard === 'ifaces' && (\n\t\t\t\t\t\t<div className=\"px-4 pb-4 pt-1 space-y-3\" style={{ borderTop: '1px solid var(--border)' }}>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleRunInterfaces}\n\t\t\t\t\t\t\t\tdisabled={!canRun || ifaces.status === 'loading'}\n\t\t\t\t\t\t\t\tclassName=\"w-full py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 flex items-center justify-center gap-2\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{ifaces.status === 'loading' ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : 'List Interfaces'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{ifaces.status === 'error' && (\n\t\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t\t\t{ifaces.error}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{ifaces.data && (\n\t\t\t\t\t\t\t\t<div className=\"space-y-2 pt-2\">\n\t\t\t\t\t\t\t\t\t{ifaces.data\n\t\t\t\t\t\t\t\t\t\t.filter((iface) => iface.addr_info?.some((a) => a.family === 'inet'))\n\t\t\t\t\t\t\t\t\t\t.map((iface) => (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tkey={iface.ifname}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center justify-between px-3 py-2 rounded-lg\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-2 h-2 rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: iface.operstate === 'UP' ? '#a6e3a1' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs font-medium\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{iface.ifname}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"font-mono text-xs\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{iface.addr_info?.find((a) => a.family === 'inet')?.local}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"rounded-xl overflow-hidden\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setExpandedCard(expandedCard === 'speedtest' ? null : 'speedtest')}\n\t\t\t\t\t\tclassName=\"w-full px-4 py-3 flex items-center justify-between active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2.5\">\n\t\t\t\t\t\t\t<Gauge className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tSpeedtest\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName=\"text-xs px-1.5 py-0.5 rounded\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tOokla\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{expandedCard === 'speedtest' ? (\n\t\t\t\t\t\t\t<ChevronUp className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<ChevronDown className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t\t{expandedCard === 'speedtest' && (\n\t\t\t\t\t\t<div className=\"px-4 pb-4 pt-1 space-y-3\" style={{ borderTop: '1px solid var(--border)' }}>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tvalue={selectedServer || 0}\n\t\t\t\t\t\t\t\toptions={serverOptions}\n\t\t\t\t\t\t\t\tonChange={(v) => setSelectedServer(v || null)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleRunSpeedtest}\n\t\t\t\t\t\t\t\tdisabled={!canRun || speedtest.status === 'loading'}\n\t\t\t\t\t\t\t\tclassName=\"w-full py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 flex items-center justify-center gap-2\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{speedtest.status === 'loading' ? (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<Loader2 className=\"w-4 h-4 animate-spin\" />\n\t\t\t\t\t\t\t\t\t\tRunning...\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t'Run Speedtest'\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{speedtest.status === 'loading' && (\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3 pt-2\">\n\t\t\t\t\t\t\t\t\t<div className=\"relative w-12 h-12\">\n\t\t\t\t\t\t\t\t\t\t<svg className=\"w-12 h-12 -rotate-90\">\n\t\t\t\t\t\t\t\t\t\t\t<circle cx=\"24\" cy=\"24\" r=\"20\" fill=\"none\" stroke=\"var(--border)\" strokeWidth=\"4\" />\n\t\t\t\t\t\t\t\t\t\t\t<circle\n\t\t\t\t\t\t\t\t\t\t\t\tcx=\"24\"\n\t\t\t\t\t\t\t\t\t\t\t\tcy=\"24\"\n\t\t\t\t\t\t\t\t\t\t\t\tr=\"20\"\n\t\t\t\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\t\t\t\tstroke=\"var(--accent)\"\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth=\"4\"\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeDasharray=\"125\"\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeDashoffset=\"30\"\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"animate-spin\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ transformOrigin: 'center', animationDuration: '1.5s' }}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t\t\t<Zap\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-4 h-4 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tThis typically takes 30-60 seconds\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{speedtest.status === 'error' && (\n\t\t\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t\t\t{speedtest.error}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{speedtest.status === 'success' && speedtest.data && (\n\t\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-2 pt-2\">\n\t\t\t\t\t\t\t\t\t<div className=\"p-3 rounded-xl\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5 mb-1\">\n\t\t\t\t\t\t\t\t\t\t\t<ArrowDown className=\"w-3 h-3\" style={{ color: 'var(--accent)' }} />\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\tDown\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-baseline gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-2xl font-mono font-bold\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{formatBandwidth(speedtest.data.download.bandwidth)}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\tMbps\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"p-3 rounded-xl\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5 mb-1\">\n\t\t\t\t\t\t\t\t\t\t\t<ArrowUp className=\"w-3 h-3\" style={{ color: '#a6e3a1' }} />\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\tUp\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-baseline gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-2xl font-mono font-bold\" style={{ color: '#a6e3a1' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{formatBandwidth(speedtest.data.upload.bandwidth)}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\tMbps\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"p-3 rounded-xl\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5 mb-1\">\n\t\t\t\t\t\t\t\t\t\t\t<Activity className=\"w-3 h-3\" style={{ color: 'var(--warning)' }} />\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\tPing\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-baseline gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-2xl font-mono font-bold\" style={{ color: 'var(--warning)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{speedtest.data.ping.latency.toFixed(0)}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\tms\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"p-3 rounded-xl\" style={{ backgroundColor: 'var(--bg-tertiary)' }}>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5 mb-1\">\n\t\t\t\t\t\t\t\t\t\t\t<Server className=\"w-3 h-3\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\tServer\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs font-medium truncate\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{speedtest.data.server.name}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs truncate\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{speedtest.data.server.location}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"rounded-xl overflow-hidden\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setExpandedCard(expandedCard === 'terminal' ? null : 'terminal')}\n\t\t\t\t\t\tclassName=\"w-full px-4 py-3 flex items-center justify-between active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2.5\">\n\t\t\t\t\t\t\t<Terminal className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tTerminal\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{expandedCard === 'terminal' ? (\n\t\t\t\t\t\t\t<ChevronUp className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<ChevronDown className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t\t{expandedCard === 'terminal' && (\n\t\t\t\t\t\t<div className=\"px-4 pb-4 pt-1 space-y-3\" style={{ borderTop: '1px solid var(--border)' }}>\n\t\t\t\t\t\t\t<div className=\"flex gap-2 overflow-x-auto pb-1 -mx-1 px-1\">\n\t\t\t\t\t\t\t\t{['ping', 'dig', 'traceroute', 'curl'].map((cmd) => (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tkey={cmd}\n\t\t\t\t\t\t\t\t\t\tonClick={() => setCommand(cmd + ' ')}\n\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{cmd}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<form\n\t\t\t\t\t\t\t\tonSubmit={(e) => {\n\t\t\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\t\t\thandleRunCommand()\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"space-y-2\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclassName=\"absolute left-3 top-1/2 -translate-y-1/2 text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t$\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={command}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setCommand(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"ping 8.8.8.8 -c 4\"\n\t\t\t\t\t\t\t\t\t\tdisabled={!canRun || commandResult.status === 'loading'}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full pl-7 pr-3 py-2.5 rounded-lg text-sm font-mono\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborder: '1px solid var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t\tfontSize: '16px',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tlist=\"cmd-history-mobile\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t{commandHistory.length > 0 && (\n\t\t\t\t\t\t\t\t\t\t<datalist id=\"cmd-history-mobile\">\n\t\t\t\t\t\t\t\t\t\t\t{commandHistory.map((c, i) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<option key={i} value={c} />\n\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t</datalist>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\t\tdisabled={!canRun || commandResult.status === 'loading' || !command.trim()}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 flex items-center justify-center gap-2\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{commandResult.status === 'loading' ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : 'Execute'}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"rounded-lg p-3 max-h-[300px] overflow-auto font-mono text-xs leading-relaxed\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{commandResult.status === 'idle' && (\n\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>ping, dig, nslookup, traceroute, curl, wget</span>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{commandResult.status === 'loading' && (\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t<Loader2 className=\"w-3 h-3 animate-spin\" style={{ color: 'var(--accent)' }} />\n\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>Executing...</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{commandResult.status === 'error' && (\n\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--error)' }}>{commandResult.error}</span>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{commandResult.status === 'success' && commandResult.data && (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<pre className=\"whitespace-pre-wrap break-all\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{commandResult.data.output || '(no output)'}\n\t\t\t\t\t\t\t\t\t\t</pre>\n\t\t\t\t\t\t\t\t\t\t{commandResult.data.error && (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"mt-2 pt-2\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderTop: '1px solid var(--border)', color: 'var(--warning)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tExit: {commandResult.data.error}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileOrphanManager.tsx",
    "content": "import { useState } from 'react'\nimport { Check, CheckCircle, ChevronLeft, HardDrive, Search } from 'lucide-react'\nimport { type Instance } from '../api/instances'\nimport { deleteTorrents } from '../api/qbittorrent'\nimport { formatSize } from '../utils/format'\n\ninterface OrphanTorrent {\n\tinstanceId: number\n\tinstanceLabel: string\n\thash: string\n\tname: string\n\tsize: number\n\treason: 'missingFiles' | 'unregistered'\n\ttrackerMessage?: string\n}\n\ninterface Props {\n\tinstances: Instance[]\n\tonBack: () => void\n}\n\nexport function MobileOrphanManager({ instances, onBack }: Props) {\n\tconst [scanning, setScanning] = useState(false)\n\tconst [orphans, setOrphans] = useState<OrphanTorrent[]>([])\n\tconst [selected, setSelected] = useState<Set<string>>(new Set())\n\tconst [scanned, setScanned] = useState(false)\n\tconst [error, setError] = useState('')\n\tconst [deleteFiles, setDeleteFiles] = useState(false)\n\tconst [deleting, setDeleting] = useState(false)\n\tconst [showConfirm, setShowConfirm] = useState(false)\n\n\tasync function scan() {\n\t\tsetScanning(true)\n\t\tsetOrphans([])\n\t\tsetSelected(new Set())\n\t\tsetScanned(false)\n\t\tsetError('')\n\t\ttry {\n\t\t\tconst res = await fetch('/api/tools/orphans/scan', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\tcredentials: 'include',\n\t\t\t})\n\t\t\tif (!res.ok) throw new Error('Scan failed')\n\t\t\tconst data = await res.json()\n\t\t\tsetOrphans(data.orphans)\n\t\t\tsetScanned(true)\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e.message : 'Scan failed')\n\t\t} finally {\n\t\t\tsetScanning(false)\n\t\t}\n\t}\n\n\tfunction toggleSelect(key: string) {\n\t\tsetSelected((prev) => {\n\t\t\tconst next = new Set(prev)\n\t\t\tif (next.has(key)) next.delete(key)\n\t\t\telse next.add(key)\n\t\t\treturn next\n\t\t})\n\t}\n\n\tfunction selectAll() {\n\t\tif (selected.size === orphans.length) {\n\t\t\tsetSelected(new Set())\n\t\t} else {\n\t\t\tsetSelected(new Set(orphans.map((o) => `${o.instanceId}:${o.hash}`)))\n\t\t}\n\t}\n\n\tasync function handleDelete() {\n\t\tsetDeleting(true)\n\t\ttry {\n\t\t\tconst byInstance = new Map<number, string[]>()\n\t\t\tfor (const key of selected) {\n\t\t\t\tconst [instanceId, hash] = key.split(':')\n\t\t\t\tconst id = parseInt(instanceId, 10)\n\t\t\t\tif (!byInstance.has(id)) byInstance.set(id, [])\n\t\t\t\tbyInstance.get(id)!.push(hash)\n\t\t\t}\n\t\t\tfor (const [instanceId, hashes] of byInstance) {\n\t\t\t\tawait deleteTorrents(instanceId, hashes, deleteFiles)\n\t\t\t}\n\t\t\tsetOrphans((prev) => prev.filter((o) => !selected.has(`${o.instanceId}:${o.hash}`)))\n\t\t\tsetSelected(new Set())\n\t\t\tsetShowConfirm(false)\n\t\t} finally {\n\t\t\tsetDeleting(false)\n\t\t}\n\t}\n\n\tconst groupedByInstance = orphans.reduce(\n\t\t(acc, o) => {\n\t\t\tif (!acc[o.instanceLabel]) acc[o.instanceLabel] = []\n\t\t\tacc[o.instanceLabel].push(o)\n\t\t\treturn acc\n\t\t},\n\t\t{} as Record<string, OrphanTorrent[]>\n\t)\n\n\treturn (\n\t\t<div className=\"flex flex-col h-full\">\n\t\t\t<div className=\"p-4 space-y-3\">\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t<button onClick={onBack} className=\"p-2 -ml-2 rounded-xl active:bg-[var(--bg-tertiary)]\">\n\t\t\t\t\t\t<ChevronLeft className=\"w-5 h-5\" style={{ color: 'var(--text-primary)' }} strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t<h2 className=\"text-lg font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tOrphan Manager\n\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tFind torrents with missing files\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<button\n\t\t\t\t\tonClick={scan}\n\t\t\t\t\tdisabled={scanning || instances.length === 0}\n\t\t\t\t\tclassName=\"w-full py-3 rounded-xl text-sm font-medium disabled:opacity-50 flex items-center justify-center gap-2\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t>\n\t\t\t\t\t{scanning ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"w-4 h-4 border-2 rounded-full animate-spin\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--accent-contrast)', borderTopColor: 'transparent' }}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\tScanning...\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<Search className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t\t\t\tScan All Instances\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</button>\n\n\t\t\t\t{error && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-xl text-sm\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 15%, transparent)', color: 'var(--error)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{error}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"flex-1 overflow-y-auto px-4 pb-4\">\n\t\t\t\t{scanning && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"p-4 rounded-xl border flex items-center gap-3\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-5 h-5 border-2 rounded-full animate-spin\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\tScanning instances...\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{instances.length === 0 && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"text-center py-12 rounded-2xl border\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<HardDrive className=\"w-8 h-8\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tNo instances configured\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{scanned && orphans.length === 0 && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"text-center py-12 rounded-2xl border\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, #a6e3a1 15%, transparent)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<CheckCircle className=\"w-8 h-8\" style={{ color: '#a6e3a1' }} strokeWidth={1.5} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tAll clear!\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<p className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tNo orphaned torrents found\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{orphans.length > 0 && (\n\t\t\t\t\t<div className=\"space-y-4\">\n\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<button onClick={selectAll} className=\"text-sm\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t{selected.size === orphans.length ? 'Deselect all' : 'Select all'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t{selected.size} of {orphans.length} selected\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{Object.entries(groupedByInstance).map(([instanceLabel, items]) => (\n\t\t\t\t\t\t\t<div key={instanceLabel}>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 px-1 py-2\">\n\t\t\t\t\t\t\t\t\t<HardDrive className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t<span className=\"text-sm font-medium\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t{instanceLabel}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t({items.length})\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t\t\t\t{items.map((item) => {\n\t\t\t\t\t\t\t\t\t\tconst key = `${item.instanceId}:${item.hash}`\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tkey={key}\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => toggleSelect(key)}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-start gap-3 p-3 rounded-xl active:scale-[0.99] transition-transform cursor-pointer\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: selected.has(key) ? 'var(--bg-tertiary)' : 'var(--bg-secondary)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-5 h-5 rounded flex items-center justify-center shrink-0 mt-0.5 border\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: selected.has(key) ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: selected.has(key) ? 'var(--accent)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{selected.has(key) && <Check className=\"w-3 h-3 text-white\" strokeWidth={3} />}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-sm font-medium leading-snug line-clamp-2\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{item.name}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 mt-1.5 text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span>{formatSize(item.size)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span>•</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{item.reason === 'missingFiles' ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--warning)' }}>Missing files</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--error)' }} title={item.trackerMessage}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tUnregistered\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{selected.size > 0 && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"sticky bottom-0 p-4 border-t\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\tpaddingBottom: 'calc(70px + env(safe-area-inset-bottom, 1rem))',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setShowConfirm(true)}\n\t\t\t\t\t\tclassName=\"w-full py-3 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\tDelete {selected.size} Torrent{selected.size > 1 ? 's' : ''}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{showConfirm && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowConfirm(false)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-4 top-1/2 -translate-y-1/2 z-50 rounded-2xl border p-5\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-semibold mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tDelete Torrents\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tDelete <strong style={{ color: 'var(--text-primary)' }}>{selected.size}</strong> torrent\n\t\t\t\t\t\t\t{selected.size > 1 ? 's' : ''}?\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<label className=\"flex items-center gap-3 mt-4 cursor-pointer\">\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\tsetDeleteFiles(!deleteFiles)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"w-5 h-5 rounded flex items-center justify-center border shrink-0\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: deleteFiles ? 'var(--error)' : 'transparent',\n\t\t\t\t\t\t\t\t\tborderColor: deleteFiles ? 'var(--error)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{deleteFiles && <Check className=\"w-3 h-3 text-white\" strokeWidth={3} />}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tAlso delete files\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<div className=\"flex gap-3 mt-5\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setShowConfirm(false)}\n\t\t\t\t\t\t\t\tdisabled={deleting}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleDelete}\n\t\t\t\t\t\t\t\tdisabled={deleting}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{deleting ? 'Deleting...' : 'Delete'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileRSSManager.tsx",
    "content": "import { useState } from 'react'\nimport { ChevronDown, ChevronLeft, ChevronRight, RefreshCw, Rss, X } from 'lucide-react'\nimport { type Instance } from '../api/instances'\nimport { useRSSManager } from '../hooks/useRSSManager'\nimport type { RSSArticle } from '../types/rss'\nimport { Checkbox, Select } from '../components/ui'\n\ntype Tab = 'feeds' | 'rules'\ntype View = 'list' | 'articles' | 'editor'\n\ninterface MobileArticleDownloadProps {\n\tarticle: RSSArticle\n\tidx: number\n\tinstances: Instance[]\n\trss: ReturnType<typeof useRSSManager>\n}\n\nfunction MobileArticleDownload({ article, idx, instances, rss }: MobileArticleDownloadProps) {\n\tconst articleId = article.id || String(idx)\n\tconst isGrabbing = rss.grabbing === articleId\n\tconst grabResult = rss.grabResult?.id === articleId ? rss.grabResult : null\n\n\tif (grabResult) {\n\t\treturn (\n\t\t\t<span\n\t\t\t\tclassName=\"px-3 py-1.5 rounded-lg text-xs font-medium\"\n\t\t\t\tstyle={{\n\t\t\t\t\tbackgroundColor: grabResult.success\n\t\t\t\t\t\t? 'color-mix(in srgb, #a6e3a1 20%, transparent)'\n\t\t\t\t\t\t: 'color-mix(in srgb, var(--error) 20%, transparent)',\n\t\t\t\t\tcolor: grabResult.success ? '#a6e3a1' : 'var(--error)',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{grabResult.success ? 'Added!' : 'Failed'}\n\t\t\t</span>\n\t\t)\n\t}\n\n\tif (instances.length === 1) {\n\t\treturn (\n\t\t\t<button\n\t\t\t\tonClick={() => rss.handleGrabArticle(article.torrentURL!, articleId, instances[0].id)}\n\t\t\t\tdisabled={isGrabbing}\n\t\t\t\tclassName=\"px-3 py-1.5 rounded-lg text-xs font-medium disabled:opacity-50\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t>\n\t\t\t\t{isGrabbing ? 'Adding...' : 'Download'}\n\t\t\t</button>\n\t\t)\n\t}\n\n\tconst isOpen = rss.instanceDropdown === articleId\n\n\treturn (\n\t\t<div className=\"relative inline-block\">\n\t\t\t<button\n\t\t\t\tonClick={() => rss.setInstanceDropdown(isOpen ? null : articleId)}\n\t\t\t\tdisabled={isGrabbing}\n\t\t\t\tclassName=\"flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium disabled:opacity-50\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t>\n\t\t\t\t{isGrabbing ? 'Adding...' : 'Download'}\n\t\t\t\t<ChevronDown className=\"w-3 h-3\" strokeWidth={2.5} />\n\t\t\t</button>\n\t\t\t{isOpen && (\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"fixed inset-0 z-10\" onClick={() => rss.setInstanceDropdown(null)} />\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"absolute left-0 top-full mt-1 z-20 min-w-[140px] rounded-xl border shadow-lg overflow-hidden\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{instances.map((i) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={i.id}\n\t\t\t\t\t\t\t\tonClick={() => rss.handleGrabArticle(article.torrentURL!, articleId, i.id)}\n\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-4 py-2.5 text-sm active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{i.label}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\ninterface Props {\n\tinstances: Instance[]\n\tonBack: () => void\n}\n\nexport function MobileRSSManager({ instances, onBack }: Props) {\n\tconst [tab, setTab] = useState<Tab>('feeds')\n\tconst [view, setView] = useState<View>('list')\n\tconst [instanceSelector, setInstanceSelector] = useState(false)\n\n\tconst rss = useRSSManager({\n\t\tinstances,\n\t\tonViewChange: setView,\n\t})\n\n\tfunction handleBackButton() {\n\t\tif (view !== 'list') {\n\t\t\tsetView('list')\n\t\t\tif (tab === 'rules') {\n\t\t\t\trss.selectRule(null)\n\t\t\t} else {\n\t\t\t\trss.setSelectedFeed(null)\n\t\t\t}\n\t\t} else {\n\t\t\tonBack()\n\t\t}\n\t}\n\n\treturn (\n\t\t<div className=\"flex flex-col h-full\" style={{ backgroundColor: 'var(--bg-primary)' }}>\n\t\t\t<header className=\"flex items-center gap-3 px-4 py-3 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t<button onClick={handleBackButton} className=\"p-1 -ml-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t<ChevronLeft className=\"w-6 h-6\" strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t\t<h1 className=\"text-lg font-semibold flex-1\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t{view === 'articles' && rss.selectedFeed\n\t\t\t\t\t\t? rss.selectedFeed.name\n\t\t\t\t\t\t: view === 'editor' && rss.selectedRule\n\t\t\t\t\t\t\t? rss.selectedRule\n\t\t\t\t\t\t\t: 'RSS Manager'}\n\t\t\t\t</h1>\n\t\t\t\t{instances.length > 1 && view === 'list' && (\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setInstanceSelector(true)}\n\t\t\t\t\t\tclassName=\"px-2 py-1 rounded text-xs\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{rss.selectedInstance?.label}\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</header>\n\n\t\t\t{view === 'list' && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"flex items-center gap-1 p-2 mx-4 mt-3 rounded-lg\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t>\n\t\t\t\t\t{(['feeds', 'rules'] as Tab[]).map((t) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={t}\n\t\t\t\t\t\t\tonClick={() => setTab(t)}\n\t\t\t\t\t\t\tclassName=\"flex-1 py-2 rounded-md text-sm font-medium capitalize transition-all\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: tab === t ? 'var(--bg-primary)' : 'transparent',\n\t\t\t\t\t\t\t\tcolor: tab === t ? 'var(--text-primary)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\tboxShadow: tab === t ? '0 1px 2px rgba(0,0,0,0.1)' : 'none',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{rss.error && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"mx-4 mt-3 px-4 py-3 rounded-lg text-sm\"\n\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 10%, transparent)', color: 'var(--error)' }}\n\t\t\t\t>\n\t\t\t\t\t{rss.error}\n\t\t\t\t\t<button onClick={rss.clearError} className=\"ml-2 opacity-70\">\n\t\t\t\t\t\t×\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t<div className=\"flex-1 overflow-y-auto p-4\">\n\t\t\t\t{rss.loading ? (\n\t\t\t\t\t<div className=\"text-center py-12\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tLoading...\n\t\t\t\t\t</div>\n\t\t\t\t) : view === 'list' && tab === 'feeds' ? (\n\t\t\t\t\t<div className=\"space-y-3\">\n\t\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => rss.setShowAddFeed(true)}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-2.5 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tAdd Feed\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => rss.setShowAddFolder(true)}\n\t\t\t\t\t\t\t\tclassName=\"py-2.5 px-4 rounded-xl text-sm border\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tFolder\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{rss.showAddFeed && (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<form onSubmit={rss.handleAddFeed} className=\"space-y-3\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"url\"\n\t\t\t\t\t\t\t\t\t\tvalue={rss.feedUrl}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => rss.setFeedUrl(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Feed URL\"\n\t\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\tvalue={rss.feedPath}\n\t\t\t\t\t\t\t\t\t\tonChange={rss.setFeedPath}\n\t\t\t\t\t\t\t\t\t\toptions={[\n\t\t\t\t\t\t\t\t\t\t\t{ value: '', label: 'None' },\n\t\t\t\t\t\t\t\t\t\t\t...rss.feeds.filter((f) => f.isFolder).map((f) => ({ value: f.path, label: f.path })),\n\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t\tminWidth=\"100%\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"h-[46px] [&>button]:h-full [&>button]:rounded-xl [&>button]:px-4 [&>button]:text-sm\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\t\t\t\tdisabled={rss.submitting}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 py-2.5 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{rss.submitting ? 'Adding...' : 'Add'}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={rss.cancelAddFeed}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"py-2.5 px-4 rounded-xl text-sm border\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{rss.showAddFolder && (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<form onSubmit={rss.handleAddFolder} className=\"space-y-3\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={rss.folderName}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => rss.setFolderName(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Folder name\"\n\t\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\t\t\t\tdisabled={rss.submitting}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 py-2.5 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{rss.submitting ? 'Creating...' : 'Create'}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={rss.cancelAddFolder}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"py-2.5 px-4 rounded-xl text-sm border\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"rounded-xl border overflow-hidden\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{rss.visibleFeeds.length === 0 ? (\n\t\t\t\t\t\t\t\t<div className=\"px-4 py-8 text-center text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tNo feeds\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\trss.visibleFeeds.map((feed) => (\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tkey={feed.path}\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-3 px-4 py-3 border-b last:border-b-0 active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', paddingLeft: `${16 + feed.depth * 16}px` }}\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tif (feed.isFolder) {\n\t\t\t\t\t\t\t\t\t\t\t\trss.toggleFolder(feed.path)\n\t\t\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\t\t\trss.setSelectedFeed(feed)\n\t\t\t\t\t\t\t\t\t\t\t\tsetView('articles')\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{feed.isFolder ? (\n\t\t\t\t\t\t\t\t\t\t\t<ChevronRight\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={`w-5 h-5 shrink-0 transition-transform ${rss.expandedFolders.has(feed.path) ? 'rotate-90' : ''}`}\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<Rss className=\"w-5 h-5 shrink-0\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t<span className=\"text-sm flex-1 truncate\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{feed.name}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{!feed.isFolder && (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\t\t\trss.handleRefresh(feed)\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={rss.refreshing === feed.path}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1.5 rounded-lg\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<RefreshCw\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={`w-4 h-4 ${rss.refreshing === feed.path ? 'animate-spin' : ''}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\t\t\trss.setDeleteConfirm(feed)\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1.5 rounded-lg\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<X className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{feed.isFolder && (\n\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\t\trss.setDeleteConfirm(feed)\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1.5 rounded-lg\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<X className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t) : view === 'articles' && rss.selectedFeed ? (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"rounded-xl border overflow-hidden\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{rss.feedArticles.length > 0 ? (\n\t\t\t\t\t\t\trss.feedArticles.map((article, idx) => (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tkey={article.id || idx}\n\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-3 border-b last:border-b-0\"\n\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName=\"text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: article.isRead ? 'var(--text-muted)' : 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{article.title}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t{article.date && (\n\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs mt-1\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{new Date(article.date).toLocaleDateString()}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t{article.torrentURL && (\n\t\t\t\t\t\t\t\t\t\t<div className=\"mt-2\">\n\t\t\t\t\t\t\t\t\t\t\t<MobileArticleDownload article={article} idx={idx} instances={instances} rss={rss} />\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t))\n\t\t\t\t\t\t) : rss.selectedFeed.data?.isLoading ? (\n\t\t\t\t\t\t\t<div className=\"px-4 py-8 text-center text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tLoading feed...\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : rss.selectedFeed.data?.hasError ? (\n\t\t\t\t\t\t\t<div className=\"px-4 py-8 text-center text-sm\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\t\tFailed to load feed\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div className=\"px-4 py-8 text-center text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tNo articles - try refreshing\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t) : view === 'list' && tab === 'rules' ? (\n\t\t\t\t\t<div className=\"space-y-3\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => rss.setShowNewRule(true)}\n\t\t\t\t\t\t\tclassName=\"w-full py-2.5 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tNew Rule\n\t\t\t\t\t\t</button>\n\n\t\t\t\t\t\t{rss.showNewRule && (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"p-4 rounded-xl border\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<form onSubmit={rss.handleCreateRule} className=\"space-y-3\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={rss.newRuleName}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => rss.setNewRuleName(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-sm\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Rule name\"\n\t\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\t\t\t\tdisabled={rss.submitting}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 py-2.5 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{rss.submitting ? 'Creating...' : 'Create'}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={rss.cancelNewRule}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"py-2.5 px-4 rounded-xl text-sm border\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"rounded-xl border overflow-hidden\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{Object.keys(rss.rules).length === 0 ? (\n\t\t\t\t\t\t\t\t<div className=\"px-4 py-8 text-center text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tNo rules\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\tObject.entries(rss.rules).map(([name, rule]) => (\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tkey={name}\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-3 px-4 py-3 border-b last:border-b-0 active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\trss.selectRule(name)\n\t\t\t\t\t\t\t\t\t\t\tsetView('editor')\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-3 h-3 rounded-full shrink-0\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: rule.enabled ? '#a6e3a1' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<span className=\"text-sm flex-1 truncate\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{name}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\trss.setRuleDeleteConfirm(name)\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-1.5 rounded-lg\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<X className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t) : view === 'editor' && rss.selectedRule && rss.editingRule ? (\n\t\t\t\t\t<div className=\"space-y-4\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tlabel=\"Enabled\"\n\t\t\t\t\t\t\tchecked={rss.editingRule.enabled}\n\t\t\t\t\t\t\tonChange={(v) => rss.setEditingRule({ ...rss.editingRule!, enabled: v })}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-1.5 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tMust Contain\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={rss.editingRule.mustContain}\n\t\t\t\t\t\t\t\tonChange={(e) => rss.setEditingRule({ ...rss.editingRule!, mustContain: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-sm font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tplaceholder=\"1080p|720p\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-1.5 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tMust NOT Contain\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={rss.editingRule.mustNotContain}\n\t\t\t\t\t\t\t\tonChange={(e) => rss.setEditingRule({ ...rss.editingRule!, mustNotContain: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-sm font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tplaceholder=\"CAM|TS\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"flex gap-4\">\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tlabel=\"Use Regex\"\n\t\t\t\t\t\t\t\tchecked={rss.editingRule.useRegex}\n\t\t\t\t\t\t\t\tonChange={(v) => rss.setEditingRule({ ...rss.editingRule!, useRegex: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tlabel=\"Smart Filter\"\n\t\t\t\t\t\t\t\tchecked={rss.editingRule.smartFilter}\n\t\t\t\t\t\t\t\tonChange={(v) => rss.setEditingRule({ ...rss.editingRule!, smartFilter: v })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-1.5 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tEpisode Filter\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={rss.editingRule.episodeFilter}\n\t\t\t\t\t\t\t\tonChange={(e) => rss.setEditingRule({ ...rss.editingRule!, episodeFilter: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-sm font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tplaceholder=\"S01E01-S01E10\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-1.5 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCategory\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\tvalue={rss.editingRule.assignedCategory}\n\t\t\t\t\t\t\t\tonChange={(e) => rss.setEditingRule({ ...rss.editingRule!, assignedCategory: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-sm\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<option value=\"\">None</option>\n\t\t\t\t\t\t\t\t{Object.keys(rss.categories).map((cat) => (\n\t\t\t\t\t\t\t\t\t<option key={cat} value={cat}>\n\t\t\t\t\t\t\t\t\t\t{cat}\n\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-1.5 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tSave Path\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={rss.editingRule.savePath}\n\t\t\t\t\t\t\t\tonChange={(e) => rss.setEditingRule({ ...rss.editingRule!, savePath: e.target.value })}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-sm font-mono\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tplaceholder=\"/downloads/tv\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tApply to Feeds\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"max-h-40 overflow-y-auto rounded-xl border p-3 space-y-2\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{rss.feedUrls.length === 0 ? (\n\t\t\t\t\t\t\t\t\t<div className=\"text-sm py-2 text-center\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tNo feeds\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\trss.feedUrls.map((url) => (\n\t\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\t\tkey={url}\n\t\t\t\t\t\t\t\t\t\t\tlabel={url}\n\t\t\t\t\t\t\t\t\t\t\tchecked={rss.editingRule!.affectedFeeds.includes(url)}\n\t\t\t\t\t\t\t\t\t\t\tonChange={(checked) => {\n\t\t\t\t\t\t\t\t\t\t\t\tconst newFeeds = checked\n\t\t\t\t\t\t\t\t\t\t\t\t\t? [...rss.editingRule!.affectedFeeds, url]\n\t\t\t\t\t\t\t\t\t\t\t\t\t: rss.editingRule!.affectedFeeds.filter((f) => f !== url)\n\t\t\t\t\t\t\t\t\t\t\t\trss.setEditingRule({ ...rss.editingRule!, affectedFeeds: newFeeds })\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"flex gap-2 pt-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={rss.handleSaveRule}\n\t\t\t\t\t\t\t\tdisabled={rss.savingRule}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: rss.ruleSaved ? '#a6e3a1' : 'var(--accent)',\n\t\t\t\t\t\t\t\t\tcolor: rss.ruleSaved ? '#1e1e2e' : 'var(--accent-contrast)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{rss.savingRule ? 'Saving...' : rss.ruleSaved ? 'Saved!' : 'Save'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={rss.handleCancelEdit}\n\t\t\t\t\t\t\t\tclassName=\"py-3 px-4 rounded-xl text-sm border\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={rss.handlePreviewMatches}\n\t\t\t\t\t\t\t\tdisabled={rss.loadingMatches}\n\t\t\t\t\t\t\t\tclassName=\"py-3 px-4 rounded-xl text-sm border disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{rss.loadingMatches ? '...' : 'Preview'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{rss.matchingArticles && (\n\t\t\t\t\t\t\t<div className=\"pt-4 border-t\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"text-xs font-semibold uppercase tracking-wider mb-2\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tMatching Articles\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"max-h-48 overflow-y-auto rounded-xl border p-3\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{Object.keys(rss.matchingArticles).length === 0 ? (\n\t\t\t\t\t\t\t\t\t\t<div className=\"text-sm py-2 text-center\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tNo matches\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\tObject.entries(rss.matchingArticles).map(([feedName, matchedTitles]) => (\n\t\t\t\t\t\t\t\t\t\t\t<div key={feedName} className=\"mb-3 last:mb-0\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs font-medium\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{feedName}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t{matchedTitles.map((title, i) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div key={i} className=\"text-xs pl-2 mt-1 truncate\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{title}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t</div>\n\n\t\t\t{rss.deleteConfirm && (\n\t\t\t\t<div className=\"fixed inset-0 z-50 flex items-end p-4\" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full rounded-2xl border p-6\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-semibold mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tDelete {rss.deleteConfirm.isFolder ? 'Folder' : 'Feed'}\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mb-6\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tDelete <strong style={{ color: 'var(--text-primary)' }}>{rss.deleteConfirm.name}</strong>?\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<div className=\"flex gap-3\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => rss.setDeleteConfirm(null)}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm border\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={rss.handleDeleteItem}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{rss.ruleDeleteConfirm && (\n\t\t\t\t<div className=\"fixed inset-0 z-50 flex items-end p-4\" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full rounded-2xl border p-6\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-semibold mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tDelete Rule\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mb-6\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tDelete <strong style={{ color: 'var(--text-primary)' }}>{rss.ruleDeleteConfirm}</strong>?\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<div className=\"flex gap-3\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => rss.setRuleDeleteConfirm(null)}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm border\"\n\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={rss.handleDeleteRule}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{instanceSelector && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-50 flex items-end p-4\"\n\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.5)' }}\n\t\t\t\t\tonClick={() => setInstanceSelector(false)}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-full rounded-2xl border overflow-hidden\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"px-4 py-3 border-b text-sm font-semibold\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tSelect Instance\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{instances.map((inst) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={inst.id}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\trss.selectInstance(inst)\n\t\t\t\t\t\t\t\t\tsetInstanceSelector(false)\n\t\t\t\t\t\t\t\t\tsetView('list')\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 text-left text-sm border-b last:border-b-0 active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: rss.selectedInstance?.id === inst.id ? 'var(--accent)' : 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{inst.label}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileSearchPanel.tsx",
    "content": "import { useState, useEffect, useRef } from 'react'\nimport { ChevronLeft, ChevronDown, Search, X, Check, Plus, Trash2, ArrowUpDown, Filter } from 'lucide-react'\nimport {\n\tgetIntegrations,\n\tcreateIntegration,\n\tdeleteIntegration,\n\ttestIntegrationConnection,\n\tgetIndexers,\n\tgetProwlarrCategories,\n\tsearch,\n\tgrabRelease,\n\ttype Integration,\n\ttype Indexer,\n\ttype ProwlarrCategory,\n\ttype SearchResult,\n} from '../api/integrations'\nimport { type Instance } from '../api/instances'\nimport { getCategories, type Category } from '../api/qbittorrent'\nimport { formatSize } from '../utils/format'\nimport { extractTags, sortResults, filterResults, type SortKey } from '../utils/search'\n\nfunction formatAge(dateStr: string): string {\n\tconst date = new Date(dateStr)\n\tconst now = new Date()\n\tconst diff = now.getTime() - date.getTime()\n\tconst days = Math.floor(diff / (1000 * 60 * 60 * 24))\n\tif (days === 0) return 'Today'\n\tif (days === 1) return '1d'\n\tif (days < 30) return `${days}d`\n\tif (days < 365) return `${Math.floor(days / 30)}mo`\n\treturn `${Math.floor(days / 365)}y`\n}\n\ninterface Props {\n\tinstances: Instance[]\n\tonBack: () => void\n}\n\nexport function MobileSearchPanel({ instances, onBack }: Props) {\n\tconst [integrations, setIntegrations] = useState<Integration[]>([])\n\tconst [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null)\n\tconst [indexers, setIndexers] = useState<Indexer[]>([])\n\tconst [prowlarrCategories, setProwlarrCategories] = useState<ProwlarrCategory[]>([])\n\tconst [selectedIndexer, setSelectedIndexer] = useState<string>('-2')\n\tconst [selectedCategory, setSelectedCategory] = useState<string>('')\n\tconst [showProwlarrCategoryPicker, setShowProwlarrCategoryPicker] = useState(false)\n\tconst [query, setQuery] = useState('')\n\tconst [results, setResults] = useState<SearchResult[]>([])\n\tconst [searching, setSearching] = useState(false)\n\tconst [error, setError] = useState('')\n\tconst [showAddForm, setShowAddForm] = useState(false)\n\tconst [formData, setFormData] = useState({ label: '', url: '', api_key: '' })\n\tconst [submitting, setSubmitting] = useState(false)\n\tconst [testing, setTesting] = useState(false)\n\tconst [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)\n\tconst [grabbing, setGrabbing] = useState<string | null>(null)\n\tconst [grabResult, setGrabResult] = useState<{ guid: string; success: boolean; message?: string } | null>(null)\n\tconst [grabInstance, setGrabInstance] = useState<number | null>(null)\n\tconst [grabCategories, setGrabCategories] = useState<Record<string, Category>>({})\n\tconst [grabCategory, setGrabCategory] = useState('')\n\tconst [grabSavepath, setGrabSavepath] = useState('')\n\tconst [grabDownloadPath, setGrabDownloadPath] = useState('')\n\tconst [loadingCategories, setLoadingCategories] = useState(false)\n\tconst [showIndexerPicker, setShowIndexerPicker] = useState(false)\n\tconst [showIntegrationPicker, setShowIntegrationPicker] = useState(false)\n\tconst [showGrabSheet, setShowGrabSheet] = useState<SearchResult | null>(null)\n\tconst [deleteConfirm, setDeleteConfirm] = useState<Integration | null>(null)\n\tconst [sortKey, setSortKey] = useState<SortKey>('seeders')\n\tconst [sortAsc, setSortAsc] = useState(false)\n\tconst [filter, setFilter] = useState('')\n\tconst [showSortPicker, setShowSortPicker] = useState(false)\n\tconst [showFilterPicker, setShowFilterPicker] = useState(false)\n\tconst [showCategoryPicker, setShowCategoryPicker] = useState(false)\n\tconst searchInputRef = useRef<HTMLInputElement>(null)\n\n\tuseEffect(() => {\n\t\tgetIntegrations()\n\t\t\t.then((data) => {\n\t\t\t\tsetIntegrations(data)\n\t\t\t\tif (data.length > 0) setSelectedIntegration((prev) => prev ?? data[0])\n\t\t\t})\n\t\t\t.catch(() => {})\n\t}, [])\n\n\tuseEffect(() => {\n\t\tif (!selectedIntegration) return\n\t\tgetIndexers(selectedIntegration.id)\n\t\t\t.then(setIndexers)\n\t\t\t.catch(() => setIndexers([]))\n\t\tgetProwlarrCategories(selectedIntegration.id)\n\t\t\t.then(setProwlarrCategories)\n\t\t\t.catch(() => setProwlarrCategories([]))\n\t}, [selectedIntegration])\n\n\tuseEffect(() => {\n\t\tif (!grabInstance) {\n\t\t\tsetGrabCategories({})\n\t\t\treturn\n\t\t}\n\t\tsetLoadingCategories(true)\n\t\tgetCategories(grabInstance)\n\t\t\t.then(setGrabCategories)\n\t\t\t.catch(() => setGrabCategories({}))\n\t\t\t.finally(() => setLoadingCategories(false))\n\t}, [grabInstance])\n\n\tuseEffect(() => {\n\t\tif (!showGrabSheet) return\n\t\tsetGrabCategory('')\n\t\tsetGrabSavepath('')\n\t\tsetGrabDownloadPath('')\n\t\tsetGrabInstance(instances.length === 1 ? instances[0].id : null)\n\t}, [showGrabSheet, instances])\n\n\tasync function handleSearch(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tif (!selectedIntegration || !query.trim()) return\n\t\tsearchInputRef.current?.blur()\n\t\tsetSearching(true)\n\t\tsetError('')\n\t\tsetResults([])\n\t\ttry {\n\t\t\tconst data = await search(selectedIntegration.id, query, {\n\t\t\t\tindexerIds: selectedIndexer,\n\t\t\t\tcategories: selectedCategory || undefined,\n\t\t\t})\n\t\t\tsetResults(data)\n\t\t\tsetFilter('')\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : 'Search failed')\n\t\t} finally {\n\t\t\tsetSearching(false)\n\t\t}\n\t}\n\n\tasync function handleAddIntegration(e: React.FormEvent) {\n\t\te.preventDefault()\n\t\tsetSubmitting(true)\n\t\tsetError('')\n\t\ttry {\n\t\t\tconst integration = await createIntegration({ type: 'prowlarr', ...formData })\n\t\t\tsetIntegrations([...integrations, integration])\n\t\t\tsetSelectedIntegration(integration)\n\t\t\tsetShowAddForm(false)\n\t\t\tsetFormData({ label: '', url: '', api_key: '' })\n\t\t\tsetTestResult(null)\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to add')\n\t\t} finally {\n\t\t\tsetSubmitting(false)\n\t\t}\n\t}\n\n\tasync function handleTestConnection() {\n\t\tsetTesting(true)\n\t\tsetTestResult(null)\n\t\ttry {\n\t\t\tconst result = await testIntegrationConnection(formData.url, formData.api_key)\n\t\t\tsetTestResult(\n\t\t\t\tresult.success\n\t\t\t\t\t? { success: true, message: `Connected! Prowlarr ${result.version}` }\n\t\t\t\t\t: { success: false, message: result.error || 'Failed' }\n\t\t\t)\n\t\t} catch (err) {\n\t\t\tsetTestResult({ success: false, message: err instanceof Error ? err.message : 'Failed' })\n\t\t} finally {\n\t\t\tsetTesting(false)\n\t\t}\n\t}\n\n\tasync function handleDeleteIntegration() {\n\t\tif (!deleteConfirm) return\n\t\ttry {\n\t\t\tawait deleteIntegration(deleteConfirm.id)\n\t\t\tconst updated = integrations.filter((i) => i.id !== deleteConfirm.id)\n\t\t\tsetIntegrations(updated)\n\t\t\tif (selectedIntegration?.id === deleteConfirm.id) {\n\t\t\t\tsetSelectedIntegration(updated[0] || null)\n\t\t\t}\n\t\t\tsetDeleteConfirm(null)\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to delete')\n\t\t}\n\t}\n\n\tasync function handleGrab(result: SearchResult, instanceId: number) {\n\t\tif (!selectedIntegration) return\n\t\tsetGrabbing(result.guid)\n\t\tsetGrabResult(null)\n\t\tconst options: { category?: string; savepath?: string; downloadPath?: string } = {}\n\t\tif (grabCategory) options.category = grabCategory\n\t\tif (grabSavepath.trim()) options.savepath = grabSavepath.trim()\n\t\tif (grabDownloadPath.trim()) options.downloadPath = grabDownloadPath.trim()\n\t\ttry {\n\t\t\tawait grabRelease(\n\t\t\t\tselectedIntegration.id,\n\t\t\t\t{\n\t\t\t\t\tguid: result.guid,\n\t\t\t\t\tindexerId: result.indexerId,\n\t\t\t\t\tdownloadUrl: result.downloadUrl,\n\t\t\t\t\tmagnetUrl: result.magnetUrl,\n\t\t\t\t},\n\t\t\t\tinstanceId,\n\t\t\t\tObject.keys(options).length > 0 ? options : undefined\n\t\t\t)\n\t\t\tsetGrabResult({ guid: result.guid, success: true })\n\t\t\tsetShowGrabSheet(null)\n\t\t\tsetTimeout(() => setGrabResult(null), 3000)\n\t\t} catch (err) {\n\t\t\tsetGrabResult({ guid: result.guid, success: false, message: err instanceof Error ? err.message : 'Failed' })\n\t\t} finally {\n\t\t\tsetGrabbing(null)\n\t\t}\n\t}\n\n\tconst torrentIndexers = indexers.filter((i) => i.enable && i.protocol === 'torrent')\n\tconst availableTags = extractTags(results.map((r) => r.title))\n\tconst filteredResults = filterResults(results, filter)\n\tconst sortedResults = sortResults(filteredResults, sortKey, sortAsc)\n\n\tif (showAddForm) {\n\t\treturn (\n\t\t\t<div className=\"p-4\">\n\t\t\t\t<div className=\"flex items-center gap-3 mb-6\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tsetShowAddForm(false)\n\t\t\t\t\t\t\tsetTestResult(null)\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"p-2 -ml-2 rounded-xl active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<ChevronLeft className=\"w-5 h-5\" style={{ color: 'var(--text-primary)' }} strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t\t<h2 className=\"text-lg font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tAdd Prowlarr\n\t\t\t\t\t</h2>\n\t\t\t\t</div>\n\t\t\t\t<form onSubmit={handleAddIntegration} className=\"space-y-4\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tLabel\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={formData.label}\n\t\t\t\t\t\t\tonChange={(e) => setFormData({ ...formData, label: e.target.value })}\n\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-base\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tplaceholder=\"My Prowlarr\"\n\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tURL\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"url\"\n\t\t\t\t\t\t\tvalue={formData.url}\n\t\t\t\t\t\t\tonChange={(e) => setFormData({ ...formData, url: e.target.value })}\n\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-base\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tplaceholder=\"http://localhost:9696\"\n\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\tclassName=\"block text-xs font-medium mb-2 uppercase tracking-wider\"\n\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tAPI Key\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\tvalue={formData.api_key}\n\t\t\t\t\t\t\tonChange={(e) => setFormData({ ...formData, api_key: e.target.value })}\n\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-base\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tplaceholder=\"••••••••\"\n\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t{testResult && (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-xl text-sm\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: testResult.success\n\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, #a6e3a1 15%, transparent)'\n\t\t\t\t\t\t\t\t\t: 'color-mix(in srgb, var(--error) 15%, transparent)',\n\t\t\t\t\t\t\t\tcolor: testResult.success ? '#a6e3a1' : 'var(--error)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{testResult.message}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t{error && (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-xl text-sm\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 15%, transparent)', color: 'var(--error)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{error}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t<div className=\"flex gap-3 pt-2\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={handleTestConnection}\n\t\t\t\t\t\t\tdisabled={testing || !formData.url || !formData.api_key}\n\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium disabled:opacity-50 border\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{testing ? 'Testing...' : 'Test'}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\tdisabled={submitting}\n\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{submitting ? 'Adding...' : 'Add'}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</form>\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (integrations.length === 0) {\n\t\treturn (\n\t\t\t<div className=\"p-4\">\n\t\t\t\t<div className=\"flex items-center gap-3 mb-6\">\n\t\t\t\t\t<button onClick={onBack} className=\"p-2 -ml-2 rounded-xl active:bg-[var(--bg-tertiary)]\">\n\t\t\t\t\t\t<ChevronLeft className=\"w-5 h-5\" style={{ color: 'var(--text-primary)' }} strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t\t<h2 className=\"text-lg font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tProwlarr Search\n\t\t\t\t\t</h2>\n\t\t\t\t</div>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"text-center py-12 rounded-2xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Search className=\"w-8 h-8\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<p className=\"text-sm mb-4\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tNo Prowlarr configured\n\t\t\t\t\t</p>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setShowAddForm(true)}\n\t\t\t\t\t\tclassName=\"px-5 py-2.5 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\tAdd Prowlarr\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t)\n\t}\n\n\treturn (\n\t\t<div className=\"flex flex-col h-full\">\n\t\t\t<div className=\"p-4 space-y-3\">\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t<button onClick={onBack} className=\"p-2 -ml-2 rounded-xl active:bg-[var(--bg-tertiary)]\">\n\t\t\t\t\t\t<ChevronLeft className=\"w-5 h-5\" style={{ color: 'var(--text-primary)' }} strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t\t<h2 className=\"text-lg font-semibold flex-1\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\tProwlarr Search\n\t\t\t\t\t</h2>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setShowIntegrationPicker(true)}\n\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-lg text-sm font-medium flex items-center gap-1.5\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{selectedIntegration?.label}\n\t\t\t\t\t\t<ChevronDown className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\n\t\t\t\t<form onSubmit={handleSearch} className=\"space-y-3\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"flex items-center gap-3 px-4 py-3 rounded-xl border\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Search className=\"w-5 h-5 shrink-0\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tref={searchInputRef}\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={query}\n\t\t\t\t\t\t\tonChange={(e) => setQuery(e.target.value)}\n\t\t\t\t\t\t\tplaceholder=\"Search torrents...\"\n\t\t\t\t\t\t\tclassName=\"flex-1 bg-transparent outline-none text-base\"\n\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\tinputMode=\"search\"\n\t\t\t\t\t\t\tenterKeyHint=\"search\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{query && (\n\t\t\t\t\t\t\t<button type=\"button\" onClick={() => setQuery('')} className=\"p-1\">\n\t\t\t\t\t\t\t\t<X className=\"w-4 h-4\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => setShowIndexerPicker(true)}\n\t\t\t\t\t\t\tclassName=\"flex-1 px-4 py-2.5 rounded-xl border text-sm flex items-center justify-between\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className=\"truncate\">\n\t\t\t\t\t\t\t\t{selectedIndexer === '-2'\n\t\t\t\t\t\t\t\t\t? 'All Indexers'\n\t\t\t\t\t\t\t\t\t: torrentIndexers.find((i) => String(i.id) === selectedIndexer)?.name || 'All'}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<ChevronDown className=\"w-4 h-4 shrink-0 ml-2\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => setShowProwlarrCategoryPicker(true)}\n\t\t\t\t\t\t\tclassName=\"flex-1 px-4 py-2.5 rounded-xl border text-sm flex items-center justify-between\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className=\"truncate\">\n\t\t\t\t\t\t\t\t{selectedCategory\n\t\t\t\t\t\t\t\t\t? prowlarrCategories.find((c) => String(c.id) === selectedCategory)?.name || 'All'\n\t\t\t\t\t\t\t\t\t: 'All Categories'}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\t\tclassName=\"w-4 h-4 shrink-0 ml-2\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M19.5 8.25l-7.5 7.5-7.5-7.5\" />\n\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\tdisabled={searching || !query.trim()}\n\t\t\t\t\t\t\tclassName=\"px-6 py-2.5 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{searching ? (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"w-5 h-5 border-2 rounded-full animate-spin\"\n\t\t\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--accent-contrast)', borderTopColor: 'transparent' }}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t'Search'\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</form>\n\n\t\t\t\t{error && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-xl text-sm\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 15%, transparent)', color: 'var(--error)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{error}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"flex-1 overflow-y-auto px-4 pb-4\">\n\t\t\t\t{searching && (\n\t\t\t\t\t<div className=\"flex items-center justify-center py-12\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-8 h-8 border-2 rounded-full animate-spin\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{!searching && results.length > 0 && (\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<div className=\"flex items-center gap-2 py-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setShowSortPicker(true)}\n\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<ArrowUpDown className=\"w-3.5 h-3.5\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t<span>{sortKey === 'seeders' ? 'Seeders' : sortKey === 'size' ? 'Size' : 'Age'}</span>\n\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>{sortAsc ? '↑' : '↓'}</span>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{availableTags.length > 0 && (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => setShowFilterPicker(true)}\n\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Filter className=\"w-3.5 h-3.5\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t<span>Filter</span>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{filter && (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => setFilter('')}\n\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--accent) 15%, transparent)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--accent)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{filter}\n\t\t\t\t\t\t\t\t\t<X className=\"w-3 h-3\" strokeWidth={2.5} />\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<span className=\"text-xs ml-auto\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t{filter ? `${sortedResults.length}/${results.length}` : results.length}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{sortedResults.map((result) => (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={result.guid}\n\t\t\t\t\t\t\t\tonClick={() => instances.length > 0 && setShowGrabSheet(result)}\n\t\t\t\t\t\t\t\tclassName=\"p-3 rounded-xl border active:scale-[0.99] transition-transform cursor-pointer\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"text-sm font-medium leading-snug line-clamp-2 flex items-start gap-1.5\"\n\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{result.indexerFlags?.some((f) => /free\\s*leech|^free$/i.test(f)) && (\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\t\talert('Freeleech')\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"shrink-0 mt-0.5 px-1 py-0.5 rounded text-[9px] font-bold\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, #a6e3a1 20%, transparent)', color: '#a6e3a1' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tFL\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t<span className=\"line-clamp-2\">{result.title}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3 mt-2 text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t<span className=\"truncate max-w-[100px]\">{result.indexer}</span>\n\t\t\t\t\t\t\t\t\t<span>{formatSize(result.size)}</span>\n\t\t\t\t\t\t\t\t\t<span>{formatAge(result.publishDate)}</span>\n\t\t\t\t\t\t\t\t\t<span className=\"ml-auto flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t<span style={{ color: '#a6e3a1' }}>{result.seeders ?? '-'}</span>\n\t\t\t\t\t\t\t\t\t\t<span>/</span>\n\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--error)' }}>{result.leechers ?? '-'}</span>\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{grabResult?.guid === result.guid && (\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName=\"mt-2 px-3 py-1.5 rounded-lg text-xs font-medium text-center\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: grabResult.success\n\t\t\t\t\t\t\t\t\t\t\t\t? 'color-mix(in srgb, #a6e3a1 20%, transparent)'\n\t\t\t\t\t\t\t\t\t\t\t\t: 'color-mix(in srgb, var(--error) 20%, transparent)',\n\t\t\t\t\t\t\t\t\t\t\tcolor: grabResult.success ? '#a6e3a1' : 'var(--error)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{grabResult.success ? 'Added!' : grabResult.message}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{!searching && results.length === 0 && query && (\n\t\t\t\t\t<div className=\"text-center py-12\">\n\t\t\t\t\t\t<p className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tNo results found\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{showIndexerPicker && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowIndexerPicker(false)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-0 bottom-0 z-50 rounded-t-3xl border-t max-h-[70vh] overflow-hidden\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tpaddingBottom: 'env(safe-area-inset-bottom, 0px)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-center pt-3 pb-2\">\n\t\t\t\t\t\t\t<div className=\"w-10 h-1 rounded-full\" style={{ backgroundColor: 'var(--text-muted)' }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"px-5 pb-3 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tSelect Indexer\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"overflow-y-auto max-h-[50vh] p-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetSelectedIndexer('-2')\n\t\t\t\t\t\t\t\t\tsetShowIndexerPicker(false)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-4 py-3 rounded-xl\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: selectedIndexer === '-2' ? 'var(--bg-tertiary)' : 'transparent' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-primary)' }}>All Indexers</span>\n\t\t\t\t\t\t\t\t{selectedIndexer === '-2' && (\n\t\t\t\t\t\t\t\t\t<Check className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{torrentIndexers.map((indexer) => (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tkey={indexer.id}\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tsetSelectedIndexer(String(indexer.id))\n\t\t\t\t\t\t\t\t\t\tsetShowIndexerPicker(false)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-4 py-3 rounded-xl\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: String(indexer.id) === selectedIndexer ? 'var(--bg-tertiary)' : 'transparent',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-primary)' }}>{indexer.name}</span>\n\t\t\t\t\t\t\t\t\t{String(indexer.id) === selectedIndexer && (\n\t\t\t\t\t\t\t\t\t\t<Check className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{showProwlarrCategoryPicker && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowProwlarrCategoryPicker(false)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-0 bottom-0 z-50 rounded-t-3xl border-t max-h-[70vh] overflow-hidden\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tpaddingBottom: 'env(safe-area-inset-bottom, 0px)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-center pt-3 pb-2\">\n\t\t\t\t\t\t\t<div className=\"w-10 h-1 rounded-full\" style={{ backgroundColor: 'var(--text-muted)' }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"px-5 pb-3 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tSelect Category\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"overflow-y-auto max-h-[50vh] p-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetSelectedCategory('')\n\t\t\t\t\t\t\t\t\tsetShowProwlarrCategoryPicker(false)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-4 py-3 rounded-xl\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: selectedCategory === '' ? 'var(--bg-tertiary)' : 'transparent' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-primary)' }}>All Categories</span>\n\t\t\t\t\t\t\t\t{selectedCategory === '' && (\n\t\t\t\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-5 h-5\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4.5 12.75l6 6 9-13.5\" />\n\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{prowlarrCategories.map((category) => (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tkey={category.id}\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tsetSelectedCategory(String(category.id))\n\t\t\t\t\t\t\t\t\t\tsetShowProwlarrCategoryPicker(false)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-4 py-3 rounded-xl\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: String(category.id) === selectedCategory ? 'var(--bg-tertiary)' : 'transparent',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-primary)' }}>{category.name}</span>\n\t\t\t\t\t\t\t\t\t{String(category.id) === selectedCategory && (\n\t\t\t\t\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-5 h-5\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4.5 12.75l6 6 9-13.5\" />\n\t\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{showIntegrationPicker && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowIntegrationPicker(false)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-0 bottom-0 z-50 rounded-t-3xl border-t max-h-[70vh] overflow-hidden\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tpaddingBottom: 'env(safe-area-inset-bottom, 0px)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-center pt-3 pb-2\">\n\t\t\t\t\t\t\t<div className=\"w-10 h-1 rounded-full\" style={{ backgroundColor: 'var(--text-muted)' }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"px-5 pb-3 border-b flex items-center justify-between\"\n\t\t\t\t\t\t\tstyle={{ borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tProwlarr Instance\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetShowIntegrationPicker(false)\n\t\t\t\t\t\t\t\t\tsetShowAddForm(true)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"p-2 -mr-2 rounded-xl\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Plus className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"overflow-y-auto max-h-[50vh] p-2\">\n\t\t\t\t\t\t\t{integrations.map((integration) => (\n\t\t\t\t\t\t\t\t<div key={integration.id} className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tsetSelectedIntegration(integration)\n\t\t\t\t\t\t\t\t\t\t\tsetShowIntegrationPicker(false)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 flex items-center justify-between px-4 py-3 rounded-xl\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\t\t\t\tselectedIntegration?.id === integration.id ? 'var(--bg-tertiary)' : 'transparent',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-primary)' }}>{integration.label}</span>\n\t\t\t\t\t\t\t\t\t\t{selectedIntegration?.id === integration.id && (\n\t\t\t\t\t\t\t\t\t\t\t<Check className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tsetShowIntegrationPicker(false)\n\t\t\t\t\t\t\t\t\t\t\tsetDeleteConfirm(integration)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName=\"p-3 rounded-xl\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--error)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Trash2 className=\"w-4 h-4\" strokeWidth={1.5} />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{showGrabSheet && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowGrabSheet(null)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-0 bottom-0 z-50 rounded-t-3xl border-t max-h-[85vh] overflow-hidden flex flex-col\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tpaddingBottom: 'env(safe-area-inset-bottom, 0px)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-center pt-3 pb-2\">\n\t\t\t\t\t\t\t<div className=\"w-10 h-1 rounded-full\" style={{ backgroundColor: 'var(--text-muted)' }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"px-5 pb-4\">\n\t\t\t\t\t\t\t<h3 className=\"font-semibold line-clamp-2 leading-snug\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t{showGrabSheet.title}\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-3 mt-2 text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t<span>{formatSize(showGrabSheet.size)}</span>\n\t\t\t\t\t\t\t\t<span>{showGrabSheet.indexer}</span>\n\t\t\t\t\t\t\t\t<span style={{ color: '#a6e3a1' }}>{showGrabSheet.seeders} seeds</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex-1 overflow-y-auto px-4 pb-4 space-y-4\">\n\t\t\t\t\t\t\t{instances.length > 1 && (\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs font-medium px-1 pb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\tInstance\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t\t\t\t{instances.map((instance) => (\n\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\tkey={instance.id}\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => setGrabInstance(grabInstance === instance.id ? null : instance.id)}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-4 py-3 rounded-xl border active:scale-[0.98] transition-transform\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: grabInstance === instance.id ? 'var(--bg-tertiary)' : 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: grabInstance === instance.id ? 'var(--accent)' : 'var(--border)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-primary)' }}>{instance.label}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{grabInstance === instance.id && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Check className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<div className=\"text-xs font-medium px-1 pb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tCategory\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonClick={() => grabInstance && setShowCategoryPicker(true)}\n\t\t\t\t\t\t\t\t\tdisabled={!grabInstance || loadingCategories}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-4 py-3 rounded-xl border text-base disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: grabCategory ? 'var(--text-primary)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span>{loadingCategories ? 'Loading...' : grabCategory || 'None'}</span>\n\t\t\t\t\t\t\t\t\t<ChevronDown className=\"w-4 h-4 shrink-0\" style={{ color: 'var(--text-muted)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<div className=\"text-xs font-medium px-1 pb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tSave Path\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tvalue={grabSavepath}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setGrabSavepath(e.target.value)}\n\t\t\t\t\t\t\t\t\tdisabled={!grabInstance}\n\t\t\t\t\t\t\t\t\tplaceholder=\"Default\"\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-base disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<div className=\"text-xs font-medium px-1 pb-2\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\tDownload Path\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tvalue={grabDownloadPath}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setGrabDownloadPath(e.target.value)}\n\t\t\t\t\t\t\t\t\tdisabled={!grabInstance}\n\t\t\t\t\t\t\t\t\tplaceholder=\"Default\"\n\t\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-base disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => grabInstance && handleGrab(showGrabSheet, grabInstance)}\n\t\t\t\t\t\t\t\tdisabled={!grabInstance || grabbing === showGrabSheet.guid}\n\t\t\t\t\t\t\t\tclassName=\"w-full py-3.5 rounded-xl text-base font-medium disabled:opacity-50 active:scale-[0.98] transition-transform\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{grabbing === showGrabSheet.guid ? 'Grabbing...' : 'Grab'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{deleteConfirm && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setDeleteConfirm(null)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-4 top-1/2 -translate-y-1/2 z-50 rounded-2xl border p-5\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-semibold mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tDelete Integration\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tDelete <strong style={{ color: 'var(--text-primary)' }}>{deleteConfirm.label}</strong>?\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<div className=\"flex gap-3 mt-5\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setDeleteConfirm(null)}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleDeleteIntegration}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{showSortPicker && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowSortPicker(false)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-0 bottom-0 z-50 rounded-t-3xl border-t\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tpaddingBottom: 'env(safe-area-inset-bottom, 0px)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-center pt-3 pb-2\">\n\t\t\t\t\t\t\t<div className=\"w-10 h-1 rounded-full\" style={{ backgroundColor: 'var(--text-muted)' }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"px-5 pb-3 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tSort By\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"p-2\">\n\t\t\t\t\t\t\t{(['seeders', 'size', 'age'] as SortKey[]).map((key) => (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tkey={key}\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tif (sortKey === key) setSortAsc(!sortAsc)\n\t\t\t\t\t\t\t\t\t\telse {\n\t\t\t\t\t\t\t\t\t\t\tsetSortKey(key)\n\t\t\t\t\t\t\t\t\t\t\tsetSortAsc(false)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tsetShowSortPicker(false)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-4 py-3 rounded-xl\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: sortKey === key ? 'var(--bg-tertiary)' : 'transparent' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t{key === 'seeders' ? 'Seeders' : key === 'size' ? 'Size' : 'Age'}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t{sortKey === key && (\n\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--accent)' }}>{sortAsc ? '↑ Ascending' : '↓ Descending'}</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{showFilterPicker && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-50\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowFilterPicker(false)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-0 bottom-0 z-50 rounded-t-3xl border-t max-h-[70vh] overflow-hidden\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tpaddingBottom: 'env(safe-area-inset-bottom, 0px)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-center pt-3 pb-2\">\n\t\t\t\t\t\t\t<div className=\"w-10 h-1 rounded-full\" style={{ backgroundColor: 'var(--text-muted)' }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"px-5 pb-3 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tFilter\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"px-4 py-3 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tplaceholder=\"Type to filter...\"\n\t\t\t\t\t\t\t\tvalue={filter}\n\t\t\t\t\t\t\t\tonChange={(e) => setFilter(e.target.value)}\n\t\t\t\t\t\t\t\tclassName=\"w-full px-4 py-2.5 rounded-xl border text-base\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"overflow-y-auto max-h-[40vh] p-2\">\n\t\t\t\t\t\t\t{availableTags.slice(0, 20).map(({ tag, count }) => (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tsetFilter(filter === tag ? '' : tag)\n\t\t\t\t\t\t\t\t\t\tsetShowFilterPicker(false)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-4 py-3 rounded-xl\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: filter === tag ? 'var(--bg-tertiary)' : 'transparent' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span style={{ color: filter === tag ? 'var(--accent)' : 'var(--text-primary)' }}>{tag}</span>\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t{count}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{showCategoryPicker && (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-[60]\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\t\tonClick={() => setShowCategoryPicker(false)}\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"fixed inset-x-0 bottom-0 z-[60] rounded-t-3xl border-t max-h-[70vh] overflow-hidden\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\tpaddingBottom: 'env(safe-area-inset-bottom, 0px)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-center pt-3 pb-2\">\n\t\t\t\t\t\t\t<div className=\"w-10 h-1 rounded-full\" style={{ backgroundColor: 'var(--text-muted)' }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"px-5 pb-3 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tCategory\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"overflow-y-auto max-h-[50vh] p-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetGrabCategory('')\n\t\t\t\t\t\t\t\t\tsetShowCategoryPicker(false)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-4 py-3 rounded-xl\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: !grabCategory ? 'var(--bg-tertiary)' : 'transparent' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span style={{ color: !grabCategory ? 'var(--accent)' : 'var(--text-primary)' }}>None</span>\n\t\t\t\t\t\t\t\t{!grabCategory && <Check className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2} />}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{Object.keys(grabCategories).map((cat) => (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tkey={cat}\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tsetGrabCategory(cat)\n\t\t\t\t\t\t\t\t\t\tsetShowCategoryPicker(false)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-between px-4 py-3 rounded-xl\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: grabCategory === cat ? 'var(--bg-tertiary)' : 'transparent' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span style={{ color: grabCategory === cat ? 'var(--accent)' : 'var(--text-primary)' }}>{cat}</span>\n\t\t\t\t\t\t\t\t\t{grabCategory === cat && (\n\t\t\t\t\t\t\t\t\t\t<Check className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n"
  },
  {
    "path": "src/mobile/MobileStatistics.tsx",
    "content": "import type { ReactNode } from 'react'\nimport { ChevronLeft, ArrowDown, ArrowUp, AlertCircle } from 'lucide-react'\nimport { formatSize } from '../utils/format'\nimport { useStats } from '../hooks/useStats'\n\ninterface Props {\n\tonBack: () => void\n}\n\nexport function MobileStatistics({ onBack }: Props): ReactNode {\n\tconst { periodData, instances, selectedInstance, setSelectedInstance, isLoading, hasAnyData } = useStats()\n\n\treturn (\n\t\t<div className=\"min-h-screen\" style={{ backgroundColor: 'var(--bg-primary)' }}>\n\t\t\t<div\n\t\t\t\tclassName=\"sticky top-0 z-10 flex items-center gap-3 px-4 py-3 border-b\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<button onClick={onBack} className=\"p-2 -ml-2 rounded-xl\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t<ChevronLeft className=\"w-6 h-6\" />\n\t\t\t\t</button>\n\t\t\t\t<h1 className=\"text-lg font-semibold flex-1\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\tStatistics\n\t\t\t\t</h1>\n\t\t\t</div>\n\n\t\t\t<div className=\"p-4 space-y-4\">\n\t\t\t\t{instances.length > 1 && (\n\t\t\t\t\t<div className=\"flex gap-2 overflow-x-auto pb-2 -mx-4 px-4\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setSelectedInstance('all')}\n\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: selectedInstance === 'all' ? 'var(--accent)' : 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\tcolor: selectedInstance === 'all' ? 'var(--accent-contrast)' : 'var(--text-secondary)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tAll\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{instances.map((inst) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={inst.id}\n\t\t\t\t\t\t\t\tonClick={() => setSelectedInstance(String(inst.id))}\n\t\t\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: selectedInstance === String(inst.id) ? 'var(--accent)' : 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\tcolor: selectedInstance === String(inst.id) ? 'var(--accent-contrast)' : 'var(--text-secondary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{inst.label}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{isLoading ? (\n\t\t\t\t\t<div className=\"flex items-center justify-center py-12\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-6 h-6 border-2 rounded-full animate-spin\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tborderColor: 'color-mix(in srgb, var(--accent) 20%, transparent)',\n\t\t\t\t\t\t\t\tborderTopColor: 'var(--accent)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{!hasAnyData && (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"flex items-start gap-3 px-4 py-3 rounded-xl text-sm\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: 'color-mix(in srgb, var(--warning) 10%, transparent)',\n\t\t\t\t\t\t\t\t\tcolor: 'var(--warning)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<AlertCircle className=\"w-5 h-5 flex-shrink-0 mt-0.5\" />\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<div className=\"font-medium\">No data yet</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-xs opacity-80 mt-0.5\">Stats recorded every 5 min</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t{periodData!.map((data) => (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tkey={data.period}\n\t\t\t\t\t\t\t\t\tclassName=\"px-4 py-3 rounded-xl border\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs font-medium uppercase tracking-wider\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{data.label}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t\t<ArrowDown className=\"w-3.5 h-3.5\" style={{ color: 'var(--accent)' }} />\n\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-sm font-semibold tabular-nums\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: data.hasData ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{data.hasData ? formatSize(data.downloaded) : 'N/A'}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t\t<ArrowUp className=\"w-3.5 h-3.5\" style={{ color: '#a6e3a1' }} />\n\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-sm font-semibold tabular-nums\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: data.hasData ? '#a6e3a1' : 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{data.hasData ? formatSize(data.uploaded) : 'N/A'}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<ul className=\"text-xs space-y-1 list-disc list-inside\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t<li>Stats recorded every 5 minutes</li>\n\t\t\t\t\t\t\t<li>All time values from qBittorrent</li>\n\t\t\t\t\t\t\t<li>Other periods show difference from historical snapshots</li>\n\t\t\t\t\t\t\t<li>Downloads include protocol traffic, not just file data</li>\n\t\t\t\t\t\t</ul>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileStats.tsx",
    "content": "import { useQueries } from '@tanstack/react-query'\nimport { ArrowDown, ArrowUp } from 'lucide-react'\nimport * as api from '../api/qbittorrent'\nimport type { Instance } from '../api/instances'\nimport { formatSpeed, formatSize } from '../utils/format'\n\ninterface Props {\n\tinstances: Instance[]\n}\n\nexport function MobileStats({ instances }: Props) {\n\tconst torrentQueries = useQueries({\n\t\tqueries: instances.map((instance) => ({\n\t\t\tqueryKey: ['torrents', instance.id],\n\t\t\tqueryFn: () => api.getTorrents(instance.id),\n\t\t\trefetchInterval: 2000,\n\t\t})),\n\t})\n\n\tconst transferQueries = useQueries({\n\t\tqueries: instances.map((instance) => ({\n\t\t\tqueryKey: ['transfer', instance.id],\n\t\t\tqueryFn: () => api.getTransferInfo(instance.id),\n\t\t\trefetchInterval: 2000,\n\t\t})),\n\t})\n\n\tconst syncQueries = useQueries({\n\t\tqueries: instances.map((instance) => ({\n\t\t\tqueryKey: ['syncMaindata', instance.id],\n\t\t\tqueryFn: () => api.getSyncMaindata(instance.id),\n\t\t\trefetchInterval: 2000,\n\t\t})),\n\t})\n\n\tconst torrents = torrentQueries.flatMap((q) => q.data || [])\n\tconst totalDownloadSpeed = transferQueries.reduce((sum, q) => sum + (q.data?.dl_info_speed || 0), 0)\n\tconst totalUploadSpeed = transferQueries.reduce((sum, q) => sum + (q.data?.up_info_speed || 0), 0)\n\tconst allTimeDownload = syncQueries.reduce((sum, q) => sum + (q.data?.server_state.alltime_dl || 0), 0)\n\tconst allTimeUpload = syncQueries.reduce((sum, q) => sum + (q.data?.server_state.alltime_ul || 0), 0)\n\n\tconst counts = {\n\t\ttotal: torrents.length,\n\t\tdownloading: torrents.filter((t) =>\n\t\t\t['downloading', 'metaDL', 'forcedDL', 'stalledDL', 'allocating'].includes(t.state)\n\t\t).length,\n\t\tseeding: torrents.filter((t) => ['uploading', 'forcedUP', 'stalledUP'].includes(t.state)).length,\n\t\tpaused: torrents.filter((t) => ['pausedDL', 'pausedUP', 'stoppedDL', 'stoppedUP'].includes(t.state)).length,\n\t}\n\n\treturn (\n\t\t<div className=\"space-y-3\">\n\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"p-4 rounded-2xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--accent) 15%, transparent)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ArrowDown className=\"w-5 h-5\" style={{ color: 'var(--accent)' }} strokeWidth={2} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"min-w-0\">\n\t\t\t\t\t\t\t<div className=\"text-[10px] uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tDownload\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"text-base font-semibold tabular-nums truncate\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)', minHeight: '1.5rem' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{formatSpeed(totalDownloadSpeed)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"p-4 rounded-2xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, #a6e3a1 15%, transparent)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ArrowUp className=\"w-5 h-5\" style={{ color: '#a6e3a1' }} strokeWidth={2} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"min-w-0\">\n\t\t\t\t\t\t\t<div className=\"text-[10px] uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tUpload\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"text-base font-semibold tabular-nums truncate\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)', minHeight: '1.5rem' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{formatSpeed(totalUploadSpeed)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"grid grid-cols-4 gap-2\">\n\t\t\t\t{[\n\t\t\t\t\t{ label: 'Total', value: counts.total, color: 'var(--text-primary)' },\n\t\t\t\t\t{ label: 'Leech', value: counts.downloading, color: 'var(--accent)' },\n\t\t\t\t\t{ label: 'Seed', value: counts.seeding, color: '#a6e3a1' },\n\t\t\t\t\t{ label: 'Paused', value: counts.paused, color: 'var(--text-muted)' },\n\t\t\t\t].map((item) => (\n\t\t\t\t\t<div\n\t\t\t\t\t\tkey={item.label}\n\t\t\t\t\t\tclassName=\"p-3 rounded-xl border text-center\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"text-xl font-bold tabular-nums\" style={{ color: item.color }}>\n\t\t\t\t\t\t\t{item.value}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"text-[10px] uppercase tracking-wider mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t{item.label}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\t\t\t</div>\n\n\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"p-3 rounded-xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"text-[10px] uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tAll-Time Down\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"text-base font-semibold tabular-nums\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t{formatSize(allTimeDownload)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"p-3 rounded-xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"text-[10px] uppercase tracking-wider\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tAll-Time Up\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"text-base font-semibold tabular-nums\" style={{ color: '#a6e3a1' }}>\n\t\t\t\t\t\t{formatSize(allTimeUpload)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileThemeManager.tsx",
    "content": "import { useState, useMemo, useRef, useEffect } from 'react'\nimport { Drawer } from 'vaul'\nimport { HexColorPicker } from 'react-colorful'\nimport { Plus, Upload, Download, Pencil, Trash2, ChevronLeft } from 'lucide-react'\nimport { generateThemeColors, isValidHex } from '../utils/colorUtils'\nimport { useTheme } from '../hooks/useTheme'\nimport type { Theme } from '../themes'\n\ntype View = 'list' | 'editor'\n\ninterface MobileThemeManagerProps {\n\tonClose: () => void\n}\n\nexport function MobileThemeManager({ onClose }: MobileThemeManagerProps) {\n\tconst { themes, customThemes, addTheme, updateTheme, deleteTheme } = useTheme()\n\tconst [view, setView] = useState<View>('list')\n\tconst [editingTheme, setEditingTheme] = useState<Theme | null>(null)\n\tconst fileInputRef = useRef<HTMLInputElement>(null)\n\n\tconst allNames = useMemo(\n\t\t() => [...themes.map((t) => t.name), ...customThemes.map((t) => t.name)],\n\t\t[themes, customThemes]\n\t)\n\n\tfunction handleNewTheme() {\n\t\tsetEditingTheme(null)\n\t\tsetView('editor')\n\t}\n\n\tfunction handleEditTheme(theme: Theme) {\n\t\tsetEditingTheme(theme)\n\t\tsetView('editor')\n\t}\n\n\tfunction handleSaveTheme(theme: Theme) {\n\t\tif (editingTheme) {\n\t\t\tupdateTheme(theme)\n\t\t} else {\n\t\t\taddTheme(theme)\n\t\t}\n\t\tsetView('list')\n\t\tsetEditingTheme(null)\n\t}\n\n\tfunction handleExport() {\n\t\tconst blob = new Blob([JSON.stringify(customThemes, null, 2)], { type: 'application/json' })\n\t\tconst url = URL.createObjectURL(blob)\n\t\tconst a = document.createElement('a')\n\t\ta.href = url\n\t\ta.download = 'qbitwebui-themes.json'\n\t\ta.click()\n\t\tURL.revokeObjectURL(url)\n\t}\n\n\tfunction handleImport(e: React.ChangeEvent<HTMLInputElement>) {\n\t\tconst file = e.target.files?.[0]\n\t\tif (!file) return\n\n\t\tconst reader = new FileReader()\n\t\treader.onload = (event) => {\n\t\t\ttry {\n\t\t\t\tconst imported = JSON.parse(event.target?.result as string) as Theme[]\n\t\t\t\tif (!Array.isArray(imported)) throw new Error('Invalid format')\n\n\t\t\t\timported.forEach((t) => {\n\t\t\t\t\tif (t.name && t.colors) {\n\t\t\t\t\t\taddTheme({\n\t\t\t\t\t\t\t...t,\n\t\t\t\t\t\t\tid: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\talert('Failed to import themes. Invalid file format.')\n\t\t\t}\n\t\t}\n\t\treader.readAsText(file)\n\t\te.target.value = ''\n\t}\n\n\treturn (\n\t\t<Drawer.Root open={true} onOpenChange={(open) => !open && onClose()} shouldScaleBackground={false}>\n\t\t\t<Drawer.Portal>\n\t\t\t\t<Drawer.Overlay className=\"fixed inset-0 z-50\" style={{ backgroundColor: 'rgba(0,0,0,0.6)' }} />\n\t\t\t\t<Drawer.Content\n\t\t\t\t\tclassName=\"fixed inset-x-0 bottom-0 z-50 rounded-t-3xl border-t flex flex-col outline-none\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\theight: '90vh',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex justify-center pt-3 pb-2\">\n\t\t\t\t\t\t<div className=\"w-10 h-1 rounded-full\" style={{ backgroundColor: 'var(--text-muted)' }} />\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{view === 'list' ? (\n\t\t\t\t\t\t<ListView\n\t\t\t\t\t\t\tcustomThemes={customThemes}\n\t\t\t\t\t\t\tonNew={handleNewTheme}\n\t\t\t\t\t\t\tonEdit={handleEditTheme}\n\t\t\t\t\t\t\tonDelete={deleteTheme}\n\t\t\t\t\t\t\tonExport={handleExport}\n\t\t\t\t\t\t\tonImportClick={() => fileInputRef.current?.click()}\n\t\t\t\t\t\t/>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<EditorView\n\t\t\t\t\t\t\tinitialTheme={editingTheme}\n\t\t\t\t\t\t\texistingNames={allNames}\n\t\t\t\t\t\t\tonSave={handleSaveTheme}\n\t\t\t\t\t\t\tonBack={() => {\n\t\t\t\t\t\t\t\tsetView('list')\n\t\t\t\t\t\t\t\tsetEditingTheme(null)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<input ref={fileInputRef} type=\"file\" accept=\".json\" className=\"hidden\" onChange={handleImport} />\n\t\t\t\t</Drawer.Content>\n\t\t\t</Drawer.Portal>\n\t\t</Drawer.Root>\n\t)\n}\n\n// ─────────────────────────────────────────────────────────────\n// List View\n// ─────────────────────────────────────────────────────────────\n\ninterface ListViewProps {\n\tcustomThemes: Theme[]\n\tonNew: () => void\n\tonEdit: (theme: Theme) => void\n\tonDelete: (id: string) => void\n\tonExport: () => void\n\tonImportClick: () => void\n}\n\nfunction ListView({ customThemes, onNew, onEdit, onDelete, onExport, onImportClick }: ListViewProps) {\n\treturn (\n\t\t<>\n\t\t\t<div className=\"px-5 pb-3\">\n\t\t\t\t<Drawer.Title className=\"text-lg font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\tManage Themes\n\t\t\t\t</Drawer.Title>\n\t\t\t\t<Drawer.Description className=\"sr-only\">Theme management options</Drawer.Description>\n\t\t\t</div>\n\n\t\t\t<div className=\"px-4 pb-3 flex items-center gap-2\">\n\t\t\t\t<button\n\t\t\t\t\tonClick={onNew}\n\t\t\t\t\tclassName=\"flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium active:scale-[0.98] transition-transform\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t>\n\t\t\t\t\t<Plus className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\tNew Theme\n\t\t\t\t</button>\n\t\t\t\t<div className=\"flex-1\" />\n\t\t\t\t<button\n\t\t\t\t\tonClick={onImportClick}\n\t\t\t\t\tclassName=\"p-2.5 rounded-xl active:scale-[0.98] transition-transform\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-muted)' }}\n\t\t\t\t>\n\t\t\t\t\t<Upload className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\tonClick={onExport}\n\t\t\t\t\tdisabled={customThemes.length === 0}\n\t\t\t\t\tclassName=\"p-2.5 rounded-xl active:scale-[0.98] transition-transform disabled:opacity-50\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-muted)' }}\n\t\t\t\t>\n\t\t\t\t\t<Download className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t<div\n\t\t\t\tclassName=\"flex-1 overflow-y-auto px-4 space-y-2\"\n\t\t\t\tstyle={{ paddingBottom: 'max(1.5rem, env(safe-area-inset-bottom))' }}\n\t\t\t>\n\t\t\t\t{customThemes.length === 0 ? (\n\t\t\t\t\t<div className=\"py-12 text-center text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tNo custom themes yet\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\tcustomThemes.map((t) => (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tkey={t.id}\n\t\t\t\t\t\t\tclassName=\"flex items-center gap-3 p-4 rounded-2xl border\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"flex gap-1.5 shrink-0\">\n\t\t\t\t\t\t\t\t<div className=\"w-4 h-4 rounded-full\" style={{ backgroundColor: t.colors.bgPrimary }} />\n\t\t\t\t\t\t\t\t<div className=\"w-4 h-4 rounded-full\" style={{ backgroundColor: t.colors.accent }} />\n\t\t\t\t\t\t\t\t<div className=\"w-4 h-4 rounded-full\" style={{ backgroundColor: t.colors.warning }} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<span className=\"flex-1 text-sm font-medium truncate\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t{t.name}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => onEdit(t)}\n\t\t\t\t\t\t\t\tclassName=\"p-2 rounded-xl active:scale-[0.95] transition-transform\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Pencil className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => onDelete(t.id)}\n\t\t\t\t\t\t\t\tclassName=\"p-2 rounded-xl active:scale-[0.95] transition-transform\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--error)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Trash2 className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</>\n\t)\n}\n\n// ─────────────────────────────────────────────────────────────\n// Editor View\n// ─────────────────────────────────────────────────────────────\n\ninterface EditorViewProps {\n\tinitialTheme: Theme | null\n\texistingNames: string[]\n\tonSave: (theme: Theme) => void\n\tonBack: () => void\n}\n\nfunction EditorView({ initialTheme, existingNames, onSave, onBack }: EditorViewProps) {\n\tconst [name, setName] = useState(initialTheme?.name ?? 'My Custom Theme')\n\tconst [bgPrimary, setBgPrimary] = useState(initialTheme?.colors.bgPrimary ?? '#1e1e2e')\n\tconst [accent, setAccent] = useState(initialTheme?.colors.accent ?? '#cba6f7')\n\tconst [textPrimary, setTextPrimary] = useState(initialTheme?.colors.textPrimary ?? '#cdd6f4')\n\tconst [warning, setWarning] = useState(initialTheme?.colors.warning ?? '#f7b731')\n\n\tconst previewColors = useMemo(() => {\n\t\tif (isValidHex(bgPrimary) && isValidHex(accent) && isValidHex(textPrimary) && isValidHex(warning)) {\n\t\t\treturn generateThemeColors(bgPrimary, accent, textPrimary, warning)\n\t\t}\n\t\treturn null\n\t}, [bgPrimary, accent, textPrimary, warning])\n\n\tconst isNameTaken = useMemo(() => {\n\t\tconst trimmed = name.trim().toLowerCase()\n\t\treturn existingNames.some((n) => {\n\t\t\tif (initialTheme?.name.toLowerCase() === trimmed) return false\n\t\t\treturn n.toLowerCase() === trimmed\n\t\t})\n\t}, [name, existingNames, initialTheme?.name])\n\n\tconst handleSave = () => {\n\t\tif (!previewColors || !name.trim() || isNameTaken) return\n\t\tonSave({\n\t\t\tid: initialTheme?.id ?? `custom-${Date.now()}`,\n\t\t\tname: name.trim(),\n\t\t\tcolors: previewColors,\n\t\t})\n\t}\n\n\tconst colorFields = [\n\t\t{ label: 'Background', val: bgPrimary, set: setBgPrimary },\n\t\t{ label: 'Accent', val: accent, set: setAccent },\n\t\t{ label: 'Text', val: textPrimary, set: setTextPrimary },\n\t\t{ label: 'Warning', val: warning, set: setWarning },\n\t]\n\n\treturn (\n\t\t<>\n\t\t\t<div className=\"px-4 pb-3 flex items-center gap-3\">\n\t\t\t\t<button\n\t\t\t\t\tonClick={onBack}\n\t\t\t\t\tclassName=\"p-2 -ml-2 rounded-xl active:scale-[0.95] transition-transform\"\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t>\n\t\t\t\t\t<ChevronLeft className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t</button>\n\t\t\t\t<Drawer.Title className=\"text-lg font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t{initialTheme ? 'Edit Theme' : 'New Theme'}\n\t\t\t\t</Drawer.Title>\n\t\t\t\t<Drawer.Description className=\"sr-only\">Theme editor</Drawer.Description>\n\t\t\t</div>\n\n\t\t\t<div\n\t\t\t\tclassName=\"flex-1 overflow-y-auto px-4 space-y-4\"\n\t\t\t\tstyle={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}\n\t\t\t>\n\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t<label className=\"text-xs font-medium flex justify-between\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t<span>Theme Name</span>\n\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>{name.length}/20</span>\n\t\t\t\t\t</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tvalue={name}\n\t\t\t\t\t\tonChange={(e) => {\n\t\t\t\t\t\t\tconst sanitized = e.target.value.replace(/[^a-zA-Z0-9\\s\\-_]/g, '')\n\t\t\t\t\t\t\tsetName(sanitized.slice(0, 20))\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tmaxLength={20}\n\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl text-sm focus:outline-none\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\tborder: `1px solid ${isNameTaken ? 'var(--error)' : 'var(--border)'}`,\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t\t{isNameTaken && (\n\t\t\t\t\t\t<p className=\"text-xs\" style={{ color: 'var(--error)' }}>\n\t\t\t\t\t\t\tName already exists\n\t\t\t\t\t\t</p>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"grid grid-cols-1 gap-3\">\n\t\t\t\t\t{colorFields.map((field) => (\n\t\t\t\t\t\t<ColorInput key={field.label} label={field.label} value={field.val} onChange={field.set} />\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\n\t\t\t\t{previewColors && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"rounded-2xl p-4 space-y-3\"\n\t\t\t\t\t\tstyle={{ backgroundColor: previewColors.bgPrimary, border: `1px solid ${previewColors.border}` }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-between items-center\">\n\t\t\t\t\t\t\t<div className=\"text-sm font-bold\" style={{ color: previewColors.textPrimary }}>\n\t\t\t\t\t\t\t\t{name || 'Theme Name'}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"px-2.5 py-1 rounded-full text-xs font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: previewColors.accent, color: previewColors.accentContrast }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tBadge\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"p-3 rounded-xl\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: previewColors.bgSecondary, border: `1px solid ${previewColors.border}` }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"text-xs mb-2\" style={{ color: previewColors.textSecondary }}>\n\t\t\t\t\t\t\t\tPreview Card\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tclassName=\"w-full py-2 rounded-lg text-xs font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: previewColors.accent, color: previewColors.accentContrast }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tAction\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t<button\n\t\t\t\t\tonClick={handleSave}\n\t\t\t\t\tdisabled={!previewColors || !name.trim() || isNameTaken}\n\t\t\t\t\tclassName=\"w-full py-3.5 rounded-xl text-sm font-semibold active:scale-[0.98] transition-transform disabled:opacity-50\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t>\n\t\t\t\t\tSave Theme\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</>\n\t)\n}\n\nfunction ColorInput({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {\n\tconst [open, setOpen] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\tuseEffect(() => {\n\t\tif (!open) return\n\t\tfunction handleClick(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClick)\n\t\treturn () => document.removeEventListener('mousedown', handleClick)\n\t}, [open])\n\n\treturn (\n\t\t<div className=\"space-y-1\">\n\t\t\t<label className=\"text-xs font-medium\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t{label}\n\t\t\t</label>\n\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t<div ref={ref} className=\"relative\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\t\t\tclassName=\"w-10 h-10 rounded-xl shrink-0 cursor-pointer active:scale-95 transition-transform\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: isValidHex(value) ? value : 'transparent',\n\t\t\t\t\t\t\tborder: '1px solid var(--border)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t\t{open && (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"absolute top-full left-0 mt-2 z-10 p-3 rounded-xl shadow-xl\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<HexColorPicker color={isValidHex(value) ? value : '#000000'} onChange={onChange} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t\t<input\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t\tvalue={value}\n\t\t\t\t\tonChange={(e) => onChange(e.target.value)}\n\t\t\t\t\tclassName=\"flex-1 px-3 py-2.5 font-mono text-xs rounded-xl focus:outline-none\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\tborder: '1px solid var(--border)',\n\t\t\t\t\t}}\n\t\t\t\t\tplaceholder=\"#000000\"\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileThemeSwitcher.tsx",
    "content": "import { useState, useRef, useEffect } from 'react'\nimport { Palette, Settings, Check } from 'lucide-react'\nimport { useTheme } from '../hooks/useTheme'\nimport { MobileThemeManager } from './MobileThemeManager'\n\nexport function MobileThemeSwitcher() {\n\tconst { theme, setTheme, themes, customThemes } = useTheme()\n\tconst [open, setOpen] = useState(false)\n\tconst [showManager, setShowManager] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\tuseEffect(() => {\n\t\tfunction handleClickOutside(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClickOutside)\n\t\treturn () => document.removeEventListener('mousedown', handleClickOutside)\n\t}, [])\n\n\treturn (\n\t\t<>\n\t\t\t<div ref={ref} className=\"relative\">\n\t\t\t\t<button\n\t\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\t\tclassName=\"w-9 h-9 rounded-full flex items-center justify-center active:scale-95 transition-transform\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)' }}\n\t\t\t\t>\n\t\t\t\t\t<Palette className=\"w-5 h-5\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t</button>\n\t\t\t\t{open && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<div className=\"fixed inset-0 z-40\" onClick={() => setOpen(false)} />\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"absolute right-0 top-full mt-2 z-50 min-w-[180px] rounded-xl border shadow-xl overflow-hidden\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{/* Official Themes */}\n\t\t\t\t\t\t\t<div className=\"px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider opacity-50 select-none text-[var(--text-muted)]\">\n\t\t\t\t\t\t\t\tOfficial\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{themes.map((t) => (\n\t\t\t\t\t\t\t\t<ThemeRow\n\t\t\t\t\t\t\t\t\tkey={t.id}\n\t\t\t\t\t\t\t\t\tt={t}\n\t\t\t\t\t\t\t\t\tisActive={theme.id === t.id}\n\t\t\t\t\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\t\t\t\t\tsetTheme(t.id)\n\t\t\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\n\t\t\t\t\t\t\t{/* Custom Themes */}\n\t\t\t\t\t\t\t{customThemes.length > 0 && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<div className=\"border-t border-[var(--border)]\" />\n\t\t\t\t\t\t\t\t\t<div className=\"px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider opacity-50 select-none text-[var(--text-muted)]\">\n\t\t\t\t\t\t\t\t\t\tCustom\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t{customThemes.map((t) => (\n\t\t\t\t\t\t\t\t\t\t<ThemeRow\n\t\t\t\t\t\t\t\t\t\t\tkey={t.id}\n\t\t\t\t\t\t\t\t\t\t\tt={t}\n\t\t\t\t\t\t\t\t\t\t\tisActive={theme.id === t.id}\n\t\t\t\t\t\t\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\t\t\t\t\t\t\tsetTheme(t.id)\n\t\t\t\t\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{/* Manage Themes */}\n\t\t\t\t\t\t\t<div className=\"border-t border-[var(--border)]\" />\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t\t\tsetShowManager(true)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"w-full flex items-center gap-2 px-4 py-3 text-left text-sm font-medium active:bg-[var(--bg-tertiary)] transition-colors\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-secondary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Settings className=\"w-4 h-4\" strokeWidth={2} />\n\t\t\t\t\t\t\t\tManage Themes\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{showManager && <MobileThemeManager onClose={() => setShowManager(false)} />}\n\t\t</>\n\t)\n}\n\nfunction ThemeRow({\n\tt,\n\tisActive,\n\tonSelect,\n}: {\n\tt: { id: string; name: string; colors: { bgPrimary: string; accent: string; warning: string } }\n\tisActive: boolean\n\tonSelect: () => void\n}) {\n\treturn (\n\t\t<button\n\t\t\tonClick={onSelect}\n\t\t\tclassName=\"w-full flex items-center gap-3 px-4 py-3 text-left transition-colors active:bg-[var(--bg-tertiary)]\"\n\t\t\tstyle={{ color: isActive ? 'var(--accent)' : 'var(--text-primary)' }}\n\t\t>\n\t\t\t<div className=\"flex gap-1\">\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"w-3 h-3 rounded-full\"\n\t\t\t\t\tstyle={{ backgroundColor: t.colors.bgPrimary, border: '1px solid var(--border)' }}\n\t\t\t\t/>\n\t\t\t\t<div className=\"w-3 h-3 rounded-full\" style={{ backgroundColor: t.colors.accent }} />\n\t\t\t\t<div className=\"w-3 h-3 rounded-full\" style={{ backgroundColor: t.colors.warning }} />\n\t\t\t</div>\n\t\t\t<span className=\"text-sm font-medium flex-1\">{t.name}</span>\n\t\t\t{isActive && <Check className=\"w-4 h-4\" style={{ color: 'var(--accent)' }} strokeWidth={2.5} />}\n\t\t</button>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileTools.tsx",
    "content": "import { useState, useEffect, lazy, Suspense, type ReactNode } from 'react'\nimport {\n\tSearch,\n\tFolderOpen,\n\tAlertTriangle,\n\tRss,\n\tFileText,\n\tArrowLeftRight,\n\tChevronRight,\n\tBarChart3,\n\tNetwork,\n} from 'lucide-react'\nimport { type Instance } from '../api/instances'\n\nconst MobileSearchPanel = lazy(() => import('./MobileSearchPanel').then((m) => ({ default: m.MobileSearchPanel })))\nconst MobileFileBrowser = lazy(() => import('./MobileFileBrowser').then((m) => ({ default: m.MobileFileBrowser })))\nconst MobileOrphanManager = lazy(() =>\n\timport('./MobileOrphanManager').then((m) => ({ default: m.MobileOrphanManager }))\n)\nconst MobileRSSManager = lazy(() => import('./MobileRSSManager').then((m) => ({ default: m.MobileRSSManager })))\nconst MobileLogViewer = lazy(() => import('./MobileLogViewer').then((m) => ({ default: m.MobileLogViewer })))\nconst MobileCrossSeedManager = lazy(() =>\n\timport('./MobileCrossSeedManager').then((m) => ({ default: m.MobileCrossSeedManager }))\n)\nconst MobileStatistics = lazy(() => import('./MobileStatistics').then((m) => ({ default: m.MobileStatistics })))\nconst MobileNetworkTools = lazy(() => import('./MobileNetworkTools').then((m) => ({ default: m.MobileNetworkTools })))\n\ntype Tool = 'search' | 'files' | 'orphans' | 'rss' | 'logs' | 'cross-seed' | 'statistics' | 'network' | null\n\nconst Spinner = (\n\t<div className=\"flex items-center justify-center p-8\">\n\t\t<div\n\t\t\tclassName=\"w-6 h-6 border-2 rounded-full animate-spin\"\n\t\t\tstyle={{ borderColor: 'color-mix(in srgb, var(--accent) 20%, transparent)', borderTopColor: 'var(--accent)' }}\n\t\t/>\n\t</div>\n)\n\nfunction LazyTool({ children }: { children: ReactNode }): ReactNode {\n\treturn <Suspense fallback={Spinner}>{children}</Suspense>\n}\n\ninterface Props {\n\tinstances: Instance[]\n\tactiveTool: Tool\n\tonToolChange: (tool: Tool) => void\n}\n\nexport function MobileTools({ instances, activeTool, onToolChange }: Props): ReactNode {\n\tconst [filesEnabled, setFilesEnabled] = useState(false)\n\n\tuseEffect(() => {\n\t\tfetch('/api/config')\n\t\t\t.then((r) => r.json())\n\t\t\t.then((c) => setFilesEnabled(c.filesEnabled))\n\t\t\t.catch(() => {})\n\t}, [])\n\n\tconst handleBack = () => onToolChange(null)\n\n\tswitch (activeTool) {\n\t\tcase 'search':\n\t\t\treturn (\n\t\t\t\t<LazyTool>\n\t\t\t\t\t<MobileSearchPanel instances={instances} onBack={handleBack} />\n\t\t\t\t</LazyTool>\n\t\t\t)\n\t\tcase 'files':\n\t\t\treturn (\n\t\t\t\t<LazyTool>\n\t\t\t\t\t<MobileFileBrowser onBack={handleBack} />\n\t\t\t\t</LazyTool>\n\t\t\t)\n\t\tcase 'orphans':\n\t\t\treturn (\n\t\t\t\t<LazyTool>\n\t\t\t\t\t<MobileOrphanManager instances={instances} onBack={handleBack} />\n\t\t\t\t</LazyTool>\n\t\t\t)\n\t\tcase 'rss':\n\t\t\treturn (\n\t\t\t\t<LazyTool>\n\t\t\t\t\t<MobileRSSManager instances={instances} onBack={handleBack} />\n\t\t\t\t</LazyTool>\n\t\t\t)\n\t\tcase 'logs':\n\t\t\treturn (\n\t\t\t\t<LazyTool>\n\t\t\t\t\t<MobileLogViewer instances={instances} onBack={handleBack} />\n\t\t\t\t</LazyTool>\n\t\t\t)\n\t\tcase 'cross-seed':\n\t\t\treturn (\n\t\t\t\t<LazyTool>\n\t\t\t\t\t<MobileCrossSeedManager instances={instances} onBack={handleBack} />\n\t\t\t\t</LazyTool>\n\t\t\t)\n\t\tcase 'statistics':\n\t\t\treturn (\n\t\t\t\t<LazyTool>\n\t\t\t\t\t<MobileStatistics onBack={handleBack} />\n\t\t\t\t</LazyTool>\n\t\t\t)\n\t\tcase 'network':\n\t\t\treturn (\n\t\t\t\t<LazyTool>\n\t\t\t\t\t<MobileNetworkTools instances={instances} onBack={handleBack} />\n\t\t\t\t</LazyTool>\n\t\t\t)\n\t}\n\n\treturn (\n\t\t<div className=\"p-4 space-y-3\">\n\t\t\t<button\n\t\t\t\tonClick={() => onToolChange('search')}\n\t\t\t\tclassName=\"w-full p-4 rounded-2xl border text-left active:scale-[0.98] transition-transform\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-start gap-4\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-12 h-12 rounded-xl flex items-center justify-center shrink-0\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--accent) 15%, transparent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Search className=\"w-6 h-6\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tProwlarr Search\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tSearch indexers and grab releases\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ChevronRight className=\"w-5 h-5 mt-1 shrink-0\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t</div>\n\t\t\t</button>\n\n\t\t\t{filesEnabled && (\n\t\t\t\t<button\n\t\t\t\t\tonClick={() => onToolChange('files')}\n\t\t\t\t\tclassName=\"w-full p-4 rounded-2xl border text-left active:scale-[0.98] transition-transform\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex items-start gap-4\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"w-12 h-12 rounded-xl flex items-center justify-center shrink-0\"\n\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--warning) 15%, transparent)' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<FolderOpen className=\"w-6 h-6\" style={{ color: 'var(--warning)' }} strokeWidth={1.5} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tFile Browser\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t<p className=\"text-sm mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\tBrowse, download, and manage files\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<ChevronRight className=\"w-5 h-5 mt-1 shrink-0\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t\t</div>\n\t\t\t\t</button>\n\t\t\t)}\n\n\t\t\t<button\n\t\t\t\tonClick={() => onToolChange('orphans')}\n\t\t\t\tclassName=\"w-full p-4 rounded-2xl border text-left active:scale-[0.98] transition-transform\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-start gap-4\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-12 h-12 rounded-xl flex items-center justify-center shrink-0\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 15%, transparent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<AlertTriangle className=\"w-6 h-6\" style={{ color: 'var(--error)' }} strokeWidth={1.5} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tOrphan Manager\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tFind torrents with missing files\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ChevronRight className=\"w-5 h-5 mt-1 shrink-0\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t</div>\n\t\t\t</button>\n\n\t\t\t<button\n\t\t\t\tonClick={() => onToolChange('rss')}\n\t\t\t\tclassName=\"w-full p-4 rounded-2xl border text-left active:scale-[0.98] transition-transform\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-start gap-4\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-12 h-12 rounded-xl flex items-center justify-center shrink-0\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--accent) 15%, transparent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Rss className=\"w-6 h-6\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tRSS Manager\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tManage feeds and auto-download rules\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ChevronRight className=\"w-5 h-5 mt-1 shrink-0\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t</div>\n\t\t\t</button>\n\n\t\t\t<button\n\t\t\t\tonClick={() => onToolChange('logs')}\n\t\t\t\tclassName=\"w-full p-4 rounded-2xl border text-left active:scale-[0.98] transition-transform\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-start gap-4\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-12 h-12 rounded-xl flex items-center justify-center shrink-0\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--text-muted) 15%, transparent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<FileText className=\"w-6 h-6\" style={{ color: 'var(--text-muted)' }} strokeWidth={1.5} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tLog Viewer\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tView application and peer logs\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ChevronRight className=\"w-5 h-5 mt-1 shrink-0\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t</div>\n\t\t\t</button>\n\n\t\t\t<button\n\t\t\t\tonClick={() => onToolChange('cross-seed')}\n\t\t\t\tclassName=\"w-full p-4 rounded-2xl border text-left active:scale-[0.98] transition-transform relative\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<span className=\"absolute top-3 right-3\" title=\"Experimental feature\">\n\t\t\t\t\t<AlertTriangle className=\"w-6 h-6\" style={{ color: 'var(--error)' }} />\n\t\t\t\t</span>\n\t\t\t\t<div className=\"flex items-start gap-4\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-12 h-12 rounded-xl flex items-center justify-center shrink-0\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--accent) 15%, transparent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<ArrowLeftRight className=\"w-6 h-6\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tCross-Seed\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tFind matching torrents across trackers\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ChevronRight className=\"w-5 h-5 mt-1 shrink-0\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t</div>\n\t\t\t</button>\n\n\t\t\t<button\n\t\t\t\tonClick={() => onToolChange('statistics')}\n\t\t\t\tclassName=\"w-full p-4 rounded-2xl border text-left active:scale-[0.98] transition-transform\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-start gap-4\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-12 h-12 rounded-xl flex items-center justify-center shrink-0\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, #a6e3a1 15%, transparent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<BarChart3 className=\"w-6 h-6\" style={{ color: '#a6e3a1' }} strokeWidth={1.5} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tStatistics\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tView transfer history over time\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ChevronRight className=\"w-5 h-5 mt-1 shrink-0\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t</div>\n\t\t\t</button>\n\n\t\t\t<button\n\t\t\t\tonClick={() => onToolChange('network')}\n\t\t\t\tclassName=\"w-full p-4 rounded-2xl border text-left active:scale-[0.98] transition-transform\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-start gap-4\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"w-12 h-12 rounded-xl flex items-center justify-center shrink-0\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--accent) 15%, transparent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Network className=\"w-6 h-6\" style={{ color: 'var(--accent)' }} strokeWidth={1.5} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t<h3 className=\"font-semibold\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tNetwork Tools\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mt-0.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tRun diagnostics from your instance\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ChevronRight className=\"w-5 h-5 mt-1 shrink-0\" style={{ color: 'var(--text-muted)' }} />\n\t\t\t\t</div>\n\t\t\t</button>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/mobile/MobileTorrentDetail.tsx",
    "content": "import { useState, useEffect } from 'react'\nimport { createPortal } from 'react-dom'\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { Drawer } from 'vaul'\nimport { Play, Pause, Trash2 } from 'lucide-react'\nimport * as api from '../api/qbittorrent'\nimport type { TorrentState } from '../types/qbittorrent'\nimport { formatSize, formatSpeed, formatDate, formatDuration } from '../utils/format'\n\ntype Tab = 'general' | 'files' | 'trackers' | 'peers' | 'http'\ntype PathEditorMode = 'savePath' | 'downloadPath' | null\n\nconst PAUSED_STATES: TorrentState[] = ['pausedDL', 'pausedUP', 'stoppedDL', 'stoppedUP']\n\nfunction getTrackerStatus(status: number): string {\n\tswitch (status) {\n\t\tcase 2:\n\t\t\treturn 'Working'\n\t\tcase 3:\n\t\t\treturn 'Updating'\n\t\tcase 4:\n\t\t\treturn 'Error'\n\t\tdefault:\n\t\t\treturn 'Disabled'\n\t}\n}\n\nfunction getPriorityLabel(priority: number): string {\n\tswitch (priority) {\n\t\tcase 0:\n\t\t\treturn 'Skip'\n\t\tcase 1:\n\t\t\treturn 'Normal'\n\t\tdefault:\n\t\t\treturn 'High'\n\t}\n}\n\ninterface Props {\n\ttorrentHash: string\n\tinstanceId: number\n\tonClose: () => void\n}\n\nexport function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props) {\n\tconst [tab, setTab] = useState<Tab>('general')\n\tconst [showDeleteConfirm, setShowDeleteConfirm] = useState(false)\n\tconst [pathEditorMode, setPathEditorMode] = useState<PathEditorMode>(null)\n\tconst [pathValue, setPathValue] = useState('')\n\tconst [deleteFiles, setDeleteFiles] = useState(false)\n\tconst queryClient = useQueryClient()\n\n\tuseEffect(() => {\n\t\tif (document.activeElement instanceof HTMLElement) {\n\t\t\tdocument.activeElement.blur()\n\t\t}\n\t\twindow.history.pushState({ drawer: 'open' }, '')\n\t\tconst handlePopState = () => onClose()\n\t\twindow.addEventListener('popstate', handlePopState)\n\t\treturn () => window.removeEventListener('popstate', handlePopState)\n\t}, [onClose])\n\n\tconst { data: torrents } = useQuery({\n\t\tqueryKey: ['torrents', instanceId],\n\t\tqueryFn: () => api.getTorrents(instanceId),\n\t\trefetchInterval: 2000,\n\t})\n\tconst torrent = torrents?.find((t) => t.hash === torrentHash)\n\n\tconst { data: properties } = useQuery({\n\t\tqueryKey: ['torrent-properties', instanceId, torrentHash],\n\t\tqueryFn: () => api.getTorrentProperties(instanceId, torrentHash),\n\t})\n\tconst { data: trackers } = useQuery({\n\t\tqueryKey: ['torrent-trackers', instanceId, torrentHash],\n\t\tqueryFn: () => api.getTorrentTrackers(instanceId, torrentHash),\n\t})\n\tconst { data: peersData } = useQuery({\n\t\tqueryKey: ['torrent-peers', instanceId, torrentHash],\n\t\tqueryFn: () => api.getTorrentPeers(instanceId, torrentHash),\n\t\trefetchInterval: 3000,\n\t})\n\tconst { data: files } = useQuery({\n\t\tqueryKey: ['torrent-files', instanceId, torrentHash],\n\t\tqueryFn: () => api.getTorrentFiles(instanceId, torrentHash),\n\t})\n\tconst { data: webSeeds } = useQuery({\n\t\tqueryKey: ['torrent-webseeds', instanceId, torrentHash],\n\t\tqueryFn: () => api.getTorrentWebSeeds(instanceId, torrentHash),\n\t})\n\n\tconst stopMutation = useMutation({\n\t\tmutationFn: () => api.stopTorrents(instanceId, [torrentHash]),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['torrents', instanceId] }),\n\t})\n\tconst startMutation = useMutation({\n\t\tmutationFn: () => api.startTorrents(instanceId, [torrentHash]),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['torrents', instanceId] }),\n\t})\n\tconst deleteMutation = useMutation({\n\t\tmutationFn: (deleteFiles: boolean) => api.deleteTorrents(instanceId, [torrentHash], deleteFiles),\n\t\tonSuccess: () => queryClient.invalidateQueries({ queryKey: ['torrents', instanceId] }),\n\t})\n\tconst setLocationMutation = useMutation({\n\t\tmutationFn: (location: string) => api.setTorrentLocation(instanceId, [torrentHash], location),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrents', instanceId] })\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrent-properties', instanceId, torrentHash] })\n\t\t},\n\t})\n\tconst setDownloadPathMutation = useMutation({\n\t\tmutationFn: (downloadPath: string) => api.setTorrentDownloadPath(instanceId, [torrentHash], downloadPath),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrents', instanceId] })\n\t\t\tqueryClient.invalidateQueries({ queryKey: ['torrent-properties', instanceId, torrentHash] })\n\t\t},\n\t})\n\n\tconst isPaused = torrent ? PAUSED_STATES.includes(torrent.state) : false\n\tconst peers = peersData?.peers ? Object.values(peersData.peers) : []\n\n\tfunction handleToggle() {\n\t\tif (isPaused) {\n\t\t\tstartMutation.mutate()\n\t\t} else {\n\t\t\tstopMutation.mutate()\n\t\t}\n\t}\n\n\tfunction handleDelete() {\n\t\tdeleteMutation.mutate(deleteFiles)\n\t\tonClose()\n\t}\n\n\tfunction openPathEditor(mode: Exclude<PathEditorMode, null>) {\n\t\tsetPathValue((mode === 'downloadPath' ? torrent?.download_path : torrent?.save_path) ?? '')\n\t\tsetPathEditorMode(mode)\n\t}\n\n\tfunction handlePathSave() {\n\t\tconst trimmed = pathValue.trim()\n\t\tif (!trimmed) return\n\n\t\tif (pathEditorMode === 'savePath') {\n\t\t\tsetLocationMutation.mutate(trimmed, {\n\t\t\t\tonSuccess: () => setPathEditorMode(null),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tif (pathEditorMode === 'downloadPath') {\n\t\t\tsetDownloadPathMutation.mutate(trimmed, {\n\t\t\t\tonSuccess: () => setPathEditorMode(null),\n\t\t\t})\n\t\t}\n\t}\n\n\tconst pathMutationPending = setLocationMutation.isPending || setDownloadPathMutation.isPending\n\tconst pathEditorTitle = pathEditorMode === 'savePath' ? 'Change Save Path' : 'Change Download Path'\n\n\tconst tabs: { id: Tab; label: string; count?: number }[] = [\n\t\t{ id: 'general', label: 'General' },\n\t\t{ id: 'files', label: 'Files', count: files?.length },\n\t\t{\n\t\t\tid: 'trackers',\n\t\t\tlabel: 'Trackers',\n\t\t\tcount: trackers?.filter((t) => t.url.startsWith('http') || t.url.startsWith('udp')).length,\n\t\t},\n\t\t{ id: 'peers', label: 'Peers', count: peers.length },\n\t\t{ id: 'http', label: 'HTTP', count: webSeeds?.length },\n\t]\n\n\tif (!torrent) {\n\t\treturn null\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Drawer.Root\n\t\t\t\topen={!showDeleteConfirm && !pathEditorMode}\n\t\t\t\tonOpenChange={(open) => !open && !showDeleteConfirm && !pathEditorMode && onClose()}\n\t\t\t\tshouldScaleBackground={false}\n\t\t\t>\n\t\t\t\t<Drawer.Portal>\n\t\t\t\t\t<Drawer.Overlay className=\"fixed inset-0 z-50\" style={{ backgroundColor: 'rgba(0,0,0,0.6)' }} />\n\t\t\t\t\t<Drawer.Content\n\t\t\t\t\t\tclassName=\"fixed inset-x-0 bottom-0 z-50 rounded-t-3xl border-t flex flex-col outline-none h-[90vh]\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-primary)',\n\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex justify-center pt-3 pb-2\">\n\t\t\t\t\t\t\t<div className=\"w-10 h-1 rounded-full\" style={{ backgroundColor: 'var(--text-muted)' }} />\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"px-5 pb-4 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<Drawer.Title\n\t\t\t\t\t\t\t\tclassName=\"text-base font-semibold leading-snug line-clamp-2\"\n\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{torrent.name}\n\t\t\t\t\t\t\t</Drawer.Title>\n\t\t\t\t\t\t\t<Drawer.Description className=\"sr-only\">Torrent details</Drawer.Description>\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 mt-2\">\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"h-1.5 flex-1 rounded-full overflow-hidden\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName=\"h-full rounded-full\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\twidth: `${Math.round(torrent.progress * 100)}%`,\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--accent)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<span className=\"text-xs font-medium tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t{Math.round(torrent.progress * 100)}%\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"flex gap-3 p-4 border-b\" style={{ borderColor: 'var(--border)' }}>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleToggle}\n\t\t\t\t\t\t\t\tdisabled={stopMutation.isPending || startMutation.isPending}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl font-medium text-sm flex items-center justify-center gap-2 active:scale-[0.98] transition-transform disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tbackgroundColor: isPaused ? 'var(--accent)' : 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\t\tcolor: isPaused ? 'var(--accent-contrast)' : 'var(--text-primary)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isPaused ? (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<Play className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\tResume\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<Pause className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\tPause\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setShowDeleteConfirm(true)}\n\t\t\t\t\t\t\t\tclassName=\"py-3 px-5 rounded-xl font-medium text-sm flex items-center justify-center gap-2 active:scale-[0.98] transition-transform\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'color-mix(in srgb, var(--error) 15%, transparent)', color: 'var(--error)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Trash2 className=\"w-5 h-5\" strokeWidth={2} />\n\t\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\n<div className=\"mx-4 mt-3\">\n\t\t\t\t\t\t\t<div className=\"flex p-1.5 rounded-xl\" style={{ backgroundColor: 'var(--bg-secondary)' }}>\n\t\t\t\t\t\t\t\t{tabs.map((t) => (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tkey={t.id}\n\t\t\t\t\t\t\t\t\t\tonClick={() => setTab(t.id)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 px-1 py-2 rounded-lg text-xs font-medium transition-all text-center whitespace-nowrap\"\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: tab === t.id ? 'var(--accent)' : 'transparent',\n\t\t\t\t\t\t\t\t\t\t\tcolor: tab === t.id ? 'var(--accent-contrast)' : 'var(--text-muted)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{t.label}\n\t\t\t\t\t\t\t\t\t\t{t.count ? ` (${t.count})` : ''}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"flex-1 min-h-0 overflow-y-auto px-4 pt-3\"\n\t\t\t\t\t\t\tstyle={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tab === 'general' && (\n\t\t\t\t\t\t\t\t<div className=\"space-y-3\">\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Size\" value={formatSize(torrent.size)} />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Downloaded\" value={formatSize(torrent.downloaded)} />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Uploaded\" value={formatSize(torrent.uploaded)} />\n\t\t\t\t\t\t\t\t\t<InfoRow\n\t\t\t\t\t\t\t\t\t\tlabel=\"Ratio\"\n\t\t\t\t\t\t\t\t\t\tvalue={\n\t\t\t\t\t\t\t\t\t\t\ttorrent.downloaded === 0 && torrent.progress >= 1 && torrent.size > 0\n\t\t\t\t\t\t\t\t\t\t\t\t? '∞'\n\t\t\t\t\t\t\t\t\t\t\t\t: torrent.ratio.toFixed(2)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<div className=\"h-px my-2\" style={{ backgroundColor: 'var(--border)' }} />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Download Speed\" value={formatSpeed(torrent.dlspeed)} accent />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Upload Speed\" value={formatSpeed(torrent.upspeed)} accent=\"#a6e3a1\" />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Seeds\" value={`${torrent.num_seeds}`} />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Peers\" value={`${torrent.num_leechs}`} />\n\t\t\t\t\t\t\t\t\t<div className=\"h-px my-2\" style={{ backgroundColor: 'var(--border)' }} />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Added\" value={formatDate(torrent.added_on)} />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Completed\" value={formatDate(torrent.completion_on)} />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Seeding Time\" value={formatDuration(torrent.seeding_time)} />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Last Activity\" value={formatDate(torrent.last_activity)} />\n\t\t\t\t\t\t\t\t\t<div className=\"h-px my-2\" style={{ backgroundColor: 'var(--border)' }} />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Category\" value={torrent.category || '-'} />\n\t\t\t\t\t\t\t\t\t<InfoRow label=\"Tags\" value={torrent.tags || '-'} />\n\t\t\t\t\t\t\t\t\t<PathRow\n\t\t\t\t\t\t\t\t\t\tlabel=\"Save Path\"\n\t\t\t\t\t\t\t\t\t\tvalue={torrent.save_path}\n\t\t\t\t\t\t\t\t\t\tonEdit={() => openPathEditor('savePath')}\n\t\t\t\t\t\t\t\t\t\teditDisabled={pathMutationPending}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t{torrent.download_path && torrent.download_path !== torrent.save_path && (\n\t\t\t\t\t\t\t\t\t\t<PathRow\n\t\t\t\t\t\t\t\t\t\t\tlabel=\"Download Path\"\n\t\t\t\t\t\t\t\t\t\t\tvalue={torrent.download_path}\n\t\t\t\t\t\t\t\t\t\t\tonEdit={torrent.progress < 1 ? () => openPathEditor('downloadPath') : undefined}\n\t\t\t\t\t\t\t\t\t\t\teditDisabled={pathMutationPending}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t{properties?.comment && <InfoRow label=\"Comment\" value={properties.comment} small />}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{tab === 'files' && (\n\t\t\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t\t\t{files?.map((file, i) => (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-3 rounded-xl border\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs break-all leading-relaxed\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{file.name}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3 mt-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 h-1 rounded-full overflow-hidden\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-full rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ width: `${Math.round(file.progress * 100)}%`, backgroundColor: 'var(--accent)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{Math.round(file.progress * 100)}%\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3 mt-2 text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t<span>{formatSize(file.size)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span>Priority: {getPriorityLabel(file.priority)}</span>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t{(!files || files.length === 0) && (\n\t\t\t\t\t\t\t\t\t\t<div className=\"py-8 text-center text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tNo files\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{tab === 'trackers' && (\n\t\t\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t\t\t{trackers\n\t\t\t\t\t\t\t\t\t\t?.filter((t) => t.url.startsWith('http') || t.url.startsWith('udp'))\n\t\t\t\t\t\t\t\t\t\t.map((tracker, i) => (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-3 rounded-xl border\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs font-mono break-all\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{tracker.url}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3 mt-2 text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: tracker.status === 2 ? '#a6e3a1' : 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{getTrackerStatus(tracker.status)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>Seeds: {tracker.num_seeds}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>Peers: {tracker.num_peers}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t{(!trackers ||\n\t\t\t\t\t\t\t\t\t\ttrackers.filter((t) => t.url.startsWith('http') || t.url.startsWith('udp')).length === 0) && (\n\t\t\t\t\t\t\t\t\t\t<div className=\"py-8 text-center text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tNo trackers\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{tab === 'peers' && (\n\t\t\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t\t\t{peers.map((peer, i) => (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-3 rounded-xl border\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs font-mono\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{peer.ip}:{peer.port}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{peer.country_code || '??'}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3 mt-2 text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--accent)' }}>↓ {formatSpeed(peer.dl_speed)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: '#a6e3a1' }}>↑ {formatSpeed(peer.up_speed)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ color: 'var(--text-muted)' }}>{Math.round(peer.progress * 100)}%</span>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t{peer.client && (\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs mt-1 truncate\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{peer.client}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t{peers.length === 0 && (\n\t\t\t\t\t\t\t\t\t\t<div className=\"py-8 text-center text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tNo peers connected\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{tab === 'http' && (\n\t\t\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t\t\t{webSeeds?.map((seed, i) => (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"p-3 rounded-xl border\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs font-mono break-all\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t{seed.url}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t{(!webSeeds || webSeeds.length === 0) && (\n\t\t\t\t\t\t\t\t\t\t<div className=\"py-8 text-center text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\tNo HTTP sources\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Drawer.Content>\n\t\t\t\t</Drawer.Portal>\n\t\t\t</Drawer.Root>\n\n\t\t\t{pathEditorMode && createPortal(\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-[9999] flex items-center justify-center\"\n\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\tonClick={() => !pathMutationPending && setPathEditorMode(null)}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"mx-4 w-full max-w-lg rounded-2xl border p-5\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-semibold mb-4\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t{pathEditorTitle}\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tref={(el) => { if (el) setTimeout(() => el.focus(), 50) }}\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tvalue={pathValue}\n\t\t\t\t\t\t\tonChange={(e) => setPathValue(e.target.value)}\n\t\t\t\t\t\t\tonKeyDown={(e) => e.key === 'Enter' && handlePathSave()}\n\t\t\t\t\t\t\tonTouchEnd={(e) => { e.stopPropagation(); (e.target as HTMLInputElement).focus() }}\n\t\t\t\t\t\t\tclassName=\"w-full px-4 py-3 rounded-xl border text-base\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-tertiary)',\n\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\tcolor: 'var(--text-primary)',\n\t\t\t\t\t\t\t\tfontSize: '16px',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div className=\"flex gap-3 mt-5\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setPathEditorMode(null)}\n\t\t\t\t\t\t\t\tdisabled={pathMutationPending}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handlePathSave}\n\t\t\t\t\t\t\t\tdisabled={!pathValue.trim() || pathMutationPending}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium disabled:opacity-50\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--accent)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{pathMutationPending ? 'Saving...' : 'Save'}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>,\n\t\t\t\tdocument.body\n\t\t\t)}\n\n\t\t\t{showDeleteConfirm && createPortal(\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-[9999] flex items-center justify-center\"\n\t\t\t\t\tstyle={{ backgroundColor: 'rgba(0,0,0,0.6)' }}\n\t\t\t\t\tonClick={() => setShowDeleteConfirm(false)}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"mx-4 w-full max-w-lg rounded-2xl border p-5\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\t\t>\n\t\t\t\t\t\t<h3 className=\"text-lg font-semibold mb-2\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\tDelete Torrent\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<p className=\"text-sm mb-4\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\tAre you sure you want to delete this torrent?\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<label className=\"flex items-center gap-3 mb-5 cursor-pointer\">\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\tchecked={deleteFiles}\n\t\t\t\t\t\t\t\tonChange={(e) => setDeleteFiles(e.target.checked)}\n\t\t\t\t\t\t\t\tclassName=\"w-5 h-5 rounded\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"text-sm\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\tAlso delete files from disk\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<div className=\"flex gap-3\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={() => setShowDeleteConfirm(false)}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonClick={handleDelete}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 py-3 rounded-xl text-sm font-medium\"\n\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--error)', color: 'var(--accent-contrast)' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>,\n\t\t\t\tdocument.body\n\t\t\t)}\n\t\t</>\n\t)\n}\n\nfunction InfoRow({\n\tlabel,\n\tvalue,\n\taccent,\n\tsmall,\n}: {\n\tlabel: string\n\tvalue: string\n\taccent?: string | boolean\n\tsmall?: boolean\n}) {\n\treturn (\n\t\t<div className=\"flex items-start justify-between gap-4\">\n\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t{label}\n\t\t\t</span>\n\t\t\t<span\n\t\t\t\tclassName={`text-xs text-right ${small ? 'max-w-[200px] truncate' : ''}`}\n\t\t\t\tstyle={{ color: accent ? (typeof accent === 'string' ? accent : 'var(--accent)') : 'var(--text-primary)' }}\n\t\t\t>\n\t\t\t\t{value}\n\t\t\t</span>\n\t\t</div>\n\t)\n}\n\nfunction PathRow({\n\tlabel,\n\tvalue,\n\tonEdit,\n\teditDisabled,\n}: {\n\tlabel: string\n\tvalue: string\n\tonEdit?: () => void\n\teditDisabled?: boolean\n}) {\n\treturn (\n\t\t<div className=\"flex items-start justify-between gap-3\">\n\t\t\t<div className=\"min-w-0 flex-1\">\n\t\t\t\t<div className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t{label}\n\t\t\t\t</div>\n\t\t\t\t<div className=\"text-xs break-all mt-0.5\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t{value}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t{onEdit && (\n\t\t\t\t<button\n\t\t\t\t\tonClick={onEdit}\n\t\t\t\t\tdisabled={editDisabled}\n\t\t\t\t\tclassName=\"shrink-0 text-xs hover:opacity-80 disabled:opacity-50\"\n\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t>\n\t\t\t\t\tEdit\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n"
  },
  {
    "path": "src/mobile/MobileTorrentList.tsx",
    "content": "import { useState, useMemo, useRef, useEffect } from 'react'\nimport {\n\tChevronDown,\n\tArrowDown,\n\tArrowUp,\n\tPause,\n\tAlertCircle,\n\tRefreshCw,\n\tPlay,\n\tLayoutGrid,\n\tList,\n\tArchive,\n} from 'lucide-react'\nimport { useQueries, useMutation, useQueryClient } from '@tanstack/react-query'\nimport * as api from '../api/qbittorrent'\nimport type { Instance } from '../api/instances'\nimport type { Torrent, TorrentState } from '../types/qbittorrent'\nimport {\n\tformatSize,\n\tformatSpeed,\n\tformatDuration,\n\tformatEta,\n\tformatCompactSpeed,\n\tformatCompactSize,\n\tformatRelativeDate,\n\tnormalizeSearch,\n} from '../utils/format'\n\ntype TorrentWithInstance = Torrent & { instanceId: number; instanceLabel: string }\n\ntype StatusFilter = 'all' | 'downloading' | 'seeding' | 'paused'\ntype SortField = 'dlspeed' | 'upspeed' | 'ratio' | 'seeding_time' | 'added_on' | 'last_activity'\n\nconst DOWNLOADING_STATES: TorrentState[] = [\n\t'downloading',\n\t'metaDL',\n\t'forcedDL',\n\t'stalledDL',\n\t'allocating',\n\t'queuedDL',\n\t'checkingDL',\n]\nconst SEEDING_STATES: TorrentState[] = ['uploading', 'forcedUP', 'stalledUP', 'queuedUP', 'checkingUP']\nconst PAUSED_STATES: TorrentState[] = ['pausedDL', 'pausedUP', 'stoppedDL', 'stoppedUP']\n\ntype StateInfo = { color: string; label: string; icon: 'download' | 'upload' | 'pause' | 'error' | 'check' }\n\nconst STATE_INFO: Partial<Record<TorrentState, StateInfo>> = {\n\tstalledDL: { color: 'var(--warning)', label: 'Stalled', icon: 'download' },\n\tqueuedDL: { color: 'var(--text-muted)', label: 'Queued', icon: 'download' },\n\tcheckingDL: { color: 'var(--accent)', label: 'Checking', icon: 'check' },\n\tstalledUP: { color: '#a6e3a1', label: 'Seeding', icon: 'upload' },\n\tqueuedUP: { color: 'var(--text-muted)', label: 'Queued', icon: 'upload' },\n\tcheckingUP: { color: '#a6e3a1', label: 'Checking', icon: 'check' },\n\terror: { color: 'var(--error)', label: 'Error', icon: 'error' },\n\tmissingFiles: { color: 'var(--error)', label: 'Error', icon: 'error' },\n}\n\nfunction getStateInfo(state: TorrentState): StateInfo {\n\tif (STATE_INFO[state]) return STATE_INFO[state]\n\tif (DOWNLOADING_STATES.includes(state)) return { color: 'var(--accent)', label: 'Downloading', icon: 'download' }\n\tif (SEEDING_STATES.includes(state)) return { color: '#a6e3a1', label: 'Seeding', icon: 'upload' }\n\tif (PAUSED_STATES.includes(state)) return { color: 'var(--text-muted)', label: 'Paused', icon: 'pause' }\n\treturn { color: 'var(--text-muted)', label: state, icon: 'pause' }\n}\n\nfunction MobileSelect<T extends string>({\n\tvalue,\n\toptions,\n\tonChange,\n}: {\n\tvalue: T\n\toptions: { value: T; label: string }[]\n\tonChange: (v: T) => void\n}) {\n\tconst [open, setOpen] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\tuseEffect(() => {\n\t\tif (!open) return\n\t\tfunction handleClickOutside(e: MouseEvent) {\n\t\t\tif (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)\n\t\t}\n\t\tdocument.addEventListener('mousedown', handleClickOutside)\n\t\treturn () => document.removeEventListener('mousedown', handleClickOutside)\n\t}, [open])\n\n\tconst selected = options.find((o) => o.value === value)\n\n\treturn (\n\t\t<div ref={ref} className=\"relative flex-1\">\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonClick={() => setOpen(!open)}\n\t\t\t\tclassName=\"w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl text-sm font-medium\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)' }}\n\t\t\t>\n\t\t\t\t<span className=\"truncate\">{selected?.label}</span>\n\t\t\t\t<ChevronDown\n\t\t\t\t\tclassName={`w-4 h-4 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`}\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t/>\n\t\t\t</button>\n\t\t\t{open && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute top-full left-0 right-0 mt-1 max-h-64 overflow-auto rounded-xl border shadow-xl z-50\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t{options.map((o) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={o.value}\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tonChange(o.value)\n\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"w-full px-3 py-2.5 text-left text-sm font-medium transition-colors active:bg-[var(--bg-tertiary)]\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tcolor: value === o.value ? 'var(--accent)' : 'var(--text-primary)',\n\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\tvalue === o.value ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : 'transparent',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{o.label}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\nconst STATE_ICONS = {\n\tdownload: ArrowDown,\n\tupload: ArrowUp,\n\tpause: Pause,\n\terror: AlertCircle,\n\tcheck: RefreshCw,\n}\n\nfunction StateIcon({ type, color }: { type: keyof typeof STATE_ICONS; color: string }) {\n\tconst Icon = STATE_ICONS[type]\n\treturn <Icon className={`w-4 h-4 ${type === 'check' ? 'animate-spin' : ''}`} style={{ color }} strokeWidth={2.5} />\n}\n\ninterface Props {\n\tinstances: Instance[]\n\tsearch?: string\n\tcompact?: boolean\n\tonToggleCompact?: () => void\n\tonSelectTorrent: (hash: string, instanceId: number) => void\n}\n\nconst STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [\n\t{ value: 'all', label: 'All' },\n\t{ value: 'downloading', label: 'Downloading' },\n\t{ value: 'seeding', label: 'Seeding' },\n\t{ value: 'paused', label: 'Paused' },\n]\n\nconst SORT_OPTIONS: { value: SortField; label: string }[] = [\n\t{ value: 'added_on', label: 'Added' },\n\t{ value: 'dlspeed', label: 'Down Speed' },\n\t{ value: 'upspeed', label: 'Up Speed' },\n\t{ value: 'ratio', label: 'Ratio' },\n\t{ value: 'seeding_time', label: 'Seed Time' },\n\t{ value: 'last_activity', label: 'Last Active' },\n]\n\nexport function MobileTorrentList({ instances, search, compact, onToggleCompact, onSelectTorrent }: Props) {\n\tconst [status, setStatus] = useState<StatusFilter>('all')\n\tconst [sortBy, setSortBy] = useState<SortField>('added_on')\n\tconst [swipedHash, setSwipedHash] = useState<string | null>(null)\n\tconst queryClient = useQueryClient()\n\n\tconst torrentQueries = useQueries({\n\t\tqueries: instances.map((instance) => ({\n\t\t\tqueryKey: ['torrents', instance.id],\n\t\t\tqueryFn: () => api.getTorrents(instance.id),\n\t\t\trefetchInterval: 2000,\n\t\t})),\n\t})\n\n\tconst isLoading = torrentQueries.some((q) => q.isLoading)\n\n\tconst torrents: TorrentWithInstance[] = useMemo(() => {\n\t\treturn torrentQueries.flatMap((q, i) =>\n\t\t\t(q.data || []).map((t) => ({\n\t\t\t\t...t,\n\t\t\t\tinstanceId: instances[i].id,\n\t\t\t\tinstanceLabel: instances[i].label,\n\t\t\t}))\n\t\t)\n\t}, [torrentQueries, instances])\n\n\tconst stopMutation = useMutation({\n\t\tmutationFn: ({ instanceId, hashes }: { instanceId: number; hashes: string[] }) =>\n\t\t\tapi.stopTorrents(instanceId, hashes),\n\t\tonSuccess: (_, { instanceId }) => queryClient.invalidateQueries({ queryKey: ['torrents', instanceId] }),\n\t})\n\n\tconst startMutation = useMutation({\n\t\tmutationFn: ({ instanceId, hashes }: { instanceId: number; hashes: string[] }) =>\n\t\t\tapi.startTorrents(instanceId, hashes),\n\t\tonSuccess: (_, { instanceId }) => queryClient.invalidateQueries({ queryKey: ['torrents', instanceId] }),\n\t})\n\n\tconst filteredTorrents = useMemo(() => {\n\t\tlet result = torrents\n\t\tif (search) {\n\t\t\tconst q = normalizeSearch(search)\n\t\t\tresult = result.filter((t) => normalizeSearch(t.name).includes(q))\n\t\t}\n\t\tswitch (status) {\n\t\t\tcase 'downloading':\n\t\t\t\tresult = result.filter((t) => DOWNLOADING_STATES.includes(t.state))\n\t\t\t\tbreak\n\t\t\tcase 'seeding':\n\t\t\t\tresult = result.filter((t) => SEEDING_STATES.includes(t.state))\n\t\t\t\tbreak\n\t\t\tcase 'paused':\n\t\t\t\tresult = result.filter((t) => PAUSED_STATES.includes(t.state))\n\t\t\t\tbreak\n\t\t}\n\t\treturn [...result].sort((a, b) => b[sortBy] - a[sortBy])\n\t}, [torrents, status, sortBy, search])\n\n\tfunction handleToggle(torrent: TorrentWithInstance, e: React.MouseEvent) {\n\t\te.stopPropagation()\n\t\tconst isPaused = PAUSED_STATES.includes(torrent.state)\n\t\tif (isPaused) {\n\t\t\tstartMutation.mutate({ instanceId: torrent.instanceId, hashes: [torrent.hash] })\n\t\t} else {\n\t\t\tstopMutation.mutate({ instanceId: torrent.instanceId, hashes: [torrent.hash] })\n\t\t}\n\t\tsetSwipedHash(null)\n\t}\n\n\tconst showInstanceLabel = instances.length > 1\n\n\treturn (\n\t\t<div className=\"space-y-3\">\n\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t<MobileSelect value={status} options={STATUS_OPTIONS} onChange={setStatus} />\n\t\t\t\t<MobileSelect value={sortBy} options={SORT_OPTIONS} onChange={setSortBy} />\n\t\t\t\t{onToggleCompact && (\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={onToggleCompact}\n\t\t\t\t\t\tclassName=\"p-2.5 rounded-xl shrink-0\"\n\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: compact ? 'var(--accent)' : 'var(--text-muted)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{compact ? (\n\t\t\t\t\t\t\t<LayoutGrid className=\"w-5 h-5\" strokeWidth={1.5} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<List className=\"w-5 h-5\" strokeWidth={1.5} />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{isLoading ? (\n\t\t\t\t<div className=\"py-12 text-center\">\n\t\t\t\t\t<div className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tLoading torrents...\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t) : filteredTorrents.length === 0 ? (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"py-12 text-center rounded-2xl border\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t>\n\t\t\t\t\t<Archive\n\t\t\t\t\t\tclassName=\"w-12 h-12 mx-auto mb-3\"\n\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)', opacity: 0.5 }}\n\t\t\t\t\t\tstrokeWidth={1}\n\t\t\t\t\t/>\n\t\t\t\t\t<div className=\"text-sm\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\tNo torrents found\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<div className={compact ? 'space-y-1' : 'space-y-2'}>\n\t\t\t\t\t{filteredTorrents.map((torrent) => {\n\t\t\t\t\t\tconst stateInfo = getStateInfo(torrent.state)\n\t\t\t\t\t\tconst isPaused = PAUSED_STATES.includes(torrent.state)\n\t\t\t\t\t\tconst isSwiped = swipedHash === torrent.hash\n\t\t\t\t\t\tconst speed =\n\t\t\t\t\t\t\ttorrent.dlspeed > 0\n\t\t\t\t\t\t\t\t? formatSpeed(torrent.dlspeed)\n\t\t\t\t\t\t\t\t: torrent.upspeed > 0\n\t\t\t\t\t\t\t\t\t? formatSpeed(torrent.upspeed)\n\t\t\t\t\t\t\t\t\t: ''\n\t\t\t\t\t\tconst progress = Math.round(torrent.progress * 100)\n\n\t\t\t\t\t\tif (compact) {\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tkey={torrent.hash}\n\t\t\t\t\t\t\t\t\tonClick={() => onSelectTorrent(torrent.hash, torrent.instanceId)}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left px-3 py-2.5 rounded-xl border active:scale-[0.99] transition-transform\"\n\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 mb-1\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"text-xs font-medium truncate flex-1\" style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t\t\t\t\t\t\t{torrent.name}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] tabular-nums shrink-0 font-medium\" style={{ color: stateInfo.color }}>\n\t\t\t\t\t\t\t\t\t\t\t{progress}%\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5 text-[10px] mb-1.5\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t<span style={{ color: stateInfo.color }}>{stateInfo.label}</span>\n\t\t\t\t\t\t\t\t\t\t{showInstanceLabel && (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ opacity: 0.4 }}>•</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span>{torrent.instanceLabel}</span>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t<span style={{ opacity: 0.4 }}>•</span>\n\t\t\t\t\t\t\t\t\t\t<span className=\"tabular-nums\">{formatCompactSize(torrent.size)}</span>\n\t\t\t\t\t\t\t\t\t\t{speed && (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ opacity: 0.4 }}>•</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"tabular-nums\" style={{ color: stateInfo.color }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{speed}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{torrent.eta > 0 && torrent.eta < 8640000 && (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<span style={{ opacity: 0.4 }}>•</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"tabular-nums\">{formatEta(torrent.eta)}</span>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName=\"h-1 rounded-full overflow-hidden mb-1.5\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-full rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ width: `${progress}%`, backgroundColor: stateInfo.color }}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-3 text-[10px] tabular-nums\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t<span style={{ opacity: 0.5 }}>↓</span>\n\t\t\t\t\t\t\t\t\t\t\t{formatCompactSize(torrent.downloaded)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t<span style={{ opacity: 0.5 }}>↑</span>\n\t\t\t\t\t\t\t\t\t\t\t{formatCompactSize(torrent.uploaded)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t<span style={{ opacity: 0.5, color: 'var(--accent)' }}>▼</span>\n\t\t\t\t\t\t\t\t\t\t\t{formatCompactSpeed(torrent.dlspeed)}/s\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t<span style={{ opacity: 0.5, color: '#a6e3a1' }}>▲</span>\n\t\t\t\t\t\t\t\t\t\t\t{formatCompactSpeed(torrent.upspeed)}/s\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span className=\"ml-auto\">\n\t\t\t\t\t\t\t\t\t\t\t<span style={{ opacity: 0.5 }}>⏱</span>\n\t\t\t\t\t\t\t\t\t\t\t{formatDuration(torrent.seeding_time)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t<span style={{ opacity: 0.5 }}>+</span>\n\t\t\t\t\t\t\t\t\t\t\t{formatRelativeDate(torrent.added_on)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div key={torrent.hash} className=\"relative overflow-hidden rounded-2xl\">\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName=\"absolute inset-y-0 right-0 flex items-center px-4 transition-transform\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: isPaused ? 'var(--accent)' : 'var(--warning)',\n\t\t\t\t\t\t\t\t\t\ttransform: isSwiped ? 'translateX(0)' : 'translateX(100%)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<button onClick={(e) => handleToggle(torrent, e)} className=\"p-2\">\n\t\t\t\t\t\t\t\t\t\t{isPaused ? (\n\t\t\t\t\t\t\t\t\t\t\t<Play className=\"w-6 h-6\" style={{ color: 'var(--accent-contrast)' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<Pause className=\"w-6 h-6\" style={{ color: '#000' }} strokeWidth={2} />\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={() => onSelectTorrent(torrent.hash, torrent.instanceId)}\n\t\t\t\t\t\t\t\t\tonTouchStart={() => {}}\n\t\t\t\t\t\t\t\t\tonContextMenu={(e) => {\n\t\t\t\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\t\t\t\tsetSwipedHash(isSwiped ? null : torrent.hash)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full text-left p-4 rounded-2xl border transition-transform active:scale-[0.98]\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'var(--bg-secondary)',\n\t\t\t\t\t\t\t\t\t\tborderColor: 'var(--border)',\n\t\t\t\t\t\t\t\t\t\ttransform: isSwiped ? 'translateX(-60px)' : 'translateX(0)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-start gap-3\">\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: `color-mix(in srgb, ${stateInfo.color} 15%, transparent)` }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<StateIcon type={stateInfo.icon} color={stateInfo.color} />\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"font-medium text-sm leading-snug line-clamp-2\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: 'var(--text-primary)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{torrent.name}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 mt-1.5 flex-wrap\">\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: stateInfo.color }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{stateInfo.label}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{showInstanceLabel && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t•\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs px-1.5 py-0.5 rounded\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{torrent.instanceLabel}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t•\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{formatSize(torrent.size)}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{speed && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t•\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs tabular-nums\" style={{ color: stateInfo.color }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{speed}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"mt-2 h-1.5 rounded-full overflow-hidden\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-tertiary)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-full rounded-full transition-all duration-300\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\twidth: `${progress}%`,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: stateInfo.color,\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between mt-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{progress}%\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{torrent.eta > 0 && torrent.eta < 8640000 && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatEta(torrent.eta)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"mt-2.5 pt-2.5 grid grid-cols-3 gap-x-3 gap-y-1.5\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ borderTop: '1px solid var(--border)' }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] opacity-50\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t↓\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatCompactSize(torrent.downloaded)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] opacity-50\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t↑\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatCompactSize(torrent.uploaded)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] opacity-50\" style={{ color: 'var(--accent)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t▼\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatCompactSpeed(torrent.dlspeed)}/s\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] opacity-50\" style={{ color: '#a6e3a1' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t▲\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatCompactSpeed(torrent.upspeed)}/s\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] opacity-50\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t⏱\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatDuration(torrent.seeding_time)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] opacity-50\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t+\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] tabular-nums\" style={{ color: 'var(--text-muted)' }}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatRelativeDate(torrent.added_on)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "src/server/db/index.ts",
    "content": "import { Database } from 'bun:sqlite'\nimport { randomBytes } from 'crypto'\n\nconst dbPath = process.env.DATABASE_PATH || './data/qbitwebui.db'\n\nimport { mkdirSync } from 'fs'\nimport { dirname } from 'path'\n\nmkdirSync(dirname(dbPath), { recursive: true })\n\nexport const db = new Database(dbPath)\ndb.exec('PRAGMA journal_mode = WAL')\ndb.exec('PRAGMA foreign_keys = ON')\n\ndb.exec(`\n\tCREATE TABLE IF NOT EXISTS users (\n\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\tusername TEXT UNIQUE NOT NULL,\n\t\tpassword_hash TEXT NOT NULL,\n\t\tcreated_at INTEGER DEFAULT (unixepoch())\n\t)\n`)\n\ndb.exec(`\n\tCREATE TABLE IF NOT EXISTS instances (\n\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\tlabel TEXT NOT NULL,\n\t\turl TEXT NOT NULL,\n\t\tqbt_username TEXT,\n\t\tqbt_password_encrypted TEXT,\n\t\tskip_auth INTEGER DEFAULT 0,\n\t\tcreated_at INTEGER DEFAULT (unixepoch()),\n\t\tUNIQUE(user_id, label)\n\t)\n`)\n\nconst instanceCols = db.query<{ name: string; notnull: number }, []>(`PRAGMA table_info(instances)`).all()\nconst hasSkipAuth = instanceCols.some((c) => c.name === 'skip_auth')\nconst usernameNotNull = instanceCols.find((c) => c.name === 'qbt_username')?.notnull\n\nif (!hasSkipAuth || usernameNotNull) {\n\tdb.exec(`\n\t\tCREATE TABLE IF NOT EXISTS instances_new (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\tlabel TEXT NOT NULL,\n\t\t\turl TEXT NOT NULL,\n\t\t\tqbt_username TEXT,\n\t\t\tqbt_password_encrypted TEXT,\n\t\t\tskip_auth INTEGER DEFAULT 0,\n\t\t\tcreated_at INTEGER DEFAULT (unixepoch()),\n\t\t\tUNIQUE(user_id, label)\n\t\t)\n\t`)\n\tdb.exec(\n\t\t`INSERT INTO instances_new SELECT id, user_id, label, url, qbt_username, qbt_password_encrypted, 0, created_at FROM instances`\n\t)\n\tdb.exec(`DROP TABLE instances`)\n\tdb.exec(`ALTER TABLE instances_new RENAME TO instances`)\n}\n\nconst hasAgentUrl = instanceCols.some((c) => c.name === 'agent_url')\nconst hasAgentEnabled = instanceCols.some((c) => c.name === 'agent_enabled')\nif (hasAgentUrl && !hasAgentEnabled) {\n\tdb.exec('ALTER TABLE instances ADD COLUMN agent_enabled INTEGER DEFAULT 0')\n\tdb.exec('UPDATE instances SET agent_enabled = 1 WHERE agent_url IS NOT NULL')\n}\nif (!hasAgentUrl && !hasAgentEnabled) {\n\tdb.exec('ALTER TABLE instances ADD COLUMN agent_enabled INTEGER DEFAULT 0')\n}\nif (!hasAgentUrl) {\n\tdb.exec('ALTER TABLE instances ADD COLUMN agent_url TEXT')\n}\n\ndb.exec(`\n\tCREATE TABLE IF NOT EXISTS sessions (\n\t\tid TEXT PRIMARY KEY,\n\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\texpires_at INTEGER NOT NULL\n\t)\n`)\n\ndb.exec(`\n\tCREATE TABLE IF NOT EXISTS integrations (\n\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\ttype TEXT NOT NULL,\n\t\tlabel TEXT NOT NULL,\n\t\turl TEXT NOT NULL,\n\t\tapi_key_encrypted TEXT NOT NULL,\n\t\tcreated_at INTEGER DEFAULT (unixepoch()),\n\t\tUNIQUE(user_id, label)\n\t)\n`)\n\ndb.exec(`\n\tCREATE TABLE IF NOT EXISTS cross_seed_config (\n\t\tinstance_id INTEGER PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE,\n\t\tenabled INTEGER DEFAULT 0,\n\t\tinterval_hours INTEGER DEFAULT 24,\n\t\tdry_run INTEGER DEFAULT 1,\n\t\tcategory_suffix TEXT DEFAULT '_cross-seed',\n\t\ttag TEXT DEFAULT 'cross-seed',\n\t\tskip_recheck INTEGER DEFAULT 0,\n\t\tintegration_id INTEGER REFERENCES integrations(id) ON DELETE SET NULL,\n\t\tlast_run INTEGER,\n\t\tnext_run INTEGER,\n\t\tupdated_at INTEGER DEFAULT (unixepoch())\n\t)\n`)\n\nconst crossSeedCols = db.query<{ name: string }, []>('PRAGMA table_info(cross_seed_config)').all()\nif (!crossSeedCols.some((c) => c.name === 'indexer_ids')) {\n\tdb.exec('ALTER TABLE cross_seed_config ADD COLUMN indexer_ids TEXT')\n}\nif (!crossSeedCols.some((c) => c.name === 'delay_seconds')) {\n\tdb.exec('ALTER TABLE cross_seed_config ADD COLUMN delay_seconds INTEGER DEFAULT 30')\n}\nif (!crossSeedCols.some((c) => c.name === 'match_mode')) {\n\tdb.exec(\"ALTER TABLE cross_seed_config ADD COLUMN match_mode TEXT DEFAULT 'strict'\")\n}\nif (!crossSeedCols.some((c) => c.name === 'link_dir')) {\n\tdb.exec('ALTER TABLE cross_seed_config ADD COLUMN link_dir TEXT')\n}\nif (!crossSeedCols.some((c) => c.name === 'blocklist')) {\n\tdb.exec('ALTER TABLE cross_seed_config ADD COLUMN blocklist TEXT')\n}\nif (!crossSeedCols.some((c) => c.name === 'include_single_episodes')) {\n\tdb.exec('ALTER TABLE cross_seed_config ADD COLUMN include_single_episodes INTEGER DEFAULT 0')\n}\n\ndb.exec(`\n\tCREATE TABLE IF NOT EXISTS cross_seed_searchee (\n\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\tinstance_id INTEGER NOT NULL REFERENCES instances(id) ON DELETE CASCADE,\n\t\ttorrent_hash TEXT NOT NULL,\n\t\ttorrent_name TEXT NOT NULL,\n\t\ttotal_size INTEGER NOT NULL,\n\t\tfile_count INTEGER NOT NULL,\n\t\tfile_sizes TEXT NOT NULL,\n\t\tfirst_searched INTEGER DEFAULT (unixepoch()),\n\t\tlast_searched INTEGER DEFAULT (unixepoch()),\n\t\tUNIQUE(instance_id, torrent_hash)\n\t)\n`)\ndb.exec(`CREATE INDEX IF NOT EXISTS idx_cross_seed_searchee_instance ON cross_seed_searchee(instance_id)`)\ndb.exec(`CREATE INDEX IF NOT EXISTS idx_cross_seed_searchee_hash ON cross_seed_searchee(torrent_hash)`)\n\ndb.exec(`\n\tCREATE TABLE IF NOT EXISTS cross_seed_decision (\n\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\tsearchee_id INTEGER NOT NULL REFERENCES cross_seed_searchee(id) ON DELETE CASCADE,\n\t\tguid TEXT NOT NULL,\n\t\tinfo_hash TEXT,\n\t\tcandidate_name TEXT NOT NULL,\n\t\tcandidate_size INTEGER,\n\t\tdecision TEXT NOT NULL,\n\t\tfirst_seen INTEGER DEFAULT (unixepoch()),\n\t\tlast_seen INTEGER DEFAULT (unixepoch()),\n\t\tUNIQUE(searchee_id, guid)\n\t)\n`)\ndb.exec(`CREATE INDEX IF NOT EXISTS idx_cross_seed_decision_searchee ON cross_seed_decision(searchee_id)`)\ndb.exec(`CREATE INDEX IF NOT EXISTS idx_cross_seed_decision_info_hash ON cross_seed_decision(info_hash)`)\n\ndb.exec(`\n\tCREATE TABLE IF NOT EXISTS cross_seed_indexer (\n\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\tintegration_id INTEGER NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,\n\t\tindexer_id INTEGER NOT NULL,\n\t\tname TEXT,\n\t\tstatus TEXT DEFAULT 'OK',\n\t\tretry_after INTEGER,\n\t\tUNIQUE(integration_id, indexer_id)\n\t)\n`)\ndb.exec(`CREATE INDEX IF NOT EXISTS idx_cross_seed_indexer_integration ON cross_seed_indexer(integration_id)`)\n\ndb.exec(`\n\tCREATE TABLE IF NOT EXISTS transfer_stats (\n\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\tinstance_id INTEGER NOT NULL REFERENCES instances(id) ON DELETE CASCADE,\n\t\ttimestamp INTEGER NOT NULL,\n\t\tuploaded INTEGER NOT NULL,\n\t\tdownloaded INTEGER NOT NULL\n\t)\n`)\ndb.exec(`CREATE INDEX IF NOT EXISTS idx_transfer_stats_lookup ON transfer_stats(instance_id, timestamp)`)\n\nexport interface User {\n\tid: number\n\tusername: string\n\tpassword_hash: string\n\tcreated_at: number\n}\n\nexport interface Instance {\n\tid: number\n\tuser_id: number\n\tlabel: string\n\turl: string\n\tqbt_username: string | null\n\tqbt_password_encrypted: string | null\n\tskip_auth: number\n\tagent_enabled: number\n\tagent_url: string | null\n\tcreated_at: number\n}\n\nexport interface Integration {\n\tid: number\n\tuser_id: number\n\ttype: string\n\tlabel: string\n\turl: string\n\tapi_key_encrypted: string\n\tcreated_at: number\n}\n\nexport const MatchMode = {\n\tSTRICT: 'strict',\n\tFLEXIBLE: 'flexible',\n} as const\n\nexport type MatchModeType = (typeof MatchMode)[keyof typeof MatchMode]\n\nexport interface CrossSeedConfig {\n\tinstance_id: number\n\tenabled: number\n\tinterval_hours: number\n\tdry_run: number\n\tcategory_suffix: string\n\ttag: string\n\tskip_recheck: number\n\tintegration_id: number | null\n\tindexer_ids: string | null\n\tdelay_seconds: number\n\tmatch_mode: MatchModeType\n\tlink_dir: string | null\n\tblocklist: string | null\n\tinclude_single_episodes: number\n\tlast_run: number | null\n\tnext_run: number | null\n\tupdated_at: number\n}\n\nexport const IndexerStatus = {\n\tOK: 'OK',\n\tRATE_LIMITED: 'RATE_LIMITED',\n\tUNKNOWN_ERROR: 'UNKNOWN_ERROR',\n} as const\n\nexport interface CrossSeedIndexer {\n\tid: number\n\tintegration_id: number\n\tindexer_id: number\n\tname: string | null\n\tstatus: string\n\tretry_after: number | null\n}\n\nexport interface CrossSeedSearchee {\n\tid: number\n\tinstance_id: number\n\ttorrent_hash: string\n\ttorrent_name: string\n\ttotal_size: number\n\tfile_count: number\n\tfile_sizes: string\n\tfirst_searched: number\n\tlast_searched: number\n}\n\nexport interface CrossSeedDecision {\n\tid: number\n\tsearchee_id: number\n\tguid: string\n\tinfo_hash: string | null\n\tcandidate_name: string\n\tcandidate_size: number | null\n\tdecision: string\n\tfirst_seen: number\n\tlast_seen: number\n}\n\nexport const CrossSeedDecisionType = {\n\tMATCH: 'MATCH',\n\tMATCH_SIZE_ONLY: 'MATCH_SIZE_ONLY',\n\tSIZE_MISMATCH: 'SIZE_MISMATCH',\n\tFILE_TREE_MISMATCH: 'FILE_TREE_MISMATCH',\n\tALREADY_EXISTS: 'ALREADY_EXISTS',\n\tDOWNLOAD_FAILED: 'DOWNLOAD_FAILED',\n\tNO_DOWNLOAD_LINK: 'NO_DOWNLOAD_LINK',\n\tBLOCKED_RELEASE: 'BLOCKED_RELEASE',\n} as const\n\nexport const BlocklistType = {\n\tNAME: 'name',\n\tNAME_REGEX: 'nameRegex',\n\tFOLDER: 'folder',\n\tFOLDER_REGEX: 'folderRegex',\n\tCATEGORY: 'category',\n\tTAG: 'tag',\n\tTRACKER: 'tracker',\n\tINFOHASH: 'infoHash',\n\tSIZE_BELOW: 'sizeBelow',\n\tSIZE_ABOVE: 'sizeAbove',\n\tLEGACY: 'legacy',\n} as const\n\nexport type BlocklistTypeValue = (typeof BlocklistType)[keyof typeof BlocklistType]\n\nexport interface TransferStats {\n\tid: number\n\tinstance_id: number\n\ttimestamp: number\n\tuploaded: number\n\tdownloaded: number\n}\n\nfunction cleanupExpiredSessions() {\n\tconst now = Math.floor(Date.now() / 1000)\n\tdb.run('DELETE FROM sessions WHERE expires_at < ?', [now])\n}\n\ncleanupExpiredSessions()\nsetInterval(cleanupExpiredSessions, 60 * 60 * 1000)\n\nexport const AUTH_DISABLED = process.env.DISABLE_AUTH === 'true'\nexport const REGISTRATION_DISABLED = process.env.DISABLE_REGISTRATION === 'true'\n\nif (AUTH_DISABLED) {\n\tconst guest = db.query<{ id: number }, []>('SELECT id FROM users WHERE id = 1').get()\n\tif (!guest) {\n\t\tdb.run('INSERT INTO users (id, username, password_hash) VALUES (1, ?, ?)', ['guest', 'disabled'])\n\t}\n}\n\nexport let defaultCredentials: { username: string; password: string } | null = null\n\nfunction generateSecurePassword(): string {\n\tconst lower = 'abcdefghijklmnopqrstuvwxyz'\n\tconst upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'\n\tconst digits = '0123456789'\n\tconst all = lower + upper + digits\n\tconst bytes = randomBytes(16)\n\tlet password = ''\n\tpassword += lower[bytes[0] % lower.length]\n\tpassword += upper[bytes[1] % upper.length]\n\tpassword += digits[bytes[2] % digits.length]\n\tfor (let i = 3; i < 16; i++) {\n\t\tpassword += all[bytes[i] % all.length]\n\t}\n\treturn password\n\t\t.split('')\n\t\t.sort(() => randomBytes(1)[0] - 128)\n\t\t.join('')\n}\n\nasync function initDefaultAdmin() {\n\tif (!REGISTRATION_DISABLED || AUTH_DISABLED) return\n\tconst userCount = db.query<{ count: number }, []>('SELECT COUNT(*) as count FROM users').get()\n\tif (!userCount || userCount.count === 0) {\n\t\tconst { hashPassword } = await import('../utils/crypto')\n\t\tconst password = generateSecurePassword()\n\t\tconst passwordHash = await hashPassword(password)\n\t\tdb.run('INSERT INTO users (username, password_hash) VALUES (?, ?)', ['admin', passwordHash])\n\t\tdefaultCredentials = { username: 'admin', password }\n\t}\n}\n\nexport function clearDefaultCredentials() {\n\tdefaultCredentials = null\n}\n\nawait initDefaultAdmin()\n"
  },
  {
    "path": "src/server/index.ts",
    "content": "import { Hono } from 'hono'\nimport { serveStatic } from 'hono/bun'\nimport { cors } from 'hono/cors'\nimport { AUTH_DISABLED, REGISTRATION_DISABLED, defaultCredentials, clearDefaultCredentials } from './db'\nimport auth from './routes/auth'\nimport instances from './routes/instances'\nimport proxy from './routes/proxy'\nimport integrations from './routes/integrations'\nimport files from './routes/files'\nimport tools from './routes/tools'\nimport { crossSeed, startScheduler } from './routes/crossSeed'\nimport stats from './routes/stats'\nimport { startStatsRecorder } from './utils/statsRecorder'\nimport { log } from './utils/logger'\n\nconst banner = `\n   ___  ____ ___ _______        _______ ____  _   _ ___\n  / _ \\\\| __ )_ _|_   _\\\\ \\\\      / / ____| __ )| | | |_ _|\n | | | |  _ \\\\| |  | |  \\\\ \\\\ /\\\\ / /|  _| |  _ \\\\| | | || |\n | |_| | |_) | |  | |   \\\\ V  V / | |___| |_) | |_| || |\n  \\\\__\\\\_\\\\____/___| |_|    \\\\_/\\\\_/  |_____|____/ \\\\___/|___|\n`\nconst app = new Hono()\n\napp.use(\n\t'*',\n\tcors({\n\t\torigin: ['http://localhost:5173', 'http://127.0.0.1:5173'],\n\t\tcredentials: true,\n\t})\n)\n\napp.get('/api/config', (c) =>\n\tc.json({\n\t\tauthDisabled: AUTH_DISABLED,\n\t\tregistrationDisabled: REGISTRATION_DISABLED,\n\t\tfilesEnabled: !!process.env.DOWNLOADS_PATH,\n\t})\n)\n\napp.route('/api/auth', auth)\napp.route('/api/instances', instances)\napp.route('/api/instances', proxy)\napp.route('/api/integrations', integrations)\napp.route('/api/files', files)\napp.route('/api/tools', tools)\napp.route('/api/cross-seed', crossSeed)\napp.route('/api/stats', stats)\n\nif (process.env.NODE_ENV === 'production') {\n\tapp.use('/*', serveStatic({ root: './dist' }))\n\tapp.get('*', serveStatic({ path: './dist/index.html' }))\n}\n\nconst port = Number(process.env.PORT) || 3000\nconst env = process.env.NODE_ENV || 'development'\n\nconsole.log(banner)\nlog.info(`Server running on port ${port} (${env})`)\n\nif (defaultCredentials) {\n\tlog.info('='.repeat(50))\n\tlog.info('Default admin account created:')\n\tlog.info(`  Username: ${defaultCredentials.username}`)\n\tlog.info(`  Password: ${defaultCredentials.password}`)\n\tlog.info('Please change your password after first login!')\n\tlog.info('='.repeat(50))\n\tclearDefaultCredentials()\n}\n\nstartScheduler()\nstartStatsRecorder()\n\nexport default {\n\tport,\n\tfetch: app.fetch,\n\tidleTimeout: 120,\n}\n"
  },
  {
    "path": "src/server/middleware/auth.ts",
    "content": "import { createMiddleware } from 'hono/factory'\nimport { getCookie } from 'hono/cookie'\nimport { db, type User, AUTH_DISABLED } from '../db'\n\nexport interface AuthUser {\n\tid: number\n\tusername: string\n}\n\ndeclare module 'hono' {\n\tinterface ContextVariableMap {\n\t\tuser: AuthUser\n\t}\n}\n\nexport const authMiddleware = createMiddleware(async (c, next) => {\n\tif (AUTH_DISABLED) {\n\t\tc.set('user', { id: 1, username: 'guest' })\n\t\treturn next()\n\t}\n\n\tconst sessionId = getCookie(c, 'session')\n\tif (!sessionId) {\n\t\treturn c.json({ error: 'Unauthorized' }, 401)\n\t}\n\n\tconst now = Math.floor(Date.now() / 1000)\n\tconst session = db\n\t\t.query<{ user_id: number; expires_at: number }, [string]>('SELECT user_id, expires_at FROM sessions WHERE id = ?')\n\t\t.get(sessionId)\n\n\tif (!session || session.expires_at < now) {\n\t\tif (session) {\n\t\t\tdb.run('DELETE FROM sessions WHERE id = ?', [sessionId])\n\t\t}\n\t\treturn c.json({ error: 'Unauthorized' }, 401)\n\t}\n\n\tconst user = db.query<User, [number]>('SELECT id, username FROM users WHERE id = ?').get(session.user_id)\n\n\tif (!user) {\n\t\treturn c.json({ error: 'Unauthorized' }, 401)\n\t}\n\n\tc.set('user', { id: user.id, username: user.username })\n\tawait next()\n})\n"
  },
  {
    "path": "src/server/routes/auth.ts",
    "content": "import { Hono } from 'hono'\nimport { setCookie, deleteCookie, getCookie } from 'hono/cookie'\nimport { db, type User, REGISTRATION_DISABLED } from '../db'\nimport { hashPassword, verifyPassword, generateSessionId } from '../utils/crypto'\nimport { checkRateLimit, resetRateLimit } from '../utils/rateLimit'\nimport { authMiddleware } from '../middleware/auth'\nimport { log } from '../utils/logger'\n\nconst auth = new Hono()\n\nconst SESSION_DURATION = 7 * 24 * 60 * 60\n\nfunction validatePassword(password: string): string | null {\n\tif (!password || password.length < 8) {\n\t\treturn 'Password must be at least 8 characters'\n\t}\n\tif (!/[a-z]/.test(password)) {\n\t\treturn 'Password must contain a lowercase letter'\n\t}\n\tif (!/[A-Z]/.test(password)) {\n\t\treturn 'Password must contain an uppercase letter'\n\t}\n\tif (!/[0-9]/.test(password)) {\n\t\treturn 'Password must contain a number'\n\t}\n\treturn null\n}\n\nauth.post('/register', async (c) => {\n\tif (REGISTRATION_DISABLED) {\n\t\treturn c.json({ error: 'Registration is disabled' }, 403)\n\t}\n\tconst body = await c.req.json<{ username: string; password: string }>()\n\tconst { username, password } = body\n\n\tif (!username || username.length < 3 || username.length > 32) {\n\t\treturn c.json({ error: 'Username must be 3-32 characters' }, 400)\n\t}\n\tconst passwordError = validatePassword(password)\n\tif (passwordError) {\n\t\treturn c.json({ error: passwordError }, 400)\n\t}\n\n\tconst existing = db.query<{ id: number }, [string]>('SELECT id FROM users WHERE username = ?').get(username)\n\tif (existing) {\n\t\treturn c.json({ error: 'Username already exists' }, 400)\n\t}\n\n\tconst passwordHash = await hashPassword(password)\n\tconst result = db.run('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, passwordHash])\n\n\tconst sessionId = generateSessionId()\n\tconst expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION\n\tdb.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [\n\t\tsessionId,\n\t\tresult.lastInsertRowid,\n\t\texpiresAt,\n\t])\n\n\tsetCookie(c, 'session', sessionId, {\n\t\thttpOnly: true,\n\t\tsameSite: 'Lax',\n\t\tmaxAge: SESSION_DURATION,\n\t\tpath: '/',\n\t})\n\n\tlog.info(`User registered: ${username}`)\n\treturn c.json({ id: result.lastInsertRowid, username }, 201)\n})\n\nauth.post('/login', async (c) => {\n\tconst ip = c.req.header('x-forwarded-for')?.split(',')[0] || 'unknown'\n\tconst rateCheck = checkRateLimit(`login:${ip}`)\n\tif (!rateCheck.allowed) {\n\t\treturn c.json({ error: `Too many attempts. Try again in ${rateCheck.retryAfter}s` }, 429)\n\t}\n\n\tconst body = await c.req.json<{ username: string; password: string }>()\n\tconst { username, password } = body\n\n\tconst user = db\n\t\t.query<User, [string]>('SELECT id, username, password_hash FROM users WHERE username = ?')\n\t\t.get(username)\n\n\tif (!user || !(await verifyPassword(password, user.password_hash))) {\n\t\tlog.warn(`Login failed for user: ${username} from ${ip}`)\n\t\treturn c.json({ error: 'Invalid credentials' }, 401)\n\t}\n\n\tresetRateLimit(`login:${ip}`)\n\n\tconst sessionId = generateSessionId()\n\tconst expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION\n\tdb.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [sessionId, user.id, expiresAt])\n\n\tsetCookie(c, 'session', sessionId, {\n\t\thttpOnly: true,\n\t\tsameSite: 'Lax',\n\t\tmaxAge: SESSION_DURATION,\n\t\tpath: '/',\n\t})\n\n\tlog.info(`User logged in: ${user.username}`)\n\treturn c.json({ id: user.id, username: user.username })\n})\n\nauth.post('/logout', async (c) => {\n\tconst sessionId = getCookie(c, 'session')\n\tif (sessionId) {\n\t\tdb.run('DELETE FROM sessions WHERE id = ?', [sessionId])\n\t}\n\tdeleteCookie(c, 'session', { path: '/' })\n\treturn c.json({ success: true })\n})\n\nauth.get('/me', authMiddleware, (c) => {\n\tconst user = c.get('user')\n\treturn c.json(user)\n})\n\nauth.post('/password', authMiddleware, async (c) => {\n\tconst user = c.get('user')\n\tconst body = await c.req.json<{ currentPassword: string; newPassword: string }>()\n\n\tconst passwordError = validatePassword(body.newPassword)\n\tif (passwordError) {\n\t\treturn c.json({ error: passwordError }, 400)\n\t}\n\n\tconst dbUser = db.query<User, [number]>('SELECT id, username, password_hash FROM users WHERE id = ?').get(user.id)\n\n\tif (!dbUser || !(await verifyPassword(body.currentPassword, dbUser.password_hash))) {\n\t\treturn c.json({ error: 'Current password is incorrect' }, 400)\n\t}\n\n\tconst newHash = await hashPassword(body.newPassword)\n\tdb.run('UPDATE users SET password_hash = ? WHERE id = ?', [newHash, user.id])\n\n\tconst currentSession = getCookie(c, 'session')\n\tif (currentSession) {\n\t\tdb.run('DELETE FROM sessions WHERE user_id = ? AND id != ?', [user.id, currentSession])\n\t}\n\n\treturn c.json({ success: true })\n})\n\nexport default auth\n"
  },
  {
    "path": "src/server/routes/crossSeed.ts",
    "content": "import { Hono } from 'hono'\nimport {\n\tdb,\n\ttype CrossSeedConfig,\n\ttype CrossSeedSearchee,\n\ttype CrossSeedDecision,\n\ttype User,\n\ttype Integration,\n\tMatchMode,\n} from '../db'\nimport { existsSync, accessSync, constants as fsConstants } from 'fs'\nimport { authMiddleware } from '../middleware/auth'\nimport {\n\tstartScheduler,\n\tstopScheduler,\n\tupdateInstanceSchedule,\n\ttriggerManualScan,\n\tgetSchedulerStatus,\n\tgetInstanceStatus,\n\tisInstanceRunning,\n\tstopScan,\n} from '../utils/crossSeedScheduler'\nimport { getLogs } from '../utils/logger'\nimport { clearCacheForInstance, clearOutputForInstance, getCacheStats, getOutputStats } from '../utils/crossSeedCache'\nimport { getTorznabIndexers } from '../utils/torznab'\nimport { decrypt } from '../utils/crypto'\n\nconst crossSeed = new Hono<{ Variables: { user: User } }>()\n\ncrossSeed.use('*', authMiddleware)\n\nfunction userOwnsInstance(userId: number, instanceId: number): boolean {\n\tconst instance = db\n\t\t.query<{ id: number }, [number, number]>('SELECT id FROM instances WHERE id = ? AND user_id = ?')\n\t\t.get(instanceId, userId)\n\treturn !!instance\n}\n\nfunction parseJsonArray<T>(json: string | null, guard: (v: unknown) => v is T): T[] {\n\tif (!json) return []\n\ttry {\n\t\tconst parsed = JSON.parse(json)\n\t\treturn Array.isArray(parsed) ? parsed.filter(guard) : []\n\t} catch {\n\t\treturn []\n\t}\n}\n\nfunction parseIndexerIds(json: string | null): number[] {\n\treturn parseJsonArray(json, (v): v is number => typeof v === 'number')\n}\n\nfunction parseBlocklist(json: string | null): string[] {\n\treturn parseJsonArray(json, (v): v is string => typeof v === 'string')\n}\n\ncrossSeed.get('/config/:instanceId', (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = parseInt(c.req.param('instanceId'))\n\tif (!userOwnsInstance(user.id, instanceId)) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tconst config = db\n\t\t.query<CrossSeedConfig, [number]>('SELECT * FROM cross_seed_config WHERE instance_id = ?')\n\t\t.get(instanceId)\n\n\tif (!config) {\n\t\treturn c.json({\n\t\t\tinstance_id: instanceId,\n\t\t\tenabled: false,\n\t\t\tinterval_hours: 24,\n\t\t\tdelay_seconds: 30,\n\t\t\tdry_run: true,\n\t\t\tcategory_suffix: '_cross-seed',\n\t\t\ttag: 'cross-seed',\n\t\t\tskip_recheck: false,\n\t\t\tintegration_id: null,\n\t\t\tindexer_ids: [],\n\t\t\tmatch_mode: MatchMode.STRICT,\n\t\t\tlink_dir: null,\n\t\t\tblocklist: [],\n\t\t\tinclude_single_episodes: false,\n\t\t\tlast_run: null,\n\t\t\tnext_run: null,\n\t\t})\n\t}\n\n\treturn c.json({\n\t\t...config,\n\t\tenabled: !!config.enabled,\n\t\tdry_run: !!config.dry_run,\n\t\tskip_recheck: !!config.skip_recheck,\n\t\tdelay_seconds: config.delay_seconds ?? 30,\n\t\tindexer_ids: parseIndexerIds(config.indexer_ids),\n\t\tmatch_mode: config.match_mode ?? MatchMode.STRICT,\n\t\tlink_dir: config.link_dir ?? null,\n\t\tblocklist: parseBlocklist(config.blocklist),\n\t\tinclude_single_episodes: !!config.include_single_episodes,\n\t})\n})\n\nfunction validateLinkDir(dir: string): { valid: boolean; writable: boolean } {\n\tif (!existsSync(dir)) return { valid: false, writable: false }\n\ttry {\n\t\taccessSync(dir, fsConstants.W_OK)\n\t\treturn { valid: true, writable: true }\n\t} catch {\n\t\treturn { valid: true, writable: false }\n\t}\n}\n\nfunction validateConfig(body: Record<string, unknown>): { valid: boolean; error?: string } {\n\tconst {\n\t\tinterval_hours,\n\t\tdelay_seconds,\n\t\tcategory_suffix,\n\t\ttag,\n\t\tintegration_id,\n\t\tindexer_ids,\n\t\tmatch_mode,\n\t\tlink_dir,\n\t\tblocklist,\n\t} = body\n\tif (interval_hours !== undefined) {\n\t\tconst hours = Number(interval_hours)\n\t\tif (isNaN(hours) || hours < 1 || hours > 168) {\n\t\t\treturn { valid: false, error: 'interval_hours must be between 1 and 168' }\n\t\t}\n\t}\n\tif (delay_seconds !== undefined) {\n\t\tconst secs = Number(delay_seconds)\n\t\tif (isNaN(secs) || secs < 30 || secs > 3600) {\n\t\t\treturn { valid: false, error: 'delay_seconds must be between 30 and 3600' }\n\t\t}\n\t}\n\tif (category_suffix !== undefined && typeof category_suffix !== 'string') {\n\t\treturn { valid: false, error: 'category_suffix must be a string' }\n\t}\n\tif (category_suffix && category_suffix.length > 50) {\n\t\treturn { valid: false, error: 'category_suffix too long (max 50)' }\n\t}\n\tif (tag !== undefined && typeof tag !== 'string') {\n\t\treturn { valid: false, error: 'tag must be a string' }\n\t}\n\tif (tag && tag.length > 100) {\n\t\treturn { valid: false, error: 'tag too long (max 100)' }\n\t}\n\tif (integration_id !== undefined && integration_id !== null && typeof integration_id !== 'number') {\n\t\treturn { valid: false, error: 'integration_id must be a number or null' }\n\t}\n\tif (indexer_ids !== undefined && !Array.isArray(indexer_ids)) {\n\t\treturn { valid: false, error: 'indexer_ids must be an array' }\n\t}\n\tif (indexer_ids && !indexer_ids.every((id: unknown) => typeof id === 'number')) {\n\t\treturn { valid: false, error: 'indexer_ids must contain only numbers' }\n\t}\n\tif (match_mode !== undefined && match_mode !== MatchMode.STRICT && match_mode !== MatchMode.FLEXIBLE) {\n\t\treturn { valid: false, error: 'match_mode must be strict or flexible' }\n\t}\n\tif (link_dir !== undefined && link_dir !== null && typeof link_dir !== 'string') {\n\t\treturn { valid: false, error: 'link_dir must be a string or null' }\n\t}\n\tif (link_dir && link_dir.length > 500) {\n\t\treturn { valid: false, error: 'link_dir too long (max 500)' }\n\t}\n\tif (blocklist !== undefined && !Array.isArray(blocklist)) {\n\t\treturn { valid: false, error: 'blocklist must be an array' }\n\t}\n\tif (blocklist && !blocklist.every((s: unknown) => typeof s === 'string')) {\n\t\treturn { valid: false, error: 'blocklist must contain only strings' }\n\t}\n\treturn { valid: true }\n}\n\ncrossSeed.put('/config/:instanceId', async (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = parseInt(c.req.param('instanceId'))\n\tif (!userOwnsInstance(user.id, instanceId)) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tconst body = await c.req.json()\n\tconst validation = validateConfig(body)\n\tif (!validation.valid) {\n\t\treturn c.json({ error: validation.error }, 400)\n\t}\n\n\tconst {\n\t\tenabled,\n\t\tinterval_hours,\n\t\tdelay_seconds,\n\t\tdry_run,\n\t\tcategory_suffix,\n\t\ttag,\n\t\tskip_recheck,\n\t\tintegration_id,\n\t\tindexer_ids,\n\t\tmatch_mode,\n\t\tlink_dir,\n\t\tblocklist,\n\t\tinclude_single_episodes,\n\t} = body\n\tconst indexerIdsJson = indexer_ids ? JSON.stringify(indexer_ids) : null\n\tconst blocklistJson = blocklist ? JSON.stringify(blocklist) : null\n\tconst linkDirValue = link_dir?.trim() || null\n\tlet linkDirValid: boolean | undefined\n\n\tif (linkDirValue) {\n\t\tconst check = validateLinkDir(linkDirValue)\n\t\tlinkDirValid = check.valid && check.writable\n\t}\n\n\tconst existing = db\n\t\t.query<{ instance_id: number }, [number]>('SELECT instance_id FROM cross_seed_config WHERE instance_id = ?')\n\t\t.get(instanceId)\n\n\tif (existing) {\n\t\tdb.run(\n\t\t\t`UPDATE cross_seed_config SET\n\t\t\t\tenabled = ?, interval_hours = ?, delay_seconds = ?, dry_run = ?, category_suffix = ?,\n\t\t\t\ttag = ?, skip_recheck = ?, integration_id = ?, indexer_ids = ?, match_mode = ?, link_dir = ?,\n\t\t\t\tblocklist = ?, include_single_episodes = ?, updated_at = unixepoch()\n\t\t\tWHERE instance_id = ?`,\n\t\t\t[\n\t\t\t\tenabled ? 1 : 0,\n\t\t\t\tinterval_hours ?? 24,\n\t\t\t\tdelay_seconds ?? 30,\n\t\t\t\tdry_run ? 1 : 0,\n\t\t\t\tcategory_suffix ?? '_cross-seed',\n\t\t\t\ttag ?? 'cross-seed',\n\t\t\t\tskip_recheck ? 1 : 0,\n\t\t\t\tintegration_id ?? null,\n\t\t\t\tindexerIdsJson,\n\t\t\t\tmatch_mode ?? MatchMode.STRICT,\n\t\t\t\tlinkDirValue,\n\t\t\t\tblocklistJson,\n\t\t\t\tinclude_single_episodes ? 1 : 0,\n\t\t\t\tinstanceId,\n\t\t\t]\n\t\t)\n\t} else {\n\t\tdb.run(\n\t\t\t`INSERT INTO cross_seed_config\n\t\t\t\t(instance_id, enabled, interval_hours, delay_seconds, dry_run, category_suffix, tag, skip_recheck, integration_id, indexer_ids, match_mode, link_dir, blocklist, include_single_episodes)\n\t\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t\t[\n\t\t\t\tinstanceId,\n\t\t\t\tenabled ? 1 : 0,\n\t\t\t\tinterval_hours ?? 24,\n\t\t\t\tdelay_seconds ?? 30,\n\t\t\t\tdry_run ? 1 : 0,\n\t\t\t\tcategory_suffix ?? '_cross-seed',\n\t\t\t\ttag ?? 'cross-seed',\n\t\t\t\tskip_recheck ? 1 : 0,\n\t\t\t\tintegration_id ?? null,\n\t\t\t\tindexerIdsJson,\n\t\t\t\tmatch_mode ?? MatchMode.STRICT,\n\t\t\t\tlinkDirValue,\n\t\t\t\tblocklistJson,\n\t\t\t\tinclude_single_episodes ? 1 : 0,\n\t\t\t]\n\t\t)\n\t}\n\n\tupdateInstanceSchedule(instanceId, !!enabled)\n\n\treturn c.json({ success: true, linkDirValid })\n})\n\ncrossSeed.get('/indexers/:instanceId', async (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = parseInt(c.req.param('instanceId'))\n\tif (!userOwnsInstance(user.id, instanceId)) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tconst integrationIdParam = c.req.query('integrationId')\n\tlet integrationId: number | null = integrationIdParam ? parseInt(integrationIdParam) : null\n\n\tif (!integrationId) {\n\t\tconst config = db\n\t\t\t.query<CrossSeedConfig, [number]>('SELECT * FROM cross_seed_config WHERE instance_id = ?')\n\t\t\t.get(instanceId)\n\t\tintegrationId = config?.integration_id ?? null\n\t}\n\n\tif (!integrationId) {\n\t\treturn c.json({ error: 'No Prowlarr integration configured' }, 400)\n\t}\n\n\tconst integration = db\n\t\t.query<Integration, [number, number]>('SELECT * FROM integrations WHERE id = ? AND user_id = ?')\n\t\t.get(integrationId, user.id)\n\n\tif (!integration) {\n\t\treturn c.json({ error: 'Integration not found' }, 404)\n\t}\n\n\ttry {\n\t\tconst apiKey = decrypt(integration.api_key_encrypted)\n\t\tconst indexers = await getTorznabIndexers(integration.url, apiKey)\n\t\treturn c.json(indexers)\n\t} catch (e) {\n\t\treturn c.json({ error: e instanceof Error ? e.message : 'Failed to fetch indexers' }, 500)\n\t}\n})\n\ncrossSeed.post('/scan/:instanceId', async (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = parseInt(c.req.param('instanceId'))\n\tif (!userOwnsInstance(user.id, instanceId)) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tif (isInstanceRunning(instanceId)) {\n\t\treturn c.json({ error: 'Scan already in progress' }, 409)\n\t}\n\n\tconst body = await c.req.json().catch(() => ({}))\n\tconst force = body.force === true\n\n\ttry {\n\t\tconst result = await triggerManualScan(instanceId, user.id, force)\n\t\treturn c.json(result)\n\t} catch (e) {\n\t\treturn c.json({ error: e instanceof Error ? e.message : 'Scan failed' }, 500)\n\t}\n})\n\ncrossSeed.post('/stop/:instanceId', (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = parseInt(c.req.param('instanceId'))\n\tif (!userOwnsInstance(user.id, instanceId)) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tif (!isInstanceRunning(instanceId)) {\n\t\treturn c.json({ error: 'No scan running' }, 400)\n\t}\n\n\tconst stopped = stopScan(instanceId)\n\treturn c.json({ stopped })\n})\n\ncrossSeed.get('/logs', (c) => {\n\tconst limit = parseInt(c.req.query('limit') || '100')\n\tconst logs = getLogs('[CrossSeed', limit)\n\treturn c.json(logs)\n})\n\ncrossSeed.get('/status', (c) => {\n\tconst user = c.get('user')\n\tconst statuses = getSchedulerStatus().filter((s) => userOwnsInstance(user.id, s.instanceId))\n\treturn c.json(statuses)\n})\n\ncrossSeed.get('/status/:instanceId', (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = parseInt(c.req.param('instanceId'))\n\tif (!userOwnsInstance(user.id, instanceId)) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tconst status = getInstanceStatus(instanceId)\n\tif (!status) {\n\t\treturn c.json({\n\t\t\tinstanceId,\n\t\t\tenabled: false,\n\t\t\trunning: false,\n\t\t\tlastRun: null,\n\t\t\tnextRun: null,\n\t\t\tlastResult: null,\n\t\t})\n\t}\n\treturn c.json(status)\n})\n\ncrossSeed.post('/cache/:instanceId/clear', (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = parseInt(c.req.param('instanceId'))\n\tif (!userOwnsInstance(user.id, instanceId)) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tconst cacheCleared = clearCacheForInstance(instanceId)\n\tconst outputCleared = clearOutputForInstance(instanceId)\n\n\treturn c.json({ cacheCleared, outputCleared })\n})\n\ncrossSeed.get('/cache/:instanceId/stats', (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = parseInt(c.req.param('instanceId'))\n\tif (!userOwnsInstance(user.id, instanceId)) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tconst cache = getCacheStats(instanceId)\n\tconst output = getOutputStats(instanceId)\n\n\treturn c.json({ cache, output })\n})\n\ncrossSeed.get('/history/:instanceId', (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = parseInt(c.req.param('instanceId'))\n\tif (!userOwnsInstance(user.id, instanceId)) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tconst limit = parseInt(c.req.query('limit') || '100')\n\tconst offset = parseInt(c.req.query('offset') || '0')\n\n\tconst searchees = db\n\t\t.query<CrossSeedSearchee & { decision_count: number }, [number, number, number]>(\n\t\t\t`\n\t\tSELECT s.*, COUNT(d.id) as decision_count\n\t\tFROM cross_seed_searchee s\n\t\tLEFT JOIN cross_seed_decision d ON s.id = d.searchee_id\n\t\tWHERE s.instance_id = ?\n\t\tGROUP BY s.id\n\t\tORDER BY s.last_searched DESC\n\t\tLIMIT ? OFFSET ?\n\t`\n\t\t)\n\t\t.all(instanceId, limit, offset)\n\n\tconst total = db\n\t\t.query<{ count: number }, [number]>('SELECT COUNT(*) as count FROM cross_seed_searchee WHERE instance_id = ?')\n\t\t.get(instanceId)\n\n\treturn c.json({ searchees, total: total?.count ?? 0 })\n})\n\ncrossSeed.get('/history/:instanceId/:searcheeId/decisions', (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = parseInt(c.req.param('instanceId'))\n\tconst searcheeId = parseInt(c.req.param('searcheeId'))\n\tif (!userOwnsInstance(user.id, instanceId)) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tconst searchee = db\n\t\t.query<{ id: number }, [number, number]>('SELECT id FROM cross_seed_searchee WHERE id = ? AND instance_id = ?')\n\t\t.get(searcheeId, instanceId)\n\tif (!searchee) {\n\t\treturn c.json({ error: 'Searchee not found' }, 404)\n\t}\n\n\tconst decisions = db\n\t\t.query<\n\t\t\tCrossSeedDecision,\n\t\t\t[number]\n\t\t>('SELECT * FROM cross_seed_decision WHERE searchee_id = ? ORDER BY last_seen DESC')\n\t\t.all(searcheeId)\n\n\treturn c.json(decisions)\n})\n\nexport { crossSeed, startScheduler, stopScheduler }\n"
  },
  {
    "path": "src/server/routes/files.ts",
    "content": "import { Hono } from 'hono'\nimport { authMiddleware } from '../middleware/auth'\nimport { readdir, stat, rm, rename, cp, writeFile, unlink } from 'node:fs/promises'\nimport { join, resolve, basename, dirname } from 'node:path'\nimport { createReadStream } from 'node:fs'\nimport * as tar from 'tar-stream'\n\nconst files = new Hono()\nconst DOWNLOADS_PATH = process.env.DOWNLOADS_PATH\nlet writableCache: boolean | null = null\n\nfiles.use('*', authMiddleware)\n\nfunction isPathSafe(requestedPath: string): string | null {\n\tif (!DOWNLOADS_PATH) return null\n\tconst base = resolve(DOWNLOADS_PATH)\n\tconst resolved = resolve(base, requestedPath.replace(/^\\/+/, ''))\n\tif (resolved !== base && !resolved.startsWith(base + '/')) return null\n\treturn resolved\n}\n\nfunction sanitizeFilename(name: string): string {\n\treturn name.replace(/[\"\\r\\n]/g, '_')\n}\n\ninterface FileEntry {\n\tname: string\n\tsize: number\n\tisDirectory: boolean\n\tmodified: number\n}\n\nfiles.get('/', async (c) => {\n\tif (!DOWNLOADS_PATH) {\n\t\treturn c.json({ error: 'File browser not configured' }, 404)\n\t}\n\n\tconst requestedPath = c.req.query('path') || '/'\n\tconst safePath = isPathSafe(requestedPath)\n\tif (!safePath) {\n\t\treturn c.json({ error: 'Invalid path' }, 400)\n\t}\n\n\ttry {\n\t\tconst entries = await readdir(safePath)\n\t\tconst result: FileEntry[] = []\n\n\t\tfor (const name of entries) {\n\t\t\ttry {\n\t\t\t\tconst fullPath = join(safePath, name)\n\t\t\t\tconst stats = await stat(fullPath)\n\t\t\t\tresult.push({\n\t\t\t\t\tname,\n\t\t\t\t\tsize: Number(stats.size),\n\t\t\t\t\tisDirectory: stats.isDirectory(),\n\t\t\t\t\tmodified: stats.mtimeMs,\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tresult.sort((a, b) => {\n\t\t\tif (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1\n\t\t\treturn a.name.localeCompare(b.name)\n\t\t})\n\n\t\treturn c.json(result)\n\t} catch (e) {\n\t\tif ((e as NodeJS.ErrnoException).code === 'ENOENT') {\n\t\t\treturn c.json({ error: 'Path not found' }, 404)\n\t\t}\n\t\tif ((e as NodeJS.ErrnoException).code === 'ENOTDIR') {\n\t\t\treturn c.json({ error: 'Not a directory' }, 400)\n\t\t}\n\t\treturn c.json({ error: 'Failed to list directory' }, 500)\n\t}\n})\n\nasync function* walkDir(\n\tdir: string,\n\tbase: string\n): AsyncGenerator<{ path: string; fullPath: string; stats: Awaited<ReturnType<typeof stat>> }> {\n\tconst entries = await readdir(dir)\n\tfor (const name of entries) {\n\t\tconst fullPath = join(dir, name)\n\t\tconst relativePath = join(base, name)\n\t\ttry {\n\t\t\tconst stats = await stat(fullPath)\n\t\t\tif (stats.isDirectory()) {\n\t\t\t\tyield* walkDir(fullPath, relativePath)\n\t\t\t} else {\n\t\t\t\tyield { path: relativePath, fullPath, stats }\n\t\t\t}\n\t\t} catch {\n\t\t\tcontinue\n\t\t}\n\t}\n}\n\nfiles.get('/download', async (c) => {\n\tif (!DOWNLOADS_PATH) {\n\t\treturn c.json({ error: 'File browser not configured' }, 404)\n\t}\n\n\tconst requestedPath = c.req.query('path')\n\tif (!requestedPath) {\n\t\treturn c.json({ error: 'Path is required' }, 400)\n\t}\n\n\tconst safePath = isPathSafe(requestedPath)\n\tif (!safePath) {\n\t\treturn c.json({ error: 'Invalid path' }, 400)\n\t}\n\n\ttry {\n\t\tconst stats = await stat(safePath)\n\t\tconst name = basename(safePath)\n\n\t\tif (stats.isDirectory()) {\n\t\t\tconst pack = tar.pack()\n\t\t\tconst chunks: Buffer[] = []\n\t\t\tlet streamEnded = false\n\t\t\tlet streamError: Error | null = null\n\t\t\tlet resolveWait: (() => void) | null = null\n\n\t\t\tpack.on('data', (chunk: Buffer) => {\n\t\t\t\tchunks.push(chunk)\n\t\t\t\tif (resolveWait) {\n\t\t\t\t\tresolveWait()\n\t\t\t\t\tresolveWait = null\n\t\t\t\t}\n\t\t\t})\n\t\t\tpack.on('end', () => {\n\t\t\t\tstreamEnded = true\n\t\t\t\tif (resolveWait) {\n\t\t\t\t\tresolveWait()\n\t\t\t\t\tresolveWait = null\n\t\t\t\t}\n\t\t\t})\n\t\t\tpack.on('error', (err: Error) => {\n\t\t\t\tstreamError = err\n\t\t\t\tif (resolveWait) {\n\t\t\t\t\tresolveWait()\n\t\t\t\t\tresolveWait = null\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tconst streamFiles = async () => {\n\t\t\t\tfor await (const file of walkDir(safePath, '')) {\n\t\t\t\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\t\t\t\tconst entry = pack.entry(\n\t\t\t\t\t\t\t{ name: file.path, size: Number(file.stats.size), mtime: file.stats.mtime },\n\t\t\t\t\t\t\t(err) => {\n\t\t\t\t\t\t\t\tif (err) reject(err)\n\t\t\t\t\t\t\t\telse resolve()\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t)\n\t\t\t\t\t\tconst stream = createReadStream(file.fullPath)\n\t\t\t\t\t\tstream.pipe(entry)\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tpack.finalize()\n\t\t\t}\n\t\t\tstreamFiles().catch(() => pack.destroy())\n\n\t\t\tconst webStream = new ReadableStream({\n\t\t\t\tasync pull(controller) {\n\t\t\t\t\twhile (chunks.length === 0 && !streamEnded && !streamError) {\n\t\t\t\t\t\tawait new Promise<void>((r) => {\n\t\t\t\t\t\t\tresolveWait = r\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\tif (streamError) {\n\t\t\t\t\t\tcontroller.error(streamError)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\twhile (chunks.length > 0) {\n\t\t\t\t\t\tcontroller.enqueue(chunks.shift()!)\n\t\t\t\t\t}\n\t\t\t\t\tif (streamEnded) {\n\t\t\t\t\t\tcontroller.close()\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tcancel() {\n\t\t\t\t\tpack.destroy()\n\t\t\t\t},\n\t\t\t})\n\n\t\t\treturn new Response(webStream, {\n\t\t\t\theaders: {\n\t\t\t\t\t'Content-Type': 'application/x-tar',\n\t\t\t\t\t'Content-Disposition': `attachment; filename=\"${sanitizeFilename(name)}.tar\"`,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\n\t\tconst file = Bun.file(safePath)\n\t\treturn new Response(file, {\n\t\t\theaders: {\n\t\t\t\t'Content-Disposition': `attachment; filename=\"${sanitizeFilename(name)}\"`,\n\t\t\t},\n\t\t})\n\t} catch (e) {\n\t\tconsole.error('Download error:', e)\n\t\tif ((e as NodeJS.ErrnoException).code === 'ENOENT') {\n\t\t\treturn c.json({ error: 'File not found' }, 404)\n\t\t}\n\t\treturn c.json({ error: 'Failed to download file' }, 500)\n\t}\n})\n\nfiles.get('/writable', async (c) => {\n\tif (!DOWNLOADS_PATH) {\n\t\treturn c.json({ writable: false })\n\t}\n\tif (writableCache !== null) {\n\t\treturn c.json({ writable: writableCache })\n\t}\n\tconst testFile = join(DOWNLOADS_PATH, `.write-test-${Date.now()}`)\n\ttry {\n\t\tawait writeFile(testFile, '')\n\t\tawait unlink(testFile)\n\t\twritableCache = true\n\t} catch {\n\t\twritableCache = false\n\t}\n\treturn c.json({ writable: writableCache })\n})\n\nfiles.post('/delete', async (c) => {\n\tif (!DOWNLOADS_PATH) {\n\t\treturn c.json({ error: 'File browser not configured' }, 404)\n\t}\n\tconst body = await c.req.json<{ paths: string[] }>()\n\tif (!Array.isArray(body.paths) || body.paths.length === 0) {\n\t\treturn c.json({ error: 'Paths array is required' }, 400)\n\t}\n\tconst errors: string[] = []\n\tfor (const path of body.paths) {\n\t\tconst safePath = isPathSafe(path)\n\t\tif (!safePath) {\n\t\t\terrors.push(`Invalid path: ${path}`)\n\t\t\tcontinue\n\t\t}\n\t\tif (safePath === resolve(DOWNLOADS_PATH)) {\n\t\t\terrors.push('Cannot delete root directory')\n\t\t\tcontinue\n\t\t}\n\t\ttry {\n\t\t\tawait rm(safePath, { recursive: true })\n\t\t} catch (e) {\n\t\t\terrors.push(`Failed to delete ${path}: ${(e as Error).message}`)\n\t\t}\n\t}\n\tif (errors.length > 0) {\n\t\treturn c.json({ error: errors.join('; ') }, 400)\n\t}\n\treturn c.json({ success: true })\n})\n\nfiles.post('/move', async (c) => {\n\tif (!DOWNLOADS_PATH) {\n\t\treturn c.json({ error: 'File browser not configured' }, 404)\n\t}\n\tconst body = await c.req.json<{ paths: string[]; destination: string }>()\n\tif (!Array.isArray(body.paths) || body.paths.length === 0) {\n\t\treturn c.json({ error: 'Paths array is required' }, 400)\n\t}\n\tif (!body.destination) {\n\t\treturn c.json({ error: 'Destination is required' }, 400)\n\t}\n\tconst destPath = isPathSafe(body.destination)\n\tif (!destPath) {\n\t\treturn c.json({ error: 'Invalid destination' }, 400)\n\t}\n\ttry {\n\t\tconst destStat = await stat(destPath)\n\t\tif (!destStat.isDirectory()) {\n\t\t\treturn c.json({ error: 'Destination must be a directory' }, 400)\n\t\t}\n\t} catch {\n\t\treturn c.json({ error: 'Destination does not exist' }, 400)\n\t}\n\tconst errors: string[] = []\n\tfor (const path of body.paths) {\n\t\tconst safePath = isPathSafe(path)\n\t\tif (!safePath) {\n\t\t\terrors.push(`Invalid path: ${path}`)\n\t\t\tcontinue\n\t\t}\n\t\tconst name = basename(safePath)\n\t\tconst newPath = join(destPath, name)\n\t\ttry {\n\t\t\tawait rename(safePath, newPath)\n\t\t} catch (e) {\n\t\t\terrors.push(`Failed to move ${path}: ${(e as Error).message}`)\n\t\t}\n\t}\n\tif (errors.length > 0) {\n\t\treturn c.json({ error: errors.join('; ') }, 400)\n\t}\n\treturn c.json({ success: true })\n})\n\nfiles.post('/copy', async (c) => {\n\tif (!DOWNLOADS_PATH) {\n\t\treturn c.json({ error: 'File browser not configured' }, 404)\n\t}\n\tconst body = await c.req.json<{ paths: string[]; destination: string }>()\n\tif (!Array.isArray(body.paths) || body.paths.length === 0) {\n\t\treturn c.json({ error: 'Paths array is required' }, 400)\n\t}\n\tif (!body.destination) {\n\t\treturn c.json({ error: 'Destination is required' }, 400)\n\t}\n\tconst destPath = isPathSafe(body.destination)\n\tif (!destPath) {\n\t\treturn c.json({ error: 'Invalid destination' }, 400)\n\t}\n\ttry {\n\t\tconst destStat = await stat(destPath)\n\t\tif (!destStat.isDirectory()) {\n\t\t\treturn c.json({ error: 'Destination must be a directory' }, 400)\n\t\t}\n\t} catch {\n\t\treturn c.json({ error: 'Destination does not exist' }, 400)\n\t}\n\tconst errors: string[] = []\n\tfor (const path of body.paths) {\n\t\tconst safePath = isPathSafe(path)\n\t\tif (!safePath) {\n\t\t\terrors.push(`Invalid path: ${path}`)\n\t\t\tcontinue\n\t\t}\n\t\tconst name = basename(safePath)\n\t\tconst newPath = join(destPath, name)\n\t\ttry {\n\t\t\tawait cp(safePath, newPath, { recursive: true })\n\t\t} catch (e) {\n\t\t\terrors.push(`Failed to copy ${path}: ${(e as Error).message}`)\n\t\t}\n\t}\n\tif (errors.length > 0) {\n\t\treturn c.json({ error: errors.join('; ') }, 400)\n\t}\n\treturn c.json({ success: true })\n})\n\nfiles.post('/rename', async (c) => {\n\tif (!DOWNLOADS_PATH) {\n\t\treturn c.json({ error: 'File browser not configured' }, 404)\n\t}\n\tconst body = await c.req.json<{ path: string; newName: string }>()\n\tif (!body.path || !body.newName) {\n\t\treturn c.json({ error: 'Path and newName are required' }, 400)\n\t}\n\tif (body.newName.includes('/') || body.newName.includes('\\\\') || body.newName === '.' || body.newName === '..') {\n\t\treturn c.json({ error: 'Invalid file name' }, 400)\n\t}\n\tconst safePath = isPathSafe(body.path)\n\tif (!safePath) {\n\t\treturn c.json({ error: 'Invalid path' }, 400)\n\t}\n\tif (safePath === resolve(DOWNLOADS_PATH)) {\n\t\treturn c.json({ error: 'Cannot rename root directory' }, 400)\n\t}\n\tconst dir = dirname(safePath)\n\tconst newPath = join(dir, body.newName)\n\tif (!newPath.startsWith(resolve(DOWNLOADS_PATH))) {\n\t\treturn c.json({ error: 'Invalid new name' }, 400)\n\t}\n\ttry {\n\t\tawait rename(safePath, newPath)\n\t\treturn c.json({ success: true })\n\t} catch (e) {\n\t\treturn c.json({ error: `Failed to rename: ${(e as Error).message}` }, 500)\n\t}\n})\n\nexport default files\n"
  },
  {
    "path": "src/server/routes/instances.ts",
    "content": "import { Hono } from 'hono'\nimport { db, type Instance } from '../db'\nimport { encrypt } from '../utils/crypto'\nimport { validateUrl } from '../utils/url'\nimport { loginToQbt, testQbtConnection, testStoredQbtInstance } from '../utils/qbt'\nimport { authMiddleware } from '../middleware/auth'\nimport { clearQbtSession } from './proxy'\nimport { fetchWithTls } from '../utils/fetch'\n\nconst instances = new Hono()\n\ninterface InstanceBackoff {\n\tnextRetry: number\n\tattempt: number\n}\n\nconst instanceBackoff = new Map<number, InstanceBackoff>()\nconst BACKOFF_DELAYS = [10000, 15000, 30000]\nconst INITIAL_TIMEOUT = 3000\n\ninstances.use('*', authMiddleware)\n\ninterface InstanceResponse {\n\tid: number\n\tlabel: string\n\turl: string\n\tqbt_username: string | null\n\tskip_auth: boolean\n\tagent_enabled: boolean\n\tagent_url: string | null\n\tcreated_at: number\n}\n\nfunction toResponse(i: Instance): InstanceResponse {\n\treturn {\n\t\tid: i.id,\n\t\tlabel: i.label,\n\t\turl: i.url,\n\t\tqbt_username: i.qbt_username,\n\t\tskip_auth: !!i.skip_auth,\n\t\tagent_enabled: !!i.agent_enabled,\n\t\tagent_url: i.agent_url,\n\t\tcreated_at: i.created_at,\n\t}\n}\n\ninstances.get('/', (c) => {\n\tconst user = c.get('user')\n\tconst list = db\n\t\t.query<Instance, [number]>('SELECT * FROM instances WHERE user_id = ? ORDER BY created_at')\n\t\t.all(user.id)\n\treturn c.json(list.map(toResponse))\n})\n\ninterface TorrentInfo {\n\tstate: string\n\tdownloaded: number\n\tuploaded: number\n}\n\ninterface TransferInfo {\n\tdl_info_speed: number\n\tup_info_speed: number\n}\n\ninterface SyncMaindata {\n\tserver_state: {\n\t\talltime_dl: number\n\t\talltime_ul: number\n\t\tfree_space_on_disk: number\n\t}\n}\n\ninterface InstanceStats {\n\tid: number\n\tlabel: string\n\tonline: boolean\n\ttotal: number\n\tdownloading: number\n\tseeding: number\n\tpaused: number\n\terror: number\n\tdlSpeed: number\n\tupSpeed: number\n\tallTimeDownload: number\n\tallTimeUpload: number\n\tfreeSpaceOnDisk: number\n}\n\nasync function fetchInstanceStats(instance: Instance): Promise<InstanceStats> {\n\tconst base: InstanceStats = {\n\t\tid: instance.id,\n\t\tlabel: instance.label,\n\t\tonline: false,\n\t\ttotal: 0,\n\t\tdownloading: 0,\n\t\tseeding: 0,\n\t\tpaused: 0,\n\t\terror: 0,\n\t\tdlSpeed: 0,\n\t\tupSpeed: 0,\n\t\tallTimeDownload: 0,\n\t\tallTimeUpload: 0,\n\t\tfreeSpaceOnDisk: 0,\n\t}\n\n\tconst backoff = instanceBackoff.get(instance.id)\n\tif (backoff && Date.now() < backoff.nextRetry) {\n\t\treturn base\n\t}\n\n\tconst setBackoff = () => {\n\t\tconst attempt = backoff ? Math.min(backoff.attempt + 1, BACKOFF_DELAYS.length - 1) : 0\n\t\tinstanceBackoff.set(instance.id, {\n\t\t\tnextRetry: Date.now() + BACKOFF_DELAYS[attempt],\n\t\t\tattempt,\n\t\t})\n\t}\n\n\ttry {\n\t\tconst loginResult = await loginToQbt(instance, INITIAL_TIMEOUT)\n\t\tif (!loginResult.success) {\n\t\t\tsetBackoff()\n\t\t\treturn base\n\t\t}\n\n\t\tconst headers: Record<string, string> = {}\n\t\tif (loginResult.cookie) headers.Cookie = loginResult.cookie\n\t\tconst [torrentsRes, transferRes, syncRes] = await Promise.all([\n\t\t\tfetchWithTls(`${instance.url}/api/v2/torrents/info`, { headers, signal: AbortSignal.timeout(INITIAL_TIMEOUT) }),\n\t\t\tfetchWithTls(`${instance.url}/api/v2/transfer/info`, { headers, signal: AbortSignal.timeout(INITIAL_TIMEOUT) }),\n\t\t\tfetchWithTls(`${instance.url}/api/v2/sync/maindata?rid=0`, {\n\t\t\t\theaders,\n\t\t\t\tsignal: AbortSignal.timeout(INITIAL_TIMEOUT),\n\t\t\t}),\n\t\t])\n\n\t\tif (!torrentsRes.ok || !transferRes.ok || !syncRes.ok) {\n\t\t\tsetBackoff()\n\t\t\treturn base\n\t\t}\n\n\t\tconst torrents = (await torrentsRes.json()) as TorrentInfo[]\n\t\tconst transfer = (await transferRes.json()) as TransferInfo\n\t\tconst sync = (await syncRes.json()) as SyncMaindata\n\n\t\tinstanceBackoff.delete(instance.id)\n\t\tbase.online = true\n\t\tbase.total = torrents.length\n\t\tbase.dlSpeed = transfer.dl_info_speed\n\t\tbase.upSpeed = transfer.up_info_speed\n\t\tbase.allTimeDownload = sync.server_state.alltime_dl\n\t\tbase.allTimeUpload = sync.server_state.alltime_ul\n\t\tbase.freeSpaceOnDisk = sync.server_state.free_space_on_disk\n\n\t\tfor (const t of torrents) {\n\t\t\tif (t.state === 'pausedUP' || t.state === 'pausedDL' || t.state === 'stoppedUP' || t.state === 'stoppedDL') {\n\t\t\t\tbase.paused++\n\t\t\t} else if (t.state === 'error' || t.state === 'missingFiles') {\n\t\t\t\tbase.error++\n\t\t\t} else if (t.state.includes('UP') || t.state === 'uploading') {\n\t\t\t\tbase.seeding++\n\t\t\t} else if (t.state.includes('DL') || t.state === 'downloading' || t.state === 'metaDL') {\n\t\t\t\tbase.downloading++\n\t\t\t}\n\t\t}\n\n\t\treturn base\n\t} catch {\n\t\tsetBackoff()\n\t\treturn base\n\t}\n}\n\ninstances.get('/stats', async (c) => {\n\tconst user = c.get('user')\n\tconst list = db\n\t\t.query<Instance, [number]>('SELECT * FROM instances WHERE user_id = ? ORDER BY created_at')\n\t\t.all(user.id)\n\n\tconst stats = await Promise.all(list.map(fetchInstanceStats))\n\treturn c.json(stats)\n})\n\ninstances.post('/', async (c) => {\n\tconst user = c.get('user')\n\tconst body = await c.req.json<{\n\t\tlabel: string\n\t\turl: string\n\t\tqbt_username?: string\n\t\tqbt_password?: string\n\t\tskip_auth?: boolean\n\t\tagent_enabled?: boolean\n\t\tagent_url?: string\n\t}>()\n\n\tif (!body.label || !body.url) {\n\t\treturn c.json({ error: 'Missing required fields' }, 400)\n\t}\n\n\tif (!body.skip_auth && (!body.qbt_username || !body.qbt_password)) {\n\t\treturn c.json({ error: 'Credentials required when skip_auth is disabled' }, 400)\n\t}\n\n\ttry {\n\t\tvalidateUrl(body.url)\n\t} catch (e) {\n\t\treturn c.json({ error: e instanceof Error ? e.message : 'Invalid URL' }, 400)\n\t}\n\n\tconst encrypted = body.qbt_password ? encrypt(body.qbt_password) : null\n\n\ttry {\n\t\tconst result = db.run(\n\t\t\t`INSERT INTO instances (user_id, label, url, qbt_username, qbt_password_encrypted, skip_auth, agent_enabled, agent_url)\n\t\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t\t[\n\t\t\t\tuser.id,\n\t\t\t\tbody.label,\n\t\t\t\tbody.url,\n\t\t\t\tbody.qbt_username || null,\n\t\t\t\tencrypted,\n\t\t\t\tbody.skip_auth ? 1 : 0,\n\t\t\t\tbody.agent_enabled ? 1 : 0,\n\t\t\t\tbody.agent_url || null,\n\t\t\t]\n\t\t)\n\n\t\tconst instance = db\n\t\t\t.query<Instance, [number]>('SELECT * FROM instances WHERE id = ?')\n\t\t\t.get(Number(result.lastInsertRowid))\n\n\t\tif (!instance) {\n\t\t\treturn c.json({ error: 'Failed to create instance' }, 500)\n\t\t}\n\n\t\treturn c.json(toResponse(instance), 201)\n\t} catch (e: unknown) {\n\t\tif (e instanceof Error && e.message.includes('UNIQUE')) {\n\t\t\treturn c.json({ error: 'Instance with this label already exists' }, 400)\n\t\t}\n\t\tthrow e\n\t}\n})\n\ninstances.put('/:id', async (c) => {\n\tconst user = c.get('user')\n\tconst id = Number(c.req.param('id'))\n\tconst body = await c.req.json<{\n\t\tlabel?: string\n\t\turl?: string\n\t\tqbt_username?: string\n\t\tqbt_password?: string\n\t\tskip_auth?: boolean\n\t\tagent_enabled?: boolean\n\t\tagent_url?: string | null\n\t}>()\n\n\tconst existing = db\n\t\t.query<Instance, [number, number]>('SELECT * FROM instances WHERE id = ? AND user_id = ?')\n\t\t.get(id, user.id)\n\n\tif (!existing) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tconst updates: string[] = []\n\tconst values: (string | number | null)[] = []\n\n\tif (body.label !== undefined) {\n\t\tupdates.push('label = ?')\n\t\tvalues.push(body.label)\n\t}\n\tif (body.url !== undefined) {\n\t\ttry {\n\t\t\tvalidateUrl(body.url)\n\t\t} catch (e) {\n\t\t\treturn c.json({ error: e instanceof Error ? e.message : 'Invalid URL' }, 400)\n\t\t}\n\t\tupdates.push('url = ?')\n\t\tvalues.push(body.url)\n\t}\n\tif (body.qbt_username !== undefined) {\n\t\tupdates.push('qbt_username = ?')\n\t\tvalues.push(body.qbt_username)\n\t}\n\tif (body.qbt_password !== undefined) {\n\t\tupdates.push('qbt_password_encrypted = ?')\n\t\tvalues.push(encrypt(body.qbt_password))\n\t}\n\tif (body.skip_auth !== undefined) {\n\t\tupdates.push('skip_auth = ?')\n\t\tvalues.push(body.skip_auth ? 1 : 0)\n\t}\n\tif (body.agent_enabled !== undefined) {\n\t\tupdates.push('agent_enabled = ?')\n\t\tvalues.push(body.agent_enabled ? 1 : 0)\n\t}\n\tif (body.agent_url !== undefined) {\n\t\tupdates.push('agent_url = ?')\n\t\tvalues.push(body.agent_url || null)\n\t}\n\n\tif (updates.length > 0) {\n\t\tvalues.push(id)\n\t\tdb.run(`UPDATE instances SET ${updates.join(', ')} WHERE id = ?`, values)\n\t}\n\n\tconst updated = db.query<Instance, [number]>('SELECT * FROM instances WHERE id = ?').get(id)\n\n\tif (!updated) {\n\t\treturn c.json({ error: 'Failed to update instance' }, 500)\n\t}\n\n\treturn c.json(toResponse(updated))\n})\n\ninstances.post('/:id/test', async (c) => {\n\tconst user = c.get('user')\n\tconst id = Number(c.req.param('id'))\n\n\tconst instance = db\n\t\t.query<Instance, [number, number]>('SELECT * FROM instances WHERE id = ? AND user_id = ?')\n\t\t.get(id, user.id)\n\n\tif (!instance) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tconst result = await testStoredQbtInstance(instance)\n\tif (!result.success) {\n\t\tconst status = result.error === 'Invalid credentials' ? 401 : 400\n\t\treturn c.json({ error: result.error }, status)\n\t}\n\n\treturn c.json({ success: true, version: result.version })\n})\n\ninstances.post('/test', async (c) => {\n\tconst body = await c.req.json<{\n\t\turl: string\n\t\tusername?: string\n\t\tpassword?: string\n\t\tskip_auth?: boolean\n\t}>()\n\n\tif (!body.url) {\n\t\treturn c.json({ error: 'URL is required' }, 400)\n\t}\n\n\tif (!body.skip_auth && (!body.username || !body.password)) {\n\t\treturn c.json({ error: 'Credentials required when skip_auth is disabled' }, 400)\n\t}\n\n\ttry {\n\t\tvalidateUrl(body.url)\n\t} catch (e) {\n\t\treturn c.json({ error: e instanceof Error ? e.message : 'Invalid URL' }, 400)\n\t}\n\n\tconst result = await testQbtConnection(body.url, body.username, body.password, body.skip_auth)\n\tif (!result.success) {\n\t\tconst status = result.error === 'Invalid credentials' ? 401 : 400\n\t\treturn c.json({ error: result.error }, status)\n\t}\n\n\treturn c.json({ success: true, version: result.version })\n})\n\ninstances.post('/test-agent', async (c) => {\n\tconst body = await c.req.json<{ url: string; agent_url?: string }>()\n\n\tif (!body.url && !body.agent_url) {\n\t\treturn c.json({ error: 'URL is required' }, 400)\n\t}\n\n\tlet targetUrl: string\n\tif (body.agent_url) {\n\t\ttry {\n\t\t\tvalidateUrl(body.agent_url)\n\t\t} catch (e) {\n\t\t\treturn c.json({ error: e instanceof Error ? e.message : 'Invalid agent URL' }, 400)\n\t\t}\n\t\ttargetUrl = body.agent_url\n\t} else {\n\t\ttry {\n\t\t\tvalidateUrl(body.url)\n\t\t} catch (e) {\n\t\t\treturn c.json({ error: e instanceof Error ? e.message : 'Invalid URL' }, 400)\n\t\t}\n\t\tconst agentUrl = new URL(body.url)\n\t\tagentUrl.port = '9876'\n\t\ttargetUrl = agentUrl.origin\n\t}\n\n\ttry {\n\t\tconst res = await fetchWithTls(`${targetUrl}/health`, { signal: AbortSignal.timeout(5000) })\n\t\tif (res.ok) return c.json({ success: true })\n\t\treturn c.json({ error: 'Agent not responding' }, 400)\n\t} catch {\n\t\treturn c.json({ error: 'Agent not reachable' }, 400)\n\t}\n})\n\ninstances.delete('/:id', (c) => {\n\tconst user = c.get('user')\n\tconst id = Number(c.req.param('id'))\n\n\tconst existing = db\n\t\t.query<Instance, [number, number]>('SELECT * FROM instances WHERE id = ? AND user_id = ?')\n\t\t.get(id, user.id)\n\n\tif (!existing) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tdb.run('DELETE FROM instances WHERE id = ?', [id])\n\tclearQbtSession(id)\n\treturn c.json({ success: true })\n})\n\nexport default instances\n"
  },
  {
    "path": "src/server/routes/integrations.ts",
    "content": "import { Hono } from 'hono'\nimport { db, type Integration } from '../db'\nimport { encrypt, decrypt } from '../utils/crypto'\nimport { validateUrl } from '../utils/url'\nimport { loginToQbt } from '../utils/qbt'\nimport { authMiddleware } from '../middleware/auth'\nimport { log } from '../utils/logger'\n\nconst integrations = new Hono()\n\nintegrations.use('*', authMiddleware)\n\ninterface IntegrationResponse {\n\tid: number\n\ttype: string\n\tlabel: string\n\turl: string\n\tcreated_at: number\n}\n\nfunction toResponse(i: Integration): IntegrationResponse {\n\treturn {\n\t\tid: i.id,\n\t\ttype: i.type,\n\t\tlabel: i.label,\n\t\turl: i.url,\n\t\tcreated_at: i.created_at,\n\t}\n}\n\nintegrations.get('/', (c) => {\n\tconst user = c.get('user')\n\tconst list = db\n\t\t.query<Integration, [number]>('SELECT * FROM integrations WHERE user_id = ? ORDER BY created_at')\n\t\t.all(user.id)\n\treturn c.json(list.map(toResponse))\n})\n\nintegrations.post('/', async (c) => {\n\tconst user = c.get('user')\n\tconst body = await c.req.json<{\n\t\ttype: string\n\t\tlabel: string\n\t\turl: string\n\t\tapi_key: string\n\t}>()\n\n\tif (!body.type || !body.label || !body.url || !body.api_key) {\n\t\treturn c.json({ error: 'Missing required fields' }, 400)\n\t}\n\n\tif (body.type !== 'prowlarr') {\n\t\treturn c.json({ error: 'Unsupported integration type' }, 400)\n\t}\n\n\ttry {\n\t\tvalidateUrl(body.url)\n\t} catch (e) {\n\t\treturn c.json({ error: e instanceof Error ? e.message : 'Invalid URL' }, 400)\n\t}\n\n\tconst encrypted = encrypt(body.api_key)\n\n\ttry {\n\t\tconst result = db.run(\n\t\t\t`INSERT INTO integrations (user_id, type, label, url, api_key_encrypted)\n\t\t\t VALUES (?, ?, ?, ?, ?)`,\n\t\t\t[user.id, body.type, body.label, body.url, encrypted]\n\t\t)\n\n\t\tconst integration = db\n\t\t\t.query<Integration, [number]>('SELECT * FROM integrations WHERE id = ?')\n\t\t\t.get(Number(result.lastInsertRowid))\n\n\t\tif (!integration) {\n\t\t\treturn c.json({ error: 'Failed to create integration' }, 500)\n\t\t}\n\n\t\treturn c.json(toResponse(integration), 201)\n\t} catch (e: unknown) {\n\t\tif (e instanceof Error && e.message.includes('UNIQUE')) {\n\t\t\treturn c.json({ error: 'Integration with this label already exists' }, 400)\n\t\t}\n\t\tthrow e\n\t}\n})\n\nfunction getUserIntegration(userId: number, integrationId: number) {\n\treturn db\n\t\t.query<Integration, [number, number]>('SELECT * FROM integrations WHERE id = ? AND user_id = ?')\n\t\t.get(integrationId, userId)\n}\n\nintegrations.delete('/:id', (c) => {\n\tconst integration = getUserIntegration(c.get('user').id, Number(c.req.param('id')))\n\tif (!integration) return c.json({ error: 'Integration not found' }, 404)\n\n\tdb.run('DELETE FROM integrations WHERE id = ?', [integration.id])\n\treturn c.json({ success: true })\n})\n\nintegrations.post('/test', async (c) => {\n\tconst body = await c.req.json<{ url: string; api_key: string }>()\n\n\tif (!body.url || !body.api_key) {\n\t\treturn c.json({ error: 'URL and API key are required' }, 400)\n\t}\n\n\ttry {\n\t\tvalidateUrl(body.url)\n\t} catch (e) {\n\t\treturn c.json({ error: e instanceof Error ? e.message : 'Invalid URL' }, 400)\n\t}\n\n\ttry {\n\t\tconst res = await fetch(`${body.url}/api/v1/system/status`, {\n\t\t\theaders: { 'X-Api-Key': body.api_key },\n\t\t})\n\n\t\tif (!res.ok) {\n\t\t\treturn c.json({ error: `Connection failed: HTTP ${res.status}` }, 400)\n\t\t}\n\n\t\tconst data = (await res.json()) as { version: string }\n\t\treturn c.json({ success: true, version: data.version })\n\t} catch (e) {\n\t\tlog.error(`Prowlarr test failed: ${e instanceof Error ? e.message : 'Unknown error'}`)\n\t\treturn c.json({ error: 'Connection failed' }, 400)\n\t}\n})\n\nasync function fetchProwlarrApi(integration: Integration, endpoint: string, label: string) {\n\tconst apiKey = decrypt(integration.api_key_encrypted)\n\tconst res = await fetch(`${integration.url}/api/v1/${endpoint}`, {\n\t\theaders: { 'X-Api-Key': apiKey },\n\t})\n\tif (!res.ok) {\n\t\tthrow new Error(`Failed to fetch ${label}: HTTP ${res.status}`)\n\t}\n\treturn res.json()\n}\n\nintegrations.get('/:id/indexers', async (c) => {\n\tconst integration = getUserIntegration(c.get('user').id, Number(c.req.param('id')))\n\tif (!integration) return c.json({ error: 'Integration not found' }, 404)\n\n\ttry {\n\t\treturn c.json(await fetchProwlarrApi(integration, 'indexer', 'indexers'))\n\t} catch (e) {\n\t\tlog.error(`Prowlarr indexers fetch failed: ${e instanceof Error ? e.message : 'Unknown error'}`)\n\t\treturn c.json({ error: 'Failed to fetch indexers' }, 400)\n\t}\n})\n\nintegrations.get('/:id/categories', async (c) => {\n\tconst integration = getUserIntegration(c.get('user').id, Number(c.req.param('id')))\n\tif (!integration) return c.json({ error: 'Integration not found' }, 404)\n\n\ttry {\n\t\treturn c.json(await fetchProwlarrApi(integration, 'indexer/categories', 'categories'))\n\t} catch (e) {\n\t\tlog.error(`Prowlarr categories fetch failed: ${e instanceof Error ? e.message : 'Unknown error'}`)\n\t\treturn c.json({ error: 'Failed to fetch categories' }, 400)\n\t}\n})\n\nintegrations.get('/:id/search', async (c) => {\n\tconst integration = getUserIntegration(c.get('user').id, Number(c.req.param('id')))\n\tif (!integration) return c.json({ error: 'Integration not found' }, 404)\n\n\tconst query = c.req.query('query')\n\tconst indexerIds = c.req.query('indexerIds')\n\tconst categories = c.req.query('categories')\n\tconst type = c.req.query('type') || 'search'\n\n\tif (!query) {\n\t\treturn c.json({ error: 'Query is required' }, 400)\n\t}\n\n\ttry {\n\t\tconst apiKey = decrypt(integration.api_key_encrypted)\n\t\tconst params = new URLSearchParams({ query, type })\n\t\tif (indexerIds) params.set('indexerIds', indexerIds)\n\t\tif (categories) params.set('categories', categories)\n\n\t\tconst res = await fetch(`${integration.url}/api/v1/search?${params}`, {\n\t\t\theaders: { 'X-Api-Key': apiKey },\n\t\t})\n\n\t\tif (!res.ok) {\n\t\t\treturn c.json({ error: `Search failed: HTTP ${res.status}` }, 400)\n\t\t}\n\n\t\tconst results = await res.json()\n\t\treturn c.json(results)\n\t} catch (e) {\n\t\tlog.error(`Prowlarr search failed: ${e instanceof Error ? e.message : 'Unknown error'}`)\n\t\treturn c.json({ error: 'Search failed' }, 400)\n\t}\n})\n\nintegrations.post('/:id/grab', async (c) => {\n\tconst userId = c.get('user').id\n\tconst integration = getUserIntegration(userId, Number(c.req.param('id')))\n\tif (!integration) return c.json({ error: 'Integration not found' }, 404)\n\n\tconst body = await c.req.json<{\n\t\tguid: string\n\t\tindexerId: number\n\t\tdownloadUrl?: string\n\t\tmagnetUrl?: string\n\t\tinstanceId: number\n\t\tcategory?: string\n\t\tsavepath?: string\n\t\tdownloadPath?: string\n\t}>()\n\n\tif (!body.instanceId) {\n\t\treturn c.json({ error: 'Instance ID is required' }, 400)\n\t}\n\n\tconst instance = db\n\t\t.query<\n\t\t\t{\n\t\t\t\tid: number\n\t\t\t\turl: string\n\t\t\t\tqbt_username: string | null\n\t\t\t\tqbt_password_encrypted: string | null\n\t\t\t\tskip_auth: number\n\t\t\t},\n\t\t\t[number, number]\n\t\t>('SELECT id, url, qbt_username, qbt_password_encrypted, skip_auth FROM instances WHERE id = ? AND user_id = ?')\n\t\t.get(body.instanceId, userId)\n\n\tif (!instance) {\n\t\treturn c.json({ error: 'qBittorrent instance not found' }, 404)\n\t}\n\n\ttry {\n\t\tconst loginResult = await loginToQbt(instance)\n\t\tif (!loginResult.success) {\n\t\t\treturn c.json({ error: 'Failed to authenticate with qBittorrent' }, 400)\n\t\t}\n\n\t\tconst formData = new FormData()\n\n\t\tif (body.category) {\n\t\t\tformData.append('category', body.category)\n\t\t}\n\t\tif (body.savepath) {\n\t\t\tformData.append('savepath', body.savepath)\n\t\t}\n\t\tif (body.downloadPath) {\n\t\t\tformData.append('downloadPath', body.downloadPath)\n\t\t}\n\n\t\tif (body.magnetUrl) {\n\t\t\tformData.append('urls', body.magnetUrl)\n\t\t} else if (body.downloadUrl) {\n\t\t\tconst apiKey = decrypt(integration.api_key_encrypted)\n\t\t\tconst prowlarrHost = new URL(integration.url).host\n\t\t\tconst isAlreadyProxied = body.downloadUrl.includes(prowlarrHost)\n\t\t\tconst fetchUrl = isAlreadyProxied\n\t\t\t\t? body.downloadUrl\n\t\t\t\t: `${integration.url}/api/v1/indexer/${body.indexerId}/download?link=${encodeURIComponent(body.downloadUrl)}`\n\t\t\tconst torrentRes = await fetch(fetchUrl, {\n\t\t\t\theaders: { 'X-Api-Key': apiKey },\n\t\t\t\tredirect: 'manual',\n\t\t\t})\n\t\t\tconst location = torrentRes.headers.get('location')\n\t\t\tif ((torrentRes.status === 301 || torrentRes.status === 302) && location?.startsWith('magnet:')) {\n\t\t\t\tformData.append('urls', location.replace(/&amp;/g, '&'))\n\t\t\t} else if (!torrentRes.ok) {\n\t\t\t\treturn c.json({ error: `Failed to download from Prowlarr: HTTP ${torrentRes.status}` }, 400)\n\t\t\t} else {\n\t\t\t\tconst torrentData = await torrentRes.arrayBuffer()\n\t\t\t\tformData.append('torrents', new Blob([torrentData], { type: 'application/x-bittorrent' }), 'release.torrent')\n\t\t\t}\n\t\t} else {\n\t\t\treturn c.json({ error: 'No download URL available' }, 400)\n\t\t}\n\n\t\tconst addHeaders: Record<string, string> = {}\n\t\tif (loginResult.cookie) addHeaders.Cookie = loginResult.cookie\n\t\tconst addRes = await fetch(`${instance.url}/api/v2/torrents/add`, {\n\t\t\tmethod: 'POST',\n\t\t\theaders: addHeaders,\n\t\t\tbody: formData,\n\t\t})\n\n\t\tconst addText = await addRes.text()\n\t\tif (!addRes.ok || (addText.trim() !== 'Ok.' && addText.trim() !== 'Ok')) {\n\t\t\treturn c.json({ error: `Failed to add torrent: ${addText || `HTTP ${addRes.status}`}` }, 400)\n\t\t}\n\n\t\treturn c.json({ success: true })\n\t} catch (e) {\n\t\tlog.error(`Prowlarr grab failed: ${e instanceof Error ? e.message : 'Unknown error'}`)\n\t\treturn c.json({ error: 'Failed to grab release' }, 400)\n\t}\n})\n\nexport default integrations\n\n"
  },
  {
    "path": "src/server/routes/proxy.ts",
    "content": "import { Hono } from 'hono'\nimport { db, type Instance } from '../db'\nimport { loginToQbt as qbtLogin } from '../utils/qbt'\nimport { authMiddleware } from '../middleware/auth'\nimport { fetchWithTls } from '../utils/fetch'\nimport { log } from '../utils/logger'\n\nconst proxy = new Hono()\n\nproxy.use('*', authMiddleware)\n\ninterface QbtSession {\n\tcookie: string | null\n\texpires: number\n}\n\nconst qbtSessions = new Map<number, QbtSession>()\nconst loginInProgress = new Map<number, Promise<string | null>>()\nconst SESSION_TTL = 30 * 60 * 1000\n\nasync function loginToQbt(instance: Instance): Promise<string | null> {\n\tconst existingLogin = loginInProgress.get(instance.id)\n\tif (existingLogin) {\n\t\treturn existingLogin\n\t}\n\n\tconst loginPromise = (async () => {\n\t\tconst result = await qbtLogin(instance)\n\t\tif (!result.success) {\n\t\t\tthrow new Error(result.error)\n\t\t}\n\t\tqbtSessions.set(instance.id, { cookie: result.cookie, expires: Date.now() + SESSION_TTL })\n\t\treturn result.cookie\n\t})()\n\n\tloginInProgress.set(instance.id, loginPromise)\n\ttry {\n\t\treturn await loginPromise\n\t} finally {\n\t\tloginInProgress.delete(instance.id)\n\t}\n}\n\nasync function getQbtSession(instance: Instance): Promise<string | null> {\n\tif (instance.skip_auth) {\n\t\treturn null\n\t}\n\tconst cached = qbtSessions.get(instance.id)\n\tif (cached && cached.expires > Date.now()) {\n\t\treturn cached.cookie\n\t}\n\treturn loginToQbt(instance)\n}\n\nexport function clearQbtSession(instanceId: number) {\n\tqbtSessions.delete(instanceId)\n\tloginInProgress.delete(instanceId)\n}\n\nproxy.all('/:id/qbt/*', async (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = Number(c.req.param('id'))\n\n\tif (isNaN(instanceId)) {\n\t\treturn c.json({ error: 'Invalid instance ID' }, 400)\n\t}\n\n\tconst instance = db\n\t\t.query<Instance, [number, number]>('SELECT * FROM instances WHERE id = ? AND user_id = ?')\n\t\t.get(instanceId, user.id)\n\n\tif (!instance) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tconst path = c.req.path.replace(`/api/instances/${instanceId}/qbt`, '')\n\tconst queryString = c.req.url.includes('?') ? c.req.url.slice(c.req.url.indexOf('?')) : ''\n\tconst targetUrl = `${instance.url}/api${path}${queryString}`\n\n\tconst makeRequest = async (cookie: string | null) => {\n\t\tconst headers = new Headers()\n\t\tif (cookie) {\n\t\t\theaders.set('Cookie', cookie)\n\t\t}\n\n\t\tconst contentType = c.req.header('content-type') || ''\n\t\tlet body: string | FormData | ArrayBuffer | undefined\n\n\t\tif (c.req.method !== 'GET' && c.req.method !== 'HEAD') {\n\t\t\tif (contentType.includes('multipart/form-data')) {\n\t\t\t\tbody = await c.req.formData()\n\t\t\t} else if (contentType.includes('application/x-www-form-urlencoded')) {\n\t\t\t\theaders.set('Content-Type', contentType)\n\t\t\t\tbody = await c.req.text()\n\t\t\t} else if (contentType.includes('application/json')) {\n\t\t\t\theaders.set('Content-Type', contentType)\n\t\t\t\tbody = await c.req.text()\n\t\t\t} else if (contentType) {\n\t\t\t\theaders.set('Content-Type', contentType)\n\t\t\t\tbody = await c.req.arrayBuffer()\n\t\t\t}\n\t\t}\n\n\t\treturn fetchWithTls(targetUrl, {\n\t\t\tmethod: c.req.method,\n\t\t\theaders,\n\t\t\tbody,\n\t\t})\n\t}\n\n\ttry {\n\t\tlet cookie = await getQbtSession(instance)\n\t\tlet res = await makeRequest(cookie)\n\n\t\tif (!instance.skip_auth && (res.status === 401 || res.status === 403)) {\n\t\t\tclearQbtSession(instance.id)\n\t\t\tcookie = await loginToQbt(instance)\n\t\t\tres = await makeRequest(cookie)\n\t\t}\n\n\t\tconst responseHeaders = new Headers()\n\t\tconst contentType = res.headers.get('content-type')\n\t\tif (contentType) {\n\t\t\tresponseHeaders.set('Content-Type', contentType)\n\t\t}\n\n\t\treturn new Response(res.body, {\n\t\t\tstatus: res.status,\n\t\t\theaders: responseHeaders,\n\t\t})\n\t} catch (e) {\n\t\tlog.error(\n\t\t\t`qBittorrent proxy failed for instance ${instanceId}: ${e instanceof Error ? e.message : 'Unknown error'}`\n\t\t)\n\t\treturn c.json({ error: 'Failed to connect to qBittorrent instance' }, 502)\n\t}\n})\n\nfunction getAgentUrl(instance: Instance): string {\n\tif (instance.agent_url) return instance.agent_url\n\tconst url = new URL(instance.url)\n\turl.port = '9876'\n\treturn url.origin\n}\n\nproxy.all('/:id/agent/*', async (c) => {\n\tconst user = c.get('user')\n\tconst instanceId = Number(c.req.param('id'))\n\n\tif (isNaN(instanceId)) {\n\t\treturn c.json({ error: 'Invalid instance ID' }, 400)\n\t}\n\n\tconst instance = db\n\t\t.query<Instance, [number, number]>('SELECT * FROM instances WHERE id = ? AND user_id = ?')\n\t\t.get(instanceId, user.id)\n\n\tif (!instance) {\n\t\treturn c.json({ error: 'Instance not found' }, 404)\n\t}\n\n\tif (!instance.agent_enabled) {\n\t\treturn c.json({ error: 'Agent not enabled for this instance' }, 400)\n\t}\n\n\tconst agentUrl = getAgentUrl(instance)\n\tconst path = c.req.path.replace(`/api/instances/${instanceId}/agent`, '')\n\tconst queryString = c.req.url.includes('?') ? c.req.url.slice(c.req.url.indexOf('?')) : ''\n\tconst targetUrl = `${agentUrl}${path}${queryString}`\n\n\ttry {\n\t\tconst cookie = await getQbtSession(instance)\n\t\tconst sid = cookie?.match(/SID=([^;]+)/)?.[1] || ''\n\n\t\tconst headers = new Headers()\n\t\theaders.set('X-QBT-SID', sid)\n\n\t\tconst res = await fetchWithTls(targetUrl, {\n\t\t\tmethod: c.req.method,\n\t\t\theaders,\n\t\t})\n\n\t\tconst responseHeaders = new Headers()\n\t\tconst contentType = res.headers.get('content-type')\n\t\tif (contentType) {\n\t\t\tresponseHeaders.set('Content-Type', contentType)\n\t\t}\n\n\t\treturn new Response(res.body, {\n\t\t\tstatus: res.status,\n\t\t\theaders: responseHeaders,\n\t\t})\n\t} catch (e) {\n\t\tlog.error(`Agent proxy failed for instance ${instanceId}: ${e instanceof Error ? e.message : 'Unknown error'}`)\n\t\treturn c.json({ error: 'Failed to connect to agent' }, 502)\n\t}\n})\n\nexport default proxy\n"
  },
  {
    "path": "src/server/routes/stats.ts",
    "content": "import { Hono } from 'hono'\nimport { db, type Instance } from '../db'\nimport { authMiddleware } from '../middleware/auth'\nimport { getStatsForPeriod, type PeriodStats } from '../utils/statsRecorder'\nimport { fetchInstanceTransferStats } from '../utils/qbt'\n\nconst stats = new Hono()\n\nstats.use('*', authMiddleware)\n\nconst PERIODS: Record<string, number> = {\n\t'15m': 15 * 60,\n\t'30m': 30 * 60,\n\t'1h': 60 * 60,\n\t'4h': 4 * 60 * 60,\n\t'12h': 12 * 60 * 60,\n\t'1d': 24 * 60 * 60,\n\t'1w': 7 * 24 * 60 * 60,\n\t'1mo': 30 * 24 * 60 * 60,\n\t'6mo': 180 * 24 * 60 * 60,\n\t'1y': 365 * 24 * 60 * 60,\n}\n\ninterface InstancePeriodStats extends PeriodStats {\n\tinstanceId: number\n\tinstanceLabel: string\n}\n\nstats.get('/', async (c) => {\n\tconst user = c.get('user')\n\tconst period = c.req.query('period') || '1d'\n\n\tconst instances = db\n\t\t.query<Instance, [number]>('SELECT * FROM instances WHERE user_id = ? ORDER BY created_at')\n\t\t.all(user.id)\n\n\tif (period === 'all') {\n\t\tconst results: InstancePeriodStats[] = []\n\t\tfor (const instance of instances) {\n\t\t\tconst live = await fetchInstanceTransferStats(instance)\n\t\t\tresults.push({\n\t\t\t\tinstanceId: instance.id,\n\t\t\t\tinstanceLabel: instance.label,\n\t\t\t\tuploaded: live?.uploaded ?? 0,\n\t\t\t\tdownloaded: live?.downloaded ?? 0,\n\t\t\t\thasData: !!live,\n\t\t\t\tdataPoints: 0,\n\t\t\t})\n\t\t}\n\t\treturn c.json(results)\n\t}\n\n\tconst periodSeconds = PERIODS[period]\n\tif (!periodSeconds) {\n\t\treturn c.json({ error: 'Invalid period' }, 400)\n\t}\n\n\tconst results: InstancePeriodStats[] = instances.map((instance) => {\n\t\tconst stats = getStatsForPeriod(instance.id, periodSeconds)\n\t\treturn {\n\t\t\tinstanceId: instance.id,\n\t\t\tinstanceLabel: instance.label,\n\t\t\t...stats,\n\t\t}\n\t})\n\n\treturn c.json(results)\n})\n\nstats.get('/periods', (c) => {\n\treturn c.json(Object.keys(PERIODS).concat('all'))\n})\n\nexport default stats\n"
  },
  {
    "path": "src/server/routes/tools.ts",
    "content": "import { Hono } from 'hono'\nimport { db, type Instance } from '../db'\nimport { loginToQbt } from '../utils/qbt'\nimport { authMiddleware } from '../middleware/auth'\nimport { fetchWithTls } from '../utils/fetch'\nimport { log } from '../utils/logger'\n\nconst tools = new Hono()\n\ntools.use('*', authMiddleware)\n\ninterface Torrent {\n\thash: string\n\tname: string\n\tsize: number\n\tstate: string\n}\n\ninterface Tracker {\n\turl: string\n\tstatus: number\n\tmsg: string\n}\n\ninterface OrphanResult {\n\tinstanceId: number\n\tinstanceLabel: string\n\thash: string\n\tname: string\n\tsize: number\n\treason: 'missingFiles' | 'unregistered'\n\ttrackerMessage?: string\n}\n\nasync function qbtRequest<T>(instance: Instance, cookie: string | null, endpoint: string): Promise<T | null> {\n\ttry {\n\t\tconst res = await fetchWithTls(`${instance.url}/api/v2${endpoint}`, {\n\t\t\theaders: cookie ? { Cookie: cookie } : {},\n\t\t})\n\t\tif (!res.ok) return null\n\t\treturn res.json() as Promise<T>\n\t} catch {\n\t\treturn null\n\t}\n}\n\ntools.post('/orphans/scan', async (c) => {\n\tconst user = c.get('user')\n\tconst instances = db.query<Instance, [number]>('SELECT * FROM instances WHERE user_id = ?').all(user.id)\n\n\tlog.info(`[Orphan Scan] Starting scan for user ${user.username} across ${instances.length} instance(s)`)\n\n\tconst orphans: OrphanResult[] = []\n\tlet totalTorrents = 0\n\tlet totalChecked = 0\n\n\tfor (const instance of instances) {\n\t\tlog.info(`[Orphan Scan] Scanning instance: ${instance.label}`)\n\n\t\tconst loginResult = await loginToQbt(instance)\n\t\tif (!loginResult.success) {\n\t\t\tlog.warn(`[Orphan Scan] Failed to connect to ${instance.label}: ${loginResult.error}`)\n\t\t\tcontinue\n\t\t}\n\n\t\tconst torrents = await qbtRequest<Torrent[]>(instance, loginResult.cookie, '/torrents/info')\n\t\tif (!torrents) {\n\t\t\tlog.warn(`[Orphan Scan] Failed to fetch torrents from ${instance.label}`)\n\t\t\tcontinue\n\t\t}\n\n\t\ttotalTorrents += torrents.length\n\t\tlog.info(`[Orphan Scan] ${instance.label}: Found ${torrents.length} torrents`)\n\n\t\tconst missingFiles = torrents.filter((t) => t.state === 'missingFiles')\n\t\tfor (const t of missingFiles) {\n\t\t\tlog.info(`[Orphan Scan] ${instance.label}: Missing files - ${t.name}`)\n\t\t\torphans.push({\n\t\t\t\tinstanceId: instance.id,\n\t\t\t\tinstanceLabel: instance.label,\n\t\t\t\thash: t.hash,\n\t\t\t\tname: t.name,\n\t\t\t\tsize: t.size,\n\t\t\t\treason: 'missingFiles',\n\t\t\t})\n\t\t}\n\n\t\tconst toCheck = torrents.filter((t) => t.state !== 'missingFiles')\n\t\tfor (const t of toCheck) {\n\t\t\ttotalChecked++\n\t\t\tconst trackers = await qbtRequest<Tracker[]>(instance, loginResult.cookie, `/torrents/trackers?hash=${t.hash}`)\n\t\t\tif (!trackers) continue\n\n\t\t\tconst realTrackers = trackers.filter((tr) => tr.url.startsWith('http'))\n\t\t\tconst hasWorkingTracker = realTrackers.some((tr) => tr.status === 2)\n\t\t\tif (hasWorkingTracker) continue\n\n\t\t\tconst unregistered = realTrackers.find(\n\t\t\t\t(tr) => tr.msg && /unregistered|not registered|torrent not found/i.test(tr.msg)\n\t\t\t)\n\t\t\tif (unregistered) {\n\t\t\t\tlog.info(`[Orphan Scan] ${instance.label}: Unregistered - ${t.name} (${unregistered.msg})`)\n\t\t\t\torphans.push({\n\t\t\t\t\tinstanceId: instance.id,\n\t\t\t\t\tinstanceLabel: instance.label,\n\t\t\t\t\thash: t.hash,\n\t\t\t\t\tname: t.name,\n\t\t\t\t\tsize: t.size,\n\t\t\t\t\treason: 'unregistered',\n\t\t\t\t\ttrackerMessage: unregistered.msg,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\tlog.info(`[Orphan Scan] Scan complete: ${orphans.length} orphan(s) found across ${totalTorrents} torrents`)\n\n\treturn c.json({ orphans, totalTorrents, totalChecked })\n})\n\nexport default tools\n"
  },
  {
    "path": "src/server/utils/crossSeedCache.ts",
    "content": "import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs'\nimport { join } from 'path'\nimport { log } from './logger'\n\nconst dirCache: { cache: string | null; output: string | null } = { cache: null, output: null }\n\nfunction getDir(type: 'cache' | 'output'): string {\n\tif (!dirCache[type]) {\n\t\tconst dataDir = process.env.DATA_PATH || './data'\n\t\tconst subdir = type === 'cache' ? 'cross-seed-cache' : 'cross-seeds'\n\t\tdirCache[type] = join(dataDir, subdir)\n\t\tmkdirSync(dirCache[type]!, { recursive: true })\n\t}\n\treturn dirCache[type]!\n}\n\nfunction getCacheDir(): string {\n\treturn getDir('cache')\n}\n\nfunction getOutputDir(): string {\n\treturn getDir('output')\n}\n\nconst VALID_HASH_REGEX = /^[a-fA-F0-9]+$/\n\nfunction sanitizeHash(hash: string): string {\n\tif (!VALID_HASH_REGEX.test(hash)) {\n\t\tthrow new Error('Invalid hash format')\n\t}\n\treturn hash.toLowerCase()\n}\n\nfunction getTorrentCachePath(instanceId: number, infoHash: string): string {\n\tconst safeHash = sanitizeHash(infoHash)\n\tconst instanceDir = join(getCacheDir(), String(instanceId))\n\tmkdirSync(instanceDir, { recursive: true })\n\treturn join(instanceDir, `${safeHash}.torrent`)\n}\n\nfunction getOutputPath(instanceId: number, name: string, infoHash: string): string {\n\tconst safeHash = sanitizeHash(infoHash)\n\tconst instanceDir = join(getOutputDir(), String(instanceId))\n\tmkdirSync(instanceDir, { recursive: true })\n\tconst safeName = name.replace(/[<>:\"/\\\\|?*]/g, '_').slice(0, 200)\n\treturn join(instanceDir, `${safeName}[${safeHash.slice(0, 8)}].torrent`)\n}\n\nexport function cacheTorrent(instanceId: number, infoHash: string, data: Buffer): void {\n\tconst path = getTorrentCachePath(instanceId, infoHash)\n\twriteFileSync(path, data)\n\tlog.info(`[CrossSeed] Cached torrent: ${infoHash}`)\n}\n\nexport function getCachedTorrent(instanceId: number, infoHash: string): Buffer | null {\n\tconst path = getTorrentCachePath(instanceId, infoHash)\n\tif (!existsSync(path)) return null\n\treturn readFileSync(path)\n}\n\nexport function hasCachedTorrent(instanceId: number, infoHash: string): boolean {\n\treturn existsSync(getTorrentCachePath(instanceId, infoHash))\n}\n\nexport function saveTorrentToOutput(instanceId: number, name: string, infoHash: string, data: Buffer): string {\n\tconst path = getOutputPath(instanceId, name, infoHash)\n\twriteFileSync(path, data)\n\tlog.info(`[CrossSeed] Saved torrent to output: ${path}`)\n\treturn path\n}\n\nfunction clearTorrentsInDir(dir: string): number {\n\tif (!existsSync(dir)) return 0\n\tconst files = readdirSync(dir).filter((f) => f.endsWith('.torrent'))\n\tfor (const file of files) unlinkSync(join(dir, file))\n\treturn files.length\n}\n\nexport function clearCacheForInstance(instanceId: number): number {\n\tconst count = clearTorrentsInDir(join(getCacheDir(), String(instanceId)))\n\tif (count > 0) log.info(`[CrossSeed] Cleared ${count} cached torrents for instance ${instanceId}`)\n\treturn count\n}\n\nexport function clearOutputForInstance(instanceId: number): number {\n\tconst count = clearTorrentsInDir(join(getOutputDir(), String(instanceId)))\n\tif (count > 0) log.info(`[CrossSeed] Cleared ${count} output torrents for instance ${instanceId}`)\n\treturn count\n}\n\nfunction getTorrentFiles(dir: string): string[] {\n\tif (!existsSync(dir)) return []\n\treturn readdirSync(dir).filter((f) => f.endsWith('.torrent'))\n}\n\nexport function getCacheStats(instanceId: number): { count: number; totalSize: number } {\n\tconst dir = join(getCacheDir(), String(instanceId))\n\tconst files = getTorrentFiles(dir)\n\tlet count = 0\n\tlet totalSize = 0\n\tfor (const f of files) {\n\t\ttry {\n\t\t\ttotalSize += statSync(join(dir, f)).size\n\t\t\tcount++\n\t\t} catch {\n\t\t\tcontinue\n\t\t}\n\t}\n\treturn { count, totalSize }\n}\n\nexport function getOutputStats(instanceId: number): { count: number; files: string[] } {\n\tconst files = getTorrentFiles(join(getOutputDir(), String(instanceId)))\n\treturn { count: files.length, files }\n}\n\nexport function _resetCachePaths(): void {\n\tdirCache.cache = null\n\tdirCache.output = null\n}\n"
  },
  {
    "path": "src/server/utils/crossSeedMatcher.ts",
    "content": "import { CrossSeedDecisionType, BlocklistType, type BlocklistTypeValue } from '../db'\nimport { dirname } from 'path'\n\nexport interface FileInfo {\n\tname: string\n\tsize: number\n}\n\nexport interface Searchee {\n\ttitle: string\n\tfiles: FileInfo[]\n\tlength: number\n\tinfoHash?: string\n\tpath?: string\n\tcategory?: string\n\ttags?: string[]\n}\n\nexport interface MatchResult {\n\tdecision: string\n\tmatched: boolean\n\tconfidence: number\n\tmatchedFiles: number\n\ttotalFiles: number\n\tdetails?: string\n}\n\nexport interface PreFilterResult {\n\tpass: boolean\n\treason?: string\n}\n\nconst RESOLUTION_REGEX = /\\b(2160p|1080p|1080i|720p|576p|576i|480p|480i|4k|uhd)\\b/i\nconst RELEASE_GROUP_REGEX =\n\t/(?<=-)(?:\\W|\\b)(?!(?:\\d{3,4}[ip]))(?!\\d+\\b)(?:\\W|\\b)(?<group>[\\w .]+?)(?:\\[.+\\])?(?:\\))?(?:\\s\\[.+\\])?$/i\nconst ANIME_GROUP_REGEX = /^\\s*\\[(?<group>.+?)\\]/i\nconst SOURCE_REGEX = /\\b(AMZN|NF|NETFLIX|DSNP|HULU|ATVP|PCOK|PMTP|HBO|HMAX|IT|RED|STAN|CRAV|MA|VUDU|iT)\\b/i\nconst PROPER_REPACK_REGEX = /\\b(PROPER|REPACK|RERIP|REAL)\\d?\\b/i\nconst VIDEO_EXTENSIONS = /\\.(mkv|mp4|avi|m4v|ts|wmv|webm)$/i\nconst SEASON_REGEX = /^(?<title>.+?)[[(_.\\s-]+(?<season>S(?:eason)?\\s*\\d+)(?=\\b(?![_.\\s~-]*E\\d+))/i\nconst EP_REGEX =\n\t/^(?<title>.+?)[_.\\s-]+(?:(?<season>S\\d+)?[_.\\s-]{0,3}(?!(?:19|20)\\d{2})(?<episode>(?:E|(?<=S\\d+[_\\s-]{1,3}))\\d+(?:[\\s-]?(?!(?:19|20)\\d{2})E?\\d+)?(?![pix]))(?!\\d+[pix])|(?<date>(?<year>(?:19|20)\\d{2})[_.\\s-](?<month>\\d{2})[_.\\s-](?<day>\\d{2})))/i\nconst VIDEO_EXTS = ['.mkv', '.mp4', '.avi', '.ts', '.m4v', '.mov', '.wmv', '.webm']\nconst BAD_GROUP_PARSE_REGEX =\n\t/^(?<badmatch>(?:dl|DDP?|aac|eac3|atmos|dts|ma|hd|[heav.c]{3,5}|[xh.]{1,2}[2456]|[0-9]+[ip]?|dxva|full|blu|ray|s(?:eason)?\\W\\d+|\\W){3,})$/i\nconst PARSE_BLOCKLIST_REGEX = /^(?<blocklistType>.+?):(?<blocklistValue>.*)$/\n\nexport function extractResolution(name: string): string | null {\n\tconst match = name.match(RESOLUTION_REGEX)\n\tif (!match) return null\n\tconst res = match[1].toLowerCase()\n\tif (res === '4k' || res === 'uhd') return '2160p'\n\treturn res\n}\n\nfunction stripExtension(name: string): string {\n\treturn name.replace(VIDEO_EXTENSIONS, '')\n}\n\nexport function extractReleaseGroup(name: string): string | null {\n\tconst stem = stripExtension(name)\n\tconst match = stem.match(RELEASE_GROUP_REGEX)\n\tif (!match?.groups?.group) return null\n\tconst group = match.groups.group.trim()\n\tif (BAD_GROUP_PARSE_REGEX.test(group)) return null\n\treturn group.toLowerCase()\n}\n\nfunction extractAnimeGroup(name: string): string | null {\n\tconst match = name.match(ANIME_GROUP_REGEX)\n\treturn match?.groups?.group?.trim()?.toLowerCase() ?? null\n}\n\nexport function extractSource(name: string): string | null {\n\tconst match = name.match(SOURCE_REGEX)\n\tif (!match) return null\n\tconst source = match[1].toUpperCase()\n\tif (source === 'NETFLIX') return 'NF'\n\tif (source === 'IT') return 'ATVP'\n\treturn source\n}\n\nexport function hasProperRepack(name: string): boolean {\n\treturn PROPER_REPACK_REGEX.test(name)\n}\n\nfunction checkMatch(\n\tsourceVal: string | null,\n\tcandidateVal: string | null,\n\tlabel: string,\n\tprefixMatch = false\n): PreFilterResult {\n\tif (!sourceVal || !candidateVal) return { pass: true }\n\tif (prefixMatch && (sourceVal.startsWith(candidateVal) || candidateVal.startsWith(sourceVal))) return { pass: true }\n\tif (sourceVal !== candidateVal) return { pass: false, reason: `${label} mismatch: ${sourceVal} vs ${candidateVal}` }\n\treturn { pass: true }\n}\n\nexport function resolutionMatches(sourceName: string, candidateName: string): PreFilterResult {\n\treturn checkMatch(extractResolution(sourceName), extractResolution(candidateName), 'Resolution')\n}\n\nexport function releaseGroupMatches(sourceName: string, candidateName: string): PreFilterResult {\n\tconst sourceGroup = extractReleaseGroup(sourceName)\n\tconst candidateGroup = extractReleaseGroup(candidateName)\n\tif (!sourceGroup || !candidateGroup) return { pass: true }\n\n\tconst prefixMatch = sourceGroup.startsWith(candidateGroup) || candidateGroup.startsWith(sourceGroup)\n\tif (prefixMatch) return { pass: true }\n\n\tconst sourceAnime = extractAnimeGroup(sourceName)\n\tconst candidateAnime = extractAnimeGroup(candidateName)\n\tconst animeMatch =\n\t\t(sourceAnime && (sourceAnime === candidateAnime || sourceAnime === candidateGroup)) ||\n\t\t(candidateAnime && sourceGroup === candidateAnime)\n\tif (animeMatch) return { pass: true }\n\n\treturn { pass: false, reason: `Release group mismatch: ${sourceGroup} vs ${candidateGroup}` }\n}\n\nexport function sourceMatches(sourceName: string, candidateName: string): PreFilterResult {\n\treturn checkMatch(extractSource(sourceName), extractSource(candidateName), 'Source')\n}\n\nexport function properRepackMatches(sourceName: string, candidateName: string): PreFilterResult {\n\tconst sourceHas = hasProperRepack(sourceName)\n\tconst candidateHas = hasProperRepack(candidateName)\n\tif (sourceHas === candidateHas) return { pass: true }\n\treturn {\n\t\tpass: false,\n\t\treason: sourceHas ? 'Source is PROPER/REPACK but candidate is not' : 'Candidate is PROPER/REPACK but source is not',\n\t}\n}\n\nfunction compareFileTrees(candidateFiles: FileInfo[], searcheeFiles: FileInfo[]): boolean {\n\treturn candidateFiles.every((cf) => searcheeFiles.some((sf) => sf.size === cf.size && sf.name === cf.name))\n}\n\nfunction compareFileTreesIgnoringNames(candidateFiles: FileInfo[], searcheeFiles: FileInfo[]): boolean {\n\tconst available = [...searcheeFiles]\n\tfor (const cf of candidateFiles) {\n\t\tlet matches = available.filter((sf) => sf.size === cf.size)\n\t\tif (matches.length > 1) {\n\t\t\tconst nameMatch = matches.find((sf) => sf.name === cf.name)\n\t\t\tif (nameMatch) matches = [nameMatch]\n\t\t}\n\t\tif (matches.length === 0) return false\n\t\tavailable.splice(available.indexOf(matches[0]), 1)\n\t}\n\treturn true\n}\n\nexport function matchTorrentsBySizes(searcheeFiles: FileInfo[], candidateFiles: FileInfo[]): MatchResult {\n\tconst total = candidateFiles.length\n\tconst makeResult = (decision: string, matched: boolean, matchedFiles: number, details: string): MatchResult => ({\n\t\tdecision,\n\t\tmatched,\n\t\tconfidence: total > 0 ? matchedFiles / total : 0,\n\t\tmatchedFiles,\n\t\ttotalFiles: total,\n\t\tdetails,\n\t})\n\n\tif (total === 0) return makeResult(CrossSeedDecisionType.SIZE_MISMATCH, false, 0, 'Candidate has no files')\n\n\tconst perfectMatch = compareFileTrees(candidateFiles, searcheeFiles)\n\tif (perfectMatch) {\n\t\treturn makeResult(CrossSeedDecisionType.MATCH, true, total, 'Perfect match (names + sizes)')\n\t}\n\n\tconst sizeMatch = compareFileTreesIgnoringNames(candidateFiles, searcheeFiles)\n\tif (sizeMatch) {\n\t\treturn makeResult(CrossSeedDecisionType.MATCH_SIZE_ONLY, true, total, 'Size-only match (names differ)')\n\t}\n\n\tconst available = [...searcheeFiles]\n\tlet matchedCount = 0\n\tfor (const cf of candidateFiles) {\n\t\tlet matches = available.filter((sf) => sf.size === cf.size)\n\t\tif (matches.length > 1) {\n\t\t\tconst nameMatch = matches.find((sf) => sf.name === cf.name)\n\t\t\tif (nameMatch) matches = [nameMatch]\n\t\t}\n\t\tif (matches.length > 0) {\n\t\t\tmatchedCount++\n\t\t\tavailable.splice(available.indexOf(matches[0]), 1)\n\t\t}\n\t}\n\n\treturn makeResult(\n\t\tCrossSeedDecisionType.SIZE_MISMATCH,\n\t\tfalse,\n\t\tmatchedCount,\n\t\t`Only ${matchedCount}/${total} candidate files matched in searchee`\n\t)\n}\n\nfunction fuzzySizeMatch(sourceSize: number, candidateSize: number, tolerance = 0.02): boolean {\n\treturn candidateSize >= sourceSize * (1 - tolerance) && candidateSize <= sourceSize * (1 + tolerance)\n}\n\nexport function preFilterCandidate(\n\tsourceName: string,\n\tsourceSize: number,\n\tcandidateName: string,\n\tcandidateSize: number | undefined,\n\ttolerance = 0.02\n): PreFilterResult {\n\tfor (const check of [\n\t\tresolutionMatches(sourceName, candidateName),\n\t\treleaseGroupMatches(sourceName, candidateName),\n\t\tsourceMatches(sourceName, candidateName),\n\t\tproperRepackMatches(sourceName, candidateName),\n\t]) {\n\t\tif (!check.pass) return check\n\t}\n\n\tif (candidateSize !== undefined && !fuzzySizeMatch(sourceSize, candidateSize, tolerance)) {\n\t\tconst diff = sourceSize > 0 ? Math.abs(candidateSize - sourceSize) / sourceSize : 1\n\t\treturn {\n\t\t\tpass: false,\n\t\t\treason: `Size mismatch: ${(diff * 100).toFixed(1)}% difference (tolerance: ${tolerance * 100}%)`,\n\t\t}\n\t}\n\n\treturn { pass: true }\n}\n\nfunction isSeasonPack(title: string): boolean {\n\treturn SEASON_REGEX.test(title)\n}\n\nfunction isSingleEpisode(title: string, files: FileInfo[]): boolean {\n\tif (EP_REGEX.test(title)) return true\n\tconst videoFiles = files.filter((f) => VIDEO_EXTS.some((ext) => f.name.toLowerCase().endsWith(ext)))\n\treturn videoFiles.length === 1\n}\n\nexport function shouldRejectSeasonEpisodeMismatch(\n\tsearcheeTitle: string,\n\tcandidateTitle: string,\n\tcandidateFiles: FileInfo[],\n\tincludeSingleEpisodes: boolean\n): boolean {\n\tif (includeSingleEpisodes) return false\n\tif (!isSeasonPack(searcheeTitle)) return false\n\treturn isSingleEpisode(candidateTitle, candidateFiles)\n}\n\nfunction parseBlocklistEntry(entry: string): { blocklistType: BlocklistTypeValue; blocklistValue: string } {\n\tconst match = entry.match(PARSE_BLOCKLIST_REGEX)\n\tif (match?.groups) {\n\t\treturn {\n\t\t\tblocklistType: match.groups.blocklistType as BlocklistTypeValue,\n\t\t\tblocklistValue: match.groups.blocklistValue,\n\t\t}\n\t}\n\treturn {\n\t\tblocklistType: BlocklistType.LEGACY,\n\t\tblocklistValue: entry,\n\t}\n}\n\nexport function findBlockedStringInRelease(searchee: Searchee, blocklist: string[]): string | undefined {\n\treturn blocklist.find((entry) => {\n\t\tconst { blocklistType, blocklistValue } = parseBlocklistEntry(entry)\n\t\tswitch (blocklistType) {\n\t\t\tcase BlocklistType.NAME:\n\t\t\t\treturn searchee.title.includes(blocklistValue)\n\t\t\tcase BlocklistType.NAME_REGEX:\n\t\t\t\ttry {\n\t\t\t\t\treturn new RegExp(blocklistValue).test(searchee.title)\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\tcase BlocklistType.FOLDER:\n\t\t\t\treturn searchee.path && dirname(searchee.path).includes(blocklistValue)\n\t\t\tcase BlocklistType.FOLDER_REGEX:\n\t\t\t\ttry {\n\t\t\t\t\treturn searchee.path && new RegExp(blocklistValue).test(dirname(searchee.path))\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\tcase BlocklistType.CATEGORY:\n\t\t\t\treturn blocklistValue === searchee.category\n\t\t\tcase BlocklistType.TAG:\n\t\t\t\tif (!searchee.tags) return false\n\t\t\t\treturn blocklistValue.length ? searchee.tags.includes(blocklistValue) : !searchee.tags.length\n\t\t\tcase BlocklistType.INFOHASH:\n\t\t\t\treturn blocklistValue === searchee.infoHash\n\t\t\tcase BlocklistType.SIZE_BELOW: {\n\t\t\t\tconst sizeBelow = parseInt(blocklistValue, 10)\n\t\t\t\treturn !Number.isNaN(sizeBelow) && searchee.length < sizeBelow\n\t\t\t}\n\t\t\tcase BlocklistType.SIZE_ABOVE: {\n\t\t\t\tconst sizeAbove = parseInt(blocklistValue, 10)\n\t\t\t\treturn !Number.isNaN(sizeAbove) && searchee.length > sizeAbove\n\t\t\t}\n\t\t\tcase BlocklistType.TRACKER:\n\t\t\t\treturn false\n\t\t\tcase BlocklistType.LEGACY:\n\t\t\t\tif (searchee.title.includes(entry)) return true\n\t\t\t\tif (entry === searchee.infoHash) return true\n\t\t\t\tif (searchee.path && dirname(searchee.path).includes(entry)) return true\n\t\t\t\treturn false\n\t\t\tdefault:\n\t\t\t\treturn false\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "src/server/utils/crossSeedScheduler.ts",
    "content": "import { db, type CrossSeedConfig } from '../db'\nimport { log } from './logger'\nimport { runCrossSeedScan, type ScanResult } from './crossSeedWorker'\n\ninterface ScheduledInstance {\n\tinstanceId: number\n\tuserId: number\n\ttimer: ReturnType<typeof setTimeout> | null\n\trunning: boolean\n\tlastResult: ScanResult | null\n}\n\nconst scheduledInstances = new Map<number, ScheduledInstance>()\nconst runningScans = new Set<number>()\nconst abortControllers = new Map<number, AbortController>()\nlet checkInterval: ReturnType<typeof setInterval> | null = null\n\nconst CHECK_INTERVAL_MS = 60 * 1000\n\nexport interface SchedulerStatus {\n\tinstanceId: number\n\tinstanceLabel: string\n\tenabled: boolean\n\tintervalHours: number\n\tdryRun: boolean\n\tlastRun: number | null\n\tnextRun: number | null\n\trunning: boolean\n\tlastResult: ScanResult | null\n}\n\nfunction getInstanceUserId(instanceId: number): number | null {\n\tconst instance = db.query<{ user_id: number }, [number]>('SELECT user_id FROM instances WHERE id = ?').get(instanceId)\n\treturn instance?.user_id ?? null\n}\n\nfunction calculateNextRun(lastRun: number | null, intervalHours: number): number {\n\tconst now = Math.floor(Date.now() / 1000)\n\tif (!lastRun) return now + 60\n\tconst nextRun = lastRun + intervalHours * 3600\n\treturn nextRun <= now ? now + 60 : nextRun\n}\n\nfunction scheduleNextRun(instanceId: number, userId: number, intervalHours: number): void {\n\tconst nextRun = calculateNextRun(Math.floor(Date.now() / 1000), intervalHours)\n\tdb.run('UPDATE cross_seed_config SET next_run = ? WHERE instance_id = ?', [nextRun, instanceId])\n\tscheduleInstance(instanceId, userId, nextRun)\n}\n\nasync function runScheduledScan(instanceId: number): Promise<void> {\n\tconst scheduled = scheduledInstances.get(instanceId)\n\tif (!scheduled || runningScans.has(instanceId)) {\n\t\treturn\n\t}\n\n\tconst config = db\n\t\t.query<CrossSeedConfig, [number]>('SELECT * FROM cross_seed_config WHERE instance_id = ? AND enabled = 1')\n\t\t.get(instanceId)\n\n\tif (!config) {\n\t\tlog.info(`[CrossSeed Scheduler] Instance ${instanceId} no longer enabled, removing from schedule`)\n\t\tscheduledInstances.delete(instanceId)\n\t\treturn\n\t}\n\n\trunningScans.add(instanceId)\n\tscheduled.running = true\n\tconst abortController = new AbortController()\n\tabortControllers.set(instanceId, abortController)\n\tlog.info(`[CrossSeed Scheduler] Starting scheduled scan for instance ${instanceId}`)\n\n\ttry {\n\t\tconst result = await runCrossSeedScan({\n\t\t\tinstanceId,\n\t\t\tuserId: scheduled.userId,\n\t\t\tforce: false,\n\t\t\tsignal: abortController.signal,\n\t\t})\n\t\tscheduled.lastResult = result\n\t} catch (e) {\n\t\tif (e instanceof Error && e.name === 'AbortError') {\n\t\t\tlog.info(`[CrossSeed Scheduler] Scan stopped for instance ${instanceId}`)\n\t\t} else {\n\t\t\tlog.error(\n\t\t\t\t`[CrossSeed Scheduler] Scan failed for instance ${instanceId}: ${e instanceof Error ? e.message : 'Unknown'}`\n\t\t\t)\n\t\t}\n\t} finally {\n\t\tscheduleNextRun(instanceId, scheduled.userId, config.interval_hours)\n\t\trunningScans.delete(instanceId)\n\t\tabortControllers.delete(instanceId)\n\t\tscheduled.running = false\n\t}\n}\n\nfunction scheduleInstance(instanceId: number, userId: number, nextRunTimestamp: number): void {\n\tconst existing = scheduledInstances.get(instanceId)\n\tif (existing?.timer) {\n\t\tclearTimeout(existing.timer)\n\t}\n\n\tconst now = Math.floor(Date.now() / 1000)\n\tconst delayMs = Math.max(0, (nextRunTimestamp - now) * 1000)\n\n\tconst scheduled: ScheduledInstance = {\n\t\tinstanceId,\n\t\tuserId,\n\t\ttimer: setTimeout(() => runScheduledScan(instanceId), delayMs),\n\t\trunning: existing?.running ?? false,\n\t\tlastResult: existing?.lastResult ?? null,\n\t}\n\n\tscheduledInstances.set(instanceId, scheduled)\n\n\tconst delayHours = (delayMs / 1000 / 3600).toFixed(1)\n\tlog.info(`[CrossSeed Scheduler] Scheduled instance ${instanceId} to run in ${delayHours}h`)\n}\n\nfunction loadEnabledConfigs(): void {\n\tconst configs = db\n\t\t.query<CrossSeedConfig & { user_id: number }, []>(\n\t\t\t`\n\t\tSELECT c.*, i.user_id\n\t\tFROM cross_seed_config c\n\t\tJOIN instances i ON c.instance_id = i.id\n\t\tWHERE c.enabled = 1\n\t`\n\t\t)\n\t\t.all()\n\n\tfor (const config of configs) {\n\t\tconst nextRun = config.next_run ?? calculateNextRun(config.last_run, config.interval_hours)\n\n\t\tif (!config.next_run) {\n\t\t\tdb.run('UPDATE cross_seed_config SET next_run = ? WHERE instance_id = ?', [nextRun, config.instance_id])\n\t\t}\n\n\t\tscheduleInstance(config.instance_id, config.user_id, nextRun)\n\t}\n\n\tlog.info(`[CrossSeed Scheduler] Loaded ${configs.length} enabled instance(s)`)\n}\n\nfunction checkMissedScans(): void {\n\tconst now = Math.floor(Date.now() / 1000)\n\tconst configs = db\n\t\t.query<CrossSeedConfig & { user_id: number }, [number]>(\n\t\t\t`\n\t\tSELECT c.*, i.user_id\n\t\tFROM cross_seed_config c\n\t\tJOIN instances i ON c.instance_id = i.id\n\t\tWHERE c.enabled = 1 AND c.next_run IS NOT NULL AND c.next_run < ?\n\t`\n\t\t)\n\t\t.all(now)\n\n\tfor (const config of configs) {\n\t\tif (!scheduledInstances.has(config.instance_id) || runningScans.has(config.instance_id)) continue\n\n\t\tlog.info(`[CrossSeed Scheduler] Triggering missed scan for instance ${config.instance_id}`)\n\t\trunScheduledScan(config.instance_id)\n\t}\n}\n\nexport function startScheduler(): void {\n\tlog.info('[CrossSeed Scheduler] Starting scheduler...')\n\tloadEnabledConfigs()\n\n\tcheckInterval = setInterval(checkMissedScans, CHECK_INTERVAL_MS)\n}\n\nexport function stopScheduler(): void {\n\tlog.info('[CrossSeed Scheduler] Stopping scheduler...')\n\n\tif (checkInterval) {\n\t\tclearInterval(checkInterval)\n\t\tcheckInterval = null\n\t}\n\n\tfor (const [, scheduled] of scheduledInstances) {\n\t\tif (scheduled.timer) {\n\t\t\tclearTimeout(scheduled.timer)\n\t\t}\n\t}\n\tscheduledInstances.clear()\n\trunningScans.clear()\n}\n\nexport function updateInstanceSchedule(instanceId: number, enabled: boolean): void {\n\tconst scheduled = scheduledInstances.get(instanceId)\n\n\tif (!enabled) {\n\t\tif (scheduled?.timer) {\n\t\t\tclearTimeout(scheduled.timer)\n\t\t}\n\t\tscheduledInstances.delete(instanceId)\n\t\tlog.info(`[CrossSeed Scheduler] Disabled schedule for instance ${instanceId}`)\n\t\treturn\n\t}\n\n\tconst config = db\n\t\t.query<CrossSeedConfig, [number]>('SELECT * FROM cross_seed_config WHERE instance_id = ?')\n\t\t.get(instanceId)\n\n\tif (!config) return\n\n\tconst userId = getInstanceUserId(instanceId)\n\tif (!userId) return\n\n\tconst nextRun = calculateNextRun(config.last_run, config.interval_hours)\n\tdb.run('UPDATE cross_seed_config SET next_run = ? WHERE instance_id = ?', [nextRun, instanceId])\n\tscheduleInstance(instanceId, userId, nextRun)\n}\n\nexport async function triggerManualScan(\n\tinstanceId: number,\n\tuserId: number,\n\tforce: boolean = false\n): Promise<ScanResult> {\n\tif (runningScans.has(instanceId)) {\n\t\tthrow new Error('Scan already in progress')\n\t}\n\n\trunningScans.add(instanceId)\n\tconst abortController = new AbortController()\n\tabortControllers.set(instanceId, abortController)\n\tconst scheduled = scheduledInstances.get(instanceId)\n\tif (scheduled) {\n\t\tscheduled.running = true\n\t}\n\n\ttry {\n\t\tconst result = await runCrossSeedScan({ instanceId, userId, force, signal: abortController.signal })\n\n\t\tif (scheduled) {\n\t\t\tscheduled.lastResult = result\n\t\t}\n\n\t\tconst config = db\n\t\t\t.query<CrossSeedConfig, [number]>('SELECT * FROM cross_seed_config WHERE instance_id = ? AND enabled = 1')\n\t\t\t.get(instanceId)\n\n\t\tif (config) {\n\t\t\tscheduleNextRun(instanceId, userId, config.interval_hours)\n\t\t}\n\n\t\treturn result\n\t} catch (e) {\n\t\tif (e instanceof Error && e.name === 'AbortError') {\n\t\t\tlog.info(`[CrossSeed] Scan stopped for instance ${instanceId}`)\n\t\t\tconst config = db\n\t\t\t\t.query<CrossSeedConfig, [number]>('SELECT * FROM cross_seed_config WHERE instance_id = ? AND enabled = 1')\n\t\t\t\t.get(instanceId)\n\n\t\t\tif (config) {\n\t\t\t\tscheduleNextRun(instanceId, userId, config.interval_hours)\n\t\t\t}\n\t\t\tthrow new Error('Scan stopped')\n\t\t}\n\t\tthrow e\n\t} finally {\n\t\trunningScans.delete(instanceId)\n\t\tabortControllers.delete(instanceId)\n\t\tif (scheduled) {\n\t\t\tscheduled.running = false\n\t\t}\n\t}\n}\n\nfunction configToStatus(config: CrossSeedConfig & { label: string }): SchedulerStatus {\n\tconst scheduled = scheduledInstances.get(config.instance_id)\n\treturn {\n\t\tinstanceId: config.instance_id,\n\t\tinstanceLabel: config.label,\n\t\tenabled: !!config.enabled,\n\t\tintervalHours: config.interval_hours,\n\t\tdryRun: !!config.dry_run,\n\t\tlastRun: config.last_run,\n\t\tnextRun: config.next_run,\n\t\trunning: runningScans.has(config.instance_id),\n\t\tlastResult: scheduled?.lastResult ?? null,\n\t}\n}\n\nexport function getSchedulerStatus(): SchedulerStatus[] {\n\treturn db\n\t\t.query<\n\t\t\tCrossSeedConfig & { label: string },\n\t\t\t[]\n\t\t>(`SELECT c.*, i.label FROM cross_seed_config c JOIN instances i ON c.instance_id = i.id`)\n\t\t.all()\n\t\t.map(configToStatus)\n}\n\nexport function getInstanceStatus(instanceId: number): SchedulerStatus | null {\n\tconst config = db\n\t\t.query<\n\t\t\tCrossSeedConfig & { label: string },\n\t\t\t[number]\n\t\t>(`SELECT c.*, i.label FROM cross_seed_config c JOIN instances i ON c.instance_id = i.id WHERE c.instance_id = ?`)\n\t\t.get(instanceId)\n\treturn config ? configToStatus(config) : null\n}\n\nexport function isInstanceRunning(instanceId: number): boolean {\n\treturn runningScans.has(instanceId)\n}\n\nexport function stopScan(instanceId: number): boolean {\n\tconst controller = abortControllers.get(instanceId)\n\tif (controller) {\n\t\tcontroller.abort()\n\t\tconst config = db\n\t\t\t.query<CrossSeedConfig, [number]>('SELECT * FROM cross_seed_config WHERE instance_id = ? AND enabled = 1')\n\t\t\t.get(instanceId)\n\t\tconst userId = getInstanceUserId(instanceId)\n\t\tif (config && userId) {\n\t\t\tscheduleNextRun(instanceId, userId, config.interval_hours)\n\t\t}\n\t\tlog.info(`[CrossSeed] Stop requested for instance ${instanceId}`)\n\t\treturn true\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "src/server/utils/crossSeedWorker.ts",
    "content": "import { createHash } from 'crypto'\nimport { constants as fsConstants } from 'fs'\nimport { link, mkdir, stat, access } from 'fs/promises'\nimport { dirname, join, basename } from 'path'\nimport {\n\tdb,\n\ttype Instance,\n\ttype Integration,\n\ttype CrossSeedConfig,\n\ttype CrossSeedSearchee,\n\tCrossSeedDecisionType,\n\tMatchMode,\n} from '../db'\nimport { loginToQbt } from './qbt'\nimport { fetchWithTls } from './fetch'\nimport { decrypt } from './crypto'\nimport { log } from './logger'\nimport {\n\tmatchTorrentsBySizes,\n\tpreFilterCandidate,\n\tshouldRejectSeasonEpisodeMismatch,\n\tfindBlockedStringInRelease,\n\ttype FileInfo,\n\ttype Searchee,\n} from './crossSeedMatcher'\nimport { cacheTorrent, saveTorrentToOutput } from './crossSeedCache'\nimport { searchAllIndexers, downloadTorrentDirect, type TorznabResult } from './torznab'\n\nconst DEFAULT_DELAY_SECONDS = 30\n\nfunction wait(ms: number): Promise<void> {\n\treturn new Promise((resolve) => setTimeout(resolve, ms))\n}\n\ninterface CandidateFileInfo {\n\tname: string\n\tpath: string\n\tsize: number\n}\n\ninterface CandidateFilesWithRoot {\n\trootName: string\n\tfiles: CandidateFileInfo[]\n}\n\nasync function createHardlinks(\n\tsourceFiles: { name: string; size: number; path: string }[],\n\tcandidateFiles: CandidateFileInfo[],\n\tlinkDir: string\n): Promise<{ success: boolean; destinationDir: string; error?: string }> {\n\tconst destinationDir = linkDir\n\tconst availableFiles = [...sourceFiles]\n\n\ttry {\n\t\tawait mkdir(destinationDir, { recursive: true })\n\n\t\tfor (const candidateFile of candidateFiles) {\n\t\t\tlet matches = availableFiles.filter((sf) => sf.size === candidateFile.size)\n\t\t\tif (matches.length > 1) {\n\t\t\t\tconst nameMatch = matches.find((sf) => basename(sf.path) === candidateFile.name)\n\t\t\t\tif (nameMatch) matches = [nameMatch]\n\t\t\t}\n\t\t\tif (matches.length === 0) {\n\t\t\t\treturn { success: false, destinationDir, error: `No matching source file for ${candidateFile.name}` }\n\t\t\t}\n\n\t\t\tconst sourceFile = matches[0]\n\t\t\tconst destPath = join(destinationDir, candidateFile.path)\n\t\t\tconst destDir = dirname(destPath)\n\n\t\t\tif (destDir !== destinationDir) {\n\t\t\t\tawait mkdir(destDir, { recursive: true })\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tawait link(sourceFile.path, destPath)\n\t\t\t\tlog.info(`[CrossSeed] Linked: ${sourceFile.path} -> ${destPath}`)\n\t\t\t} catch (e) {\n\t\t\t\tif ((e as NodeJS.ErrnoException).code === 'EEXIST') {\n\t\t\t\t\tlog.info(`[CrossSeed] Link already exists: ${destPath}`)\n\t\t\t\t} else {\n\t\t\t\t\tthrow e\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst idx = availableFiles.indexOf(sourceFile)\n\t\t\tif (idx !== -1) availableFiles.splice(idx, 1)\n\t\t}\n\n\t\treturn { success: true, destinationDir }\n\t} catch (e) {\n\t\treturn { success: false, destinationDir, error: e instanceof Error ? e.message : 'Unknown error' }\n\t}\n}\n\nasync function canCreateHardlink(sourcePath: string, destDir: string): Promise<boolean> {\n\ttry {\n\t\tconst sourceStat = await stat(sourcePath)\n\t\tconst destStat = await stat(destDir)\n\t\tif (sourceStat.dev !== destStat.dev) {\n\t\t\treturn false\n\t\t}\n\t\tawait access(destDir, fsConstants.W_OK)\n\t\treturn true\n\t} catch {\n\t\treturn false\n\t}\n}\n\nexport interface ScanOptions {\n\tinstanceId: number\n\tuserId: number\n\tforce?: boolean\n\tdryRunOverride?: boolean\n\tsignal?: AbortSignal\n}\n\nexport interface ScanResult {\n\tinstanceId: number\n\ttorrentsTotal: number\n\ttorrentsScanned: number\n\ttorrentsSkipped: number\n\tmatchesFound: number\n\ttorrentsAdded: number\n\terrors: string[]\n\tdryRun: boolean\n\tstartedAt: number\n\tcompletedAt: number\n}\n\ninterface QbtTorrent {\n\thash: string\n\tname: string\n\tsize: number\n\tstate: string\n\tcategory: string\n\ttags: string\n\tsave_path: string\n\tcontent_path: string\n\tprogress: number\n\tamount_left: number\n}\n\ninterface QbtFile {\n\tname: string\n\tsize: number\n\tprogress: number\n\tpriority: number\n\tindex: number\n}\n\ninterface QbtVersion {\n\tmajor: number\n\tminor: number\n\tpatch: number\n}\n\nconst RESUME_SLEEP_MS = 15 * 1000\nconst RESUME_ERROR_SLEEP_MS = 5 * 60 * 1000\nconst RESUME_TIMEOUT_MS = 60 * 60 * 1000\n\nasync function qbtFetch(instance: Instance, cookie: string | null, endpoint: string): Promise<Response | null> {\n\ttry {\n\t\tconst res = await fetchWithTls(`${instance.url}/api/v2${endpoint}`, {\n\t\t\theaders: cookie ? { Cookie: cookie } : {},\n\t\t})\n\t\treturn res.ok ? res : null\n\t} catch {\n\t\treturn null\n\t}\n}\n\nasync function qbtRequest<T>(instance: Instance, cookie: string | null, endpoint: string): Promise<T | null> {\n\tconst res = await qbtFetch(instance, cookie, endpoint)\n\treturn res ? (res.json() as Promise<T>) : null\n}\n\nasync function qbtRequestText(instance: Instance, cookie: string | null, endpoint: string): Promise<string | null> {\n\tconst res = await qbtFetch(instance, cookie, endpoint)\n\treturn res ? res.text() : null\n}\n\nconst DEFAULT_QBT_VERSION: QbtVersion = { major: 4, minor: 0, patch: 0 }\n\nasync function getQbtVersion(instance: Instance, cookie: string | null): Promise<QbtVersion> {\n\tconst versionStr = await qbtRequestText(instance, cookie, '/app/version')\n\tconst match = versionStr?.match(/v?(\\d+)\\.(\\d+)\\.?(\\d+)?/)\n\tif (!match) return DEFAULT_QBT_VERSION\n\treturn {\n\t\tmajor: parseInt(match[1], 10) || 4,\n\t\tminor: parseInt(match[2], 10) || 0,\n\t\tpatch: parseInt(match[3], 10) || 0,\n\t}\n}\n\nasync function getTorrentInfo(\n\tinstance: Instance,\n\tcookie: string | null,\n\thash: string,\n\tnumRetries = 0\n): Promise<QbtTorrent | null> {\n\tconst retries = Math.max(numRetries, 0)\n\tfor (let i = 0; i <= retries; i++) {\n\t\tconst torrents = await qbtRequest<QbtTorrent[]>(instance, cookie, `/torrents/info?hashes=${hash}`)\n\t\tif (torrents && torrents.length > 0) {\n\t\t\treturn torrents[0]\n\t\t}\n\t\tif (i < retries) {\n\t\t\tconst delay = Math.min(1000 * 2 ** i, 10000)\n\t\t\tawait wait(delay)\n\t\t}\n\t}\n\treturn null\n}\n\nasync function qbtPost(instance: Instance, cookie: string | null, endpoint: string, body: string): Promise<boolean> {\n\ttry {\n\t\tconst res = await fetchWithTls(`${instance.url}/api/v2${endpoint}`, {\n\t\t\tmethod: 'POST',\n\t\t\theaders: {\n\t\t\t\t'Content-Type': 'application/x-www-form-urlencoded',\n\t\t\t\t...(cookie ? { Cookie: cookie } : {}),\n\t\t\t},\n\t\t\tbody,\n\t\t})\n\t\treturn res.ok\n\t} catch {\n\t\treturn false\n\t}\n}\n\nasync function recheckTorrent(\n\tinstance: Instance,\n\tcookie: string | null,\n\thash: string,\n\tversion: QbtVersion\n): Promise<void> {\n\tconst stopEndpoint = version.major >= 5 ? '/torrents/stop' : '/torrents/pause'\n\tawait qbtPost(instance, cookie, stopEndpoint, `hashes=${hash}`)\n\tawait qbtPost(instance, cookie, '/torrents/recheck', `hashes=${hash}`)\n}\n\nasync function resumeInjection(\n\tinstance: Instance,\n\tcookie: string | null,\n\tinfoHash: string,\n\tversion: QbtVersion\n): Promise<void> {\n\tconst stopTime = Date.now() + RESUME_TIMEOUT_MS\n\tlet sleepTime = RESUME_SLEEP_MS\n\n\twhile (Date.now() < stopTime) {\n\t\tawait wait(sleepTime)\n\n\t\tconst torrentInfo = await getTorrentInfo(instance, cookie, infoHash)\n\t\tif (!torrentInfo) {\n\t\t\tsleepTime = RESUME_ERROR_SLEEP_MS\n\t\t\tcontinue\n\t\t}\n\n\t\tif (['checkingDL', 'checkingUP', 'checkingResumeData'].includes(torrentInfo.state)) {\n\t\t\tsleepTime = RESUME_SLEEP_MS\n\t\t\tcontinue\n\t\t}\n\n\t\tconst pausedStates = ['pausedDL', 'stoppedDL', 'pausedUP', 'stoppedUP']\n\t\tif (!pausedStates.includes(torrentInfo.state)) {\n\t\t\tlog.warn(`[CrossSeed] Will not resume ${torrentInfo.name}: state is ${torrentInfo.state}`)\n\t\t\treturn\n\t\t}\n\n\t\tif (torrentInfo.amount_left > 0) {\n\t\t\tlog.warn(`[CrossSeed] Will not resume ${torrentInfo.name}: ${torrentInfo.amount_left} bytes remaining`)\n\t\t\treturn\n\t\t}\n\n\t\tlog.info(`[CrossSeed] Resuming ${torrentInfo.name}`)\n\t\tconst resumeEndpoint = version.major >= 5 ? '/torrents/start' : '/torrents/resume'\n\t\tawait qbtPost(instance, cookie, resumeEndpoint, `hashes=${infoHash}`)\n\t\treturn\n\t}\n\n\tlog.warn(`[CrossSeed] Resume monitoring timeout for ${infoHash}`)\n}\n\nasync function addTorrentToQbt(\n\tinstance: Instance,\n\tcookie: string | null,\n\ttorrentData: Buffer,\n\topts: {\n\t\tsavepath: string\n\t\tcategory: string\n\t\ttags: string\n\t\tskipRecheck: boolean\n\t\tinfoHash?: string\n\t\tversion: QbtVersion\n\t}\n): Promise<boolean> {\n\tconst toRecheck = !opts.skipRecheck\n\tconst formData = new FormData()\n\tformData.append('torrents', new Blob([torrentData], { type: 'application/x-bittorrent' }), 'release.torrent')\n\tformData.append('savepath', opts.savepath)\n\tif (opts.category) formData.append('category', opts.category)\n\tif (opts.tags) formData.append('tags', opts.tags)\n\tformData.append('skip_checking', toRecheck ? 'false' : 'true')\n\tformData.append(opts.version.major >= 5 ? 'stopped' : 'paused', toRecheck ? 'true' : 'false')\n\tformData.append('autoTMM', 'false')\n\tformData.append('contentLayout', 'Original')\n\n\ttry {\n\t\tconst res = await fetchWithTls(`${instance.url}/api/v2/torrents/add`, {\n\t\t\tmethod: 'POST',\n\t\t\theaders: cookie ? { Cookie: cookie } : {},\n\t\t\tbody: formData,\n\t\t})\n\t\tconst text = await res.text()\n\t\tconst apiSuccess = res.ok && text.trim().startsWith('Ok')\n\n\t\tif (!apiSuccess) {\n\t\t\tlog.error(`[CrossSeed] API returned failure: ${text}`)\n\t\t\treturn false\n\t\t}\n\t} catch (e) {\n\t\tlog.error(`[CrossSeed] Failed to add torrent: ${e instanceof Error ? e.message : 'Unknown'}`)\n\t\treturn false\n\t}\n\n\tif (!opts.infoHash) {\n\t\treturn true\n\t}\n\n\tconst newInfo = await getTorrentInfo(instance, cookie, opts.infoHash, 5)\n\tif (!newInfo) {\n\t\tlog.error(`[CrossSeed] Failed to verify torrent was added: ${opts.infoHash}`)\n\t\treturn false\n\t}\n\n\tif (toRecheck) {\n\t\tawait recheckTorrent(instance, cookie, newInfo.hash, opts.version)\n\t\tresumeInjection(instance, cookie, opts.infoHash, opts.version).catch((e) => {\n\t\t\tlog.error(`[CrossSeed] Resume monitoring error: ${e instanceof Error ? e.message : 'Unknown'}`)\n\t\t})\n\t}\n\n\treturn true\n}\n\ninterface TorrentInfo {\n\tfiles?: { path?: Buffer[]; length: number }[]\n\tname?: Buffer\n\tlength?: number\n}\n\nfunction parseTorrentInfo(torrentData: Buffer): TorrentInfo | null {\n\ttry {\n\t\tconst decoded = decodeBencode(torrentData) as { info?: TorrentInfo }\n\t\treturn decoded?.info ?? null\n\t} catch {\n\t\treturn null\n\t}\n}\n\nfunction parseFileSizesFromTorrent(torrentData: Buffer): FileInfo[] | null {\n\tconst info = parseTorrentInfo(torrentData)\n\tif (!info) return null\n\n\tif (info.files) {\n\t\tconst torrentName = info.name?.toString() || ''\n\t\treturn info.files.map((file) => {\n\t\t\tconst pathParts = file.path || []\n\t\t\tconst internalPath = pathParts.map((p) => p.toString()).join('/')\n\t\t\tconst name = torrentName ? `${torrentName}/${internalPath}` : internalPath\n\t\t\treturn { name, size: Number(file.length) }\n\t\t})\n\t}\n\tif (info.name && info.length) {\n\t\treturn [{ name: info.name.toString(), size: Number(info.length) }]\n\t}\n\treturn []\n}\n\nfunction parseFilesWithPathsFromTorrent(torrentData: Buffer): CandidateFilesWithRoot | null {\n\tconst info = parseTorrentInfo(torrentData)\n\tif (!info) return null\n\n\tconst torrentName = info.name?.toString() || ''\n\n\tif (info.files) {\n\t\treturn {\n\t\t\trootName: torrentName,\n\t\t\tfiles: info.files.map((file) => {\n\t\t\t\tconst pathParts = file.path || []\n\t\t\t\tconst internalPath = pathParts.map((p) => p.toString()).join('/')\n\t\t\t\treturn {\n\t\t\t\t\tname: pathParts.length > 0 ? pathParts[pathParts.length - 1].toString() : '',\n\t\t\t\t\tpath: torrentName ? `${torrentName}/${internalPath}` : internalPath,\n\t\t\t\t\tsize: Number(file.length),\n\t\t\t\t}\n\t\t\t}),\n\t\t}\n\t}\n\tif (info.name && info.length) {\n\t\treturn { rootName: torrentName, files: [{ name: torrentName, path: torrentName, size: Number(info.length) }] }\n\t}\n\treturn { rootName: torrentName, files: [] }\n}\n\ntype BencodeValue = number | Buffer | string | BencodeValue[] | { [key: string]: BencodeValue }\n\nconst MAX_BENCODE_DEPTH = 100\nconst MAX_BENCODE_ITERATIONS = 100000\n\nfunction decodeBencode(buffer: Buffer): BencodeValue {\n\tlet pos = 0\n\tlet iterations = 0\n\n\tfunction checkBounds(): void {\n\t\tif (pos >= buffer.length) {\n\t\t\tthrow new Error('Unexpected end of buffer')\n\t\t}\n\t\tif (++iterations > MAX_BENCODE_ITERATIONS) {\n\t\t\tthrow new Error('Bencode parsing exceeded maximum iterations')\n\t\t}\n\t}\n\n\tfunction decode(depth: number): BencodeValue {\n\t\tif (depth > MAX_BENCODE_DEPTH) {\n\t\t\tthrow new Error('Bencode parsing exceeded maximum depth')\n\t\t}\n\t\tcheckBounds()\n\t\tconst char = String.fromCharCode(buffer[pos])\n\n\t\tif (char === 'd') {\n\t\t\tpos++\n\t\t\tconst dict: { [key: string]: BencodeValue } = {}\n\t\t\twhile (pos < buffer.length && buffer[pos] !== 0x65) {\n\t\t\t\tconst key = decode(depth + 1)\n\t\t\t\tconst value = decode(depth + 1)\n\t\t\t\tdict[key.toString()] = value\n\t\t\t}\n\t\t\tif (pos >= buffer.length) throw new Error('Unterminated dict')\n\t\t\tpos++\n\t\t\treturn dict\n\t\t}\n\n\t\tif (char === 'l') {\n\t\t\tpos++\n\t\t\tconst list: BencodeValue[] = []\n\t\t\twhile (pos < buffer.length && buffer[pos] !== 0x65) {\n\t\t\t\tlist.push(decode(depth + 1))\n\t\t\t}\n\t\t\tif (pos >= buffer.length) throw new Error('Unterminated list')\n\t\t\tpos++\n\t\t\treturn list\n\t\t}\n\n\t\tif (char === 'i') {\n\t\t\tpos++\n\t\t\tconst end = buffer.indexOf(0x65, pos)\n\t\t\tif (end === -1 || end > pos + 20) throw new Error('Invalid integer encoding')\n\t\t\tconst num = parseInt(buffer.slice(pos, end).toString(), 10)\n\t\t\tif (isNaN(num)) throw new Error('Invalid integer value')\n\t\t\tpos = end + 1\n\t\t\treturn num\n\t\t}\n\n\t\tif (char >= '0' && char <= '9') {\n\t\t\tconst colonIdx = buffer.indexOf(0x3a, pos)\n\t\t\tif (colonIdx === -1 || colonIdx > pos + 10) throw new Error('Invalid string length')\n\t\t\tconst len = parseInt(buffer.slice(pos, colonIdx).toString(), 10)\n\t\t\tif (isNaN(len) || len < 0 || len > buffer.length) throw new Error('Invalid string length value')\n\t\t\tpos = colonIdx + 1\n\t\t\tif (pos + len > buffer.length) throw new Error('String extends past buffer')\n\t\t\tconst str = buffer.slice(pos, pos + len)\n\t\t\tpos += len\n\t\t\treturn str\n\t\t}\n\n\t\tthrow new Error(`Unknown bencode type at pos ${pos}`)\n\t}\n\n\treturn decode(0)\n}\n\nfunction getInfoHashFromTorrent(torrentData: Buffer): string | null {\n\ttry {\n\t\tconst decoded = decodeBencode(torrentData)\n\t\tif (typeof decoded !== 'object' || decoded === null || Array.isArray(decoded) || Buffer.isBuffer(decoded))\n\t\t\treturn null\n\t\tif (!('info' in decoded)) return null\n\n\t\tconst bencodedInfo = encodeBencode(decoded.info)\n\t\treturn createHash('sha1').update(bencodedInfo).digest('hex')\n\t} catch {\n\t\treturn null\n\t}\n}\n\nfunction encodeBencode(data: BencodeValue): Buffer {\n\tif (typeof data === 'number') {\n\t\treturn Buffer.from(`i${data}e`)\n\t}\n\n\tif (Buffer.isBuffer(data)) {\n\t\treturn Buffer.concat([Buffer.from(`${data.length}:`), data])\n\t}\n\n\tif (typeof data === 'string') {\n\t\tconst buf = Buffer.from(data)\n\t\treturn Buffer.concat([Buffer.from(`${buf.length}:`), buf])\n\t}\n\n\tif (Array.isArray(data)) {\n\t\tconst parts: Buffer[] = [Buffer.from('l')]\n\t\tfor (const item of data) {\n\t\t\tparts.push(encodeBencode(item))\n\t\t}\n\t\tparts.push(Buffer.from('e'))\n\t\treturn Buffer.concat(parts)\n\t}\n\n\tif (typeof data === 'object' && data !== null) {\n\t\tconst parts: Buffer[] = [Buffer.from('d')]\n\t\tconst keys = Object.keys(data).sort()\n\t\tfor (const key of keys) {\n\t\t\tparts.push(encodeBencode(key))\n\t\t\tparts.push(encodeBencode(data[key]))\n\t\t}\n\t\tparts.push(Buffer.from('e'))\n\t\treturn Buffer.concat(parts)\n\t}\n\n\treturn Buffer.from('')\n}\n\nfunction upsertSearchee(\n\tinstanceId: number,\n\thash: string,\n\tname: string,\n\tsize: number,\n\tfileCount: number,\n\tfileSizesJson: string\n): number {\n\tdb.run(\n\t\t`INSERT INTO cross_seed_searchee (instance_id, torrent_hash, torrent_name, total_size, file_count, file_sizes)\n\t\t VALUES (?, ?, ?, ?, ?, ?)\n\t\t ON CONFLICT(instance_id, torrent_hash) DO UPDATE SET last_searched = unixepoch()`,\n\t\t[instanceId, hash, name, size, fileCount, fileSizesJson]\n\t)\n\tconst row = db\n\t\t.query<\n\t\t\t{ id: number },\n\t\t\t[number, string]\n\t\t>('SELECT id FROM cross_seed_searchee WHERE instance_id = ? AND torrent_hash = ?')\n\t\t.get(instanceId, hash)\n\treturn row!.id\n}\n\nexport async function runCrossSeedScan(options: ScanOptions): Promise<ScanResult> {\n\tconst startedAt = Date.now()\n\tconst result: ScanResult = {\n\t\tinstanceId: options.instanceId,\n\t\ttorrentsTotal: 0,\n\t\ttorrentsScanned: 0,\n\t\ttorrentsSkipped: 0,\n\t\tmatchesFound: 0,\n\t\ttorrentsAdded: 0,\n\t\terrors: [],\n\t\tdryRun: true,\n\t\tstartedAt,\n\t\tcompletedAt: 0,\n\t}\n\n\tconst config = db\n\t\t.query<CrossSeedConfig, [number]>('SELECT * FROM cross_seed_config WHERE instance_id = ?')\n\t\t.get(options.instanceId)\n\n\tif (!config) {\n\t\tresult.errors.push('Cross-seed not configured for this instance')\n\t\tresult.completedAt = Date.now()\n\t\treturn result\n\t}\n\n\tresult.dryRun = options.dryRunOverride !== undefined ? options.dryRunOverride : !!config.dry_run\n\n\tif (!config.integration_id) {\n\t\tresult.errors.push('No Prowlarr integration configured')\n\t\tresult.completedAt = Date.now()\n\t\treturn result\n\t}\n\n\tconst integration = db\n\t\t.query<Integration, [number, number]>('SELECT * FROM integrations WHERE id = ? AND user_id = ?')\n\t\t.get(config.integration_id, options.userId)\n\n\tif (!integration) {\n\t\tresult.errors.push('Prowlarr integration not found or access denied')\n\t\tresult.completedAt = Date.now()\n\t\treturn result\n\t}\n\n\tconst instance = db\n\t\t.query<Instance, [number, number]>('SELECT * FROM instances WHERE id = ? AND user_id = ?')\n\t\t.get(options.instanceId, options.userId)\n\n\tif (!instance) {\n\t\tresult.errors.push('Instance not found or access denied')\n\t\tresult.completedAt = Date.now()\n\t\treturn result\n\t}\n\n\tconst delaySeconds = config.delay_seconds ?? DEFAULT_DELAY_SECONDS\n\tlog.info(\n\t\t`[CrossSeed] Starting scan for instance ${instance.label} (dry_run=${result.dryRun}, force=${options.force}, delay=${delaySeconds}s)`\n\t)\n\n\tconst loginResult = await loginToQbt(instance)\n\tif (!loginResult.success) {\n\t\tresult.errors.push(`qBittorrent login failed: ${loginResult.error}`)\n\t\tresult.completedAt = Date.now()\n\t\treturn result\n\t}\n\n\tconst qbtVersion = await getQbtVersion(instance, loginResult.cookie)\n\tlog.info(`[CrossSeed] qBittorrent version: ${qbtVersion.major}.${qbtVersion.minor}.${qbtVersion.patch}`)\n\n\tconst torrents = await qbtRequest<QbtTorrent[]>(instance, loginResult.cookie, '/torrents/info')\n\tif (!torrents) {\n\t\tresult.errors.push('Failed to fetch torrents from qBittorrent')\n\t\tresult.completedAt = Date.now()\n\t\treturn result\n\t}\n\n\tresult.torrentsTotal = torrents.length\n\n\tconst completedTorrents = torrents.filter((t) => t.progress === 1)\n\tlog.info(`[CrossSeed] Found ${completedTorrents.length} completed torrents out of ${torrents.length} total`)\n\n\tconst existingSearchees = new Map<string, CrossSeedSearchee>()\n\tconst searchees = db\n\t\t.query<CrossSeedSearchee, [number]>('SELECT * FROM cross_seed_searchee WHERE instance_id = ?')\n\t\t.all(options.instanceId)\n\tfor (const s of searchees) {\n\t\texistingSearchees.set(s.torrent_hash, s)\n\t}\n\n\tconst existingHashes = new Set(torrents.map((t) => t.hash.toLowerCase()))\n\tlet lastSearchTime = 0\n\n\tconst blocklist: string[] = config.blocklist ? JSON.parse(config.blocklist) : []\n\tconst includeSingleEpisodes = !!config.include_single_episodes\n\n\tlet processedCount = 0\n\tconst totalToProcess = completedTorrents.length\n\n\tfor (const torrent of completedTorrents) {\n\t\tif (options.signal?.aborted) {\n\t\t\tconst error = new Error('Scan aborted')\n\t\t\terror.name = 'AbortError'\n\t\t\tthrow error\n\t\t}\n\n\t\tprocessedCount++\n\t\tconst existingSearchee = existingSearchees.get(torrent.hash)\n\t\tif (existingSearchee && !options.force) {\n\t\t\tresult.torrentsSkipped++\n\t\t\tcontinue\n\t\t}\n\n\t\tresult.torrentsScanned++\n\t\tlog.info(`[CrossSeed] [${processedCount}/${totalToProcess}] Searching for: ${torrent.name}`)\n\n\t\ttry {\n\t\t\tconst files = await qbtRequest<QbtFile[]>(instance, loginResult.cookie, `/torrents/files?hash=${torrent.hash}`)\n\t\t\tif (!files || files.length === 0) {\n\t\t\t\tlog.warn(`[CrossSeed] No files found for torrent: ${torrent.name}`)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tconst sourceFiles: FileInfo[] = files.map((f) => ({ name: f.name, size: f.size }))\n\n\t\t\tif (blocklist.length > 0) {\n\t\t\t\tconst searchee: Searchee = {\n\t\t\t\t\ttitle: torrent.name,\n\t\t\t\t\tfiles: sourceFiles,\n\t\t\t\t\tlength: torrent.size,\n\t\t\t\t\tinfoHash: torrent.hash,\n\t\t\t\t\tpath: torrent.content_path,\n\t\t\t\t\tcategory: torrent.category,\n\t\t\t\t\ttags: torrent.tags ? torrent.tags.split(',').map((t) => t.trim()) : undefined,\n\t\t\t\t}\n\t\t\t\tconst blocked = findBlockedStringInRelease(searchee, blocklist)\n\t\t\t\tif (blocked) {\n\t\t\t\t\tlog.info(`[CrossSeed] Skipping ${torrent.name} - matches blocklist: ${blocked}`)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst fileSizesJson = JSON.stringify(sourceFiles.map((f) => f.size).sort((a, b) => a - b))\n\n\t\t\tconst searchQuery = torrent.name\n\t\t\t\t.replace(/\\[.*?\\]/g, '')\n\t\t\t\t.replace(/\\(.*?\\)/g, '')\n\t\t\t\t.replace(/\\.\\w{2,4}$/, '')\n\t\t\t\t.replace(/[._-]/g, ' ')\n\t\t\t\t.trim()\n\n\t\t\tconst apiKey = decrypt(integration.api_key_encrypted)\n\t\t\tlet indexerIds: number[] | undefined\n\t\t\tif (config.indexer_ids) {\n\t\t\t\ttry {\n\t\t\t\t\tconst parsed = JSON.parse(config.indexer_ids)\n\t\t\t\t\tindexerIds = Array.isArray(parsed) ? parsed.filter((id): id is number => typeof id === 'number') : undefined\n\t\t\t\t} catch {\n\t\t\t\t\tindexerIds = undefined\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst now = Date.now()\n\t\t\tconst waitUntil = lastSearchTime + delaySeconds * 1000\n\t\t\tif (now < waitUntil) {\n\t\t\t\tconst waitMs = waitUntil - now\n\t\t\t\tlog.info(`[CrossSeed] Waiting ${(waitMs / 1000).toFixed(1)}s before next search`)\n\t\t\t\tawait wait(waitMs)\n\t\t\t}\n\t\t\tlastSearchTime = Date.now()\n\n\t\t\tlet searchResults: TorznabResult[]\n\t\t\ttry {\n\t\t\t\tsearchResults = await searchAllIndexers(\n\t\t\t\t\tintegration.url,\n\t\t\t\t\tapiKey,\n\t\t\t\t\tsearchQuery,\n\t\t\t\t\tconfig.integration_id!,\n\t\t\t\t\tindexerIds\n\t\t\t\t)\n\t\t\t} catch (e) {\n\t\t\t\tresult.errors.push(`Search failed for ${torrent.name}: ${e instanceof Error ? e.message : 'Unknown'}`)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlog.info(`[CrossSeed] Found ${searchResults.length} results for: ${torrent.name}`)\n\n\t\t\tconst preFiltered: Array<{ result: TorznabResult; reason?: string }> = []\n\t\t\tfor (const r of searchResults) {\n\t\t\t\tconst check = preFilterCandidate(torrent.name, torrent.size, r.title, r.size)\n\t\t\t\tif (check.pass) {\n\t\t\t\t\tpreFiltered.push({ result: r })\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.info(`[CrossSeed] ${preFiltered.length} results passed pre-filter`)\n\n\t\t\tfor (const { result: candidate } of preFiltered) {\n\t\t\t\tif (candidate.infoHash && existingHashes.has(candidate.infoHash.toLowerCase())) {\n\t\t\t\t\tlog.info(`[CrossSeed] Skipping ${candidate.title} - already in client`)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tconst existingDecision = db\n\t\t\t\t\t.query<\n\t\t\t\t\t\t{ decision: string; info_hash: string | null },\n\t\t\t\t\t\t[number, string]\n\t\t\t\t\t>('SELECT decision, info_hash FROM cross_seed_decision WHERE searchee_id = ? AND guid = ?')\n\t\t\t\t\t.get(existingSearchee?.id ?? 0, candidate.guid)\n\n\t\t\t\tif (existingDecision) {\n\t\t\t\t\tdb.run('UPDATE cross_seed_decision SET last_seen = ? WHERE searchee_id = ? AND guid = ?', [\n\t\t\t\t\t\tMath.floor(Date.now() / 1000),\n\t\t\t\t\t\texistingSearchee?.id ?? 0,\n\t\t\t\t\t\tcandidate.guid,\n\t\t\t\t\t])\n\t\t\t\t\tif (\n\t\t\t\t\t\t!options.force &&\n\t\t\t\t\t\t(existingDecision.decision === CrossSeedDecisionType.MATCH ||\n\t\t\t\t\t\t\texistingDecision.decision === CrossSeedDecisionType.MATCH_SIZE_ONLY)\n\t\t\t\t\t) {\n\t\t\t\t\t\tconst inClient = existingDecision.info_hash && existingHashes.has(existingDecision.info_hash.toLowerCase())\n\t\t\t\t\t\tif (inClient) {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst torrentData = await downloadTorrentDirect(candidate.link)\n\t\t\t\tif (!torrentData) {\n\t\t\t\t\tlog.warn(`[CrossSeed] Failed to download torrent for: ${candidate.title}`)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tconst candidateInfoHash = getInfoHashFromTorrent(torrentData)\n\t\t\t\tif (candidateInfoHash && existingHashes.has(candidateInfoHash.toLowerCase())) {\n\t\t\t\t\tlog.info(`[CrossSeed] Skipping ${candidate.title} - already in client (by infohash)`)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tconst candidateFiles = parseFileSizesFromTorrent(torrentData)\n\t\t\t\tif (!candidateFiles) {\n\t\t\t\t\tlog.warn(`[CrossSeed] Failed to parse torrent file for: ${candidate.title}`)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif (blocklist.length > 0) {\n\t\t\t\t\tconst candidateSearchee: Searchee = {\n\t\t\t\t\t\ttitle: candidate.title,\n\t\t\t\t\t\tfiles: candidateFiles,\n\t\t\t\t\t\tlength: candidate.size ?? candidateFiles.reduce((sum, f) => sum + f.size, 0),\n\t\t\t\t\t}\n\t\t\t\t\tconst blockedCandidate = findBlockedStringInRelease(candidateSearchee, blocklist)\n\t\t\t\t\tif (blockedCandidate) {\n\t\t\t\t\t\tlog.info(`[CrossSeed] Skipping candidate ${candidate.title} - matches blocklist: ${blockedCandidate}`)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (shouldRejectSeasonEpisodeMismatch(torrent.name, candidate.title, candidateFiles, includeSingleEpisodes)) {\n\t\t\t\t\tlog.info(`[CrossSeed] Skipping ${candidate.title} - single episode cannot match season pack`)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tconst matchResult = matchTorrentsBySizes(sourceFiles, candidateFiles)\n\n\t\t\t\tif (candidateInfoHash) {\n\t\t\t\t\tcacheTorrent(options.instanceId, candidateInfoHash, torrentData)\n\t\t\t\t}\n\n\t\t\t\tconst searcheeId =\n\t\t\t\t\texistingSearchee?.id ??\n\t\t\t\t\tupsertSearchee(options.instanceId, torrent.hash, torrent.name, torrent.size, files.length, fileSizesJson)\n\n\t\t\t\tdb.run(\n\t\t\t\t\t`INSERT INTO cross_seed_decision (searchee_id, guid, info_hash, candidate_name, candidate_size, decision)\n\t\t\t\t\t VALUES (?, ?, ?, ?, ?, ?)\n\t\t\t\t\t ON CONFLICT(searchee_id, guid) DO UPDATE SET\n\t\t\t\t\t \tinfo_hash = excluded.info_hash,\n\t\t\t\t\t \tdecision = excluded.decision,\n\t\t\t\t\t \tlast_seen = unixepoch()`,\n\t\t\t\t\t[searcheeId, candidate.guid, candidateInfoHash, candidate.title, candidate.size ?? null, matchResult.decision]\n\t\t\t\t)\n\n\t\t\t\tif (matchResult.matched) {\n\t\t\t\t\tconst isFlexibleMode = config.match_mode === MatchMode.FLEXIBLE\n\t\t\t\t\tconst hasLinkDir = !!config.link_dir\n\n\t\t\t\t\tif (matchResult.decision === CrossSeedDecisionType.MATCH_SIZE_ONLY) {\n\t\t\t\t\t\tif (!isFlexibleMode) {\n\t\t\t\t\t\t\tlog.info(`[CrossSeed] Skipping MATCH_SIZE_ONLY: ${torrent.name} -> ${candidate.title} (strict mode)`)\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (!hasLinkDir) {\n\t\t\t\t\t\t\tlog.info(\n\t\t\t\t\t\t\t\t`[CrossSeed] Skipping MATCH_SIZE_ONLY: ${torrent.name} -> ${candidate.title} (no link_dir configured)`\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tresult.matchesFound++\n\t\t\t\t\tlog.info(`[CrossSeed] MATCH: ${torrent.name} -> ${candidate.title} (${matchResult.decision})`)\n\n\t\t\t\t\tif (!result.dryRun) {\n\t\t\t\t\t\tconst category = torrent.category\n\t\t\t\t\t\t\t? `${torrent.category}${config.category_suffix}`\n\t\t\t\t\t\t\t: config.category_suffix.replace(/^_/, '')\n\t\t\t\t\t\tconst tags = config.tag || 'cross-seed'\n\n\t\t\t\t\t\tlet savepath = torrent.save_path\n\t\t\t\t\t\tlet needsRecheck = !config.skip_recheck\n\n\t\t\t\t\t\tif (matchResult.decision === CrossSeedDecisionType.MATCH_SIZE_ONLY && config.link_dir) {\n\t\t\t\t\t\t\tconst candidateFilesWithRoot = parseFilesWithPathsFromTorrent(torrentData)\n\t\t\t\t\t\t\tif (!candidateFilesWithRoot) {\n\t\t\t\t\t\t\t\tlog.warn(`[CrossSeed] Failed to parse file paths for linking: ${candidate.title}`)\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tconst sourceFilesWithPaths = files.map((f) => ({\n\t\t\t\t\t\t\t\tname: basename(f.name),\n\t\t\t\t\t\t\t\tsize: f.size,\n\t\t\t\t\t\t\t\tpath: join(torrent.save_path, f.name),\n\t\t\t\t\t\t\t}))\n\n\t\t\t\t\t\t\tconst canLink = await canCreateHardlink(sourceFilesWithPaths[0]?.path || '', config.link_dir)\n\t\t\t\t\t\t\tif (!canLink) {\n\t\t\t\t\t\t\t\tlog.warn(\n\t\t\t\t\t\t\t\t\t`[CrossSeed] Cannot create hardlinks (different filesystem or no write access): ${candidate.title}`\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tconst linkResult = await createHardlinks(\n\t\t\t\t\t\t\t\tsourceFilesWithPaths,\n\t\t\t\t\t\t\t\tcandidateFilesWithRoot.files,\n\t\t\t\t\t\t\t\tconfig.link_dir\n\t\t\t\t\t\t\t)\n\n\t\t\t\t\t\t\tif (!linkResult.success) {\n\t\t\t\t\t\t\t\tlog.error(`[CrossSeed] Failed to create hardlinks: ${linkResult.error}`)\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tsavepath = linkResult.destinationDir\n\t\t\t\t\t\t\tneedsRecheck = true\n\t\t\t\t\t\t\tlog.info(`[CrossSeed] Created hardlinks in: ${savepath}`)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tconst added = await addTorrentToQbt(instance, loginResult.cookie, torrentData, {\n\t\t\t\t\t\t\tsavepath,\n\t\t\t\t\t\t\tcategory,\n\t\t\t\t\t\t\ttags,\n\t\t\t\t\t\t\tskipRecheck: !needsRecheck,\n\t\t\t\t\t\t\tinfoHash: candidateInfoHash ?? undefined,\n\t\t\t\t\t\t\tversion: qbtVersion,\n\t\t\t\t\t\t})\n\n\t\t\t\t\t\tif (added) {\n\t\t\t\t\t\t\tresult.torrentsAdded++\n\t\t\t\t\t\t\tlog.info(`[CrossSeed] Added torrent: ${candidate.title}`)\n\t\t\t\t\t\t\tif (candidateInfoHash) {\n\t\t\t\t\t\t\t\texistingHashes.add(candidateInfoHash.toLowerCase())\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tresult.errors.push(`Failed to add torrent: ${candidate.title}`)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlog.info(`[CrossSeed] DRY RUN - Would add: ${candidate.title}`)\n\t\t\t\t\t\tif (candidateInfoHash) {\n\t\t\t\t\t\t\tsaveTorrentToOutput(options.instanceId, candidate.title, candidateInfoHash, torrentData)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tupsertSearchee(options.instanceId, torrent.hash, torrent.name, torrent.size, files.length, fileSizesJson)\n\t\t} catch (e) {\n\t\t\tresult.errors.push(`Error processing ${torrent.name}: ${e instanceof Error ? e.message : 'Unknown'}`)\n\t\t\tlog.error(`[CrossSeed] Error processing ${torrent.name}: ${e instanceof Error ? e.message : 'Unknown'}`)\n\t\t}\n\t}\n\n\tresult.completedAt = Date.now()\n\tconst duration = ((result.completedAt - result.startedAt) / 1000).toFixed(1)\n\tlog.info(\n\t\t`[CrossSeed] Scan complete for ${instance.label}: ${result.torrentsScanned} scanned, ${result.torrentsSkipped} skipped, ${result.matchesFound} matches, ${result.torrentsAdded} added (${duration}s)`\n\t)\n\n\tdb.run('UPDATE cross_seed_config SET last_run = ? WHERE instance_id = ?', [\n\t\tMath.floor(Date.now() / 1000),\n\t\toptions.instanceId,\n\t])\n\n\treturn result\n}\n"
  },
  {
    "path": "src/server/utils/crypto.ts",
    "content": "import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto'\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'\nimport { dirname } from 'path'\n\nconst SALT_PATH = process.env.SALT_PATH || './data/.salt'\n\nlet _key: Buffer | null = null\n\nfunction getKey(): Buffer {\n\tif (_key) return _key\n\n\tconst ENCRYPTION_KEY = process.env.ENCRYPTION_KEY\n\tif (!ENCRYPTION_KEY) {\n\t\tthrow new Error('ENCRYPTION_KEY environment variable is required')\n\t}\n\tif (ENCRYPTION_KEY.length < 32) {\n\t\tthrow new Error('ENCRYPTION_KEY must be at least 32 characters')\n\t}\n\n\tconst salt = getOrCreateSalt()\n\t_key = pbkdf2Sync(ENCRYPTION_KEY, salt, 100000, 32, 'sha256')\n\treturn _key\n}\n\nfunction getOrCreateSalt(): Buffer {\n\tif (existsSync(SALT_PATH)) {\n\t\treturn readFileSync(SALT_PATH)\n\t}\n\tmkdirSync(dirname(SALT_PATH), { recursive: true })\n\tconst salt = randomBytes(32)\n\twriteFileSync(SALT_PATH, salt)\n\treturn salt\n}\n\nexport async function hashPassword(password: string): Promise<string> {\n\treturn Bun.password.hash(password, { algorithm: 'bcrypt', cost: 12 })\n}\n\nexport async function verifyPassword(password: string, hash: string): Promise<boolean> {\n\treturn Bun.password.verify(password, hash)\n}\n\nexport function encrypt(text: string): string {\n\tconst iv = randomBytes(16)\n\tconst cipher = createCipheriv('aes-256-gcm', getKey(), iv)\n\tconst encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])\n\tconst authTag = cipher.getAuthTag()\n\treturn `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`\n}\n\nexport function decrypt(encrypted: string): string {\n\tif (!encrypted || typeof encrypted !== 'string') {\n\t\tthrow new Error('Invalid encrypted data: empty or not a string')\n\t}\n\n\tconst parts = encrypted.split(':')\n\tif (parts.length !== 3) {\n\t\tthrow new Error('Invalid encrypted data format: expected iv:authTag:data')\n\t}\n\n\tconst [ivB64, authTagB64, dataB64] = parts\n\tif (!ivB64 || !authTagB64 || !dataB64) {\n\t\tthrow new Error('Invalid encrypted data: missing components')\n\t}\n\n\ttry {\n\t\tconst iv = Buffer.from(ivB64, 'base64')\n\t\tconst authTag = Buffer.from(authTagB64, 'base64')\n\t\tconst data = Buffer.from(dataB64, 'base64')\n\t\tconst decipher = createDecipheriv('aes-256-gcm', getKey(), iv)\n\t\tdecipher.setAuthTag(authTag)\n\t\treturn decipher.update(data) + decipher.final('utf8')\n\t} catch {\n\t\tthrow new Error('Decryption failed: data may be corrupted')\n\t}\n}\n\nexport function generateSessionId(): string {\n\treturn randomBytes(32).toString('hex')\n}\n"
  },
  {
    "path": "src/server/utils/fetch.ts",
    "content": "const allowSelfSigned = process.env.ALLOW_SELF_SIGNED_CERTS === 'true'\n\ntype FetchOptions = RequestInit & {\n\ttls?: { rejectUnauthorized: boolean }\n}\n\nexport async function fetchWithTls(url: string, options: RequestInit = {}): Promise<Response> {\n\tconst fetchOptions: FetchOptions = { ...options }\n\tif (allowSelfSigned) {\n\t\tfetchOptions.tls = { rejectUnauthorized: false }\n\t}\n\ttry {\n\t\treturn await fetch(url, fetchOptions)\n\t} catch (err) {\n\t\tif (\n\t\t\t!allowSelfSigned &&\n\t\t\terr instanceof Error &&\n\t\t\t(err.message.includes('self-signed') ||\n\t\t\t\terr.message.includes('SELF_SIGNED') ||\n\t\t\t\terr.message.includes('certificate') ||\n\t\t\t\terr.message.includes('CERT_'))\n\t\t) {\n\t\t\tthrow new Error(\n\t\t\t\t'TLS certificate validation failed. If using self-signed certificates, set ALLOW_SELF_SIGNED_CERTS=true'\n\t\t\t)\n\t\t}\n\t\tthrow err\n\t}\n}\n"
  },
  {
    "path": "src/server/utils/logger.ts",
    "content": "export interface LogEntry {\n\ttimestamp: string\n\tlevel: 'INFO' | 'WARN' | 'ERROR'\n\tmessage: string\n}\n\nconst MAX_LOG_ENTRIES = 500\nconst logBuffer: LogEntry[] = []\n\nconst timestamp = () => new Date().toISOString()\n\nfunction addToBuffer(level: LogEntry['level'], msg: string) {\n\tconst entry: LogEntry = { timestamp: timestamp(), level, message: msg }\n\tlogBuffer.push(entry)\n\tif (logBuffer.length > MAX_LOG_ENTRIES) {\n\t\tlogBuffer.shift()\n\t}\n}\n\nexport const log = {\n\tinfo: (msg: string) => {\n\t\taddToBuffer('INFO', msg)\n\t\tconsole.log(`[${timestamp()}] [INFO] ${msg}`)\n\t},\n\twarn: (msg: string) => {\n\t\taddToBuffer('WARN', msg)\n\t\tconsole.warn(`[${timestamp()}] [WARN] ${msg}`)\n\t},\n\terror: (msg: string) => {\n\t\taddToBuffer('ERROR', msg)\n\t\tconsole.error(`[${timestamp()}] [ERROR] ${msg}`)\n\t},\n}\n\nexport function getLogs(filter?: string, limit = 100): LogEntry[] {\n\tlet entries = logBuffer\n\tif (filter) {\n\t\tentries = entries.filter((e) => e.message.includes(filter))\n\t}\n\treturn entries.slice(-limit)\n}\n\nexport function clearLogs(): void {\n\tlogBuffer.length = 0\n}\n"
  },
  {
    "path": "src/server/utils/qbt.ts",
    "content": "import { decrypt } from './crypto'\nimport { fetchWithTls } from './fetch'\nimport { log } from './logger'\n\ninterface QbtInstance {\n\turl: string\n\tqbt_username: string | null\n\tqbt_password_encrypted: string | null\n\tskip_auth: number\n}\n\nexport type QbtLoginResult =\n\t| {\n\t\t\tsuccess: true\n\t\t\tcookie: string | null\n\t\t\tversion?: string\n\t  }\n\t| {\n\t\t\tsuccess: false\n\t\t\terror: string\n\t\t\tstatus?: number\n\t  }\n\nexport async function loginToQbt(instance: QbtInstance, timeout?: number): Promise<QbtLoginResult> {\n\tif (instance.skip_auth) {\n\t\treturn { success: true, cookie: null }\n\t}\n\n\tif (!instance.qbt_username || !instance.qbt_password_encrypted) {\n\t\treturn { success: false, error: 'Credentials required' }\n\t}\n\n\ttry {\n\t\tconst password = decrypt(instance.qbt_password_encrypted)\n\t\tconst res = await fetchWithTls(`${instance.url}/api/v2/auth/login`, {\n\t\t\tmethod: 'POST',\n\t\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\t\tbody: new URLSearchParams({\n\t\t\t\tusername: instance.qbt_username,\n\t\t\t\tpassword,\n\t\t\t}),\n\t\t\tsignal: timeout ? AbortSignal.timeout(timeout) : undefined,\n\t\t})\n\n\t\tif (!res.ok) {\n\t\t\treturn { success: false, error: `Login failed: HTTP ${res.status}`, status: res.status }\n\t\t}\n\n\t\tconst text = await res.text()\n\t\tif (text !== 'Ok.') {\n\t\t\treturn { success: false, error: 'Invalid credentials', status: 401 }\n\t\t}\n\n\t\tconst cookie = res.headers.get('set-cookie')?.split(';')[0]\n\t\tif (!cookie) {\n\t\t\treturn { success: false, error: 'No session cookie received' }\n\t\t}\n\n\t\treturn { success: true, cookie }\n\t} catch (e) {\n\t\tlog.error(`qBittorrent login failed: ${e instanceof Error ? e.message : 'Unknown error'}`)\n\t\treturn { success: false, error: 'Connection failed' }\n\t}\n}\n\nexport async function testQbtConnection(\n\turl: string,\n\tusername?: string,\n\tpassword?: string,\n\tskipAuth?: boolean\n): Promise<QbtLoginResult> {\n\ttry {\n\t\tlet cookie: string | null = null\n\n\t\tif (!skipAuth) {\n\t\t\tif (!username || !password) {\n\t\t\t\treturn { success: false, error: 'Credentials required' }\n\t\t\t}\n\n\t\t\tconst loginRes = await fetchWithTls(`${url}/api/v2/auth/login`, {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\t\t\tbody: new URLSearchParams({ username, password }),\n\t\t\t})\n\n\t\t\tif (!loginRes.ok) {\n\t\t\t\treturn { success: false, error: `Login failed: HTTP ${loginRes.status}`, status: loginRes.status }\n\t\t\t}\n\n\t\t\tconst loginText = await loginRes.text()\n\t\t\tif (loginText !== 'Ok.') {\n\t\t\t\treturn { success: false, error: 'Invalid credentials', status: 401 }\n\t\t\t}\n\n\t\t\tcookie = loginRes.headers.get('set-cookie')?.split(';')[0] || null\n\t\t\tif (!cookie) {\n\t\t\t\treturn { success: false, error: 'No session cookie received' }\n\t\t\t}\n\t\t}\n\n\t\tconst versionRes = await fetchWithTls(`${url}/api/v2/app/version`, {\n\t\t\theaders: cookie ? { Cookie: cookie } : {},\n\t\t})\n\n\t\tif (!versionRes.ok) {\n\t\t\treturn { success: false, error: skipAuth ? 'Connection failed - is IP bypass enabled?' : 'Failed to get version' }\n\t\t}\n\n\t\tconst version = await versionRes.text()\n\t\treturn { success: true, cookie, version }\n\t} catch (e) {\n\t\tlog.error(`qBittorrent connection test failed: ${e instanceof Error ? e.message : 'Unknown error'}`)\n\t\treturn { success: false, error: 'Connection failed' }\n\t}\n}\n\nexport async function testStoredQbtInstance(instance: QbtInstance): Promise<QbtLoginResult> {\n\tconst loginResult = await loginToQbt(instance)\n\tif (!loginResult.success) {\n\t\treturn loginResult\n\t}\n\n\ttry {\n\t\tconst versionRes = await fetchWithTls(`${instance.url}/api/v2/app/version`, {\n\t\t\theaders: loginResult.cookie ? { Cookie: loginResult.cookie } : {},\n\t\t})\n\n\t\tif (!versionRes.ok) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: instance.skip_auth ? 'Connection failed - is IP bypass enabled?' : 'Failed to get version',\n\t\t\t}\n\t\t}\n\n\t\tconst version = await versionRes.text()\n\t\treturn { success: true, cookie: loginResult.cookie, version }\n\t} catch (e) {\n\t\tlog.error(`qBittorrent stored instance test failed: ${e instanceof Error ? e.message : 'Unknown error'}`)\n\t\treturn { success: false, error: 'Connection failed' }\n\t}\n}\n\ninterface SyncMaindata {\n\tserver_state: {\n\t\talltime_dl: number\n\t\talltime_ul: number\n\t}\n}\n\nexport async function fetchInstanceTransferStats(\n\tinstance: QbtInstance\n): Promise<{ uploaded: number; downloaded: number } | null> {\n\ttry {\n\t\tconst loginResult = await loginToQbt(instance)\n\t\tif (!loginResult.success) return null\n\n\t\tconst headers: Record<string, string> = {}\n\t\tif (loginResult.cookie) headers.Cookie = loginResult.cookie\n\n\t\tconst res = await fetchWithTls(`${instance.url}/api/v2/sync/maindata?rid=0`, { headers })\n\t\tif (!res.ok) return null\n\n\t\tconst data = (await res.json()) as SyncMaindata\n\t\treturn {\n\t\t\tuploaded: data.server_state.alltime_ul,\n\t\t\tdownloaded: data.server_state.alltime_dl,\n\t\t}\n\t} catch {\n\t\treturn null\n\t}\n}\n"
  },
  {
    "path": "src/server/utils/rateLimit.ts",
    "content": "interface RateLimitEntry {\n\tcount: number\n\tresetAt: number\n}\n\nconst limits = new Map<string, RateLimitEntry>()\n\nconst WINDOW_MS = 60 * 1000\nconst MAX_ATTEMPTS = 5\n\nexport function checkRateLimit(key: string): { allowed: boolean; retryAfter?: number } {\n\tconst now = Date.now()\n\tconst entry = limits.get(key)\n\n\tif (!entry || entry.resetAt < now) {\n\t\tlimits.set(key, { count: 1, resetAt: now + WINDOW_MS })\n\t\treturn { allowed: true }\n\t}\n\n\tif (entry.count >= MAX_ATTEMPTS) {\n\t\treturn { allowed: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) }\n\t}\n\n\tentry.count++\n\treturn { allowed: true }\n}\n\nexport function resetRateLimit(key: string): void {\n\tlimits.delete(key)\n}\n\nsetInterval(() => {\n\tconst now = Date.now()\n\tfor (const [key, entry] of limits) {\n\t\tif (entry.resetAt < now) {\n\t\t\tlimits.delete(key)\n\t\t}\n\t}\n}, 60 * 1000)\n"
  },
  {
    "path": "src/server/utils/statsRecorder.ts",
    "content": "import { db, type Instance, type TransferStats } from '../db'\nimport { log } from './logger'\nimport { fetchInstanceTransferStats } from './qbt'\n\nconst RECORD_INTERVAL_MS = 5 * 60 * 1000\nconst PRUNE_AFTER_DAYS = 365\n\nlet recordInterval: ReturnType<typeof setInterval> | null = null\n\nasync function recordStats(): Promise<void> {\n\tconst instances = db.query<Instance, []>('SELECT * FROM instances').all()\n\tif (instances.length === 0) return\n\n\tconst now = Math.floor(Date.now() / 1000)\n\n\tfor (const instance of instances) {\n\t\tconst stats = await fetchInstanceTransferStats(instance)\n\t\tif (!stats) continue\n\n\t\tdb.run('INSERT INTO transfer_stats (instance_id, timestamp, uploaded, downloaded) VALUES (?, ?, ?, ?)', [\n\t\t\tinstance.id,\n\t\t\tnow,\n\t\t\tstats.uploaded,\n\t\t\tstats.downloaded,\n\t\t])\n\t}\n}\n\nfunction pruneOldStats(): void {\n\tconst cutoff = Math.floor(Date.now() / 1000) - PRUNE_AFTER_DAYS * 24 * 60 * 60\n\tconst result = db.run('DELETE FROM transfer_stats WHERE timestamp < ?', [cutoff])\n\tif (result.changes > 0) {\n\t\tlog.info(`[Stats Recorder] Pruned ${result.changes} old records`)\n\t}\n}\n\nexport function startStatsRecorder(): void {\n\tlog.info('[Stats Recorder] Starting stats recorder (5 min interval)')\n\trecordStats()\n\tpruneOldStats()\n\trecordInterval = setInterval(() => {\n\t\trecordStats()\n\t\tpruneOldStats()\n\t}, RECORD_INTERVAL_MS)\n}\n\nexport function stopStatsRecorder(): void {\n\tif (recordInterval) {\n\t\tclearInterval(recordInterval)\n\t\trecordInterval = null\n\t}\n\tlog.info('[Stats Recorder] Stopped')\n}\n\nexport interface PeriodStats {\n\tuploaded: number\n\tdownloaded: number\n\thasData: boolean\n\tdataPoints: number\n}\n\nexport function getStatsForPeriod(instanceId: number, periodSeconds: number): PeriodStats {\n\tconst now = Math.floor(Date.now() / 1000)\n\tconst periodStart = now - periodSeconds\n\n\tconst latest = db\n\t\t.query<\n\t\t\tTransferStats,\n\t\t\t[number]\n\t\t>('SELECT * FROM transfer_stats WHERE instance_id = ? ORDER BY timestamp DESC LIMIT 1')\n\t\t.get(instanceId)\n\n\tif (!latest) {\n\t\treturn { uploaded: 0, downloaded: 0, hasData: false, dataPoints: 0 }\n\t}\n\n\tconst absoluteOldest = db\n\t\t.query<TransferStats, [number]>('SELECT * FROM transfer_stats WHERE instance_id = ? ORDER BY timestamp ASC LIMIT 1')\n\t\t.get(instanceId)\n\n\tif (!absoluteOldest) {\n\t\treturn { uploaded: 0, downloaded: 0, hasData: false, dataPoints: 0 }\n\t}\n\n\tconst dataDuration = now - absoluteOldest.timestamp\n\tif (periodSeconds > dataDuration) {\n\t\treturn { uploaded: 0, downloaded: 0, hasData: false, dataPoints: 0 }\n\t}\n\n\tconst oldest = db\n\t\t.query<\n\t\t\tTransferStats,\n\t\t\t[number, number]\n\t\t>('SELECT * FROM transfer_stats WHERE instance_id = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1')\n\t\t.get(instanceId, periodStart)\n\n\tconst dataPoints = db\n\t\t.query<\n\t\t\t{ count: number },\n\t\t\t[number, number]\n\t\t>('SELECT COUNT(*) as count FROM transfer_stats WHERE instance_id = ? AND timestamp >= ?')\n\t\t.get(instanceId, periodStart)\n\n\tif (!oldest) {\n\t\treturn { uploaded: 0, downloaded: 0, hasData: false, dataPoints: 0 }\n\t}\n\n\treturn {\n\t\tuploaded: Math.max(0, latest.uploaded - oldest.uploaded),\n\t\tdownloaded: Math.max(0, latest.downloaded - oldest.downloaded),\n\t\thasData: true,\n\t\tdataPoints: dataPoints?.count ?? 0,\n\t}\n}\n"
  },
  {
    "path": "src/server/utils/torznab.ts",
    "content": "import xml2js from 'xml2js'\nimport { fetchWithTls } from './fetch'\nimport { log } from './logger'\nimport { db, IndexerStatus, type CrossSeedIndexer } from '../db'\n\nconst RATE_LIMIT_SNOOZE_MS = 60 * 60 * 1000\nconst ERROR_SNOOZE_MS = 10 * 60 * 1000\n\nexport interface TorznabIndexer {\n\tid: number\n\tname: string\n\tprotocol: string\n\tsupportsSearch: boolean\n\tcategories: number[]\n}\n\nexport interface TorznabResult {\n\tguid: string\n\ttitle: string\n\tlink: string\n\tsize: number | undefined\n\tpubDate: string\n\tindexer: string\n\tindexerId: number\n\tinfoHash?: string\n}\n\ninterface TorznabXmlResult {\n\tguid: [string]\n\ttitle: [string]\n\tlink: [string]\n\tsize?: [string]\n\tpubDate?: [string]\n\t'torznab:attr'?: Array<{ $: { name: string; value: string } }>\n}\n\ninterface TorznabXmlResponse {\n\trss?: {\n\t\tchannel?: [{ item?: TorznabXmlResult[] }]\n\t}\n}\n\nexport async function getTorznabIndexers(prowlarrUrl: string, apiKey: string): Promise<TorznabIndexer[]> {\n\tconst res = await fetchWithTls(`${prowlarrUrl}/api/v1/indexer`, {\n\t\theaders: { 'X-Api-Key': apiKey },\n\t})\n\tif (!res.ok) {\n\t\tthrow new Error(`Failed to fetch indexers: HTTP ${res.status}`)\n\t}\n\tconst indexers = (await res.json()) as Array<{\n\t\tid: number\n\t\tname: string\n\t\tprotocol: string\n\t\tsupportsSearch: boolean\n\t\tenable: boolean\n\t\tcapabilities?: { categories?: Array<{ id: number }> }\n\t}>\n\treturn indexers\n\t\t.filter((i) => i.enable && i.supportsSearch && i.protocol === 'torrent')\n\t\t.map((i) => ({\n\t\t\tid: i.id,\n\t\t\tname: i.name,\n\t\t\tprotocol: i.protocol,\n\t\t\tsupportsSearch: i.supportsSearch,\n\t\t\tcategories: i.capabilities?.categories?.map((c) => c.id) ?? [],\n\t\t}))\n}\n\nfunction getIndexerStatus(integrationId: number, indexerId: number): CrossSeedIndexer | null {\n\treturn db\n\t\t.query<\n\t\t\tCrossSeedIndexer,\n\t\t\t[number, number]\n\t\t>('SELECT * FROM cross_seed_indexer WHERE integration_id = ? AND indexer_id = ?')\n\t\t.get(integrationId, indexerId)\n}\n\nfunction updateIndexerStatus(\n\tintegrationId: number,\n\tindexerId: number,\n\tname: string,\n\tstatus: string,\n\tretryAfter: number\n): void {\n\tdb.run(\n\t\t`INSERT INTO cross_seed_indexer (integration_id, indexer_id, name, status, retry_after)\n\t\t VALUES (?, ?, ?, ?, ?)\n\t\t ON CONFLICT(integration_id, indexer_id) DO UPDATE SET\n\t\t \tname = excluded.name,\n\t\t \tstatus = excluded.status,\n\t\t \tretry_after = excluded.retry_after`,\n\t\t[integrationId, indexerId, name, status, retryAfter]\n\t)\n\tconst retryDate = new Date(retryAfter).toISOString()\n\tlog.info(`[CrossSeed] Snoozing ${name} (${status}) until ${retryDate}`)\n}\n\nfunction isIndexerAvailable(integrationId: number, indexerId: number): boolean {\n\tconst status = getIndexerStatus(integrationId, indexerId)\n\tif (!status) return true\n\tif (status.status === IndexerStatus.OK) return true\n\tif (!status.retry_after) return true\n\treturn Date.now() > status.retry_after\n}\n\nfunction clearIndexerStatus(integrationId: number, indexerId: number): void {\n\tdb.run('UPDATE cross_seed_indexer SET status = ?, retry_after = NULL WHERE integration_id = ? AND indexer_id = ?', [\n\t\tIndexerStatus.OK,\n\t\tintegrationId,\n\t\tindexerId,\n\t])\n}\n\nfunction handleResponseError(\n\tresponse: Response,\n\tintegrationId: number,\n\tindexerId: number,\n\tindexerName: string\n): number {\n\tconst retryAfterHeader = response.headers.get('Retry-After')\n\tconst retryAfterSeconds = retryAfterHeader ? Number(retryAfterHeader) : NaN\n\n\tlet retryAfter: number\n\tif (!Number.isNaN(retryAfterSeconds)) {\n\t\tretryAfter = Date.now() + retryAfterSeconds * 1000\n\t} else if (response.status === 429) {\n\t\tretryAfter = Date.now() + RATE_LIMIT_SNOOZE_MS\n\t} else {\n\t\tretryAfter = Date.now() + ERROR_SNOOZE_MS\n\t}\n\n\tconst status = response.status === 429 ? IndexerStatus.RATE_LIMITED : IndexerStatus.UNKNOWN_ERROR\n\tupdateIndexerStatus(integrationId, indexerId, indexerName, status, retryAfter)\n\treturn retryAfter\n}\n\nfunction parseTorznabResults(xml: TorznabXmlResponse, indexer: TorznabIndexer): TorznabResult[] {\n\tconst items = xml?.rss?.channel?.[0]?.item\n\tif (!items || !Array.isArray(items)) {\n\t\treturn []\n\t}\n\n\treturn items\n\t\t.map((item) => {\n\t\t\tconst attrs = item['torznab:attr'] ?? []\n\t\t\tconst infoHashAttr = attrs.find((a) => a.$.name === 'infohash')\n\t\t\treturn {\n\t\t\t\tguid: item.guid[0],\n\t\t\t\ttitle: item.title[0],\n\t\t\t\tlink: item.link[0],\n\t\t\t\tsize: item.size?.[0] ? parseInt(item.size[0], 10) || undefined : undefined,\n\t\t\t\tpubDate: item.pubDate?.[0] ?? '',\n\t\t\t\tindexer: indexer.name,\n\t\t\t\tindexerId: indexer.id,\n\t\t\t\tinfoHash: infoHashAttr?.$.value,\n\t\t\t}\n\t\t})\n\t\t.filter((r) => r.title && r.link)\n}\n\nexport async function searchTorznab(\n\tprowlarrUrl: string,\n\tapiKey: string,\n\tindexer: TorznabIndexer,\n\tquery: string,\n\tintegrationId: number\n): Promise<TorznabResult[]> {\n\tif (!isIndexerAvailable(integrationId, indexer.id)) {\n\t\tconst status = getIndexerStatus(integrationId, indexer.id)\n\t\tconst retryDate = status?.retry_after ? new Date(status.retry_after).toISOString() : 'unknown'\n\t\tlog.info(`[CrossSeed] Skipping ${indexer.name} - snoozed until ${retryDate}`)\n\t\treturn []\n\t}\n\n\tconst torznabUrl = `${prowlarrUrl}/${indexer.id}/api`\n\tconst params = new URLSearchParams({\n\t\tt: 'search',\n\t\tapikey: apiKey,\n\t\tq: query,\n\t})\n\n\tconst url = `${torznabUrl}?${params}`\n\tlog.info(`[CrossSeed] Searching ${indexer.name}: ${query}`)\n\n\tlet res: Response\n\ttry {\n\t\tres = await fetchWithTls(url, {\n\t\t\theaders: { 'User-Agent': 'cross-seed-webui/1.0' },\n\t\t\tsignal: AbortSignal.timeout(60000),\n\t\t})\n\t} catch (e) {\n\t\tconst retryAfter = Date.now() + ERROR_SNOOZE_MS\n\t\tupdateIndexerStatus(integrationId, indexer.id, indexer.name, IndexerStatus.UNKNOWN_ERROR, retryAfter)\n\t\tthrow new Error(`${indexer.name} failed to respond: ${e instanceof Error ? e.message : 'Unknown'}`)\n\t}\n\n\tif (!res.ok) {\n\t\thandleResponseError(res, integrationId, indexer.id, indexer.name)\n\t\tif (res.status === 429) {\n\t\t\treturn []\n\t\t}\n\t\tthrow new Error(`Torznab search failed for ${indexer.name}: HTTP ${res.status}`)\n\t}\n\n\tclearIndexerStatus(integrationId, indexer.id)\n\n\tconst xmlText = await res.text()\n\ttry {\n\t\tconst parsed: TorznabXmlResponse = await xml2js.parseStringPromise(xmlText)\n\t\tconst results = parseTorznabResults(parsed, indexer)\n\t\tlog.info(`[CrossSeed] ${indexer.name} returned ${results.length} results`)\n\t\treturn results\n\t} catch (e) {\n\t\tlog.warn(`[CrossSeed] Failed to parse XML from ${indexer.name}: ${e instanceof Error ? e.message : 'Unknown'}`)\n\t\treturn []\n\t}\n}\n\nexport async function searchAllIndexers(\n\tprowlarrUrl: string,\n\tapiKey: string,\n\tquery: string,\n\tintegrationId: number,\n\tindexerIds?: number[]\n): Promise<TorznabResult[]> {\n\tlet indexers = await getTorznabIndexers(prowlarrUrl, apiKey)\n\tif (indexerIds && indexerIds.length > 0) {\n\t\tconst idSet = new Set(indexerIds)\n\t\tindexers = indexers.filter((i) => idSet.has(i.id))\n\t}\n\n\tconst availableIndexers = indexers.filter((i) => isIndexerAvailable(integrationId, i.id))\n\tconst skippedCount = indexers.length - availableIndexers.length\n\tif (skippedCount > 0) {\n\t\tlog.info(`[CrossSeed] Skipping ${skippedCount} snoozed indexer(s)`)\n\t}\n\tif (availableIndexers.length === 0) {\n\t\treturn []\n\t}\n\tlog.info(`[CrossSeed] Searching ${availableIndexers.length} indexer(s) in parallel`)\n\n\tconst outcomes = await Promise.allSettled(\n\t\tavailableIndexers.map((indexer) => searchTorznab(prowlarrUrl, apiKey, indexer, query, integrationId))\n\t)\n\n\tconst results: TorznabResult[] = []\n\tfor (let i = 0; i < outcomes.length; i++) {\n\t\tconst outcome = outcomes[i]\n\t\tconst indexer = availableIndexers[i]\n\t\tif (outcome.status === 'fulfilled') {\n\t\t\tresults.push(...outcome.value)\n\t\t} else {\n\t\t\tlog.warn(\n\t\t\t\t`[CrossSeed] Search failed for ${indexer.name}: ${outcome.reason instanceof Error ? outcome.reason.message : 'Unknown'}`\n\t\t\t)\n\t\t}\n\t}\n\n\treturn results\n}\n\nconst SNATCH_RETRIES = 4\nconst SNATCH_DELAY_MS = 60 * 1000\n\nasync function snatchOnce(link: string): Promise<Buffer | { error: string; retryAfterMs?: number; noRetry?: boolean }> {\n\ttry {\n\t\tconst res = await fetchWithTls(link, {\n\t\t\theaders: { 'User-Agent': 'cross-seed-webui/1.0' },\n\t\t\tsignal: AbortSignal.timeout(60000),\n\t\t})\n\n\t\tconst retryAfterSeconds = Number(res.headers.get('Retry-After'))\n\t\tconst retryAfterMs = !Number.isNaN(retryAfterSeconds) ? retryAfterSeconds * 1000 : undefined\n\n\t\tif (res.status === 429) {\n\t\t\treturn { error: 'Rate limited', retryAfterMs, noRetry: true }\n\t\t}\n\t\tif (!res.ok) {\n\t\t\treturn { error: `HTTP ${res.status}`, retryAfterMs }\n\t\t}\n\n\t\tconst contentType = res.headers.get('content-type') || ''\n\t\tif (contentType.includes('magnet') || link.startsWith('magnet:')) {\n\t\t\treturn { error: 'Magnet links not supported', noRetry: true }\n\t\t}\n\n\t\tconst data = await res.arrayBuffer()\n\t\treturn Buffer.from(data)\n\t} catch (e) {\n\t\tif (e instanceof Error && (e.name === 'AbortError' || e.name === 'TimeoutError')) {\n\t\t\treturn { error: 'Timeout' }\n\t\t}\n\t\treturn { error: e instanceof Error ? e.message : 'Unknown error' }\n\t}\n}\n\nexport async function downloadTorrentDirect(\n\tlink: string,\n\toptions: { retries?: number; delayMs?: number } = {}\n): Promise<Buffer | null> {\n\tif (!link) return null\n\n\tconst retries = options.retries ?? SNATCH_RETRIES\n\tconst delayMs = options.delayMs ?? SNATCH_DELAY_MS\n\tconst retryAfterEndTime = Date.now() + retries * delayMs\n\n\tfor (let i = 0; i <= retries; i++) {\n\t\tconst result = await snatchOnce(link)\n\n\t\tif (Buffer.isBuffer(result)) {\n\t\t\tif (i > 0) {\n\t\t\t\tlog.info(`[CrossSeed] Snatched torrent on attempt ${i + 1}/${retries + 1}`)\n\t\t\t}\n\t\t\treturn result\n\t\t}\n\n\t\tconst { error, retryAfterMs, noRetry } = result\n\n\t\tif (noRetry) {\n\t\t\tlog.warn(`[CrossSeed] Download failed (no retry): ${error}`)\n\t\t\treturn null\n\t\t}\n\n\t\tif (retryAfterMs && Date.now() + retryAfterMs >= retryAfterEndTime) {\n\t\t\tlog.warn(`[CrossSeed] Download failed, Retry-After exceeds timeout: ${error}`)\n\t\t\treturn null\n\t\t}\n\n\t\tconst actualDelay = Math.max(delayMs, retryAfterMs ?? 0)\n\n\t\tif (i < retries) {\n\t\t\tlog.warn(\n\t\t\t\t`[CrossSeed] Snatch attempt ${i + 1}/${retries + 1} failed, retrying in ${actualDelay / 1000}s: ${error}`\n\t\t\t)\n\t\t\tawait new Promise((resolve) => setTimeout(resolve, actualDelay))\n\t\t} else {\n\t\t\tlog.warn(`[CrossSeed] Download failed after ${retries + 1} attempts: ${error}`)\n\t\t}\n\t}\n\n\treturn null\n}\n"
  },
  {
    "path": "src/server/utils/url.ts",
    "content": "const CLOUD_METADATA = ['169.254.169.254', 'metadata.google.internal', 'metadata.aws.internal', '169.254.170.2']\n\nexport function isUrlAllowed(urlString: string): { allowed: boolean; reason?: string } {\n\tlet url: URL\n\ttry {\n\t\turl = new URL(urlString)\n\t} catch {\n\t\treturn { allowed: false, reason: 'Invalid URL format' }\n\t}\n\n\tif (url.protocol !== 'http:' && url.protocol !== 'https:') {\n\t\treturn { allowed: false, reason: 'Only HTTP/HTTPS protocols allowed' }\n\t}\n\n\tconst hostname = url.hostname.toLowerCase()\n\n\tif (CLOUD_METADATA.includes(hostname) || hostname.startsWith('169.254.')) {\n\t\treturn { allowed: false, reason: 'Cloud metadata endpoints not allowed' }\n\t}\n\n\treturn { allowed: true }\n}\n\nexport function validateUrl(urlString: string): void {\n\tconst result = isUrlAllowed(urlString)\n\tif (!result.allowed) {\n\t\tthrow new Error(result.reason)\n\t}\n}\n"
  },
  {
    "path": "src/themes/index.ts",
    "content": "export interface Theme {\n\tid: string\n\tname: string\n\tcolors: {\n\t\tbgPrimary: string\n\t\tbgSecondary: string\n\t\tbgTertiary: string\n\t\ttextPrimary: string\n\t\ttextSecondary: string\n\t\ttextMuted: string\n\t\taccent: string\n\t\taccentContrast: string\n\t\twarning: string\n\t\terror: string\n\t\tborder: string\n\t\tprogress: string\n\t}\n}\n\nexport const themes: Theme[] = [\n\t{\n\t\tid: 'default',\n\t\tname: 'Midnight',\n\t\tcolors: {\n\t\t\tbgPrimary: '#07070a',\n\t\t\tbgSecondary: '#0a0a0f',\n\t\t\tbgTertiary: '#0e0e14',\n\t\t\ttextPrimary: '#e8e8ed',\n\t\t\ttextSecondary: '#b8b8c8',\n\t\t\ttextMuted: '#8a8a9e',\n\t\t\taccent: '#00d4aa',\n\t\t\taccentContrast: '#070a09',\n\t\t\twarning: '#f7b731',\n\t\t\terror: '#f43f5e',\n\t\t\tborder: '#32323e',\n\t\t\tprogress: '#00d4aa',\n\t\t},\n\t},\n\t{\n\t\tid: 'catppuccin',\n\t\tname: 'Catppuccin',\n\t\tcolors: {\n\t\t\tbgPrimary: '#1e1e2e',\n\t\t\tbgSecondary: '#181825',\n\t\t\tbgTertiary: '#313244',\n\t\t\ttextPrimary: '#cdd6f4',\n\t\t\ttextSecondary: '#bac2de',\n\t\t\ttextMuted: '#9399b2',\n\t\t\taccent: '#cba6f7',\n\t\t\taccentContrast: '#1e1e2e',\n\t\t\twarning: '#f9e2af',\n\t\t\terror: '#f38ba8',\n\t\t\tborder: '#45475a',\n\t\t\tprogress: '#a6e3a1',\n\t\t},\n\t},\n\t{\n\t\tid: 'dracula',\n\t\tname: 'Dracula',\n\t\tcolors: {\n\t\t\tbgPrimary: '#282a36',\n\t\t\tbgSecondary: '#21222c',\n\t\t\tbgTertiary: '#343746',\n\t\t\ttextPrimary: '#f8f8f2',\n\t\t\ttextSecondary: '#d0d0d8',\n\t\t\ttextMuted: '#8b95c9',\n\t\t\taccent: '#bd93f9',\n\t\t\taccentContrast: '#21222c',\n\t\t\twarning: '#f1fa8c',\n\t\t\terror: '#ff5555',\n\t\t\tborder: '#44475a',\n\t\t\tprogress: '#50fa7b',\n\t\t},\n\t},\n\t{\n\t\tid: 'nord',\n\t\tname: 'Nord',\n\t\tcolors: {\n\t\t\tbgPrimary: '#2e3440',\n\t\t\tbgSecondary: '#292e39',\n\t\t\tbgTertiary: '#3b4252',\n\t\t\ttextPrimary: '#eceff4',\n\t\t\ttextSecondary: '#d8dee9',\n\t\t\ttextMuted: '#8b92a8',\n\t\t\taccent: '#88c0d0',\n\t\t\taccentContrast: '#2e3440',\n\t\t\twarning: '#ebcb8b',\n\t\t\terror: '#bf616a',\n\t\t\tborder: '#434c5e',\n\t\t\tprogress: '#a3be8c',\n\t\t},\n\t},\n\t{\n\t\tid: 'gruvbox',\n\t\tname: 'Gruvbox',\n\t\tcolors: {\n\t\t\tbgPrimary: '#1d2021',\n\t\t\tbgSecondary: '#282828',\n\t\t\tbgTertiary: '#3c3836',\n\t\t\ttextPrimary: '#ebdbb2',\n\t\t\ttextSecondary: '#d5c4a1',\n\t\t\ttextMuted: '#a89984',\n\t\t\taccent: '#fe8019',\n\t\t\taccentContrast: '#1d2021',\n\t\t\twarning: '#fabd2f',\n\t\t\terror: '#fb4934',\n\t\t\tborder: '#504945',\n\t\t\tprogress: '#b8bb26',\n\t\t},\n\t},\n\t{\n\t\tid: 'everforest',\n\t\tname: 'Everforest',\n\t\tcolors: {\n\t\t\tbgPrimary: '#272E33',\n\t\t\tbgSecondary: '#1E2326',\n\t\t\tbgTertiary: '#2E383C',\n\t\t\ttextPrimary: '#D3C6AA',\n\t\t\ttextSecondary: '#9DA9A0',\n\t\t\ttextMuted: '#7A8478',\n\t\t\taccent: '#A7C080',\n\t\t\taccentContrast: '#272E33',\n\t\t\twarning: '#DBBC7F',\n\t\t\terror: '#E67E80',\n\t\t\tborder: '#414B50',\n\t\t\tprogress: '#83C092',\n\t\t},\n\t},\n]\n\nexport function getThemeById(id: string): Theme {\n\treturn themes.find((t) => t.id === id) ?? themes[0]\n}\n"
  },
  {
    "path": "src/types/preferences.ts",
    "content": "export interface QBittorrentPreferences {\n\tlocale: string\n\tconfirm_torrent_deletion: boolean\n\tstatus_bar_external_ip: boolean\n\tperformance_warning: boolean\n\tfile_log_enabled: boolean\n\tfile_log_path: string\n\tfile_log_max_size: number\n\tfile_log_age: number\n\tfile_log_age_type: number\n\tfile_log_backup_enabled: boolean\n\tfile_log_delete_old: boolean\n\n\ttorrent_content_layout: 'Original' | 'Subfolder' | 'NoSubfolder'\n\tadd_to_top_of_queue: boolean\n\tadd_stopped_enabled: boolean\n\ttorrent_stop_condition: 'None' | 'MetadataReceived' | 'FilesChecked'\n\tmerge_trackers: boolean\n\tauto_delete_mode: number\n\tpreallocate_all: boolean\n\tincomplete_files_ext: boolean\n\tuse_unwanted_folder: boolean\n\tsave_path: string\n\ttemp_path: string\n\ttemp_path_enabled: boolean\n\texport_dir: string\n\texport_dir_fin: string\n\tauto_tmm_enabled: boolean\n\ttorrent_changed_tmm_enabled: boolean\n\tsave_path_changed_tmm_enabled: boolean\n\tcategory_changed_tmm_enabled: boolean\n\tuse_subcategories: boolean\n\tuse_category_paths_in_manual_mode: boolean\n\tscan_dirs: Record<string, number | string>\n\texcluded_file_names_enabled: boolean\n\texcluded_file_names: string\n\tmail_notification_enabled: boolean\n\tmail_notification_sender: string\n\tmail_notification_email: string\n\tmail_notification_smtp: string\n\tmail_notification_ssl_enabled: boolean\n\tmail_notification_auth_enabled: boolean\n\tmail_notification_username: string\n\tmail_notification_password: string\n\tautorun_on_torrent_added_enabled: boolean\n\tautorun_on_torrent_added_program: string\n\tautorun_enabled: boolean\n\tautorun_program: string\n\n\tlisten_port: number\n\tupnp: boolean\n\trandom_port: boolean\n\tmax_connec: number\n\tmax_connec_per_torrent: number\n\tmax_uploads: number\n\tmax_uploads_per_torrent: number\n\ti2p_enabled: boolean\n\ti2p_address: string\n\ti2p_port: number\n\ti2p_mixed_mode: boolean\n\ti2p_inbound_quantity: number\n\ti2p_outbound_quantity: number\n\ti2p_inbound_length: number\n\ti2p_outbound_length: number\n\tproxy_type: number\n\tproxy_ip: string\n\tproxy_port: number\n\tproxy_auth_enabled: boolean\n\tproxy_username: string\n\tproxy_password: string\n\tproxy_bittorrent: boolean\n\tproxy_peer_connections: boolean\n\tproxy_hostname_lookup: boolean\n\tproxy_rss: boolean\n\tproxy_misc: boolean\n\tip_filter_enabled: boolean\n\tip_filter_path: string\n\tip_filter_trackers: boolean\n\tbanned_IPs: string\n\n\tdl_limit: number\n\tup_limit: number\n\talt_dl_limit: number\n\talt_up_limit: number\n\tscheduler_enabled: boolean\n\tschedule_from_hour: number\n\tschedule_from_min: number\n\tschedule_to_hour: number\n\tschedule_to_min: number\n\tscheduler_days: number\n\tlimit_utp_rate: boolean\n\tlimit_tcp_overhead: boolean\n\tlimit_lan_peers: boolean\n\tbittorrent_protocol: number\n\n\tdht: boolean\n\tpex: boolean\n\tlsd: boolean\n\tencryption: number\n\tanonymous_mode: boolean\n\tmax_active_checking_torrents: number\n\tqueueing_enabled: boolean\n\tmax_active_downloads: number\n\tmax_active_uploads: number\n\tmax_active_torrents: number\n\tdont_count_slow_torrents: boolean\n\tslow_torrent_dl_rate_threshold: number\n\tslow_torrent_ul_rate_threshold: number\n\tslow_torrent_inactive_timer: number\n\tmax_ratio_enabled: boolean\n\tmax_ratio: number\n\tmax_ratio_act: number\n\tmax_seeding_time_enabled: boolean\n\tmax_seeding_time: number\n\tmax_inactive_seeding_time_enabled: boolean\n\tmax_inactive_seeding_time: number\n\n\trss_refresh_interval: number\n\trss_fetch_delay: number\n\trss_max_articles_per_feed: number\n\trss_processing_enabled: boolean\n\trss_auto_downloading_enabled: boolean\n\trss_download_repack_proper_episodes: boolean\n\trss_smart_episode_filters: string\n\n\tweb_ui_port: number\n\tweb_ui_address: string\n\tweb_ui_domain_list: string\n\tweb_ui_upnp: boolean\n\tuse_https: boolean\n\tweb_ui_https_cert_path: string\n\tweb_ui_https_key_path: string\n\tweb_ui_username: string\n\tbypass_local_auth: boolean\n\tbypass_auth_subnet_whitelist_enabled: boolean\n\tbypass_auth_subnet_whitelist: string\n\tweb_ui_max_auth_fail_count: number\n\tweb_ui_ban_duration: number\n\tweb_ui_session_timeout: number\n\tweb_ui_clickjacking_protection_enabled: boolean\n\tweb_ui_csrf_protection_enabled: boolean\n\tweb_ui_secure_cookie_enabled: boolean\n\tweb_ui_host_header_validation_enabled: boolean\n\tweb_ui_use_custom_http_headers_enabled: boolean\n\tweb_ui_custom_http_headers: string\n\tweb_ui_reverse_proxy_enabled: boolean\n\tweb_ui_reverse_proxies_list: string\n\talternative_webui_enabled: boolean\n\talternative_webui_path: string\n\tdyndns_enabled: boolean\n\tdyndns_service: number\n\tdyndns_domain: string\n\tdyndns_username: string\n\tdyndns_password: string\n\n\tresume_data_storage_type: string\n\ttorrent_content_remove_option: string\n\tmemory_working_set_limit: number\n\tcurrent_network_interface: string\n\tcurrent_interface_name: string\n\tcurrent_interface_address: string\n\tsave_resume_data_interval: number\n\tsave_statistics_interval: number\n\ttorrent_file_size_limit: number\n\tconfirm_torrent_recheck: boolean\n\trecheck_completed_torrents: boolean\n\tapp_instance_name: string\n\trefresh_interval: number\n\tresolve_peer_countries: boolean\n\treannounce_when_address_changed: boolean\n\tenable_embedded_tracker: boolean\n\tembedded_tracker_port: number\n\tembedded_tracker_port_forwarding: boolean\n\tignore_ssl_errors: boolean\n\tpython_executable_path: string\n\n\tbdecode_depth_limit: number\n\tbdecode_token_limit: number\n\tasync_io_threads: number\n\thashing_threads: number\n\tfile_pool_size: number\n\tchecking_memory_use: number\n\tdisk_cache: number\n\tdisk_cache_ttl: number\n\tdisk_queue_size: number\n\tdisk_io_type: number\n\tdisk_io_read_mode: number\n\tdisk_io_write_mode: number\n\tenable_coalesce_read_write: boolean\n\tenable_piece_extent_affinity: boolean\n\tenable_upload_suggestions: boolean\n\tsend_buffer_watermark: number\n\tsend_buffer_low_watermark: number\n\tsend_buffer_watermark_factor: number\n\tconnection_speed: number\n\tsocket_send_buffer_size: number\n\tsocket_receive_buffer_size: number\n\tsocket_backlog_size: number\n\toutgoing_ports_min: number\n\toutgoing_ports_max: number\n\tupnp_lease_duration: number\n\tpeer_tos: number\n\tutp_tcp_mixed_mode: number\n\tidn_support_enabled: boolean\n\tenable_multi_connections_from_same_ip: boolean\n\tvalidate_https_tracker_certificate: boolean\n\tssrf_mitigation: boolean\n\tblock_peers_on_privileged_ports: boolean\n\tupload_slots_behavior: number\n\tupload_choking_algorithm: number\n\tannounce_to_all_trackers: boolean\n\tannounce_to_all_tiers: boolean\n\tannounce_ip: string\n\tannounce_port: number\n\tmax_concurrent_http_announces: number\n\tstop_tracker_timeout: number\n\tpeer_turnover: number\n\tpeer_turnover_cutoff: number\n\tpeer_turnover_interval: number\n\trequest_queue_size: number\n\tdht_bootstrap_nodes: string\n\tadd_trackers_enabled: boolean\n\tadd_trackers: string\n\tadd_trackers_from_url_enabled: boolean\n\tadd_trackers_url: string\n\tadd_trackers_url_list: string\n}\n"
  },
  {
    "path": "src/types/qbittorrent.ts",
    "content": "export type TorrentState =\n\t| 'error'\n\t| 'missingFiles'\n\t| 'uploading'\n\t| 'pausedUP'\n\t| 'stoppedUP'\n\t| 'queuedUP'\n\t| 'stalledUP'\n\t| 'checkingUP'\n\t| 'forcedUP'\n\t| 'allocating'\n\t| 'downloading'\n\t| 'metaDL'\n\t| 'pausedDL'\n\t| 'stoppedDL'\n\t| 'queuedDL'\n\t| 'stalledDL'\n\t| 'checkingDL'\n\t| 'forcedDL'\n\t| 'checkingResumeData'\n\t| 'moving'\n\t| 'unknown'\n\nexport interface Torrent {\n\thash: string\n\tname: string\n\tsize: number\n\tprogress: number\n\tdlspeed: number\n\tupspeed: number\n\tpriority: number\n\tnum_seeds: number\n\tnum_leechs: number\n\tratio: number\n\teta: number\n\tstate: TorrentState\n\tcategory: string\n\ttags: string\n\tadded_on: number\n\tcompletion_on: number\n\tlast_activity: number\n\tsave_path: string\n\tdownload_path: string\n\tdownloaded: number\n\tuploaded: number\n\ttracker: string\n\tseeding_time: number\n}\n\nexport interface TransferInfo {\n\tdl_info_speed: number\n\tdl_info_data: number\n\tup_info_speed: number\n\tup_info_data: number\n\tdl_rate_limit: number\n\tup_rate_limit: number\n\tdht_nodes: number\n\tconnection_status: 'connected' | 'firewalled' | 'disconnected'\n}\n\nexport interface SyncServerState {\n\talltime_dl: number\n\talltime_ul: number\n\tdl_info_speed: number\n\tup_info_speed: number\n\tfree_space_on_disk: number\n}\n\nexport interface SyncMaindata {\n\tserver_state: SyncServerState\n}\n\nexport type TorrentFilter =\n\t| 'all'\n\t| 'downloading'\n\t| 'seeding'\n\t| 'completed'\n\t| 'stopped'\n\t| 'active'\n\t| 'inactive'\n\t| 'resumed'\n\t| 'stalled'\n\t| 'errored'\n"
  },
  {
    "path": "src/types/rss.ts",
    "content": "export interface RSSArticle {\n\tid: string\n\ttitle: string\n\ttorrentURL?: string\n\tlink?: string\n\tdescription?: string\n\tdate: string\n\tisRead?: boolean\n}\n\nexport interface RSSFeedData {\n\tuid: string\n\turl: string\n\ttitle?: string\n\tlastBuildDate?: string\n\tisLoading?: boolean\n\thasError?: boolean\n\tarticles?: RSSArticle[]\n}\n\nexport type RSSItems = {\n\t[key: string]: string | RSSFeedData | RSSItems\n}\n\nexport interface RSSRule {\n\tenabled: boolean\n\tmustContain: string\n\tmustNotContain: string\n\tuseRegex: boolean\n\tepisodeFilter: string\n\tsmartFilter: boolean\n\tpreviouslyMatchedEpisodes: string[]\n\taffectedFeeds: string[]\n\tignoreDays: number\n\tlastMatch: string\n\taddPaused: boolean | null\n\tassignedCategory: string\n\tsavePath: string\n\ttorrentParams?: {\n\t\tcategory?: string\n\t\ttags?: string[]\n\t\tsave_path?: string\n\t\tuse_auto_tmm?: boolean\n\t\tstopped?: boolean | null\n\t\tcontent_layout?: string | null\n\t}\n}\n\nexport type RSSRules = Record<string, RSSRule>\n\nexport interface MatchingArticles {\n\t[feedName: string]: string[]\n}\n"
  },
  {
    "path": "src/types/torrentDetails.ts",
    "content": "export interface TorrentProperties {\n\tsave_path: string\n\tdownload_path: string\n\tcreation_date: number\n\tpiece_size: number\n\tcomment: string\n\ttotal_wasted: number\n\ttotal_uploaded: number\n\ttotal_uploaded_session: number\n\ttotal_downloaded: number\n\ttotal_downloaded_session: number\n\tup_limit: number\n\tdl_limit: number\n\ttime_elapsed: number\n\tseeding_time: number\n\tnb_connections: number\n\tnb_connections_limit: number\n\tshare_ratio: number\n\taddition_date: number\n\tcompletion_date: number\n\tcreated_by: string\n\tdl_speed_avg: number\n\tdl_speed: number\n\teta: number\n\tlast_seen: number\n\tpeers: number\n\tpeers_total: number\n\tpieces_have: number\n\tpieces_num: number\n\treannounce: number\n\tseeds: number\n\tseeds_total: number\n\ttotal_size: number\n\tup_speed_avg: number\n\tup_speed: number\n\tis_private?: boolean\n\tinfohash_v1?: string\n\tinfohash_v2?: string\n\tpopularity?: number\n}\n\nexport interface Tracker {\n\turl: string\n\tstatus: number\n\ttier: number\n\tnum_peers: number\n\tnum_seeds: number\n\tnum_leeches: number\n\tnum_downloaded: number\n\tmsg: string\n}\n\nexport interface Peer {\n\tip: string\n\tport: number\n\tclient: string\n\tprogress: number\n\tdl_speed: number\n\tup_speed: number\n\tdownloaded: number\n\tuploaded: number\n\tconnection: string\n\tflags: string\n\tflags_desc: string\n\trelevance: number\n\tfiles: string\n\tcountry?: string\n\tcountry_code?: string\n}\n\nexport interface PeersResponse {\n\tfull_update: boolean\n\tpeers: Record<string, Peer>\n\trid: number\n\tshow_flags: boolean\n}\n\nexport interface TorrentFile {\n\tindex: number\n\tname: string\n\tsize: number\n\tprogress: number\n\tpriority: number\n\tis_seed: boolean\n\tavailability: number\n\tpiece_range: [number, number]\n}\n\nexport interface WebSeed {\n\turl: string\n}\n"
  },
  {
    "path": "src/types/views.ts",
    "content": "import type { TorrentFilter } from './qbittorrent'\nimport type { SortKey } from '../components/columns'\n\nexport interface CustomView {\n\tid: string\n\tname: string\n\tsortKey: SortKey\n\tsortAsc: boolean\n\tvisibleColumns: string[]\n\tcolumnOrder: string[]\n\tcolumnWidths: Record<string, number>\n\tfilter: TorrentFilter\n\tcategoryFilter: string | null\n\ttagFilter: string | null\n\ttrackerFilter: string | null\n\tsearch: string\n\tcreatedAt: number\n\tupdatedAt: number\n}\n\nexport interface CustomViewsStorage {\n\tviews: CustomView[]\n\tactiveViewId: string | null\n}\n"
  },
  {
    "path": "src/utils/colorUtils.ts",
    "content": "import { colord, extend } from 'colord'\nimport mixPlugin from 'colord/plugins/mix'\nimport type { Theme } from '../themes'\n\nextend([mixPlugin])\n\nexport function generateThemeColors(\n\tbase: string,\n\taccent: string,\n\ttext: string,\n\twarning: string = '#f7b731'\n): Theme['colors'] {\n\tconst baseColor = colord(base)\n\tconst isDark = baseColor.isDark()\n\tconst modifier = isDark ? 1 : -1\n\n\t// Function to generate variants\n\t// If dark theme: lighten (positive)\n\t// If light theme: darken (negative)\n\tconst variant = (ratio: number) => baseColor.lighten(ratio * modifier).toHex()\n\n\tconst textColor = colord(text)\n\n\treturn {\n\t\tbgPrimary: base,\n\t\tbgSecondary: variant(0.04),\n\t\tbgTertiary: variant(0.08),\n\n\t\ttextPrimary: text,\n\t\t// Using mix with background for solid colors usually looks better than opacity for text\n\t\t// consistent with existing themes which use hex codes\n\t\ttextSecondary: textColor.mix(base, 0.3).toHex(),\n\t\ttextMuted: textColor.mix(base, 0.5).toHex(),\n\n\t\taccent: accent,\n\t\taccentContrast: colord(accent).isDark() ? '#ffffff' : '#000000',\n\n\t\twarning: warning,\n\t\terror: '#f43f5e',\n\n\t\tborder: variant(0.15),\n\t\tprogress: accent,\n\t}\n}\n\nexport function isValidHex(hex: string): boolean {\n\treturn colord(hex).isValid()\n}\n"
  },
  {
    "path": "src/utils/customViews.ts",
    "content": "import type { CustomView, CustomViewsStorage } from '../types/views'\nimport type { TorrentFilter } from '../types/qbittorrent'\nimport type { SortKey } from '../components/columns'\nimport { COLUMNS } from '../components/columns'\n\nconst STORAGE_KEY = 'customViews'\n\nconst DEFAULT_STORAGE: CustomViewsStorage = {\n\tviews: [],\n\tactiveViewId: null,\n}\n\nexport function loadCustomViews(): CustomViewsStorage {\n\tconst stored = localStorage.getItem(STORAGE_KEY)\n\tif (!stored) return DEFAULT_STORAGE\n\ttry {\n\t\tconst parsed = JSON.parse(stored) as CustomViewsStorage\n\t\tconst knownColumns = new Set(COLUMNS.map((c) => c.id))\n\t\tparsed.views = parsed.views.map((view) => ({\n\t\t\t...view,\n\t\t\tvisibleColumns: view.visibleColumns.filter((id) => knownColumns.has(id)),\n\t\t\tcolumnOrder: view.columnOrder.filter((id) => knownColumns.has(id)),\n\t\t}))\n\t\treturn parsed\n\t} catch {\n\t\treturn DEFAULT_STORAGE\n\t}\n}\n\nexport function saveCustomViews(storage: CustomViewsStorage): void {\n\tlocalStorage.setItem(STORAGE_KEY, JSON.stringify(storage))\n}\n\nexport function createView(\n\tname: string,\n\tconfig: {\n\t\tsortKey: SortKey\n\t\tsortAsc: boolean\n\t\tvisibleColumns: Set<string>\n\t\tcolumnOrder: string[]\n\t\tcolumnWidths: Record<string, number>\n\t\tfilter: TorrentFilter\n\t\tcategoryFilter: string | null\n\t\ttagFilter: string | null\n\t\ttrackerFilter: string | null\n\t\tsearch: string\n\t}\n): CustomView {\n\treturn {\n\t\tid: crypto.randomUUID(),\n\t\tname: name.trim(),\n\t\tsortKey: config.sortKey,\n\t\tsortAsc: config.sortAsc,\n\t\tvisibleColumns: [...config.visibleColumns],\n\t\tcolumnOrder: config.columnOrder,\n\t\tcolumnWidths: config.columnWidths,\n\t\tfilter: config.filter,\n\t\tcategoryFilter: config.categoryFilter,\n\t\ttagFilter: config.tagFilter,\n\t\ttrackerFilter: config.trackerFilter,\n\t\tsearch: config.search,\n\t\tcreatedAt: Date.now(),\n\t\tupdatedAt: Date.now(),\n\t}\n}\n\nexport function viewsAreEqual(\n\tview: CustomView,\n\tcurrent: {\n\t\tsortKey: SortKey\n\t\tsortAsc: boolean\n\t\tvisibleColumns: Set<string>\n\t\tcolumnOrder: string[]\n\t\tcolumnWidths: Record<string, number>\n\t\tfilter: TorrentFilter\n\t\tcategoryFilter: string | null\n\t\ttagFilter: string | null\n\t\ttrackerFilter: string | null\n\t\tsearch: string\n\t}\n): boolean {\n\tif (view.sortKey !== current.sortKey) return false\n\tif (view.sortAsc !== current.sortAsc) return false\n\tif (view.filter !== current.filter) return false\n\tif (view.categoryFilter !== current.categoryFilter) return false\n\tif (view.tagFilter !== current.tagFilter) return false\n\tif (view.trackerFilter !== current.trackerFilter) return false\n\tif (view.search !== current.search) return false\n\tif (view.visibleColumns.length !== current.visibleColumns.size) return false\n\tif (!view.visibleColumns.every((c) => current.visibleColumns.has(c))) return false\n\tif (JSON.stringify(view.columnOrder) !== JSON.stringify(current.columnOrder)) return false\n\tif (JSON.stringify(view.columnWidths) !== JSON.stringify(current.columnWidths)) return false\n\treturn true\n}\n"
  },
  {
    "path": "src/utils/dateSettings.ts",
    "content": "export function loadHideAddedTime(): boolean {\n\treturn localStorage.getItem('hideAddedTime') === 'true'\n}\n\nexport function saveHideAddedTime(hide: boolean): void {\n\tlocalStorage.setItem('hideAddedTime', String(hide))\n}\n"
  },
  {
    "path": "src/utils/fileTree.ts",
    "content": "import type { TorrentFile } from '../types/torrentDetails'\n\nexport interface FileTreeNode {\n\tname: string\n\tpath: string\n\tisFolder: boolean\n\tsize: number\n\tprogress: number\n\tpriority: 'skip' | 'normal' | 'high' | 'max' | 'mixed'\n\tavailability: number\n\tchildren: FileTreeNode[]\n\tfileIndices: number[]\n}\n\nexport function buildFileTree(files: TorrentFile[]): FileTreeNode[] {\n\tconst root: FileTreeNode[] = []\n\n\tfor (let i = 0; i < files.length; i++) {\n\t\tconst file = files[i]\n\t\tconst parts = file.name.split('/')\n\t\tlet currentLevel = root\n\t\tlet currentPath = ''\n\n\t\tfor (let j = 0; j < parts.length; j++) {\n\t\t\tconst part = parts[j]\n\t\t\tconst isFile = j === parts.length - 1\n\t\t\tcurrentPath = currentPath ? `${currentPath}/${part}` : part\n\n\t\t\tlet existing = currentLevel.find((n) => n.name === part)\n\n\t\t\tif (!existing) {\n\t\t\t\tconst newNode: FileTreeNode = {\n\t\t\t\t\tname: part,\n\t\t\t\t\tpath: currentPath,\n\t\t\t\t\tisFolder: !isFile,\n\t\t\t\t\tsize: isFile ? file.size : 0,\n\t\t\t\t\tprogress: isFile ? file.progress : 0,\n\t\t\t\t\tpriority: isFile ? getPriorityLabel(file.priority) : 'normal',\n\t\t\t\t\tavailability: isFile ? file.availability : 0,\n\t\t\t\t\tchildren: [],\n\t\t\t\t\tfileIndices: isFile ? [i] : [],\n\t\t\t\t}\n\t\t\t\tcurrentLevel.push(newNode)\n\t\t\t\texisting = newNode\n\t\t\t}\n\n\t\t\tif (!isFile) {\n\t\t\t\texisting.fileIndices.push(i)\n\t\t\t\texisting.size += file.size\n\t\t\t\tcurrentLevel = existing.children\n\t\t\t}\n\t\t}\n\t}\n\n\tcalculateFolderStats(root, files)\n\tsortNodes(root)\n\n\treturn root\n}\n\nfunction getPriorityLabel(priority: number): 'skip' | 'normal' | 'high' | 'max' {\n\tif (priority === 0) return 'skip'\n\tif (priority === 6) return 'high'\n\tif (priority === 7) return 'max'\n\treturn 'normal'\n}\n\nfunction calculateFolderStats(nodes: FileTreeNode[], files: TorrentFile[]): void {\n\tfor (const node of nodes) {\n\t\tif (node.isFolder) {\n\t\t\tcalculateFolderStats(node.children, files)\n\n\t\t\tlet totalSize = 0\n\t\t\tlet downloadedSize = 0\n\t\t\tlet totalAvailability = 0\n\n\t\t\tfor (const idx of node.fileIndices) {\n\t\t\t\tconst file = files[idx]\n\t\t\t\ttotalSize += file.size\n\t\t\t\tdownloadedSize += file.size * file.progress\n\t\t\t\ttotalAvailability += file.availability\n\t\t\t}\n\n\t\t\tnode.progress = totalSize > 0 ? downloadedSize / totalSize : 0\n\t\t\tnode.availability = node.fileIndices.length > 0 ? totalAvailability / node.fileIndices.length : 0\n\n\t\t\tconst priorities = new Set(node.fileIndices.map((idx) => files[idx].priority))\n\t\t\tif (priorities.size === 1) {\n\t\t\t\tnode.priority = getPriorityLabel([...priorities][0])\n\t\t\t} else {\n\t\t\t\tnode.priority = 'mixed'\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunction sortNodes(nodes: FileTreeNode[]): void {\n\tnodes.sort((a, b) => {\n\t\tif (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1\n\t\treturn a.name.localeCompare(b.name)\n\t})\n\tfor (const node of nodes) {\n\t\tif (node.isFolder) sortNodes(node.children)\n\t}\n}\n\nexport function flattenVisibleNodes(\n\tnodes: FileTreeNode[],\n\texpanded: Set<string>,\n\tdepth: number = 0\n): { node: FileTreeNode; depth: number }[] {\n\tconst result: { node: FileTreeNode; depth: number }[] = []\n\tfor (const node of nodes) {\n\t\tresult.push({ node, depth })\n\t\tif (node.isFolder && expanded.has(node.path)) {\n\t\t\tresult.push(...flattenVisibleNodes(node.children, expanded, depth + 1))\n\t\t}\n\t}\n\treturn result\n}\n\nexport function getInitialExpanded(nodes: FileTreeNode[]): Set<string> {\n\tconst expanded = new Set<string>()\n\n\tfunction expandSingleChildPath(children: FileTreeNode[]) {\n\t\tconst folders = children.filter((n) => n.isFolder)\n\t\tif (folders.length === 1) {\n\t\t\texpanded.add(folders[0].path)\n\t\t\texpandSingleChildPath(folders[0].children)\n\t\t}\n\t}\n\n\texpandSingleChildPath(nodes)\n\treturn expanded\n}\n"
  },
  {
    "path": "src/utils/format.ts",
    "content": "export function formatSpeed(bytes: number, showZero = true): string {\n\tif (bytes === 0 && !showZero) return '—'\n\tif (bytes < 1024) return `${bytes} B/s`\n\tif (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB/s`\n\treturn `${(bytes / 1024 / 1024).toFixed(2)} MiB/s`\n}\n\nexport function formatSize(bytes: number): string {\n\tif (bytes < 1024) return `${bytes} B`\n\tif (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`\n\tif (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`\n\tif (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GiB`\n\treturn `${(bytes / 1024 / 1024 / 1024 / 1024).toFixed(2)} TiB`\n}\n\nexport function formatCompactSpeed(bytes: number): string {\n\tif (bytes === 0) return '-'\n\tif (bytes < 1024) return `${bytes}B`\n\tif (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}Ki`\n\treturn `${(bytes / 1024 / 1024).toFixed(1)}Mi`\n}\n\nexport function formatCompactSize(bytes: number): string {\n\tif (bytes < 1024) return `${bytes}B`\n\tif (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}Ki`\n\tif (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)}Mi`\n\treturn `${(bytes / 1024 / 1024 / 1024).toFixed(1)}Gi`\n}\n\nexport function formatEta(seconds: number): string {\n\tif (seconds < 0 || seconds === 8640000) return '∞'\n\tif (seconds < 60) return `${seconds}s`\n\tif (seconds < 3600) return `${Math.floor(seconds / 60)}m`\n\tif (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`\n\treturn `${Math.floor(seconds / 86400)}d`\n}\n\nexport function formatDate(timestamp: number): string {\n\tif (timestamp <= 0 || timestamp === -1) return '—'\n\treturn new Date(timestamp * 1000).toLocaleString(undefined, {\n\t\tyear: 'numeric',\n\t\tmonth: 'numeric',\n\t\tday: 'numeric',\n\t\thour: 'numeric',\n\t\tminute: 'numeric',\n\t})\n}\n\nexport function formatDuration(seconds: number): string {\n\tif (seconds < 0) return '—'\n\tconst d = Math.floor(seconds / 86400)\n\tconst h = Math.floor((seconds % 86400) / 3600)\n\tconst m = Math.floor((seconds % 3600) / 60)\n\tconst s = seconds % 60\n\tif (d > 0) return `${d}d ${h}h ${m}m`\n\tif (h > 0) return `${h}h ${m}m ${s}s`\n\tif (m > 0) return `${m}m ${s}s`\n\treturn `${s}s`\n}\n\nexport function formatRelativeTime(timestamp: number): string {\n\tif (timestamp <= 0) return 'Never'\n\tconst now = Math.floor(Date.now() / 1000)\n\tconst diff = now - timestamp\n\tif (diff < 60) return 'Just now'\n\tif (diff < 3600) return `${Math.floor(diff / 60)}m ago`\n\tif (diff < 86400) return `${Math.floor(diff / 3600)}h ago`\n\tif (diff < 604800) return `${Math.floor(diff / 86400)}d ago`\n\treturn `${Math.floor(diff / 604800)}w ago`\n}\n\nexport function formatRelativeDate(timestamp: number): string {\n\tif (!timestamp || timestamp < 0) return '-'\n\tconst date = new Date(timestamp * 1000)\n\tconst now = new Date()\n\tconst diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24))\n\tif (diffDays === 0) return 'Today'\n\tif (diffDays === 1) return 'Yesterday'\n\tif (diffDays < 7) return `${diffDays}d ago`\n\treturn date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })\n}\n\nexport function normalizeSearch(str: string): string {\n\treturn str.toLowerCase().replace(/[._-]+/g, ' ')\n}\n\nexport function formatCountdown(timestamp: number | null, fallback = '—'): string {\n\tif (!timestamp) return fallback\n\tconst diff = timestamp - Math.floor(Date.now() / 1000)\n\tif (diff <= 0) return 'Now'\n\tconst hours = Math.floor(diff / 3600)\n\tconst mins = Math.floor((diff % 3600) / 60)\n\tif (hours > 0) return `${hours}h ${mins}m`\n\treturn `${mins}m`\n}\n"
  },
  {
    "path": "src/utils/markdown.tsx",
    "content": "import type { ReactNode } from 'react'\n\nfunction renderInline(text: string): ReactNode[] {\n\tconst nodes: ReactNode[] = []\n\tconst pattern = /(\\[([^\\]]+)\\]\\(([^)]+)\\))|(`([^`]+)`)|(\\*\\*([^*]+)\\*\\*)|(\\*([^*]+)\\*)/g\n\tlet lastIndex = 0\n\tlet match: RegExpExecArray | null\n\n\twhile ((match = pattern.exec(text)) !== null) {\n\t\tif (match.index > lastIndex) {\n\t\t\tnodes.push(text.slice(lastIndex, match.index))\n\t\t}\n\n\t\tif (match[2] && match[3]) {\n\t\t\tconst label = match[2]\n\t\t\tconst url = match[3]\n\t\t\tif (/^https?:\\/\\//i.test(url)) {\n\t\t\t\tnodes.push(\n\t\t\t\t\t<a\n\t\t\t\t\t\tkey={`link-${match.index}`}\n\t\t\t\t\t\thref={url}\n\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\t\tclassName=\"underline underline-offset-2\"\n\t\t\t\t\t\tstyle={{ color: 'var(--accent)' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{label}\n\t\t\t\t\t</a>\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tnodes.push(`${label} (${url})`)\n\t\t\t}\n\t\t} else if (match[5]) {\n\t\t\tnodes.push(\n\t\t\t\t<code\n\t\t\t\t\tkey={`code-${match.index}`}\n\t\t\t\t\tclassName=\"px-1 py-0.5 rounded\"\n\t\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}\n\t\t\t\t>\n\t\t\t\t\t{match[5]}\n\t\t\t\t</code>\n\t\t\t)\n\t\t} else if (match[7]) {\n\t\t\tnodes.push(\n\t\t\t\t<strong key={`bold-${match.index}`} style={{ color: 'var(--text-primary)' }}>\n\t\t\t\t\t{match[7]}\n\t\t\t\t</strong>\n\t\t\t)\n\t\t} else if (match[9]) {\n\t\t\tnodes.push(\n\t\t\t\t<em key={`em-${match.index}`} style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t{match[9]}\n\t\t\t\t</em>\n\t\t\t)\n\t\t}\n\n\t\tlastIndex = match.index + match[0].length\n\t}\n\n\tif (lastIndex < text.length) {\n\t\tnodes.push(text.slice(lastIndex))\n\t}\n\n\treturn nodes\n}\n\nexport function renderMarkdown(markdown: string): ReactNode[] {\n\tconst lines = markdown.replace(/\\r\\n/g, '\\n').split('\\n')\n\tconst nodes: ReactNode[] = []\n\tlet listItems: string[] = []\n\tlet inCodeBlock = false\n\tlet codeLines: string[] = []\n\n\tconst flushList = () => {\n\t\tif (listItems.length === 0) return\n\t\tconst items = listItems\n\t\tlistItems = []\n\t\tnodes.push(\n\t\t\t<ul key={`list-${nodes.length}`} className=\"list-disc pl-4 space-y-1\">\n\t\t\t\t{items.map((item, idx) => (\n\t\t\t\t\t<li key={`li-${idx}`} className=\"text-xs\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t\t\t{renderInline(item)}\n\t\t\t\t\t</li>\n\t\t\t\t))}\n\t\t\t</ul>\n\t\t)\n\t}\n\n\tconst flushCode = () => {\n\t\tif (codeLines.length === 0) return\n\t\tconst code = codeLines.join('\\n')\n\t\tcodeLines = []\n\t\tnodes.push(\n\t\t\t<pre\n\t\t\t\tkey={`code-${nodes.length}`}\n\t\t\t\tclassName=\"text-xs whitespace-pre-wrap rounded-lg p-2\"\n\t\t\t\tstyle={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}\n\t\t\t>\n\t\t\t\t<code>{code}</code>\n\t\t\t</pre>\n\t\t)\n\t}\n\n\tfor (const rawLine of lines) {\n\t\tconst line = rawLine.trimEnd()\n\t\tif (line.startsWith('```')) {\n\t\t\tif (inCodeBlock) {\n\t\t\t\tflushCode()\n\t\t\t\tinCodeBlock = false\n\t\t\t} else {\n\t\t\t\tflushList()\n\t\t\t\tinCodeBlock = true\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tif (inCodeBlock) {\n\t\t\tcodeLines.push(rawLine)\n\t\t\tcontinue\n\t\t}\n\n\t\tif (line.trim().length === 0) {\n\t\t\tflushList()\n\t\t\tcontinue\n\t\t}\n\n\t\tconst headingMatch = /^(#{1,6})\\s+(.*)$/.exec(line)\n\t\tif (headingMatch) {\n\t\t\tflushList()\n\t\t\tnodes.push(\n\t\t\t\t<div\n\t\t\t\t\tkey={`heading-${nodes.length}`}\n\t\t\t\t\tclassName=\"text-xs font-semibold uppercase tracking-wide\"\n\t\t\t\t\tstyle={{ color: 'var(--text-muted)' }}\n\t\t\t\t>\n\t\t\t\t\t{renderInline(headingMatch[2])}\n\t\t\t\t</div>\n\t\t\t)\n\t\t\tcontinue\n\t\t}\n\n\t\tconst listMatch = /^[-*+]\\s+(.*)$/.exec(line)\n\t\tif (listMatch) {\n\t\t\tlistItems.push(listMatch[1])\n\t\t\tcontinue\n\t\t}\n\n\t\tflushList()\n\t\tnodes.push(\n\t\t\t<p key={`p-${nodes.length}`} className=\"text-xs leading-relaxed\" style={{ color: 'var(--text-secondary)' }}>\n\t\t\t\t{renderInline(line)}\n\t\t\t</p>\n\t\t)\n\t}\n\n\tif (inCodeBlock) {\n\t\tflushCode()\n\t}\n\tflushList()\n\n\treturn nodes\n}\n"
  },
  {
    "path": "src/utils/pagination.ts",
    "content": "export const PER_PAGE_OPTIONS = [25, 50, 100, 200] as const\n"
  },
  {
    "path": "src/utils/ratioThresholds.ts",
    "content": "const DEFAULT_THRESHOLD = 1.0\n\nexport function loadRatioThreshold(): number {\n\tconst stored = localStorage.getItem('ratioThreshold')\n\tif (stored) {\n\t\tconst parsed = parseFloat(stored)\n\t\tif (!isNaN(parsed) && parsed >= 0) return parsed\n\t}\n\treturn DEFAULT_THRESHOLD\n}\n\nexport function saveRatioThreshold(threshold: number): void {\n\tlocalStorage.setItem('ratioThreshold', threshold.toString())\n}\n"
  },
  {
    "path": "src/utils/search.ts",
    "content": "const PATTERNS = [\n\t/\\b(2160p|1080p|720p|480p|4K|UHD)\\b/gi,\n\t/\\b(x264|x265|HEVC|AVC|H\\.?264|H\\.?265|AV1)\\b/gi,\n\t/\\b(BluRay|BDRip|WEB-DL|WEBRip|HDRip|DVDRip|HDTV|WEB)\\b/gi,\n\t/\\b(REMUX|HDR|HDR10|DV|Dolby\\.?Vision|ATMOS)\\b/gi,\n\t/\\b(MKV|MP4|AVI|ISO|FLAC|MP3|AAC)\\b/gi,\n]\n\nexport function extractTags(titles: string[]): { tag: string; count: number }[] {\n\tconst counts = new Map<string, number>()\n\tfor (const title of titles) {\n\t\tconst found = new Set<string>()\n\t\tfor (const pattern of PATTERNS) {\n\t\t\tconst matches = title.match(pattern)\n\t\t\tif (matches) {\n\t\t\t\tfor (const m of matches) {\n\t\t\t\t\tfound.add(m.toUpperCase())\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tfor (const tag of found) {\n\t\t\tcounts.set(tag, (counts.get(tag) || 0) + 1)\n\t\t}\n\t}\n\treturn Array.from(counts.entries())\n\t\t.map(([tag, count]) => ({ tag, count }))\n\t\t.sort((a, b) => b.count - a.count)\n}\n\nexport type SortKey = 'seeders' | 'size' | 'age'\n\nexport function sortResults<T extends { seeders?: number; size: number; publishDate: string }>(\n\tresults: T[],\n\tsortKey: SortKey,\n\tsortAsc: boolean\n): T[] {\n\treturn [...results].sort((a, b) => {\n\t\tlet cmp = 0\n\t\tif (sortKey === 'seeders') {\n\t\t\tcmp = (b.seeders || 0) - (a.seeders || 0)\n\t\t} else if (sortKey === 'size') {\n\t\t\tcmp = b.size - a.size\n\t\t} else if (sortKey === 'age') {\n\t\t\tcmp = new Date(b.publishDate).getTime() - new Date(a.publishDate).getTime()\n\t\t}\n\t\treturn sortAsc ? -cmp : cmp\n\t})\n}\n\nexport function filterResults<T extends { title: string }>(results: T[], filter: string): T[] {\n\tif (!filter) return results\n\treturn results.filter((r) => r.title.toLowerCase().includes(filter.toLowerCase()))\n}\n"
  },
  {
    "path": "tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2022\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"vite/client\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"src\"],\n\t\"exclude\": [\"src/server\"]\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n\t\"files\": [],\n\t\"references\": [\n\t\t{ \"path\": \"./tsconfig.app.json\" },\n\t\t{ \"path\": \"./tsconfig.node.json\" },\n\t\t{ \"path\": \"./tsconfig.server.json\" }\n\t]\n}\n"
  },
  {
    "path": "tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2023\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"node\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "tsconfig.server.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.server.tsbuildinfo\",\n\t\t\"target\": \"ES2022\",\n\t\t\"lib\": [\"ES2022\"],\n\t\t\"module\": \"ESNext\",\n\t\t\"types\": [\"bun-types\"],\n\t\t\"skipLibCheck\": true,\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"verbatimModuleSyntax\": true,\n\t\t\"moduleDetection\": \"force\",\n\t\t\"noEmit\": true,\n\t\t\"strict\": true,\n\t\t\"noUnusedLocals\": true,\n\t\t\"noUnusedParameters\": true,\n\t\t\"erasableSyntaxOnly\": true,\n\t\t\"noFallthroughCasesInSwitch\": true,\n\t\t\"noUncheckedSideEffectImports\": true\n\t},\n\t\"include\": [\"src/server\"]\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\nimport tailwindcss from '@tailwindcss/vite'\nimport pkg from './package.json'\n\nexport default defineConfig(() => {\n\treturn {\n\t\tplugins: [react(), tailwindcss()],\n\t\tdefine: {\n\t\t\t__APP_VERSION__: JSON.stringify(pkg.version),\n\t\t},\n\t\tbuild: {\n\t\t\trollupOptions: {\n\t\t\t\toutput: {\n\t\t\t\t\tmanualChunks(id) {\n\t\t\t\t\t\tif (id.includes('node_modules')) {\n\t\t\t\t\t\t\tif (id.includes('react') || id.includes('scheduler')) return 'vendor'\n\t\t\t\t\t\t\tif (id.includes('@tanstack')) return 'vendor'\n\t\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t\tid.includes('vaul') ||\n\t\t\t\t\t\t\t\tid.includes('react-remove-scroll') ||\n\t\t\t\t\t\t\t\tid.includes('use-callback-ref') ||\n\t\t\t\t\t\t\t\tid.includes('use-sidecar') ||\n\t\t\t\t\t\t\t\tid.includes('react-style-singleton') ||\n\t\t\t\t\t\t\t\tid.includes('aria-hidden') ||\n\t\t\t\t\t\t\t\tid.includes('@radix-ui')\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\treturn 'vendor'\n\t\t\t\t\t\t\tif (id.includes('jszip')) return 'jszip'\n\t\t\t\t\t\t\tconst match = id.match(/node_modules\\/(?:\\.pnpm\\/)?([^@/][^/]*)/)\n\t\t\t\t\t\t\tif (match) return `lib-${match[1]}`\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tserver: {\n\t\t\tproxy: {\n\t\t\t\t'/api/instances': {\n\t\t\t\t\ttarget: 'http://localhost:3000',\n\t\t\t\t\tchangeOrigin: true,\n\t\t\t\t\ttimeout: 120000,\n\t\t\t\t},\n\t\t\t\t'/api': {\n\t\t\t\t\ttarget: 'http://localhost:3000',\n\t\t\t\t\tchangeOrigin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n})\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config'\nimport { loadEnv } from 'vite'\nimport react from '@vitejs/plugin-react'\nimport { resolve } from 'path'\n\nconst env = loadEnv('', process.cwd(), '')\nconst isCI = env.CI === 'true' || process.env.CI === 'true'\n\nexport default defineConfig({\n\tplugins: [react()],\n\tresolve: {\n\t\talias: {\n\t\t\t'bun:sqlite': resolve(__dirname, '__tests__/__mocks__/bun-sqlite.ts'),\n\t\t},\n\t},\n\ttest: {\n\t\tglobals: true,\n\t\tenvironment: 'jsdom',\n\t\tinclude: ['__tests__/**/*.{test,spec}.{ts,tsx}'],\n\t\treporters: isCI ? ['default'] : ['./__tests__/reporter.ts'],\n\t\tcoverage: {\n\t\t\tprovider: 'v8',\n\t\t\treporter: ['text', 'json', 'html'],\n\t\t\tinclude: ['src/**/*.{ts,tsx}'],\n\t\t\texclude: ['src/**/*.d.ts', 'src/main.tsx'],\n\t\t},\n\t},\n})\n"
  }
]