Repository: journey-ad/Bitmagnet-Next-Web Branch: main Commit: 2e634df92db3 Files: 76 Total size: 201.6 KB Directory structure: gitextract_3wsm7kau/ ├── .dockerignore ├── .eslintignore ├── .eslintrc.json ├── .github/ │ └── workflows/ │ └── docker-image.yml ├── .gitignore ├── .npmrc ├── .prettierrc.js ├── .vscode/ │ └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── README_zh-CN.md ├── app/ │ ├── api/ │ │ ├── detail/ │ │ │ └── route.ts │ │ ├── graphql/ │ │ │ ├── moke.ts │ │ │ ├── route.ts │ │ │ └── service.ts │ │ ├── preview/ │ │ │ ├── [hash64]/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── service.ts │ │ ├── search/ │ │ │ └── route.ts │ │ └── stats/ │ │ └── route.ts │ ├── detail/ │ │ ├── [hash64]/ │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ └── page.tsx │ ├── error.tsx │ ├── layout.tsx │ ├── not-found.tsx │ ├── page.tsx │ ├── providers.tsx │ └── search/ │ ├── layout.tsx │ └── page.tsx ├── components/ │ ├── BgEffect.tsx │ ├── DemoMode.tsx │ ├── DetailContent.tsx │ ├── EmblaCarousel.css │ ├── EmblaCarousel.tsx │ ├── FileList.tsx │ ├── FileTypeIcon.tsx │ ├── FloatTool.tsx │ ├── HomeLogo.tsx │ ├── SearchInput.tsx │ ├── SearchResultsItem.tsx │ ├── SearchResultsList.tsx │ ├── Stats.tsx │ └── icons.tsx ├── config/ │ ├── constant.ts │ ├── fonts.ts │ └── site.ts ├── docker-compose.yml ├── hooks/ │ ├── useBreakpoints.ts │ └── useHydration.ts ├── i18n/ │ ├── config.ts │ ├── index.ts │ └── locales/ │ ├── en.json │ ├── zh-CN.json │ └── zh-TW.json ├── lib/ │ ├── apolloClient.ts │ ├── jieba.ts │ └── pgdb.ts ├── moke/ │ ├── detail.json │ ├── index.ts │ ├── search.json │ └── stats.json ├── next.config.js ├── package.json ├── postcss.config.js ├── styles/ │ ├── file-type-icon/ │ │ └── icons.css │ ├── globals.css │ └── style.css ├── tailwind.config.js ├── tsconfig.json ├── types/ │ └── index.ts └── utils/ ├── Toast.tsx ├── api.ts └── index.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Node.js dependencies /node_modules /jspm_packages # TypeScript v1 declaration files typings # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.test # local env files .env*.local # Next.js build output .next out # Nuxt.js build output .nuxt dist # Gatsby files .cache/ # Vuepress build output .vuepress/dist # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # Temporary folders tmp temp # IDE and editor directories .idea .vscode *.swp *.swo *~ .history # OS generated files .DS_Store Thumbs.db # secret key *.key *.key.pub ================================================ FILE: .eslintignore ================================================ .now/* *.css .changeset dist esm/* public/* tests/* scripts/* *.config.js .DS_Store node_modules coverage .next build !.commitlintrc.cjs !.lintstagedrc.cjs !jest.config.js !plopfile.js !react-shim.js !tsup.config.ts ================================================ FILE: .eslintrc.json ================================================ { "$schema": "https://json.schemastore.org/eslintrc.json", "env": { "browser": false, "es2021": true, "node": true }, "extends": [ "plugin:react/recommended", "plugin:prettier/recommended", "plugin:react-hooks/recommended", "plugin:jsx-a11y/recommended" ], "plugins": ["react", "unused-imports", "import", "@typescript-eslint", "jsx-a11y", "prettier"], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaFeatures": { "jsx": true }, "ecmaVersion": 12, "sourceType": "module" }, "settings": { "react": { "version": "detect" } }, "rules": { "no-console": "warn", "react/prop-types": "off", "react/jsx-uses-react": "off", "react/react-in-jsx-scope": "off", "react-hooks/exhaustive-deps": "off", "jsx-a11y/click-events-have-key-events": "warn", "jsx-a11y/interactive-supports-focus": "warn", "prettier/prettier": "warn", "no-unused-vars": "off", "unused-imports/no-unused-vars": "off", "unused-imports/no-unused-imports": "warn", "@typescript-eslint/no-unused-vars": [ "warn", { "args": "after-used", "ignoreRestSiblings": false, "argsIgnorePattern": "^_.*?$" } ], "import/order": [ "warn", { "groups": [ "type", "builtin", "object", "external", "internal", "parent", "sibling", "index" ], "pathGroups": [ { "pattern": "~/**", "group": "external", "position": "after" } ], "newlines-between": "always" } ], "react/self-closing-comp": "warn", "react/jsx-sort-props": [ "warn", { "callbacksLast": true, "shorthandFirst": true, "noSortAlphabetically": false, "reservedFirst": true } ], "padding-line-between-statements": [ "warn", {"blankLine": "always", "prev": "*", "next": "return"}, {"blankLine": "always", "prev": ["const", "let", "var"], "next": "*"}, { "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"] } ] } } ================================================ FILE: .github/workflows/docker-image.yml ================================================ name: Publish Docker image on: workflow_dispatch: release: types: [published] jobs: push_to_registry: name: Push Docker image to Docker Hub runs-on: ubuntu-latest steps: - name: Check out the repo uses: actions/checkout@v3 - name: Log in to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v4 with: images: journey0ad/bitmagnet-next-web tags: | type=raw,value=latest,enable={{is_default_branch}} type=ref,event=branch type=ref,event=tag - name: Set up QEMU uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Build and push Docker image uses: docker/build-push-action@v4 with: context: . platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts .history ================================================ FILE: .npmrc ================================================ package-lock=false ================================================ FILE: .prettierrc.js ================================================ module.exports = { printWidth: 80, tabWidth: 2, useTabs: false, semi: true, singleQuote: false, trailingComma: 'all', bracketSpacing: true, arrowParens: 'always', endOfLine: 'auto', }; ================================================ FILE: .vscode/settings.json ================================================ { "typescript.tsdk": "node_modules/typescript/lib" } ================================================ FILE: Dockerfile ================================================ # Use node:20-alpine as the base image FROM node:20-alpine AS base # Set the working directory WORKDIR /app # Install dependencies FROM base AS deps COPY package.json ./ RUN npm install # Build the application FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build # Prepare the runner stage FROM node:20-alpine AS runner # Set the working directory WORKDIR /app # Copy the necessary files from the builder stage COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/server ./.next/server # Expose the application port EXPOSE 3000 # Set environment variables ENV HOSTNAME=:: PORT=3000 # Start the application CMD ["node", "server.js"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 journey-ad Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
Bitmagnet-Next-Web

Bitmagnet-Next-Web

English / [中文文档](./README_zh-CN.md) A more modern magnet search website program, developed using [Next.js 14](https://nextjs.org/docs/getting-started) + [NextUI v2](https://nextui.org/), with the backend powered by [Bitmagnet](https://github.com/bitmagnet-io/bitmagnet). ![Index](.readme/en_Index.jpg) ![Search](.readme/en_Search.jpg)
## Deployment Instructions ### Container Deployment The most convenient way to deploy is using Docker Compose. Refer to the [docker-compose.yml](./docker-compose.yml) ### Running with docker run If not using Docker Compose, you can run each container separately using the following commands: 1. Run the PostgreSQL container: ```bash docker run -d \ --name bitmagnet-postgres \ -p 5432:5432 \ -e POSTGRES_PASSWORD=postgres \ -e POSTGRES_DB=bitmagnet \ -e PGUSER=postgres \ -v ./data/postgres:/var/lib/postgresql/data \ --shm-size=1g \ postgres:16-alpine ``` 2. Run the Bitmagnet container: ```bash docker run -d \ --name bitmagnet \ --link bitmagnet-postgres:postgres \ -p 3333:3333 \ -p 3334:3334/tcp \ -p 3334:3334/udp \ -e POSTGRES_HOST=postgres \ -e POSTGRES_PASSWORD=postgres \ ghcr.io/bitmagnet-io/bitmagnet:latest \ worker run --keys=http_server --keys=queue_server --keys=dht_crawler ``` 3. Run the Bitmagnet-Next-Web container: ```bash docker run -d \ --name bitmagnet-next-web \ --link bitmagnet-postgres:postgres \ -p 3000:3000 \ -e POSTGRES_DB_URL=postgres://postgres:postgres@postgres:5432/bitmagnet \ journey0ad/bitmagnet-next-web:latest ``` ### Full-Text Search Optimization The search capability relies on the `torrents.name` and `torrent_files.path` columns. The original Bitmagnet does not index these columns, so it's recommended to create indexes to improve query efficiency: ```sql create extension pg_trgm; -- Enable pg_trgm extension -- Create indexes on `torrents.name` and `torrent_files.path` CREATE INDEX idx_torrents_name_1 ON torrents USING gin (name gin_trgm_ops); CREATE INDEX idx_torrent_files_path_1 ON torrent_files USING gin (path gin_trgm_ops); ``` ## Development Guide Before starting development, create a `.env.local` file in the project root directory and fill in the environment variables: ```bash # .env.local POSTGRES_DB_URL=postgres://postgres:postgres@localhost:5432/bitmagnet ``` It's recommended to use `pnpm` as the package manager. ### Install Dependencies ```bash pnpm install ``` ### Run Development Environment ```bash pnpm run dev ``` ### Build & Deploy ```bash pnpm run build pnpm run serve ``` ## Credits - [Bitmagnet](https://github.com/bitmagnet-io/bitmagnet) - [Next.js](https://nextjs.org/) - [NextUI](https://nextui.org/) - [Tailwind CSS](https://tailwindcss.com/) - [Fluent Emoji](https://github.com/microsoft/fluentui-emoji) ## License Licensed under the [MIT license](./LICENSE). ================================================ FILE: README_zh-CN.md ================================================
Bitmagnet-Next-Web

Bitmagnet-Next-Web

[English](./README.md) / 中文文档 更现代的磁力搜索网站程序,使用 [Next.js 14](https://nextjs.org/docs/getting-started) + [NextUI v2](https://nextui.org/) 开发,后端使用 [Bitmagnet](https://github.com/bitmagnet-io/bitmagnet) ![Index](.readme/zh_Index.jpg) ![Search](.readme/zh_Search.jpg)
## 部署说明 ### 容器部署 最方便的部署方式是用 Docker Compose,参考 [docker-compose.yml](./docker-compose.yml) 配置 #### 使用 docker run 运行 如果不使用 Docker Compose,可以使用以下命令分别运行各个容器: 1. 运行 PostgreSQL 容器: ```bash docker run -d \ --name bitmagnet-postgres \ -p 5432:5432 \ -e POSTGRES_PASSWORD=postgres \ -e POSTGRES_DB=bitmagnet \ -e PGUSER=postgres \ -v ./data/postgres:/var/lib/postgresql/data \ --shm-size=1g \ postgres:16-alpine ``` 2. 运行 Bitmagnet 容器: ```bash docker run -d \ --name bitmagnet \ --link bitmagnet-postgres:postgres \ -p 3333:3333 \ -p 3334:3334/tcp \ -p 3334:3334/udp \ -e POSTGRES_HOST=postgres \ -e POSTGRES_PASSWORD=postgres \ ghcr.io/bitmagnet-io/bitmagnet:latest \ worker run --keys=http_server --keys=queue_server --keys=dht_crawler ``` 3. 运行 Bitmagnet-Next-Web 容器: ```bash docker run -d \ --name bitmagnet-next-web \ --link bitmagnet-postgres:postgres \ -p 3000:3000 \ -e POSTGRES_DB_URL=postgres://postgres:postgres@postgres:5432/bitmagnet \ journey0ad/bitmagnet-next-web:latest ``` ### 全文搜索优化 搜索能力依赖 `torrents.name` 和 `torrent_files.path` 两列数据,原版 Bitmagnet 未对此建立索引,建议先建立索引提升查询效率: ```sql create extension pg_trgm; -- 启用 pg_trgm 扩展 -- 对 `torrents.name` 和 `torrent_files.path` 建立索引 CREATE INDEX idx_torrents_name_1 ON torrents USING gin (name gin_trgm_ops); CREATE INDEX idx_torrent_files_path_1 ON torrent_files USING gin (path gin_trgm_ops); ``` ## 开发指引 开发之前,需要先在项目根目录创建一个 `.env.local` 文件,并填写环境变量: ```bash # .env.local POSTGRES_DB_URL=postgres://postgres:postgres@localhost:5432/bitmagnet ``` 推荐使用 `pnpm` 作为包管理器 ### 安装依赖 ```bash pnpm install ``` ### 开发环境运行 ```bash pnpm run dev ``` ### 打包 & 部署 ```bash pnpm run build pnpm run serve ``` ## Credits - [Bitmagnet](https://github.com/bitmagnet-io/bitmagnet) - [Next.js](https://nextjs.org/) - [NextUI](https://nextui.org/) - [Tailwind CSS](https://tailwindcss.com/) - [Fluent Emoji](https://github.com/microsoft/fluentui-emoji) ## License Licensed under the [MIT license](./LICENSE). ## 免责声明 - 本程序为免费开源项目,旨在方便对 Bitmagnet 程序的索引数据进行检索和重新排版,以及学习 Next.js 开发,本程序不涉及采集、存储和下载功能; - 本程序仅用于学习和研究,不得用于商业用途,使用时请遵守相关法律法规,不得侵犯任何第三方的知识产权; - 本程序不提供任何支持或保证,由使用者自身滥用本程序导致的一切后果均由使用者自行承担。使用者对本程序的使用即表示接受并同意本声明。 ================================================ FILE: app/api/detail/route.ts ================================================ import { NextResponse } from "next/server"; import { gql } from "@apollo/client"; import client from "@/lib/apolloClient"; // Define the GraphQL query to fetch torrent details by hash const query = gql` query TorrentByHash($hash: String!) { torrentByHash(hash: $hash) { hash name size magnet_uri single_file files_count files { index path extension size } created_at updated_at } } `; // Function to handle GET requests const handler = async (request: Request) => { const { searchParams } = new URL(request.url); const hash = searchParams.get("hash"); // Return a 400 response if the hash parameter is missing if (!hash) { return NextResponse.json( { message: "`hash` is required", status: 400, }, { status: 400, }, ); } try { // Execute the GraphQL query with the provided hash variable const { data } = await client.query({ query, variables: { hash }, }); // Return a 200 response with the query data return NextResponse.json( { data: data.torrentByHash, message: "success", status: 200, }, { status: 200, headers: { "Content-Type": "application/json; charset=utf-8", }, }, ); } catch (error: any) { console.error(error); // Return a 500 response if there's an error during the query execution return NextResponse.json( { message: error?.message || "Internal Server Error", status: 500, }, { status: 500, }, ); } }; export { handler as GET, handler as POST }; ================================================ FILE: app/api/graphql/moke.ts ================================================ import mokeData from "@/moke"; export function search() { return mokeData.search; } export function torrentByHash() { return mokeData.detail; } export function statsInfo() { return mokeData.stats; } ================================================ FILE: app/api/graphql/route.ts ================================================ import { ApolloServer } from "@apollo/server"; import { startServerAndCreateNextHandler } from "@as-integrations/next/dist"; import { gql } from "graphql-tag"; import { NextRequest } from "next/server"; // import { search, torrentByHash, statsInfo } from "./service"; const isDemoMode = process.env.DEMO_MODE === "true"; const { search, torrentByHash, statsInfo } = isDemoMode ? require("./moke") : require("./service"); if (isDemoMode) { console.log("[Bitmagnet-Next-Web] This website is running in demo mode."); } // Define GraphQL Schema const typeDefs = gql` type TorrentFile { index: Int path: String extension: String size: String } type Torrent { hash: String! name: String! size: String! magnet_uri: String! single_file: Boolean! files_count: Int! files: [TorrentFile!]! created_at: Int! updated_at: Int! } input SearchQueryInput { keyword: String! offset: Int! limit: Int! sortType: String filterTime: String filterSize: String withTotalCount: Boolean } type SearchResult { keywords: [String!]! torrents: [Torrent!]! total_count: Int! has_more: Boolean! } type statsInfoResult { size: String! total_count: Int! updated_at: Int! latest_torrent_hash: String latest_torrent: Torrent } type Query { search(queryInput: SearchQueryInput!): SearchResult! torrentByHash(hash: String!): Torrent statsInfo: statsInfoResult } `; // Create Apollo Server instance const server = new ApolloServer({ typeDefs, resolvers: { Query: { search, torrentByHash, statsInfo, }, }, }); // req has the type NextRequest const handler = startServerAndCreateNextHandler(server, { context: async (req) => ({ req }), }); export { handler as GET, handler as POST }; ================================================ FILE: app/api/graphql/service.ts ================================================ import { query } from "@/lib/pgdb"; import { jiebaCut } from "@/lib/jieba"; import { SEARCH_KEYWORD_SPLIT_REGEX } from "@/config/constant"; type Torrent = { info_hash: Buffer; // The hash info of the torrent name: string; // The name of the torrent size: string; // The size of the torrent files_count: number; // The count of files in the torrent files: TorrentFile[]; // The list of files in the torrent created_at: number; // The timestamp when the torrent was created updated_at: number; // The timestamp when the torrent was last updated }; type TorrentFile = { index: number; // The index of the file in the torrent path: string; // The path of the file in the torrent size: string; // The size of the file in the torrent extension: string; // The extension of the file }; const REGEX_PADDING_FILE = /^(_____padding_file_|\.pad\/\d+&)/; // Regular expression to identify padding files export function formatTorrent(row: Torrent) { const hash = row.info_hash.toString("hex"); // Convert info_hash from Buffer to hex string const generateSingleFiles = (row: Torrent) => { return [ { index: 0, path: row.name, size: row.size, extension: row.name.split(".").pop() || "", }, ]; }; return { hash: hash, name: row.name, size: row.size, magnet_uri: `magnet:?xt=urn:btih:${hash}&dn=${encodeURIComponent(row.name)}&xl=${row.size}`, // Create magnet URI single_file: row.files_count <= 1, files_count: row.files_count || 1, files: (row.files_count > 0 ? row.files : generateSingleFiles(row)) .map((file) => ({ index: file.index, path: file.path, size: file.size, extension: file.extension, })) .sort((a, b) => { // Sorting priority: padding_file lowest -> extension empty next -> ascending index const aPadding = REGEX_PADDING_FILE.test(a.path) ? 1 : 0; const bPadding = REGEX_PADDING_FILE.test(b.path) ? 1 : 0; if (aPadding !== bPadding) { return aPadding - bPadding; // padding_file has the lowest priority } const aNoExtension = !a.extension ? 1 : 0; const bNoExtension = !b.extension ? 1 : 0; if (aNoExtension !== bNoExtension) { return aNoExtension - bNoExtension; // Files with no extension have lower priority } return a.index - b.index; // Within the same priority, sort by index in ascending order }), created_at: Math.floor(row.created_at / 1000), // Convert timestamps to seconds updated_at: Math.floor(row.updated_at / 1000), // Convert timestamps to seconds }; } // Utility functions for query building const buildOrderBy = (sortType: keyof typeof orderByMap) => { const orderByMap = { size: "torrents.size DESC", count: "COALESCE(torrents.files_count, 0) DESC", date: "torrents.created_at ASC", }; return orderByMap[sortType] || "torrents.created_at DESC"; }; const buildTimeFilter = (filterTime: keyof typeof timeFilterMap) => { const timeFilterMap = { "gt-1day": "AND torrents.created_at > now() - interval '1 day'", "gt-7day": "AND torrents.created_at > now() - interval '1 week'", "gt-31day": "AND torrents.created_at > now() - interval '1 month'", "gt-365day": "AND torrents.created_at > now() - interval '1 year'", }; return timeFilterMap[filterTime] || ""; }; const buildSizeFilter = (filterSize: keyof typeof sizeFilterMap) => { const sizeFilterMap = { lt100mb: "AND torrents.size < 100 * 1024 * 1024::bigint", "gt100mb-lt500mb": "AND torrents.size BETWEEN 100 * 1024 * 1024::bigint AND 500 * 1024 * 1024::bigint", "gt500mb-lt1gb": "AND torrents.size BETWEEN 500 * 1024 * 1024::bigint AND 1024 * 1024 * 1024::bigint", "gt1gb-lt5gb": "AND torrents.size BETWEEN 1 * 1024 * 1024 * 1024::bigint AND 5 * 1024 * 1024 * 1024::bigint", gt5gb: "AND torrents.size > 5 * 1024 * 1024 * 1024::bigint", }; return sizeFilterMap[filterSize] || ""; }; const QUOTED_KEYWORD_REGEX = /"([^"]+)"/g; const extractKeywords = ( keyword: string, ): { keyword: string; required: boolean }[] => { let keywords = []; let match; // Extract exact keywords using quotation marks while ((match = QUOTED_KEYWORD_REGEX.exec(keyword)) !== null) { keywords.push({ keyword: match[1], required: true }); } const remainingKeywords = keyword.replace(QUOTED_KEYWORD_REGEX, ""); // Extract remaining keywords using regex tokenizer keywords.push( ...remainingKeywords .trim() .split(SEARCH_KEYWORD_SPLIT_REGEX) .map((k) => ({ keyword: k, required: false })), ); // Use jieba to words segment if input is a full sentence if (keywords.length === 1 && keyword.length >= 4) { keywords.push(...jiebaCut(keyword)); } // Remove duplicates and filter out keywords shorter than 2 characters to avoid slow SQL queries keywords = Array.from( new Map(keywords.map((k) => [k.keyword, k])).values(), ).filter(({ keyword }) => keyword.trim().length >= 2); // Ensure at least 1/3 keyword is required when there is no required keyword if (keywords.length && !keywords.some(({ required }) => required)) { [...keywords] .sort((a, b) => b.keyword.length - a.keyword.length) .slice(0, Math.ceil(keywords.length / 3)) .forEach((k) => (k.required = true)); } const fullKeyword = keyword.replace(/"/g, ""); // Ensure full keyword is the first item if (!keywords.some((k) => k.keyword === fullKeyword)) { keywords.unshift({ keyword: fullKeyword, required: false }); } return keywords; }; export async function search(_: any, { queryInput }: any) { try { console.info("-".repeat(50)); console.info("search params", queryInput); // trim keyword queryInput.keyword = queryInput.keyword.trim(); const no_result = { keywords: [queryInput.keyword], torrents: [], total_count: 0, has_more: false, }; // Return an empty result if no keywords are provided if (queryInput.keyword.length < 2) { return no_result; } const REGEX_HASH = /^[a-f0-9]{40}$/; if (REGEX_HASH.test(queryInput.keyword)) { const torrent = await torrentByHash(_, { hash: queryInput.keyword }); if (torrent) { return { keywords: [queryInput.keyword], torrents: [torrent], total_count: 1, has_more: false, }; } return no_result; } // Build SQL conditions and parameters const orderBy = buildOrderBy(queryInput.sortType); const timeFilter = buildTimeFilter(queryInput.filterTime); const sizeFilter = buildSizeFilter(queryInput.filterSize); const keywords = extractKeywords(queryInput.keyword); // Construct the keyword filter condition const requiredKeywords: string[] = []; const optionalKeywords: string[] = []; keywords.forEach(({ required }, i) => { const condition = `torrents.name ILIKE $${i + 1}`; if (required) { requiredKeywords.push(condition); } else { optionalKeywords.push(condition); } }); const fullConditions = [...requiredKeywords]; if (optionalKeywords.length > 0) { optionalKeywords.push("TRUE"); fullConditions.push(`(${optionalKeywords.join(" OR ")})`); } const keywordFilter = fullConditions.join(" AND "); const keywordsParams = keywords.map(({ keyword }) => `%${keyword}%`); const keywordsPlain = keywords.map(({ keyword }) => keyword); // SQL query to fetch filtered torrent data and files information const sql = ` -- 先查到符合过滤条件的数据 WITH filtered AS ( SELECT torrents.info_hash, -- 种子哈希 torrents.name, -- 种子名称 torrents.size, -- 种子大小 torrents.created_at, -- 创建时间戳 torrents.updated_at, -- 更新时间戳 torrents.files_count -- 种子文件数 FROM torrents WHERE (${keywordFilter}) -- 关键词过滤条件 ${timeFilter} -- 时间范围过滤条件 ${sizeFilter} -- 大小范围过滤条件 ${orderBy ? `ORDER BY ${orderBy}` : ""} -- 排序方式 LIMIT $${keywords.length + 1} -- 返回数量 OFFSET $${keywords.length + 2} -- 分页偏移 ) -- 从过滤后的数据中查询文件信息 SELECT filtered.info_hash, -- 种子哈希 filtered.name, -- 种子名称 filtered.size, -- 种子大小 filtered.created_at, -- 创建时间戳 filtered.updated_at, -- 更新时间戳 filtered.files_count, -- 种子文件数 -- 检查 files_count, 是否有文件数量 CASE WHEN filtered.files_count IS NOT NULL THEN ( -- 如果有数量, 根据 info_hash 查询文件信息到 'files' 列, 聚合成JSON SELECT json_agg(json_build_object( 'index', torrent_files.index, -- 文件在种子中的索引 'path', torrent_files.path, -- 文件在种子中的路径 'size', torrent_files.size, -- 文件大小 'extension', torrent_files.extension -- 文件扩展名 )) FROM torrent_files WHERE torrent_files.info_hash = filtered.info_hash -- 根据 info_hash 匹配文件 ) ELSE NULL -- 如果 files_count 为空, 则设置为NULL END AS files -- 结果别名设为 'files' FROM filtered; -- 从过滤后的数据中查询 `; const params = [...keywordsParams, queryInput.limit, queryInput.offset]; console.debug("SQL:", sql, params); console.debug( "keywords:", keywords.map((item, i) => ({ _: `$${i + 1}`, ...item })), ); const queryArr = [query(sql, params)]; // SQL query to get the total count if requested if (queryInput.withTotalCount) { const countSql = ` SELECT COUNT(*) AS total FROM ( SELECT 1 FROM torrents WHERE (${keywordFilter}) ${timeFilter} ${sizeFilter} ) AS limited_total; `; const countParams = [...keywordsParams]; queryArr.push(query(countSql, countParams)); } else { queryArr.push(Promise.resolve({ rows: [{ total: 0 }] }) as any); } // Execute queries and process results const [{ rows: torrentsResp }, { rows: countResp }] = await Promise.all(queryArr); const torrents = torrentsResp.map(formatTorrent); const total_count = countResp[0].total; const has_more = queryInput.withTotalCount && queryInput.offset + queryInput.limit < total_count; return { keywords: keywordsPlain, torrents, total_count, has_more }; } catch (error) { console.error("Error in search resolver:", error); throw new Error("Failed to execute search query"); } } export async function torrentByHash(_: any, { hash }: { hash: string }) { try { // SQL query to fetch torrent data and files information by hash const sql = ` SELECT t.info_hash, t.name, t.size, t.created_at, t.updated_at, t.files_count, json_agg(json_build_object( 'index', f.index, 'path', f.path, 'size', f.size, 'extension', f.extension )) AS files FROM torrents t LEFT JOIN torrent_files f ON t.info_hash = f.info_hash WHERE t.info_hash = decode($1, 'hex') GROUP BY t.info_hash, t.name, t.size, t.created_at, t.updated_at, t.files_count; `; const params = [hash]; const { rows } = await query(sql, params); const torrent = rows[0]; if (!torrent) { return null; } return formatTorrent(torrent); } catch (error) { console.error("Error in torrentByHash resolver:", error); throw new Error("Failed to fetch torrent by hash"); } } export async function statsInfo() { try { const sql = ` WITH db_size AS ( SELECT pg_database_size('bitmagnet') AS size ), torrent_count AS ( SELECT COUNT(*) AS total_count FROM torrents ), latest_torrent AS ( SELECT * FROM torrents ORDER BY created_at DESC LIMIT 1 ) SELECT db_size.size, latest_torrent.created_at as updated_at, torrent_count.total_count, encode(latest_torrent.info_hash, 'hex') AS latest_torrent_hash, json_build_object( 'hash', encode(latest_torrent.info_hash, 'hex'), 'name', latest_torrent.name, 'size', latest_torrent.size, 'created_at', latest_torrent.created_at, 'updated_at', latest_torrent.updated_at ) AS latest_torrent FROM db_size, torrent_count, latest_torrent; `; const { rows } = await query(sql, []); const data = rows[0]; if (!data) { return null; } return { ...data, updated_at: Math.floor(new Date(data.updated_at).getTime() / 1000), latest_torrent: { ...data.latest_torrent, created_at: Math.floor( new Date(data.latest_torrent.created_at).getTime() / 1000, ), updated_at: Math.floor( new Date(data.latest_torrent.updated_at).getTime() / 1000, ), }, }; } catch (error) { console.error("Error in statsInfo resolver:", error); throw new Error("Failed to fetch torrents count"); } } ================================================ FILE: app/api/preview/[hash64]/[id]/route.ts ================================================ import { NextResponse } from "next/server"; import { fail, getPreviewInfo } from "../../service"; // Function to handle GET requests const handler = async ( request: Request, { params }: { params: { hash64: string; id: number } }, ) => { try { const linkInfo = await getPreviewInfo(params.hash64); const screenshots = linkInfo.screenshots?.map((item) => item.screenshot); const imageUrl = screenshots?.[params.id]; if (!imageUrl) { return fail("Image not found", 404); } // Fetch the image from the URL and return it const response = await fetch(imageUrl, { headers: { Referer: request.url, }, }); const buffer = await response.arrayBuffer(); console.log("===================================="); console.log("imageUrl", imageUrl); console.log("buffer", buffer); console.log("===================================="); // Return a 200 response with the image data return new Response(buffer, { headers: { "Content-Type": response.headers.get("content-type") || "image/jpeg", }, }); } catch (error: any) { console.error(error); // Return a 500 response if there's an error during the query execution return NextResponse.json( { message: error?.message || "Internal Server Error", status: 500, }, { status: 500, }, ); } }; export { handler as GET, handler as POST }; ================================================ FILE: app/api/preview/[hash64]/route.ts ================================================ import { NextResponse } from "next/server"; import { base64ToHex, getLinkInfoFromWhatsLink } from "@/utils"; import { getPreviewInfo, success, fail } from "../service"; const invalid = (message: string) => { return NextResponse.json( { message, status: 400, }, { status: 400, }, ); }; // Function to handle GET requests const handler = async ( request: Request, { params }: { params: { hash64: string } }, ) => { try { const linkInfo = await getPreviewInfo(params.hash64); console.log(linkInfo); const data = { name: linkInfo.name, size: linkInfo.size, screenshots: linkInfo.screenshots?.map( (_item, index) => `${request.url}/${index}`, ), }; return success(data); } catch (error: any) { console.error(error); return fail(error.message || "Internal Server Error"); } }; export { handler as GET, handler as POST }; ================================================ FILE: app/api/preview/route.ts ================================================ import { NextResponse } from "next/server"; const handler = async () => { return NextResponse.json( { message: "Invalid request", status: 400, }, { status: 400, }, ); }; export { handler as GET, handler as POST }; ================================================ FILE: app/api/preview/service.ts ================================================ import { NextResponse } from "next/server"; import { base64ToHex, getLinkInfoFromWhatsLink } from '@/utils'; export const fail = (message: string, status: number = 500) => { return NextResponse.json( { message, status, }, { status, headers: { "Content-Type": "application/json; charset=utf-8", }, }, ); }; export const success = (data: any) => { return NextResponse.json( { data, message: "success", status: 200, }, { status: 200, headers: { "Content-Type": "application/json; charset=utf-8", }, }, ); }; export async function getPreviewInfo(hash64: string) { const hash = base64ToHex(hash64); if (!hash || hash.length !== 40) { console.error("Invalid hash", hash); throw new Error("Invalid hash"); } const magnet_uri = `magnet:?xt=urn:btih:${hash}`; const linkInfo = await getLinkInfoFromWhatsLink(magnet_uri); if (!linkInfo || linkInfo.error) { console.error("Invalid link", linkInfo); throw new Error("Invalid link"); } return linkInfo; } ================================================ FILE: app/api/search/route.ts ================================================ import { NextResponse } from "next/server"; import { gql } from "@apollo/client"; import { z } from "zod"; import client from "@/lib/apolloClient"; import { SEARCH_PARAMS, SEARCH_KEYWORD_LENGTH_MIN, SEARCH_KEYWORD_LENGTH_MAX, SEARCH_PAGE_SIZE, DEFAULT_SORT_TYPE, DEFAULT_FILTER_TIME, DEFAULT_FILTER_SIZE, } from "@/config/constant"; // GraphQL query to search for torrents const SEARCH = gql` query Search($queryInput: SearchQueryInput!) { search(queryInput: $queryInput) { keywords torrents { hash name size magnet_uri single_file files_count files { index path extension size } created_at updated_at } total_count has_more } } `; // Define the schema for the request parameters using Zod const schema = z.object({ keyword: z .string() .min(SEARCH_KEYWORD_LENGTH_MIN) .max(SEARCH_KEYWORD_LENGTH_MAX), offset: z.coerce.number().min(0).default(0), limit: z.coerce .number() .min(1) .max(SEARCH_PAGE_SIZE) .default(SEARCH_PAGE_SIZE), sortType: z.enum(SEARCH_PARAMS.sortType).default(DEFAULT_SORT_TYPE), filterTime: z.enum(SEARCH_PARAMS.filterTime).default(DEFAULT_FILTER_TIME), filterSize: z.enum(SEARCH_PARAMS.filterSize).default(DEFAULT_FILTER_SIZE), withTotalCount: z .enum(["0", "1"]) .transform((value) => value === "1") .default("1"), }); const handler = async (request: Request) => { // Extract search parameters from the request URL const { searchParams } = new URL(request.url); const params = Object.fromEntries(searchParams.entries()); let safeParams; // Validate and parse the parameters using Zod schema try { safeParams = schema.parse(params); } catch (error: any) { console.error(error); const { path, message } = error.errors[0] || {}; const errMessage = path ? `${path[0]}: ${message}` : message; return NextResponse.json( { data: null, message: errMessage || "Invalid request", status: 400, }, { status: 400, }, ); } // Perform the search query using Apollo Client try { const { data } = await client.query({ query: SEARCH, variables: { queryInput: safeParams, }, fetchPolicy: "no-cache", }); return NextResponse.json( { data: data.search, message: "success", status: 200, }, { status: 200, headers: { "Content-Type": "application/json; charset=utf-8", }, }, ); } catch (error: any) { console.error(error); return NextResponse.json( { data: null, message: error?.message || "Internal Server Error", status: 500, }, { status: 500, }, ); } }; export { handler as GET, handler as POST }; ================================================ FILE: app/api/stats/route.ts ================================================ import { NextResponse } from "next/server"; import { gql } from "@apollo/client"; import client from "@/lib/apolloClient"; // Define the GraphQL query to fetch torrent details by hash const query = gql` query StatsInfo { statsInfo { size total_count updated_at latest_torrent_hash latest_torrent { hash name size created_at updated_at } } } `; // Function to handle GET requests const handler = async () => { try { // Execute the GraphQL query with the provided hash variable const { data } = await client.query({ query, fetchPolicy: "no-cache" }); await new Promise((resolve) => setTimeout(resolve, 5000)); // Return a 200 response with the query data return NextResponse.json( { data: data.statsInfo, message: "success", status: 200, }, { status: 200, headers: { "Content-Type": "application/json; charset=utf-8", }, }, ); } catch (error: any) { console.error(error); // Return a 500 response if there's an error during the query execution return NextResponse.json( { message: error?.message || "Internal Server Error", status: 500, }, { status: 500, }, ); } }; export { handler as GET, handler as POST }; ================================================ FILE: app/detail/[hash64]/layout.tsx ================================================ import { Link } from "@nextui-org/react"; import { FloatTool } from "@/components/FloatTool"; import { SearchInput } from "@/components/SearchInput"; import { MagnetIcon } from "@/components/icons"; import { siteConfig } from "@/config/site"; export default function DetailLayout({ children, }: { children: React.ReactNode; }) { return (
{children}
); } ================================================ FILE: app/detail/[hash64]/page.tsx ================================================ import { Metadata } from "next"; import { notFound } from "next/navigation"; import { base64ToHex, getLinkInfoFromWhatsLink } from "@/utils"; import apiFetch from "@/utils/api"; import { DetailContent } from "@/components/DetailContent"; // Function to fetch torrent data based on the hash async function fetchData(hash64: string) { const hash = base64ToHex(hash64); // Convert base64 hash to hex if (!hash || hash.length !== 40) { console.error("Invalid hash", hash); notFound(); } const data = await apiFetch(`/api/detail?hash=${hash}`, { next: { revalidate: 60 * 60 * 24 * 7 }, // cache for 7 days }); return data; } // Function to generate metadata for the page export async function generateMetadata({ params: { hash64 }, }: { params: { hash64: string }; }): Promise { const { data } = await fetchData(hash64); return { title: data.name, }; } // Component to render the detail page export default async function Detail({ params: { hash64 }, }: { params: { hash64: string }; }) { const { data } = await fetchData(hash64); const linkInfo = getLinkInfoFromWhatsLink(data.magnet_uri); return ( <> ); } ================================================ FILE: app/detail/page.tsx ================================================ import { redirect } from "next/navigation"; export default function DetailPage() { redirect("/"); } ================================================ FILE: app/error.tsx ================================================ "use client"; import { useEffect } from "react"; import { useTranslations } from "next-intl"; export default function Error({ error, reset: _reset, }: { error: Error & { digest?: string }; reset: () => void; }) { useEffect(() => { // Log the error to an error reporting service /* eslint-disable no-console */ console.error("error", error.message, error.digest); }, [error]); const t = useTranslations("ERROR_MESSAGE"); return (

{t("INTERNAL_SERVER_ERROR")}

{t("Message")}: {error.message}

{t("Digest")}: {error.digest}

{t("GoHome")}
); } ================================================ FILE: app/layout.tsx ================================================ import "@/styles/globals.css"; import { Metadata, Viewport } from "next"; import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; import clsx from "clsx"; import { Providers } from "./providers"; import { siteConfig } from "@/config/site"; import { fontSans, fontNoto, fontMono } from "@/config/fonts"; import { DemoMode } from "@/components/DemoMode"; import { BgEffect } from "@/components/BgEffect"; export const metadata: Metadata = { title: { default: siteConfig.name, template: `%s - ${siteConfig.name}`, }, description: siteConfig.description, icons: { icon: "/favicon.ico", }, }; export const viewport: Viewport = { themeColor: [ { media: "(prefers-color-scheme: light)", color: "white" }, { media: "(prefers-color-scheme: dark)", color: "black" }, ], width: "device-width", height: "device-height", initialScale: 1, maximumScale: 1, userScalable: false, viewportFit: "cover", }; export default async function RootLayout({ children, }: { children: React.ReactNode; }) { const locale = await getLocale(); const messages = await getMessages(); return (
{children}
); } ================================================ FILE: app/not-found.tsx ================================================ import { getTranslations } from "next-intl/server"; export default async function NotFound() { const t = await getTranslations("ERROR_MESSAGE"); return (

404

{t("NOT_FOUND")}
{t("GoHome")}
); } ================================================ FILE: app/page.tsx ================================================ import { HomeLogo } from "@/components/HomeLogo"; import { SearchInput } from "@/components/SearchInput"; import { ToggleTheme, SwitchLanguage } from "@/components/FloatTool"; import { Stats } from "@/components/Stats"; export default function Home() { return (
); } ================================================ FILE: app/providers.tsx ================================================ "use client"; import * as React from "react"; import { NextUIProvider } from "@nextui-org/system"; import { useRouter } from "next/navigation"; import { ThemeProvider as NextThemesProvider } from "next-themes"; import { ThemeProviderProps } from "next-themes/dist/types"; import { ApolloProvider } from "@apollo/client"; import apolloClient from "@/lib/apolloClient"; export interface ProvidersProps { children: React.ReactNode; themeProps?: ThemeProviderProps; } export function Providers({ children, themeProps }: ProvidersProps) { const router = useRouter(); return ( {children} ); } ================================================ FILE: app/search/layout.tsx ================================================ import { FloatTool } from "@/components/FloatTool"; export default function SearchLayout({ children, }: { children: React.ReactNode; }) { return ( <>
{children}
); } ================================================ FILE: app/search/page.tsx ================================================ import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { Link } from "@nextui-org/react"; import { SearchInput } from "@/components/SearchInput"; import SearchResultsList from "@/components/SearchResultsList"; import apiFetch from "@/utils/api"; import { MagnetIcon } from "@/components/icons"; import { siteConfig } from "@/config/site"; import { DEFAULT_SORT_TYPE, SEARCH_PAGE_SIZE, DEFAULT_FILTER_TIME, DEFAULT_FILTER_SIZE, SEARCH_PAGE_MAX, } from "@/config/constant"; type SearchParams = { keyword: string; p?: number; ps?: number; sortType?: string; filterTime?: string; filterSize?: string; }; type SearchRequestType = { keyword: string; limit?: number; offset?: number; sortType?: string; filterTime?: string; filterSize?: string; }; let cachedSearchOption: SearchParams | null = null; let totalCount = 0; // Fetch data from the API based on search parameters async function fetchData({ keyword, limit = SEARCH_PAGE_SIZE, offset = 0, sortType, filterTime, filterSize, }: SearchRequestType): Promise { const params = new URLSearchParams({ keyword, limit: String(limit), offset: String(offset), }); if (sortType) params.set("sortType", sortType); if (filterTime) params.set("filterTime", filterTime); if (filterSize) params.set("filterSize", filterSize); // Check if it is a new search const isNewSearch = !cachedSearchOption || keyword !== cachedSearchOption.keyword || filterTime !== cachedSearchOption.filterTime || filterSize !== cachedSearchOption.filterSize; if (isNewSearch) { cachedSearchOption = null; // Reset cachedSearchOption for new search } else { params.set("withTotalCount", "0"); } try { const resp = await apiFetch(`/api/search?${params.toString()}`, { next: { revalidate: 60 * 60 * 6 }, // cache for 6 hours }); if (isNewSearch) { totalCount = resp.data.total_count; } cachedSearchOption = { keyword, sortType, filterTime, filterSize, p: cachedSearchOption?.p, }; return resp; } catch (error: any) { console.error(error); throw error; } } // Generate metadata for the search page export async function generateMetadata({ searchParams: { keyword }, }: { searchParams: { keyword: string }; }): Promise { const t = await getTranslations(); return { title: t("Metadata.search.title", { keyword }), }; } // Get search options from the search parameters function getSearchOption(searchParams: SearchParams) { const isNewSearch = !cachedSearchOption || searchParams.keyword !== cachedSearchOption.keyword; return { keyword: searchParams.keyword, p: Math.min(isNewSearch ? 1 : searchParams.p || 1, SEARCH_PAGE_MAX), ps: searchParams.ps || SEARCH_PAGE_SIZE, sortType: searchParams.sortType || DEFAULT_SORT_TYPE, filterTime: searchParams.filterTime || DEFAULT_FILTER_TIME, filterSize: searchParams.filterSize || DEFAULT_FILTER_SIZE, }; } // Component to render the search page export default async function SearchPage({ searchParams, }: { searchParams: SearchParams; }) { const searchOption = getSearchOption(searchParams); const start_time = Date.now(); const { data } = await fetchData({ keyword: searchOption.keyword, limit: searchOption.ps, // Number of items per page offset: (searchOption.p - 1) * searchOption.ps, // Offset calculated based on the page number sortType: searchOption.sortType, filterTime: searchOption.filterTime, filterSize: searchOption.filterSize, }); const cost_time = Date.now() - start_time; return (
); } ================================================ FILE: components/BgEffect.tsx ================================================ "use client"; import { useEffect, useState, useMemo } from "react"; import Particles, { initParticlesEngine } from "@tsparticles/react"; import { loadSlim } from "@tsparticles/slim"; import { useIsSSR } from "@react-aria/ssr"; import { useTheme } from "next-themes"; import { UI_BACKGROUND_ANIMATION } from "@/config/constant"; export const BgEffect = () => { const [init, setInit] = useState(false); const [colorScheme, setColorScheme] = useState<"light" | "dark">("light"); const { theme } = useTheme(); const isSSR = useIsSSR(); const colorPreset = useMemo( () => ({ light: ["#f1f1f1", "#d1d9e1"], dark: ["#1d1d1d", "#0d1521"], }), [], ); useEffect(() => { if (theme === "system") { const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); if (!mediaQuery) return; mediaQuery.addEventListener("change", (event) => { setColorScheme(event.matches ? "dark" : "light"); }); setColorScheme(mediaQuery.matches ? "dark" : "light"); } else { setColorScheme(theme === "dark" ? "dark" : "light"); } }, [theme]); useEffect(() => { if (!UI_BACKGROUND_ANIMATION) return; initParticlesEngine(async (engine) => { await loadSlim(engine); }).then(() => { setInit(true); }); return () => setInit(false); }, []); const particlesOptions = useMemo( () => ({ background: { image: colorScheme === "light" ? `linear-gradient(145deg, ${colorPreset.light[0]}, ${colorPreset.light[1]})` : `linear-gradient(145deg, ${colorPreset.dark[0]}, ${colorPreset.dark[1]})`, }, fpsLimit: 120, interactivity: { events: { onHover: { enable: true, mode: "grab", }, }, modes: { push: { quantity: 4, }, repulse: { distance: 200, duration: 0.4, }, }, }, particles: { color: { value: colorScheme === "light" ? "#c1c7d1" : "#3b4250", }, links: { value: colorScheme === "light" ? "#c1c7d1" : "#3b4250", distance: 150, enable: true, opacity: colorScheme === "light" ? 0.8 : 0.1, width: 1, }, move: { direction: "none", enable: true, outModes: { default: "bounce", }, random: false, speed: 1, straight: false, }, number: { density: { enable: true, }, value: 80, }, opacity: { value: 0.8, }, shape: { type: "circle", }, size: { value: { min: 1, max: 5 }, }, }, detectRetina: true, }) as any, [colorScheme, colorPreset], ); if (isSSR) return null; if (!UI_BACKGROUND_ANIMATION) { const linearGradient = colorPreset[colorScheme]; return (
); } if (!init) return null; return ( { // console.log(container); }} /> ); }; ================================================ FILE: components/DemoMode.tsx ================================================ import { getTranslations } from "next-intl/server"; const isDemoMode = process.env.DEMO_MODE === "true"; export const DemoMode = async () => { if (!isDemoMode) { return null; } const t = await getTranslations(); return ( ); }; ================================================ FILE: components/DetailContent.tsx ================================================ "use client"; import { Card, CardHeader, CardBody, CardFooter, Divider, Link, Button, Image, Modal, ModalContent, ModalBody, useDisclosure, } from "@nextui-org/react"; import { useTranslations } from "next-intl"; import { useEffect, useState, Suspense } from "react"; import { TorrentItemProps } from "@/types"; import { formatByteSize, formatDate, GetLinkInfoFromWhatsLinkResponse, setClipboard, Toast, } from "@/utils"; import useBreakpoint from "@/hooks/useBreakpoints"; import FileList from "@/components/FileList"; import { CopyIcon } from "@/components/icons"; import EmblaCarousel from "@/components//EmblaCarousel"; import { useHydration } from "@/hooks/useHydration"; const Preview = ({ linkInfo, }: { linkInfo: Promise; }) => { const t = useTranslations(); const { isOpen, onOpen, onOpenChange } = useDisclosure(); const [curIdx, setCurIdx] = useState(0); const [linkData, setLinkData] = useState(); useEffect(() => { linkInfo.then((data) => { // console.log("linkData", data); setLinkData(data); }); }, [linkInfo]); if (!linkData || !linkData.screenshots) return null; const screenshots = linkData.screenshots ?? []; return ( <> {t("Detail.preview")}
{screenshots.map((item, index) => ( { setCurIdx(index); onOpen(); }} /> ))}
{() => ( item.screenshot)} options={{ startIndex: curIdx, duration: 22, loop: true, }} /> )}
); }; export const DetailContent = ({ data, linkInfo, }: { data: TorrentItemProps; linkInfo: Promise; }) => { const t = useTranslations(); const { isXs } = useBreakpoint(); const hydrated = useHydration(); return ( <> {/* Torrent name */}

{data.name}

{/* Magnet link and file list */}
{/* Torrent details card */} {t("Detail.details")}
{t("Search.file_size")} {formatByteSize(data.size)} {t("Search.file_count")} {data.files.length} {t("Search.created_at")} {formatDate( data.created_at, t("COMMON.DATE_FORMAT"), !hydrated, )} {t("Search.hash")} {data.hash}
{/* Magnet link card */} {t("Detail.magnet")}
🧲 {`magnet:?xt=urn:btih:${data.hash}`}
{/* Magnet content preview */} {/* File list card */} {t("Detail.file_list")}
{t("Search.file_size")} {formatByteSize(data.size)} {t("Search.file_count")} {data.files.length} {t("Search.created_at")} {formatDate( data.created_at, t("COMMON.DATE_FORMAT"), !hydrated, )}
); }; ================================================ FILE: components/EmblaCarousel.css ================================================ .embla { max-width: 48rem; margin: auto; --slide-height: 19rem; --slide-spacing: 1rem; --slide-size: 100%; --detail-medium-contrast: hsl(var(--nextui-default-200)); --detail-high-contrast: hsl(var(--nextui-default-300)); --text-body: hsl(var(--nextui-default-600)); } .embla__viewport { overflow: hidden; } .embla__container { backface-visibility: hidden; display: flex; touch-action: pan-y pinch-zoom; margin-left: calc(var(--slide-spacing) * -1); } .embla__slide { flex: 0 0 var(--slide-size); min-width: 0; max-height: 80vh; padding-left: var(--slide-spacing); } .embla__slide__img { width: var(--slide-size); height: var(--slide-size); object-fit: contain; cursor: grab; } .embla__slide__img:active { cursor: grabbing; } .embla__controls { display: grid; grid-template-columns: auto 1fr; justify-content: space-between; gap: 1.2rem; margin-top: 1rem; } .embla__buttons { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.4rem; align-items: center; } .embla__button { -webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5); -webkit-appearance: none; appearance: none; background-color: transparent; touch-action: manipulation; display: inline-flex; text-decoration: none; cursor: pointer; border: 0; padding: 0; margin: 0; box-shadow: inset 0 0 0 0.2rem var(--detail-medium-contrast); z-index: 1; border-radius: 50%; color: var(--text-body); display: flex; align-items: center; justify-content: center; } .embla__button:disabled { color: var(--detail-high-contrast); } .embla__button__svg { width: 35%; height: 35%; } .embla__dots { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; margin-right: calc((1.4rem - 0.8rem) / 2 * -1); } .embla__dot { -webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5); -webkit-appearance: none; appearance: none; background-color: transparent; touch-action: manipulation; display: inline-flex; text-decoration: none; cursor: pointer; border: 0; padding: 0; margin: 0; width: 1.4rem; height: 1.4rem; display: flex; align-items: center; justify-content: center; border-radius: 50%; } .embla__dot:after { box-shadow: inset 0 0 0 0.1rem var(--detail-medium-contrast); width: 0.8rem; height: 0.8rem; border-radius: 50%; display: flex; align-items: center; content: ''; } .embla__dot--selected:after { box-shadow: inset 0 0 0 0.1rem var(--text-body); } ================================================ FILE: components/EmblaCarousel.tsx ================================================ import React, { ComponentPropsWithRef, useCallback, useEffect, useState, } from "react"; import { EmblaOptionsType, EmblaCarouselType } from "embla-carousel"; import useEmblaCarousel from "embla-carousel-react"; import "./EmblaCarousel.css"; type UsePrevNextButtonsType = { prevBtnDisabled: boolean; nextBtnDisabled: boolean; onPrevButtonClick: () => void; onNextButtonClick: () => void; }; const usePrevNextButtons = ( emblaApi: EmblaCarouselType | undefined, ): UsePrevNextButtonsType => { const [prevBtnDisabled, setPrevBtnDisabled] = useState(true); const [nextBtnDisabled, setNextBtnDisabled] = useState(true); const onPrevButtonClick = useCallback(() => { if (!emblaApi) return; emblaApi.scrollPrev(); }, [emblaApi]); const onNextButtonClick = useCallback(() => { if (!emblaApi) return; emblaApi.scrollNext(); }, [emblaApi]); const onSelect = useCallback((emblaApi: EmblaCarouselType) => { setPrevBtnDisabled(!emblaApi.canScrollPrev()); setNextBtnDisabled(!emblaApi.canScrollNext()); }, []); useEffect(() => { if (!emblaApi) return; onSelect(emblaApi); emblaApi.on("reInit", onSelect).on("select", onSelect); }, [emblaApi, onSelect]); return { prevBtnDisabled, nextBtnDisabled, onPrevButtonClick, onNextButtonClick, }; }; type ButtonPropType = ComponentPropsWithRef<"button">; const PrevButton: React.FC = (props) => { const { children, ...restProps } = props; return ( ); }; const NextButton: React.FC = (props) => { const { children, ...restProps } = props; return ( ); }; type UseDotButtonType = { selectedIndex: number; scrollSnaps: number[]; onDotButtonClick: (index: number) => void; }; export const useDotButton = ( emblaApi: EmblaCarouselType | undefined, ): UseDotButtonType => { const [selectedIndex, setSelectedIndex] = useState(0); const [scrollSnaps, setScrollSnaps] = useState([]); const onDotButtonClick = useCallback( (index: number) => { if (!emblaApi) return; emblaApi.scrollTo(index); }, [emblaApi], ); const onInit = useCallback((emblaApi: EmblaCarouselType) => { setScrollSnaps(emblaApi.scrollSnapList()); }, []); const onSelect = useCallback((emblaApi: EmblaCarouselType) => { setSelectedIndex(emblaApi.selectedScrollSnap()); }, []); useEffect(() => { if (!emblaApi) return; onInit(emblaApi); onSelect(emblaApi); emblaApi.on("reInit", onInit).on("reInit", onSelect).on("select", onSelect); }, [emblaApi, onInit, onSelect]); return { selectedIndex, scrollSnaps, onDotButtonClick, }; }; export const DotButton: React.FC = (props) => { const { children, ...restProps } = props; return ( ); }; type PropType = { images: string[] | { image: string; caption: string }[]; options?: EmblaOptionsType; }; const EmblaCarousel: React.FC = (props) => { const { images, options } = props; const slides: { image: string; caption: string }[] = images.map((image) => { if (typeof image === "string") { return { image, caption: "" }; } else { return image; } }); const [emblaRef, emblaApi] = useEmblaCarousel(options); const { selectedIndex, scrollSnaps, onDotButtonClick } = useDotButton(emblaApi); const { prevBtnDisabled, nextBtnDisabled, onPrevButtonClick, onNextButtonClick, } = usePrevNextButtons(emblaApi); return (
{slides.map(({ image, caption }, index) => (
{caption}
))}
{scrollSnaps.map((_, index) => ( onDotButtonClick(index)} /> ))}
); }; export default EmblaCarousel; ================================================ FILE: components/FileList.tsx ================================================ "use client"; import React from "react"; import { Link, Chip } from "@nextui-org/react"; import { useTranslations } from "next-intl"; import clsx from "clsx"; import { TorrentItemProps } from "@/types"; import { hexToBase64, formatByteSize, getSizeColor, parseHighlight, } from "@/utils"; import FileTypeIcon from "@/components/FileTypeIcon"; type FileItem = TorrentItemProps["files"][0] & { index: number | string; path: string; extension?: string; size?: number | string; type: "file"; name: string; }; type Directory = { index: string; type: "folder"; name: string; path: string; children: (Directory | FileItem)[]; }; /** * Constructs a file tree from a flat list of file items. * * @param {FileItem[]} data - The flat list of file items. * @param {number} [maxDepth=3] - The maximum depth of the tree. * @returns {Directory[]} The constructed file tree. */ function fileTree(data: FileItem[], maxDepth: number = 3): Directory[] { const root: Directory = { index: "root", type: "folder", name: "", path: "", children: [], }; for (const file of data) { const parts = file.path.split("/"); const rootName = parts[0]; if (parts.length === 1) { // This is a root-level file file.type = "file"; file.name = rootName; root.children.push(file); continue; } let currentLevel = root; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (i === parts.length - 1) { // It's the last part, so it's a file file.type = "file"; file.name = part; currentLevel.children.push(file); } else if (i === maxDepth) { // If max depth is reached, treat remaining parts as part of the file name const remainingPath = parts.slice(i).join("/"); const sub = { ...file, path: remainingPath, name: remainingPath, type: "file", }; currentLevel.children.push(sub as FileItem); break; } else { let nextLevel = currentLevel.children.find( (child): child is Directory => { return child.type === "folder" && child.name === part; }, ); if (!nextLevel) { nextLevel = { index: "_" + part, type: "folder", name: part, path: part, // Path is now just the part name children: [], }; currentLevel.children.push(nextLevel); } currentLevel = nextLevel; } } } return root.children as Directory[]; } /** * Renders a file or directory item. * * @param {Object} props - The component props. * @param {FileItem | Directory} props.file - The file or directory item. * @param {string} [props.highlight] - The text to highlight. * @returns {JSX.Element} The rendered file item. */ function FileItem({ file, highlight, }: { file: FileItem | Directory; highlight?: string | string[]; }) { return (
  • {file.type === "file" && file.size && ( {formatByteSize(file.size)} )}
    {file.type === "folder" && (
      {file.children.map((child) => ( ))}
    )}
  • ); } /** * Renders a file list for a torrent. * * @param {Object} props - The component props. * @param {TorrentItemProps} props.torrent - The torrent data. * @param {string} [props.highlight] - The text to highlight. * @param {number} [props.max=-1] - The maximum number of files to show. * @returns {JSX.Element} The rendered file list. */ export default function FileList({ torrent, highlight, max = -1, }: { torrent: TorrentItemProps; highlight?: string | string[]; max?: number; }) { const t = useTranslations(); const list = max > 0 ? torrent.files.slice(0, max) : torrent.files; const tree = fileTree(list as FileItem[], 3); return (
      {tree.map((file) => ( ))} {max > 0 && torrent.files.length > max && ( {t("Search.more_files", { count: torrent.files.length - max, })} )}
    ); } ================================================ FILE: components/FileTypeIcon.tsx ================================================ "use client"; const extensionMap = { folder: "folder", audio: "mp3,wav,ogg,m4a,flac,wma,aac,mid,midi,cue", image: "jpg,jpeg,png,gif,bmp,svg,webp,tiff,ico,heic,raw,psd,ai", video: "mp4,mkv,webm,avi,mov,flv,wmv,mpeg,mpg,3gp,m4v,rm,rmvb,ts,m2ts,pmp", book: "pdf,epub,fb2,mobi,azw,azw3,cbr,cbz,chm", web: "torrent,html,htm,php,url,asp,aspx,jsp", archive: "zip,rar,7z,gz,bz2,tar,xpi,rpm,cab,lzh,dmg,z,lz,xz,tgz,tbz2", disk: "iso,img,vmdk,vdi", executable: "exe,msi,apk,xpi,deb,bat,sh,bin,dll,so,cmd,com,run,vbs,app", subtitle: "srt,sub,ssa,ass,vtt,rt,rtx,smi", }; const extensionArr = Object.fromEntries( Object.entries(extensionMap).map(([key, value]) => [key, value.split(",")]), ); const getFileType = (extension?: string) => { if (!extension) return "file"; extension = String(extension).toLowerCase(); for (const [type, extensions] of Object.entries(extensionArr)) { if (extensions.includes(extension)) { return type; } } return "file"; // Default type for unknown file extensions }; export default function FileTypeIcon({ extension, className, }: { extension?: string; className?: string; }) { const type = getFileType(extension); const defaultClassName = "file-type-icon"; if (!className) className = defaultClassName; else className = `${defaultClassName} ${className}`; return ; } ================================================ FILE: components/FloatTool.tsx ================================================ /* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/no-static-element-interactions */ "use client"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, } from "@nextui-org/react"; import clsx from "clsx"; import { $env, Cookie } from "@/utils"; import { SunFilledIcon, MoonFilledIcon, LangFilledIcon, } from "@/components/icons"; import { locales, defaultLocale } from "@/i18n/config"; function handleBackTop() { window.scrollTo({ top: 0, behavior: "smooth", }); } const BackTop = () => { const [showBackTop, setShowBackTop] = useState(false); useEffect(() => { const handleScroll = () => { if (window.scrollY > 800) { setShowBackTop(true); } else { setShowBackTop(false); } }; handleScroll(); window.addEventListener("scroll", handleScroll); return () => { window.removeEventListener("scroll", handleScroll); }; }, []); return (
    handleBackTop()} >
    ); }; export const ToggleTheme = ({ noBg = false }: { noBg?: boolean }) => { const { theme, setTheme } = useTheme(); const [colorScheme, setColorScheme] = useState<"light" | "dark">("light"); useEffect(() => { if (theme === "system") { const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); if (!mediaQuery) return; mediaQuery.addEventListener("change", (event) => { setColorScheme(event.matches ? "dark" : "light"); }); setColorScheme(mediaQuery.matches ? "dark" : "light"); } else { setColorScheme(theme === "dark" ? "dark" : "light"); } }, [theme]); return (
    setTheme(colorScheme === "dark" ? "light" : "dark")} > {colorScheme === "dark" ? : }
    ); }; export const SwitchLanguage = ({ noBg = false }: { noBg?: boolean }) => { const cookieLocale = typeof window !== "undefined" ? Cookie.get("NEXT_LOCALE") : null; const browserLocale = typeof window !== "undefined" ? navigator.language : null; const locale = cookieLocale || browserLocale || defaultLocale; const [lang, setlang] = useState(new Set([locale])); const handleChangeLocale = (key: Set) => { setlang(key); Cookie.set("NEXT_LOCALE", Array.from(key)[0], { path: "/", expires: 365, }); if (typeof window !== "undefined") { window.location.reload(); } }; return ( <>
    {Object.entries(locales).map(([key, value]) => ( {key} {value} ))}
    ); }; export const FloatTool = () => { const [enabled, setEnabled] = useState(false); useEffect(() => { if ($env.isMobile) { setEnabled(false); return; } setEnabled(true); }, []); if (!enabled) return null; return (
    {/* */}
    ); }; ================================================ FILE: components/HomeLogo.tsx ================================================ "use client"; import clsx from "clsx"; import { useState } from "react"; import { MagnetIcon } from "@/components/icons"; import { siteConfig } from "@/config/site"; import { $env } from "@/utils"; export const HomeLogo = () => { const [isAnimating, setIsAnimating] = useState(false); const doClickAnimation = () => { if (!$env.isMobile) { return; } if (isAnimating) { return; } setIsAnimating(true); setTimeout(() => { setIsAnimating(false); }, 400); }; return (

    doClickAnimation()} >

    ); }; ================================================ FILE: components/SearchInput.tsx ================================================ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ "use client"; import { Input, Button, Spinner } from "@nextui-org/react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import clsx from "clsx"; import { SearchIcon } from "@/components/icons"; import { $env } from "@/utils"; export const SearchInput = ({ defaultValue = "", isReplace = false, }: { defaultValue?: string; isReplace?: boolean; }) => { const [keyword, setKeyword] = useState(""); const [loading, setLoading] = useState(false); const [active, setActive] = useState(false); const [errMessage, setErrMessage] = useState(""); const router = useRouter(); const searchParams = useSearchParams(); useEffect(() => { // Reset loading state when search parameters change setLoading(false); }, [searchParams]); useEffect(() => { // Set default value for keyword when provided if (defaultValue) { setKeyword(defaultValue); } }, [defaultValue]); function handleSearch() { // Trim the keyword and set it to state setKeyword(keyword.trim()); // If keyword is empty, do nothing if (!keyword) { return; } // If search params equals current search params, do nothing if (searchParams.get("keyword") === keyword && !searchParams.get("p")) { return; } if (keyword.length < 2) { // If keyword length is less than 2, display warning toast // Toast.warn(t("Toast.keyword_too_short")); setErrMessage(t("Toast.keyword_too_short")); return; } if (keyword.length > 100) { // limit keyword length to 100 characters setKeyword(keyword.slice(0, 100)); } const params = new URLSearchParams(); // Create URLSearchParams object params.set("keyword", keyword.trim()); // Set keyword in URLSearchParams const url = `/search?${params.toString()}`; // Construct URL with search keyword setLoading(true); // Set loading state to true if (isReplace) { router.replace(url); } else { router.push(url); } } function handleKeyup(e: any) { // Handle Enter key press for triggering search if (e.key === "Enter" || e.keyCode === 13) { // If on desktop, trigger search if (!$env.isMobile) { handleSearch(); } // Blur input, on mobile that will trigger search e.target.blur(); } } function handleBlur() { if ($env.isMobile) { // If on mobile, trigger search handleSearch(); } setActive(false); } function handleFocus() { setErrMessage(""); setActive(true); } const t = useTranslations(); // Translation function return ( setKeyword("")} > } errorMessage={errMessage} isInvalid={!!errMessage} labelPlacement="outside" placeholder={t("Search.placeholder")} value={keyword} onBlur={handleBlur} onFocus={handleFocus} onKeyUp={handleKeyup} onValueChange={setKeyword} /> ); }; ================================================ FILE: components/SearchResultsItem.tsx ================================================ "use client"; import { Suspense } from "react"; import { Card, CardHeader, CardBody, CardFooter, Divider, Link, } from "@nextui-org/react"; import { useTranslations } from "next-intl"; import { TorrentItemProps } from "@/types"; import { $env, hexToBase64, formatByteSize, formatDate, parseHighlight, setClipboard, Toast, } from "@/utils"; import FileList from "@/components/FileList"; import { SEARCH_DISPLAY_FILES_MAX } from "@/config/constant"; import { useHydration } from "@/hooks/useHydration"; export default function SearchResultsItem({ item, keywords, }: { item: TorrentItemProps; keywords: string | string[]; }) { const data = { ...item, name: item.name, url: `/detail/${hexToBase64(item.hash)}`, files: item.files, }; const t = useTranslations(); const hydrated = useHydration(); return (

    { if ($env.isMobile) { e.preventDefault(); setClipboard(data.magnet_uri); Toast.success(t("Toast.copy_success")); } }} > 🧲 {t("Search.magnet")}
    {t("Search.file_size")} {formatByteSize(data.size)} {t("Search.file_count")} {data.files.length} {t("Search.created_at")} {formatDate(data.created_at, t("COMMON.DATE_FORMAT"), !hydrated)}
    ); } ================================================ FILE: components/SearchResultsList.tsx ================================================ "use client"; import { useRouter } from "next/navigation"; import { Pagination, Select, SelectItem } from "@nextui-org/react"; import { useTranslations } from "next-intl"; import { useIsSSR } from "@react-aria/ssr"; import SearchResultsItem from "./SearchResultsItem"; import { SearchResultsListProps } from "@/types"; import { $env } from "@/utils"; import { SEARCH_PARAMS, SEARCH_PAGE_MAX } from "@/config/constant"; export default function SearchResultsList({ resultList, keywords, cost_time = 0, total_count = 0, searchOption, }: { resultList: SearchResultsListProps["torrents"]; keywords: string[]; cost_time: number; total_count: number; searchOption: { keyword: string; p: number; ps: number; sortType: string; filterTime: string; filterSize: string; }; }) { const router = useRouter(); const isSSR = useIsSSR(); const t = useTranslations(); const handleFilterChange = (type: string, value: string) => { const updatedSearchOption = { ...searchOption, [type]: value, }; handlePageChange(1, updatedSearchOption); }; const handlePageChange = ( page: number, newSearchOption: typeof searchOption, ) => { const params = new URLSearchParams(); params.set("keyword", newSearchOption.keyword); params.set("p", String(page)); params.set("ps", String(newSearchOption.ps)); if (newSearchOption.sortType) { params.set("sortType", newSearchOption.sortType); } if (newSearchOption.filterTime) { params.set("filterTime", newSearchOption.filterTime); } if (newSearchOption.filterSize) { params.set("filterSize", newSearchOption.filterSize); } const url = `/search?${params.toString()}`; router.push(url); }; const pagiConf = { page: searchOption.p, total: Math.min(Math.ceil(total_count / searchOption.ps), SEARCH_PAGE_MAX), siblinds: $env.isMobile ? 1 : 3, }; return ( <>
    {Object.entries(SEARCH_PARAMS).map(([key, value]) => ( ))}
    {t("Search.results_found", { count: total_count })} {cost_time > 0 && ( {t("Search.cost_time", { cost_time: cost_time })} )}
    {resultList.map((item) => (
    ))} {!isSSR && pagiConf.total > 1 && ( handlePageChange(page, searchOption)} /> )} ); } ================================================ FILE: components/Stats.tsx ================================================ import { Suspense } from "react"; import { getTranslations } from "next-intl/server"; import { Tooltip, Spinner } from "@nextui-org/react"; import apiFetch from "@/utils/api"; import { InfoFilledIcon } from "@/components/icons"; import { formatByteSize, formatDate } from "@/utils"; async function StatsCard() { const t = await getTranslations(); const { data } = await apiFetch("/api/stats", { next: { revalidate: 60 }, }); return (

    {t("Stats.title")}

    • {t("Stats.size", { size: formatByteSize(data.size) })}
    • {t("Stats.total_count", { total_count: data.total_count.toLocaleString(), })}
    • {t("Stats.updated_at", { updated_at: formatDate( data.updated_at, t("COMMON.DATE_FORMAT_SHORT"), ), })}
    ); } export function Stats() { return ( }> } delay={0} radius="sm" > ); } ================================================ FILE: components/icons.tsx ================================================ import * as React from "react"; import { IconSvgProps } from "@/types"; export const Logo: React.FC = ({ size = 36, width, height, ...props }) => ( ); export const DiscordIcon: React.FC = ({ size = 24, width, height, ...props }) => { return ( ); }; export const TwitterIcon: React.FC = ({ size = 24, width, height, ...props }) => { return ( ); }; export const GithubIcon: React.FC = ({ size = 24, width, height, ...props }) => { return ( ); }; export const MoonFilledIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => ( ); export const SunFilledIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => ( ); export const HeartFilledIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => ( ); export const SearchIcon = (props: IconSvgProps) => ( ); export const NextUILogo: React.FC = (props) => { const { width, height = 40 } = props; return ( ); }; export const LangFilledIcon: React.FC = ({ size = 24, width, height, ...props }: IconSvgProps) => ( ); export const InfoFilledIcon: React.FC = ({ size = 24, width, height, ...props }: IconSvgProps) => ( ); export const MagnetIcon: React.FC = (props: IconSvgProps) => ( ); export const CopyIcon: React.FC = (props: IconSvgProps) => ( ); export const PrevIcon: React.FC = ({ size = 24, width, height, ...props }: IconSvgProps) => ( ); export const NextIcon: React.FC = ({ size = 24, width, height, ...props }: IconSvgProps) => ( ); ================================================ FILE: config/constant.ts ================================================ // Define search parameters export const SEARCH_PARAMS = { sortType: ["default", "size", "count", "date"], filterSize: [ "all", "lt100mb", "gt100mb-lt500mb", "gt500mb-lt1gb", "gt1gb-lt5gb", "gt5gb", ], filterTime: ["all", "gt-1day", "gt-7day", "gt-31day", "gt-365day"], } as const; // Tokenizer for search keywords export const SEARCH_KEYWORD_SPLIT_REGEX = /[.,!?;—()\[\]{}<>@#%^&*~`"'|\-,。!?;“”‘’“”「」『』《》、【】……()· \s]/g; // Using for Search page export const SEARCH_DISPLAY_FILES_MAX = 10; export const SEARCH_KEYWORD_LENGTH_MIN = 2; export const SEARCH_KEYWORD_LENGTH_MAX = 100; export const SEARCH_PAGE_SIZE = 10; export const SEARCH_PAGE_MAX = 100; export const DEFAULT_SORT_TYPE = "default"; export const DEFAULT_FILTER_TIME = "all"; export const DEFAULT_FILTER_SIZE = "all"; // TODO: Support UI_HIDE_PADDING_FILE export const UI_HIDE_PADDING_FILE = true; // https://www.bittorrent.org/beps/bep_0047.html export const UI_BACKGROUND_ANIMATION = true; export const UI_BREAKPOINTS = { xs: "(max-width: 649px)", sm: "(min-width: 650px)", md: "(min-width: 960px)", lg: "(min-width: 1280px)", xl: "(min-width: 1400px)", }; ================================================ FILE: config/fonts.ts ================================================ import { Fira_Code as FontMono, Inter as FontSans, Noto_Sans_SC, } from "next/font/google"; export const fontSans = FontSans({ subsets: ["latin"], variable: "--font-sans", }); export const fontMono = FontMono({ subsets: ["latin"], variable: "--font-mono", }); export const fontNoto = Noto_Sans_SC({ weight: ["300", "400", "500", "700"], preload: false, }); ================================================ FILE: config/site.ts ================================================ export type SiteConfig = typeof siteConfig; export const siteConfig = { name: "Bitmagnet Next Web", description: "🧲 A modern BitTorrent indexer, powered by Bitmagnet.", }; ================================================ FILE: docker-compose.yml ================================================ version: "3" services: bitmagnet-next-web: image: journey0ad/bitmagnet-next-web:latest container_name: bitmagnet-next-web ports: - "3000:3000" restart: unless-stopped environment: - POSTGRES_DB_URL=postgres://postgres:postgres@postgres:5432/bitmagnet # - POSTGRES_HOST=postgres # - POSTGRES_PASSWORD=postgres depends_on: postgres: condition: service_healthy bitmagnet: image: ghcr.io/bitmagnet-io/bitmagnet:latest container_name: bitmagnet ports: # API and WebUI port: - "3333:3333" # BitTorrent ports: - "3334:3334/tcp" - "3334:3334/udp" restart: unless-stopped environment: - POSTGRES_HOST=postgres - POSTGRES_PASSWORD=postgres # - TMDB_API_KEY=your_api_key command: - worker - run - --keys=http_server - --keys=queue_server # disable the next line to run without DHT crawler - --keys=dht_crawler depends_on: postgres: condition: service_healthy postgres: image: postgres:16-alpine container_name: bitmagnet-postgres volumes: - ./data/postgres:/var/lib/postgresql/data ports: - "5432:5432" restart: unless-stopped environment: - POSTGRES_PASSWORD=postgres - POSTGRES_DB=bitmagnet - PGUSER=postgres shm_size: 1g healthcheck: test: - CMD-SHELL - pg_isready start_period: 20s interval: 10s ================================================ FILE: hooks/useBreakpoints.ts ================================================ import { useIsSSR } from "@react-aria/ssr"; import { useMediaQuery } from "react-responsive"; const useBreakpoints = () => { const isSSR = useIsSSR(); // detect screen size const isXs = useMediaQuery({ query: "(max-width: 649px)" }); const isSmUp = useMediaQuery({ query: "(min-width: 650px)" }); const isMdUp = useMediaQuery({ query: "(min-width: 960px)" }); const isLgUp = useMediaQuery({ query: "(min-width: 1280px)" }); const isXlUp = useMediaQuery({ query: "(min-width: 1400px)" }); // client-side rendering return the actual media query result // server-side rendering return the default value return { isXs: !isSSR && isXs, isSmUp: !isSSR && isSmUp, isMdUp: !isSSR && isMdUp, isLgUp: !isSSR && isLgUp, isXlUp: !isSSR && isXlUp, }; }; export default useBreakpoints; ================================================ FILE: hooks/useHydration.ts ================================================ import { useState, useEffect } from "react"; export function useHydration() { const [hydrated, setHydrated] = useState(false); useEffect(() => { setHydrated(true); }, []); return hydrated; } ================================================ FILE: i18n/config.ts ================================================ export const defaultLocale = "en" as const; export const locales = { en: "English", "zh-CN": "简体中文", "zh-TW": "繁體中文", } as const; ================================================ FILE: i18n/index.ts ================================================ import { getRequestConfig } from "next-intl/server"; import { headers, cookies } from "next/headers"; import { mergeDeep } from "@apollo/client/utilities"; import { defaultLocale } from "./config"; export default getRequestConfig(async () => { // Provide a static locale, fetch a user setting, // read from `cookies()`, `headers()`, etc. const browserLocale = (() => { let locale = headers().get("accept-language") ?? ""; locale = locale?.split(",")[0]; if (!locale.startsWith("zh")) { locale = locale.split("-")[0]; } return locale; })(); const cookieLocale = (() => { const locale = cookies().get("NEXT_LOCALE")?.value; return locale; })(); const locale = cookieLocale || browserLocale || defaultLocale; const defaultLocaleFile = (await import(`./locales/${defaultLocale}.json`)) .default; if (!defaultLocaleFile) { throw new Error("Default locale file not found"); } try { const localeFile = (await import(`./locales/${locale}.json`)).default; const localeMessages = mergeDeep(defaultLocaleFile, localeFile); return { locale, messages: localeMessages, }; } catch (error) { return { locale: defaultLocale, messages: defaultLocaleFile, }; } }); ================================================ FILE: i18n/locales/en.json ================================================ { "COMMON": { "DATE_FORMAT": "MM/DD/YYYY HH:mm:ss", "DATE_FORMAT_SHORT": "MM/DD/YYYY HH:mm", "DATE_FORMAT_DATE": "MM/DD/YYYY", "DATE_FORMAT_TIME": "HH:mm:ss", "DEMO_MODE_TIPS": "This website is running in demo mode" }, "Stats": { "title": "Statistics", "size": "Database size: {size}", "total_count": "Total torrents: {total_count}", "updated_at": "Last updated: {updated_at}" }, "ERROR_MESSAGE": { "INTERNAL_SERVER_ERROR": "Something went wrong!", "NOT_FOUND": "Resource not found", "Digest": "Digest", "Message": "Message", "GoHome": "Go Home" }, "Metadata": { "search": { "title": "{keyword} search results" } }, "Search": { "placeholder": "Search what you want...", "filterLabel": { "sortType": "Sort by", "filterTime": "Filter by time", "filterSize": "Filter by size" }, "sortType": { "default": "Default sorting", "size": "File size", "count": "File count", "date": "Date added" }, "filterTime": { "all": "All time", "gt-1day": "Past day", "gt-7day": "Past week", "gt-31day": "Past month", "gt-365day": "Past year" }, "filterSize": { "all": "All sizes", "lt100mb": "Less than 100MB", "gt100mb-lt500mb": "100MB-500MB", "gt500mb-lt1gb": "500MB-1GB", "gt1gb-lt5gb": "1GB-5GB", "gt5gb": "More than 5GB" }, "results_found": "About {count} results found", "cost_time": "(Processed in {cost_time}ms)", "more_files": "…and {count} more files", "magnet": "Magnet link", "file_size": "File size: ", "file_count": "File count: ", "created_at": "Date added: ", "hash": "Hash: " }, "Detail": { "details": "Torrent details", "preview": "Preview", "magnet": "Magnet link", "file_list": "File list", "copy": "Copy magnet link" }, "Toast": { "keyword_too_short": "Keyword needs to be at least 2 characters", "copy_success": "Magnet link copied to clipboard" } } ================================================ FILE: i18n/locales/zh-CN.json ================================================ { "COMMON": { "DATE_FORMAT": "YYYY-MM-DD HH:mm:ss", "DATE_FORMAT_SHORT": "YYYY-MM-DD HH:mm", "DATE_FORMAT_DATE": "YYYY-MM-DD", "DATE_FORMAT_TIME": "HH:mm:ss", "DEMO_MODE_TIPS": "这个网站正在演示模式下运行" }, "Stats": { "title": "统计信息", "size": "数据库大小: {size}", "total_count": "收录数量: {total_count}", "updated_at": "最后更新: {updated_at}" }, "ERROR_MESSAGE": { "INTERNAL_SERVER_ERROR": "发生意外错误", "NOT_FOUND": "资源不存在", "GoHome": "返回首页" }, "Metadata": { "search": { "title": "{keyword} 的搜索结果" } }, "Search": { "placeholder": "搜你想搜...", "filterLabel": { "sortType": "排序方式", "filterTime": "按时间筛选", "filterSize": "按大小筛选" }, "sortType": { "default": "默认排序", "size": "文件大小", "count": "文件数量", "date": "收录时间" }, "filterTime": { "all": "不限时间", "gt-1day": "最近一天内", "gt-7day": "最近一周内", "gt-31day": "最近一月内", "gt-365day": "最近一年内" }, "filterSize": { "all": "不限大小", "lt100mb": "小于100MB", "gt100mb-lt500mb": "100MB-500MB", "gt500mb-lt1gb": "500MB-1GB", "gt1gb-lt5gb": "1GB-5GB", "gt5gb": "大于5GB" }, "results_found": "找到约 {count} 条结果", "cost_time": "(用时 {cost_time}ms)", "more_files": "…还有 {count} 个文件", "magnet": "磁力链接", "file_size": "文件大小: ", "file_count": "文件数量: ", "created_at": "收录时间: ", "hash": "哈希值: " }, "Detail": { "details": "资源详情", "preview": "内容预览", "magnet": "磁链地址", "file_list": "文件列表", "copy": "复制磁链" }, "Toast": { "keyword_too_short": "关键词需要大于两个字符", "copy_success": "磁力链接已复制到剪贴板" } } ================================================ FILE: i18n/locales/zh-TW.json ================================================ { "COMMON": { "DATE_FORMAT": "YYYY-MM-DD HH:mm:ss", "DATE_FORMAT_SHORT": "YYYY-MM-DD HH:mm", "DATE_FORMAT_DATE": "YYYY-MM-DD", "DATE_FORMAT_TIME": "HH:mm:ss", "DEMO_MODE_TIPS": "這個網站正在演示模式下運行" }, "Stats": { "title": "統計資訊", "size": "資料庫大小: {size}", "total_count": "收錄數量: {total_count}", "updated_at": "最後更新: {updated_at}" }, "ERROR_MESSAGE": { "INTERNAL_SERVER_ERROR": "發生意外錯誤", "NOT_FOUND": "資源不存在", "GoHome": "返回首頁" }, "Metadata": { "search": { "title": "{keyword} 的搜尋結果" } }, "Search": { "placeholder": "搜尋你想要的...", "filterLabel": { "sortType": "排序方式", "filterTime": "按時間篩選", "filterSize": "按大小篩選" }, "sortType": { "default": "默認排序", "size": "文件大小", "count": "文件數量", "date": "收錄時間" }, "filterTime": { "all": "不限時間", "gt-1day": "最近一天內", "gt-7day": "最近一週內", "gt-31day": "最近一月內", "gt-365day": "最近一年內" }, "filterSize": { "all": "不限大小", "lt100mb": "小於100MB", "gt100mb-lt500mb": "100MB-500MB", "gt500mb-lt1gb": "500MB-1GB", "gt1gb-lt5gb": "1GB-5GB", "gt5gb": "大於5GB" }, "results_found": "找到約 {count} 條結果", "cost_time": "(耗時 {cost_time}ms)", "more_files": "…還有 {count} 個文件", "magnet": "磁力連結", "file_size": "文件大小: ", "file_count": "文件數量: ", "created_at": "收錄時間: ", "hash": "Hash: " }, "Detail": { "details": "詳細資訊", "preview": "內容預覽", "magnet": "磁力連結", "file_list": "檔案清單", "copy": "複製磁力連結" }, "Toast": { "keyword_too_short": "關鍵字需大於兩個字元", "copy_success": "磁力連結已複製到剪貼簿" } } ================================================ FILE: lib/apolloClient.ts ================================================ import { ApolloClient, InMemoryCache, from, HttpLink } from "@apollo/client"; import { removeTypenameFromVariables } from "@apollo/client/link/remove-typename"; import { getBaseUrl } from "@/utils/api"; const httpLink = new HttpLink({ uri: `${getBaseUrl()}/api/graphql`, // 从环境变量中获取 URI }); const removeTypename = removeTypenameFromVariables(); const client = new ApolloClient({ cache: new InMemoryCache({ addTypename: false, }), link: from([removeTypename, httpLink]), }); export default client; ================================================ FILE: lib/jieba.ts ================================================ import { tag } from "@node-rs/jieba"; const requiredTags = [ ["n", "nr", "ns", "nt", "nz"], // noun "vn", // gerund "x", // other ].flat(); export function jiebaCut(text: string) { // return cut(text, true); return tag(text, true).map((_) => ({ keyword: _.word, required: requiredTags.includes(_.tag), })); } ================================================ FILE: lib/pgdb.ts ================================================ import { Pool } from "pg"; // Load connection string from environment let connectionString = process.env.POSTGRES_DB_URL; if (!connectionString) { const host = process.env.POSTGRES_HOST; const password = process.env.POSTGRES_PASSWORD; const user = process.env.POSTGRES_USER || "postgres"; // optional, defaults to 'postgres' const db = process.env.POSTGRES_DB || "bitmagnet"; // optional, defaults to 'bitmagnet' const port = process.env.POSTGRES_PORT || "5432"; // optional, defaults to 5432 if (!host || !password) { // eslint-disable-next-line no-console console.warn( "Missing environment variables `POSTGRES_DB_URL` or `POSTGRES_HOST` and `POSTGRES_PASSWORD`", ); } // Build connection string connectionString = `postgres://${user}:${password}@${host}:${port}/${db}`; } const pool = new Pool({ connectionString, ssl: false, }); export const query = (text: string, params: any) => pool.query(text, params); ================================================ FILE: moke/detail.json ================================================ { "data": { "hash": "3e14aa4ca819da595e311629a42e952804179b1e", "name": "[异域-11番小队][攻壳机动队_GHOST IN THE SHELL][TV+MOV+OVA][BDRIP][X264-10bit_AAC][720P]", "size": "13725714362", "magnet_uri": "magnet:?xt=urn:btih:3e14aa4ca819da595e311629a42e952804179b1e&dn=%5B%E5%BC%82%E5%9F%9F-11%E7%95%AA%E5%B0%8F%E9%98%9F%5D%5B%E6%94%BB%E5%A3%B3%E6%9C%BA%E5%8A%A8%E9%98%9F_GHOST%20IN%20THE%20SHELL%5D%5BTV%2BMOV%2BOVA%5D%5BBDRIP%5D%5BX264-10bit_AAC%5D%5B720P%5D&xl=13725714362", "single_file": false, "files_count": 55, "files": [ { "index": 0, "path": "[YYDM-11FANS][Ghost_In_The_Shell- 1995-2.0][OVA1][BDRIP][X264-10bit_AAC][720P][B2CA6F21].mp4", "extension": "mp4", "size": "820940608" }, { "index": 1, "path": "[YYDM-11FANS][Ghost_In_The_Shell- Innocence][OVA2][BDRIP][X264-10bit_AAC][720P][6D31B2CC].mp4", "extension": "mp4", "size": "1477291205" }, { "index": 2, "path": "[YYDM-11FANS][Ghost_In_The_Shell- Solid_State_Society][MOV][BDRIP][X264-10bit_AAC][720P][7D82BAB9].mp4", "extension": "mp4", "size": "1211589555" }, { "index": 3, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][01][BDRIP][X264-10bit_AAC][720P][245D25CF].mp4", "extension": "mp4", "size": "179138179" }, { "index": 4, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][02][BDRIP][X264-10bit_AAC][720P][2C45FC38].mp4", "extension": "mp4", "size": "192401042" }, { "index": 5, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][03][BDRIP][X264-10bit_AAC][720P][F0F773B3].mp4", "extension": "mp4", "size": "158353368" }, { "index": 6, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][04][BDRIP][X264-10bit_AAC][720P][2817DC37].mp4", "extension": "mp4", "size": "233529190" }, { "index": 7, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][05][BDRIP][X264-10bit_AAC][720P][3FF5288A].mp4", "extension": "mp4", "size": "180157857" }, { "index": 8, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][06][BDRIP][X264-10bit_AAC][720P][1C651B8C].mp4", "extension": "mp4", "size": "169356140" }, { "index": 9, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][07][BDRIP][X264-10bit_AAC][720P][A678C81E].mp4", "extension": "mp4", "size": "162118487" }, { "index": 10, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][08][BDRIP][X264-10bit_AAC][720P][F6BCD6D9].mp4", "extension": "mp4", "size": "178676935" }, { "index": 11, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][09][BDRIP][X264-10bit_AAC][720P][974EB705].mp4", "extension": "mp4", "size": "218396271" }, { "index": 12, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][10][BDRIP][X264-10bit_AAC][720P][BF25A962].mp4", "extension": "mp4", "size": "129101309" }, { "index": 13, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][11][BDRIP][X264-10bit_AAC][720P][86EC6E14].mp4", "extension": "mp4", "size": "167561897" }, { "index": 14, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][12][BDRIP][X264-10bit_AAC][720P][0DE533B5].mp4", "extension": "mp4", "size": "226534058" }, { "index": 15, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][13][BDRIP][X264-10bit_AAC][720P][0E1FD3BA].mp4", "extension": "mp4", "size": "170549748" }, { "index": 16, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][14][BDRIP][X264-10bit_AAC][720P][F0EBAD7B].mp4", "extension": "mp4", "size": "193001803" }, { "index": 17, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][15][BDRIP][X264-10bit_AAC][720P][F9FFB81F].mp4", "extension": "mp4", "size": "244055506" }, { "index": 18, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][16][BDRIP][X264-10bit_AAC][720P][9965B364].mp4", "extension": "mp4", "size": "394481670" }, { "index": 19, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][17][BDRIP][X264-10bit_AAC][720P][C5A2E8BD].mp4", "extension": "mp4", "size": "153461978" }, { "index": 20, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][18][BDRIP][X264-10bit_AAC][720P][564D5DF6].mp4", "extension": "mp4", "size": "196135187" }, { "index": 21, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][19][BDRIP][X264-10bit_AAC][720P][5F0E7F39].mp4", "extension": "mp4", "size": "276436131" }, { "index": 22, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][20][BDRIP][X264-10bit_AAC][720P][3855C712].mp4", "extension": "mp4", "size": "159015017" }, { "index": 23, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][21][BDRIP][X264-10bit_AAC][720P][342A6443].mp4", "extension": "mp4", "size": "209327874" }, { "index": 24, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][22][BDRIP][X264-10bit_AAC][720P][13E82C1F].mp4", "extension": "mp4", "size": "163124932" }, { "index": 25, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][23][BDRIP][X264-10bit_AAC][720P][CB606A0C].mp4", "extension": "mp4", "size": "191399204" }, { "index": 26, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][24][BDRIP][X264-10bit_AAC][720P][CEBA6E78].mp4", "extension": "mp4", "size": "207995098" }, { "index": 27, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][25][BDRIP][X264-10bit_AAC][720P][AE3BCF93].mp4", "extension": "mp4", "size": "195694248" }, { "index": 28, "path": "[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][26][BDRIP][X264-10bit_AAC][720P][E1A9C9E3].mp4", "extension": "mp4", "size": "234359649" }, { "index": 29, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][01][BDRIP][X264-10bit_AAC][720P][71E585A9].mp4", "extension": "mp4", "size": "211892101" }, { "index": 30, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][02][BDRIP][X264-10bit_AAC][720P][8F3A41C0].mp4", "extension": "mp4", "size": "234208152" }, { "index": 31, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][03][BDRIP][X264-10bit_AAC][720P][AA23400C].mp4", "extension": "mp4", "size": "183357713" }, { "index": 32, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][04][BDRIP][X264-10bit_AAC][720P][43FAD622].mp4", "extension": "mp4", "size": "200668950" }, { "index": 33, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][05][BDRIP][X264-10bit_AAC][720P][CE4C5012].mp4", "extension": "mp4", "size": "203272712" }, { "index": 34, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][06][BDRIP][X264-10bit_AAC][720P][7BE1C43B].mp4", "extension": "mp4", "size": "197970638" }, { "index": 35, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][07][BDRIP][X264-10bit_AAC][720P][B0295CB4].mp4", "extension": "mp4", "size": "187205757" }, { "index": 36, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][08][BDRIP][X264-10bit_AAC][720P][E8186BE1].mp4", "extension": "mp4", "size": "184845049" }, { "index": 37, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][09][BDRIP][X264-10bit_AAC][720P][581B1DF2].mp4", "extension": "mp4", "size": "233519030" }, { "index": 38, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][10][BDRIP][X264-10bit_AAC][720P][6E0BC9E8].mp4", "extension": "mp4", "size": "181936037" }, { "index": 39, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][11][BDRIP][X264-10bit_AAC][720P][A8C2BCEA].mp4", "extension": "mp4", "size": "176088934" }, { "index": 40, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][12][BDRIP][X264-10bit_AAC][720P][64107CBF].mp4", "extension": "mp4", "size": "212766756" }, { "index": 41, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][13][BDRIP][X264-10bit_AAC][720P][0D41BFC6].mp4", "extension": "mp4", "size": "195490816" }, { "index": 42, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][14][BDRIP][X264-10bit_AAC][720P][BCD3CF8F].mp4", "extension": "mp4", "size": "195657219" }, { "index": 43, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][15][BDRIP][X264-10bit_AAC][720P][C6185A93].mp4", "extension": "mp4", "size": "210634833" }, { "index": 44, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][16][BDRIP][X264-10bit_AAC][720P][6CB1728E].mp4", "extension": "mp4", "size": "185283575" }, { "index": 45, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][17][BDRIP][X264-10bit_AAC][720P][BAC8DAD5].mp4", "extension": "mp4", "size": "146701611" }, { "index": 46, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][18][BDRIP][X264-10bit_AAC][720P][B18FDFE8].mp4", "extension": "mp4", "size": "153565409" }, { "index": 47, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][19][BDRIP][X264-10bit_AAC][720P][33014230].mp4", "extension": "mp4", "size": "168688704" }, { "index": 48, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][20][BDRIP][X264-10bit_AAC][720P][B30ECD8E].mp4", "extension": "mp4", "size": "184604559" }, { "index": 49, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][21][BDRIP][X264-10bit_AAC][720P][93392110].mp4", "extension": "mp4", "size": "200411359" }, { "index": 50, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][22][BDRIP][X264-10bit_AAC][720P][FE607B50].mp4", "extension": "mp4", "size": "175165358" }, { "index": 51, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][23][BDRIP][X264-10bit_AAC][720P][6E436042].mp4", "extension": "mp4", "size": "178309602" }, { "index": 52, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][24][BDRIP][X264-10bit_AAC][720P][45164B6F].mp4", "extension": "mp4", "size": "178795852" }, { "index": 53, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][25][BDRIP][X264-10bit_AAC][720P][0DCFFC26].mp4", "extension": "mp4", "size": "252105105" }, { "index": 54, "path": "[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][26][BDRIP][X264-10bit_AAC][720P][C2FD9A64].mp4", "extension": "mp4", "size": "198384385" } ], "created_at": 1719147067, "updated_at": 1719147067 }, "message": "success", "status": 200 } ================================================ FILE: moke/index.ts ================================================ import search from "./search.json"; import detail from "./detail.json"; import stats from "./stats.json"; export default { search: search.data, detail: detail.data, stats: stats.data, }; ================================================ FILE: moke/search.json ================================================ { "data": { "keywords": [ "哆啦a梦" ], "torrents": [ { "hash": "181bf8eced82d3d215187f920d0ea4a511bfd17c", "name": "[搬运整理]哆啦A梦好看的剧集", "size": "55356425642", "magnet_uri": "magnet:?xt=urn:btih:181bf8eced82d3d215187f920d0ea4a511bfd17c&dn=%5B%E6%90%AC%E8%BF%90%E6%95%B4%E7%90%86%5D%E5%93%86%E5%95%A6A%E6%A2%A6%E5%A5%BD%E7%9C%8B%E7%9A%84%E5%89%A7%E9%9B%86&xl=55356425642", "single_file": false, "files_count": 363, "files": [ { "index": 0, "path": "001/[银光字幕组][哆啦A梦新番Doraemon][001][GB][2005.04.15]在书房里钓鱼&时光机不见了!!&唤醒记忆!那天的感动[480P][MP4]/5CA2E549434ADF8B14C2BEF81AE9BC3B2941431C.torrent", "extension": "torrent", "size": "39673" }, { "index": 2, "path": "001/[银光字幕组][哆啦A梦新番Doraemon][001][GB][2005.04.15]在书房里钓鱼&时光机不见了!!&唤醒记忆!那天的感动[480P][MP4]/[银光字幕组][哆啦A梦新番Doraemon][001][GB][2005.04.15]在书房里钓鱼&时光机不见了!!&唤醒记忆!那天的感动[480P][MP4].mp4", "extension": "mp4", "size": "243497353" }, { "index": 4, "path": "001/[银光字幕组][哆啦A梦新番Doraemon][001][GB][2005.04.15]在书房里钓鱼&时光机不见了!!&唤醒记忆!那天的感动[480P][MP4]/关于字幕组招募通知.doc", "extension": "doc", "size": "13824" }, { "index": 6, "path": "001/[银光字幕组][哆啦A梦新番Doraemon][001][GB][2005.04.15]在书房里钓鱼&时光机不见了!!&唤醒记忆!那天的感动[480P][MP4]/关于银光动漫招聘工作成员.doc", "extension": "doc", "size": "17920" }, { "index": 8, "path": "002/[银光字幕组][哆啦A梦新番Doraemon][002][GB][2005.04.22]慢吞吞,乱糟糟 & 大雄的新娘[480P][MP4].mp4/F893E84493B1C62F23013604F9B5F2EF86B7C181.torrent", "extension": "torrent", "size": "21058" }, { "index": 10, "path": "002/[银光字幕组][哆啦A梦新番Doraemon][002][GB][2005.04.22]慢吞吞,乱糟糟 & 大雄的新娘[480P][MP4].mp4/[银光字幕组][哆啦A梦新番Doraemon][002][GB][2005.04.22]慢吞吞,乱糟糟 & 大雄的新娘[480P][MP4].mp4", "extension": "mp4", "size": "124983831" }, { "index": 12, "path": "002/[银光字幕组][哆啦A梦新番Doraemon][002][GB][2005.04.22]慢吞吞,乱糟糟 & 大雄的新娘[480P][MP4].mp4/关于字幕组招募通知.doc", "extension": "doc", "size": "13824" }, { "index": 14, "path": "002/[银光字幕组][哆啦A梦新番Doraemon][002][GB][2005.04.22]慢吞吞,乱糟糟 & 大雄的新娘[480P][MP4].mp4/关于银光动漫招聘工作成员.doc", "extension": "doc", "size": "17920" }, { "index": 16, "path": "003/[银光字幕组][哆啦A梦新番Doraemon][003][GB][2005.04.29]独裁者按钮[720P][MP4]/B5AA4660B6DE824290C6B0F4BBAE617B406EF0AE.torrent", "extension": "torrent", "size": "24739" }, { "index": 18, "path": "003/[银光字幕组][哆啦A梦新番Doraemon][003][GB][2005.04.29]独裁者按钮[720P][MP4]/[银光字幕组][哆啦A梦新番Doraemon][003][GB][2005.04.29]独裁者按钮[720P][MP4].mp4", "extension": "mp4", "size": "145345377" }, { "index": 20, "path": "003/[银光字幕组][哆啦A梦新番Doraemon][003][GB][2005.04.29]独裁者按钮[720P][MP4]/关于字幕组招募通知.doc", "extension": "doc", "size": "13824" }, { "index": 22, "path": "003/[银光字幕组][哆啦A梦新番Doraemon][003][GB][2005.04.29]独裁者按钮[720P][MP4]/关于银光动漫招聘工作成员.doc", "extension": "doc", "size": "17920" }, { "index": 24, "path": "007/[银光字幕组][哆啦A梦新番Doraemon][007][GB][2005.05.27]大雄的地底国[720P][MP4].mp4", "extension": "mp4", "size": "208938030" }, { "index": 26, "path": "009/[银光字幕组][哆啦A梦新番Doraemon][009][GB][2005.06.10]到处是哆啦A梦&心情香水[720P][MP4].mp4", "extension": "mp4", "size": "195064761" }, { "index": 28, "path": "013/[银光字幕组][哆啦A梦新番Doraemon][013][GB][2005.07.08]变身饼干&再见了!静香[720P][MP4].mp4", "extension": "mp4", "size": "192928020" }, { "index": 30, "path": "032/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2005.12.31][032][GB]除夕特别篇[HDRip][X264-AAC][720P][MP4].MP4", "extension": "mp4", "size": "657966509" }, { "index": 32, "path": "035/[银光字幕组][哆啦A梦新番Doraemon][035][GB][2006.01.27]榻榻米水田&夸张外套[720P][MP4].mp4", "extension": "mp4", "size": "190951847" }, { "index": 34, "path": "041/[银光字幕组][哆啦A梦新番Doraemon][041][GB][2006.03.10]生存下来的人是谁!?&无人岛的大怪物[720P][MP4].mp4", "extension": "mp4", "size": "357760351" }, { "index": 36, "path": "044/[银光字幕组][哆啦A梦新番Doraemon][044][GB][2006.04.21]从未来之国千里迢迢而来[720P][MP4].mp4", "extension": "mp4", "size": "183420521" }, { "index": 38, "path": "045/[银光字幕组][哆啦A梦新番Doraemon][045][GB][2006.04.28]梦想的小镇 大雄乐园&大雄的25年后[720P][MP4].mp4", "extension": "mp4", "size": "218778228" }, { "index": 40, "path": "047/[银光字幕组][哆啦A梦新番Doraemon][2006.05.12][047]即使在胃之中、水之中&复活吧!佩罗[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "184085686" }, { "index": 42, "path": "054/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2006.06.30][054][GB]吃糖果做歌星&怀念奶奶&令人感动的麦克风[HDRip][X264-AAC][720P][MP4].MP4", "extension": "mp4", "size": "323174538" }, { "index": 44, "path": "062/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2006.09.01][062][GB]哆啦A梦生日特别篇之大雄 再见了!哆啦A梦 回未来了...[HDRip][X264-AAC][720P][MP4].MP4", "extension": "mp4", "size": "288228916" }, { "index": 46, "path": "067/[银光字幕组][哆啦A梦新番Doraemon][2006.10.27][067]变身树叶&对神仙机器人伸出爱的援手[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "218046461" }, { "index": 48, "path": "072/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2006.12.01][072][GB]切浦岛糖果&哆啦A梦和哆啦美酱[HDRip][X264-AAC][720P][MP4].MP4", "extension": "mp4", "size": "232498857" }, { "index": 50, "path": "075/[YGSUB][2007.01.12][075][HDTV][X264.MP4].mp4", "extension": "mp4", "size": "177596651" }, { "index": 52, "path": "076/[银光字幕组][哆啦A梦新番Doraemon][2007.01.19][076]漂亮的小咪& 把静香夺回来!(后篇)[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "151202437" }, { "index": 54, "path": "080/[银光字幕组][哆啦A梦新番Doraemon][2007.02.16][080]地底之国的探险(上集)[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "158217771" }, { "index": 56, "path": "081/[银光字幕组][哆啦A梦新番Doraemon][2007.02.23][081]地底之国的探险(下集)[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "186907099" }, { "index": 58, "path": "084/[银光字幕组][哆啦A梦新番Doraemon][2007.03.16][084]大雄的黑洞&不可能制作动画[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "156549644" }, { "index": 60, "path": "086/[银光字幕组][哆啦A梦新番Doraemon][2007.04.29][086]穿着红色鞋子的女孩&空地上的大白鲨[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "181393575" }, { "index": 62, "path": "094/[银光字幕组][哆啦A梦新番Doraemon][2007.06.29][094]海贼大决战南海的爱情罗曼史[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "423946689" }, { "index": 64, "path": "102/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2007.09.07][102][GB]哆啦A梦生日特别篇之哆啦A梦的重生之日[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "364222712" }, { "index": 66, "path": "109/[银光字幕组][哆啦A梦新番Doraemon][2007.12.07][109]保护好王子!传说中的哆啦美三剑士[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "354175558" }, { "index": 68, "path": "120/[银光字幕组][哆啦A梦新番Doraemon][2008.03.14][120]森林活起来了[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "177836666" }, { "index": 70, "path": "123/[银光字幕组][哆啦A梦新番Doraemon][123][GB][2008.04.25]我出生的那一天[720P][MP4].mp4", "extension": "mp4", "size": "126308164" }, { "index": 72, "path": "124/[银光字幕组][哆啦A梦新番Doraemon][124][GB][2008.05.02]试着说再见[720P][MP4].mp4", "extension": "mp4", "size": "133803565" }, { "index": 74, "path": "127/[银光字幕组][哆啦A梦新番Doraemon][127][GB][2008.05.23]戏剧性瓦斯&大雄真了不起!再来一次[720P][MP4].mp4", "extension": "mp4", "size": "133455382" }, { "index": 76, "path": "128/[银光字幕组][哆啦A梦新番Doraemon][128][GB][2008.06.06]送给静香的礼物是大雄[720P][MP4].mp4", "extension": "mp4", "size": "155692995" }, { "index": 78, "path": "132/[银光字幕组][哆啦A梦新番Doraemon][132][GB][2008.06.27]1小时特别篇-沉睡的海之王国[720P][MP4]/AEF3FEF54C489C6FDA79DD33111055E968782DBF.torrent", "extension": "torrent", "size": "27243" }, { "index": 80, "path": "132/[银光字幕组][哆啦A梦新番Doraemon][132][GB][2008.06.27]1小时特别篇-沉睡的海之王国[720P][MP4]/[银光字幕组][哆啦A梦新番Doraemon][132][GB][2008.06.27]1小时特别篇-沉睡的海之王国[720P][MP4].mp4", "extension": "mp4", "size": "322440312" }, { "index": 82, "path": "132/[银光字幕组][哆啦A梦新番Doraemon][132][GB][2008.06.27]1小时特别篇-沉睡的海之王国[720P][MP4]/关于字幕组招募通知.doc", "extension": "doc", "size": "13824" }, { "index": 84, "path": "132/[银光字幕组][哆啦A梦新番Doraemon][132][GB][2008.06.27]1小时特别篇-沉睡的海之王国[720P][MP4]/关于银光动漫招聘工作成员.doc", "extension": "doc", "size": "17920" }, { "index": 86, "path": "133/[银光字幕组][哆啦A梦新番Doraemon][133][GB][2008.07.11]在七夕的宇宙战争[720P][MP4].mp4", "extension": "mp4", "size": "198786331" }, { "index": 88, "path": "134/[银光字幕组][哆啦A梦新番Doraemon][134][GB][2008.07.18]比我更差的家伙来了&保镖是背后灵[720P][MP4].mp4", "extension": "mp4", "size": "168116259" }, { "index": 90, "path": "136/[银光字幕组][哆啦A梦新番Doraemon][136][GB][2008.08.01]鬼魂出现了[720P][MP4].mp4", "extension": "mp4", "size": "117184890" }, { "index": 92, "path": "141/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2008.09.05][141][GB]哆啦A梦生日特别篇之哆啦A梦的青之泪[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "313876425" }, { "index": 94, "path": "142/[Doraemon][142][2008.09.12][HDTVrip][129.3字幕组][720P][AVC_AAC][GB][冒险茶&舌相神卜!][重播版](DA7FEFB3).mp4", "extension": "mp4", "size": "297519066" }, { "index": 96, "path": "157/[Doraemon][157][2009.01.23][HDTVRip][sy2006]一半的一半的又一半&那一天.那一刻.那个不倒翁.mp4", "extension": "mp4", "size": "145482921" }, { "index": 98, "path": "162/[银光字幕组][哆啦A梦新番Doraemon][2009.03.06][162]银河铁道之夜[HDRip][X264-AAC][720P][MP4].mp4", "extension": "mp4", "size": "221172956" }, { "index": 1, "path": "_____padding_file_0_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8348935" }, { "index": 3, "path": "_____padding_file_1_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8160887" }, { "index": 5, "path": "_____padding_file_2_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8374784" }, { "index": 7, "path": "_____padding_file_3_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8370688" }, { "index": 9, "path": "_____padding_file_4_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8367550" }, { "index": 11, "path": "_____padding_file_5_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "845289" }, { "index": 13, "path": "_____padding_file_6_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8374784" }, { "index": 15, "path": "_____padding_file_7_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8370688" }, { "index": 17, "path": "_____padding_file_8_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8363869" }, { "index": 19, "path": "_____padding_file_9_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "5649567" }, { "index": 21, "path": "_____padding_file_10_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8374784" }, { "index": 23, "path": "_____padding_file_11_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8370688" }, { "index": 25, "path": "_____padding_file_12_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "777170" }, { "index": 27, "path": "_____padding_file_13_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "6261831" }, { "index": 29, "path": "_____padding_file_14_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "9964" }, { "index": 31, "path": "_____padding_file_15_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "4733523" }, { "index": 33, "path": "_____padding_file_16_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "1986137" }, { "index": 35, "path": "_____padding_file_17_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "2949793" }, { "index": 37, "path": "_____padding_file_18_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "1128855" }, { "index": 39, "path": "_____padding_file_19_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "7714188" }, { "index": 41, "path": "_____padding_file_20_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "463690" }, { "index": 43, "path": "_____padding_file_21_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "3981174" }, { "index": 45, "path": "_____padding_file_22_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "5372364" }, { "index": 47, "path": "_____padding_file_23_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "57347" }, { "index": 49, "path": "_____padding_file_24_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "2382167" }, { "index": 51, "path": "_____padding_file_25_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "6952725" }, { "index": 53, "path": "_____padding_file_26_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8181115" }, { "index": 55, "path": "_____padding_file_27_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "1165781" }, { "index": 57, "path": "_____padding_file_28_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "6030885" }, { "index": 59, "path": "_____padding_file_29_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "2833908" }, { "index": 61, "path": "_____padding_file_30_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "3155801" }, { "index": 63, "path": "_____padding_file_31_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "3872319" }, { "index": 65, "path": "_____padding_file_32_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "4876040" }, { "index": 67, "path": "_____padding_file_33_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "6534586" }, { "index": 69, "path": "_____padding_file_34_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "6712710" }, { "index": 71, "path": "_____padding_file_35_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "7909564" }, { "index": 73, "path": "_____padding_file_36_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "414163" }, { "index": 75, "path": "_____padding_file_37_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "762346" }, { "index": 77, "path": "_____padding_file_38_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "3690557" }, { "index": 79, "path": "_____padding_file_39_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8361365" }, { "index": 81, "path": "_____padding_file_40_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "4715400" }, { "index": 83, "path": "_____padding_file_41_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8374784" }, { "index": 85, "path": "_____padding_file_42_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8370688" }, { "index": 87, "path": "_____padding_file_43_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "2540261" }, { "index": 89, "path": "_____padding_file_44_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "8044509" }, { "index": 91, "path": "_____padding_file_45_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "255622" }, { "index": 93, "path": "_____padding_file_46_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "4890679" }, { "index": 95, "path": "_____padding_file_47_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "4470822" }, { "index": 97, "path": "_____padding_file_48_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "5512023" }, { "index": 99, "path": "_____padding_file_49_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "5319460" } ], "created_at": 1719328705, "updated_at": 1719328705 }, { "hash": "b3858d7ba81101fbed15c5ff2086976b848bf0f4", "name": "[哆啦A梦新番][Doraemon][2013]318-347", "size": "9413092144", "magnet_uri": "magnet:?xt=urn:btih:b3858d7ba81101fbed15c5ff2086976b848bf0f4&dn=%5B%E5%93%86%E5%95%A6A%E6%A2%A6%E6%96%B0%E7%95%AA%5D%5BDoraemon%5D%5B2013%5D318-347&xl=9413092144", "single_file": false, "files_count": 33, "files": [ { "index": 0, "path": "DodSpeed@tvboxnow.txt", "extension": "txt", "size": "60" }, { "index": 1, "path": "[哆啦A梦新番][Doraemon][2013]318-347.txt", "extension": "txt", "size": "4184" }, { "index": 2, "path": "[天空字幕组][[哆啦A梦新番][Doraemon].jpg", "extension": "jpg", "size": "152328" }, { "index": 3, "path": "[天空字幕组][哆啦A梦新番][318][2013.01.11][720P][GB]人体储蓄罐&诞生!名侦探大雄.mp4", "extension": "mp4", "size": "307517228" }, { "index": 4, "path": "[天空字幕组][哆啦A梦新番][319][2013.01.18][720P][GB]我想吃螃蟹!&跨时代购物.mp4", "extension": "mp4", "size": "307507593" }, { "index": 5, "path": "[天空字幕组][哆啦A梦新番][320][2013.01.25][720P][GB]超级惠方卷&雪好热.mp4", "extension": "mp4", "size": "307558859" }, { "index": 6, "path": "[天空字幕组][哆啦A梦新番][321][2013.02.01][720P][GB]小夫一见钟情了&静香的宇宙露天浴池.mp4", "extension": "mp4", "size": "307515277" }, { "index": 7, "path": "[天空字幕组][哆啦A梦新番][322][2013.02.15][720P][GB]怪盗大雄参上!&用小鸟帽飞到天空.mp4", "extension": "mp4", "size": "307577531" }, { "index": 8, "path": "[天空字幕组][哆啦A梦新番][323][2013.02.22][720P][GB]爸爸妈妈家庭大战&绝对神准?手相组合.mp4", "extension": "mp4", "size": "307528720" }, { "index": 9, "path": "[天空字幕组][哆啦A梦新番][324][2013.03.01][720P][GB]最强!跌倒专家Z.mp4", "extension": "mp4", "size": "307538634" }, { "index": 10, "path": "[天空字幕组][哆啦A梦新番][326][2013.04.12][720P][GB]爆发胡椒&一定要去赏花.mp4", "extension": "mp4", "size": "304726661" }, { "index": 11, "path": "[天空字幕组][哆啦A梦新番][327][2013.04.26][720P][GB]节约攒钱去夏威夷旅行&静香的羽衣.mp4", "extension": "mp4", "size": "307828211" }, { "index": 12, "path": "[天空字幕组][哆啦A梦新番][328][2013.05.03][720P][GB]在天国睡午觉&独裁者按钮.mp4", "extension": "mp4", "size": "307056316" }, { "index": 13, "path": "[天空字幕组][哆啦A梦新番][329][2013.05.10][720P][GB]速成妈妈&被盯上的胖虎.mp4", "extension": "mp4", "size": "307133480" }, { "index": 14, "path": "[天空字幕组][哆啦A梦新番][330][2013.05.17][720P][GB]逃出!恐怖的骨川家豪宅&洞悉头盔.mp4", "extension": "mp4", "size": "307104598" }, { "index": 15, "path": "[天空字幕组][哆啦A梦新番][331][2013.05.24][720P][GB]房屋竹蜻蜓&静香最糟糕的生日.mp4", "extension": "mp4", "size": "307137523" }, { "index": 16, "path": "[天空字幕组][哆啦A梦新番][332][2013.05.31][720P][GB]近朱者棒&胖虎是熊猫.mp4", "extension": "mp4", "size": "307119346" }, { "index": 17, "path": "[天空字幕组][哆啦A梦新番][333][2013.06.07][720P][GB]送礼包巾&最强!黑带大雄.mp4", "extension": "mp4", "size": "307080489" }, { "index": 18, "path": "[天空字幕组][哆啦A梦新番][334][2013.06.14][720P][GB]胖虎的告别演唱会&梦境导演椅.mp4", "extension": "mp4", "size": "307053950" }, { "index": 19, "path": "[天空字幕组][哆啦A梦新番][335][2013.06.21][720P][GB]颠倒世界镜&大雄的秘密隧道.mp4", "extension": "mp4", "size": "307081243" }, { "index": 20, "path": "[天空字幕组][哆啦A梦新番][336][2013.07.05][720P][GB]用搬家地图来搬家&用镜子做广告.mp4", "extension": "mp4", "size": "307130249" }, { "index": 21, "path": "[天空字幕组][哆啦A梦新番][337][2013.07.12][720P][GB]万能冰棍&决心混凝土.mp4", "extension": "mp4", "size": "307041957" }, { "index": 22, "path": "[天空字幕组][哆啦A梦新番][338][2013.07.26][720P][GB]夏季大冒险1小时特别篇.mp4", "extension": "mp4", "size": "543134387" }, { "index": 23, "path": "[天空字幕组][哆啦A梦新番][339][2013.08.09][720P][GB]深海骑行&大雄的整人摄像机.mp4", "extension": "mp4", "size": "307440793" }, { "index": 24, "path": "[天空字幕组][哆啦A梦新番][340][2013.08.16][720P][GB]种烟花吧!&分身槌.mp4", "extension": "mp4", "size": "307535174" }, { "index": 25, "path": "[天空字幕组][哆啦A梦新番][341][2013.08.23][720P][GB]和妖怪们共度的暑假&凝固灯.mp4", "extension": "mp4", "size": "307507234" }, { "index": 26, "path": "[天空字幕组][哆啦A梦新番][342][2013.08.30][720P][GB]大雄的夏日祭典大作战&在撒哈拉沙漠无法学习.mp4", "extension": "mp4", "size": "306373991" }, { "index": 27, "path": "[天空字幕组][哆啦A梦新番][343][2013.09.06][720P][GB]大雄能量的使用方法&用鼻子气球飞到天空.mp4", "extension": "mp4", "size": "315509263" }, { "index": 28, "path": "[天空字幕组][哆啦A梦新番][344][2013.09.13][720P][GB]1小时生日特别篇_深夜的巨大哆啦狸猫.mp4", "extension": "mp4", "size": "545206665" }, { "index": 29, "path": "[天空字幕组][哆啦A梦新番][345][2013.10.18][720P]人体火车头&在山水盆景里采松茸.mp4", "extension": "mp4", "size": "315713375" }, { "index": 30, "path": "[天空字幕组][哆啦A梦新番][346][2013.10.25][720P]万圣节是什么节日?&品尝汤匙.mp4", "extension": "mp4", "size": "312584893" }, { "index": 31, "path": "[天空字幕组][哆啦A梦新番][347][2013.11.01][720P]用魔术手为所欲为&戈耳工之首.mp4", "extension": "mp4", "size": "315690000" }, { "index": 32, "path": "公仔箱論壇 - Powered by Discuz!.url", "extension": "url", "size": "1932" } ], "created_at": 1719239454, "updated_at": 1719239454 }, { "hash": "88f82254253637f8e6f4e4ca38f495e157c18e13", "name": "[梦蓝字幕组]New Doraemon 哆啦A梦新番[765][2023.07.01][AVC][1080P][GB_JP][MP4].mp4", "size": "571197146", "magnet_uri": "magnet:?xt=urn:btih:88f82254253637f8e6f4e4ca38f495e157c18e13&dn=%5B%E6%A2%A6%E8%93%9D%E5%AD%97%E5%B9%95%E7%BB%84%5DNew%20Doraemon%20%E5%93%86%E5%95%A6A%E6%A2%A6%E6%96%B0%E7%95%AA%5B765%5D%5B2023.07.01%5D%5BAVC%5D%5B1080P%5D%5BGB_JP%5D%5BMP4%5D.mp4&xl=571197146", "single_file": true, "files_count": 1, "files": [ { "index": 0, "path": "[梦蓝字幕组]New Doraemon 哆啦A梦新番[765][2023.07.01][AVC][1080P][GB_JP][MP4].mp4", "extension": "mp4", "size": "571197146" } ], "created_at": 1719229193, "updated_at": 1719229193 }, { "hash": "6f5131c7936d09f7dec03be071f634daa3d140fb", "name": "[电影天堂www.dytt89.com]哆啦A梦:大雄的新魔界大冒险之7个魔法师-2007_BD日粤国三语中字.mp4", "size": "1754739449", "magnet_uri": "magnet:?xt=urn:btih:6f5131c7936d09f7dec03be071f634daa3d140fb&dn=%5B%E7%94%B5%E5%BD%B1%E5%A4%A9%E5%A0%82www.dytt89.com%5D%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E5%A4%A7%E9%9B%84%E7%9A%84%E6%96%B0%E9%AD%94%E7%95%8C%E5%A4%A7%E5%86%92%E9%99%A9%E4%B9%8B7%E4%B8%AA%E9%AD%94%E6%B3%95%E5%B8%88-2007_BD%E6%97%A5%E7%B2%A4%E5%9B%BD%E4%B8%89%E8%AF%AD%E4%B8%AD%E5%AD%97.mp4&xl=1754739449", "single_file": true, "files_count": 1, "files": [ { "index": 0, "path": "[电影天堂www.dytt89.com]哆啦A梦:大雄的新魔界大冒险之7个魔法师-2007_BD日粤国三语中字.mp4", "extension": "mp4", "size": "1754739449" } ], "created_at": 1719214794, "updated_at": 1719214794 }, { "hash": "9dd169d115cd13a90aa036b43cd1eb25c87f215e", "name": "哆啦A梦:大雄的南极冰冰凉大冒险.Doraemon.Nobita'S.Great.Adventure.in.the.Antarctic.Kachi.Kochi.2017.HK.BluRay.1080p.AVC.TrueHD5.1-HDHome", "size": "24471978770", "magnet_uri": "magnet:?xt=urn:btih:9dd169d115cd13a90aa036b43cd1eb25c87f215e&dn=%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E5%A4%A7%E9%9B%84%E7%9A%84%E5%8D%97%E6%9E%81%E5%86%B0%E5%86%B0%E5%87%89%E5%A4%A7%E5%86%92%E9%99%A9.Doraemon.Nobita'S.Great.Adventure.in.the.Antarctic.Kachi.Kochi.2017.HK.BluRay.1080p.AVC.TrueHD5.1-HDHome&xl=24471978770", "single_file": false, "files_count": 6, "files": [ { "index": 0, "path": "Doraemon.Nobita'S.Great.Adventure.in.the.Antarctic.Kachi.Kochi.2017.HK.BluRay.1080p.AVC.TrueHD5.1-HDHome.iso", "extension": "iso", "size": "24468193280" }, { "index": 1, "path": "上海硬盘之家~专业代拷高清电影片库.txt", "extension": "txt", "size": "311" }, { "index": 2, "path": "上海硬盘之家~专业销售高清硬盘,提供海量高清资源代拷!.url", "extension": "url", "size": "125" }, { "index": 3, "path": "上海硬盘之家~微信及公众号二维码.png", "extension": "png", "size": "45084" }, { "index": 4, "path": "关于BT种子资源库服务及硬盘租赁共享服务的说明.txt", "extension": "txt", "size": "1717" }, { "index": 5, "path": "本店有海量高清片库可供代拷.mp4", "extension": "mp4", "size": "3738253" } ], "created_at": 1719192535, "updated_at": 1719192535 }, { "hash": "1e9db18e52bd838183e6558be605e3748127e12e", "name": "哆啦A梦:伴我同行.Stand.by.Me.Doraemon.2014.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba", "size": "1898971258", "magnet_uri": "magnet:?xt=urn:btih:1e9db18e52bd838183e6558be605e3748127e12e&dn=%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E4%BC%B4%E6%88%91%E5%90%8C%E8%A1%8C.Stand.by.Me.Doraemon.2014.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba&xl=1898971258", "single_file": false, "files_count": 5, "files": [ { "index": 0, "path": "哆啦A梦:伴我同行.Stand.by.Me.Doraemon.2014.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba.mp4", "extension": "mp4", "size": "1897954225" }, { "index": 2, "path": "更多高清请访问www.mp4ba.com.txt", "extension": "txt", "size": "27" }, { "index": 4, "path": "点击进入高清MP4ba.url", "extension": "url", "size": "122" }, { "index": 1, "path": "_____padding_file_0_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "492623" }, { "index": 3, "path": "_____padding_file_1_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "524261" } ], "created_at": 1719184658, "updated_at": 1719184658 }, { "hash": "80ddf0bff59baedee4f461adb05fb085954003fd", "name": "哆啦A梦:大雄的宇宙英雄记.Doraemon.Nobita's.Space.Heros.2015.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba", "size": "2035810426", "magnet_uri": "magnet:?xt=urn:btih:80ddf0bff59baedee4f461adb05fb085954003fd&dn=%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E5%A4%A7%E9%9B%84%E7%9A%84%E5%AE%87%E5%AE%99%E8%8B%B1%E9%9B%84%E8%AE%B0.Doraemon.Nobita's.Space.Heros.2015.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba&xl=2035810426", "single_file": false, "files_count": 7, "files": [ { "index": 0, "path": "哆啦A梦:大雄的宇宙英雄记.Doraemon.Nobita's.Space.Heros.2015.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba.mp4", "extension": "mp4", "size": "2034695488" }, { "index": 2, "path": "更多高清请访问www.mp4ba.com.txt", "extension": "txt", "size": "27" }, { "index": 4, "path": "本站唯一域名www.mp4ba.com.txt", "extension": "txt", "size": "50" }, { "index": 6, "path": "点击进入高清MP4ba.url", "extension": "url", "size": "122" }, { "index": 1, "path": "_____padding_file_0_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "66240" }, { "index": 3, "path": "_____padding_file_1_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "524261" }, { "index": 5, "path": "_____padding_file_2_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "524238" } ], "created_at": 1719177553, "updated_at": 1719177553 }, { "hash": "e26f61e220e604d1530ae3f3d9ee136096f751f5", "name": "[4kii.net]哆啦A梦:大雄与天空的理想乡.Doraemon.Nobita's.Sky.Utopia.2023.2160p.HQ.WEB-DL.H265.DDP5.1.2Audio-[简中]-蓝光高清网", "size": "16905142890", "magnet_uri": "magnet:?xt=urn:btih:e26f61e220e604d1530ae3f3d9ee136096f751f5&dn=%5B4kii.net%5D%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E5%A4%A7%E9%9B%84%E4%B8%8E%E5%A4%A9%E7%A9%BA%E7%9A%84%E7%90%86%E6%83%B3%E4%B9%A1.Doraemon.Nobita's.Sky.Utopia.2023.2160p.HQ.WEB-DL.H265.DDP5.1.2Audio-%5B%E7%AE%80%E4%B8%AD%5D-%E8%93%9D%E5%85%89%E9%AB%98%E6%B8%85%E7%BD%91&xl=16905142890", "single_file": false, "files_count": 5, "files": [ { "index": 0, "path": "4KII.NET.txt", "extension": "txt", "size": "546" }, { "index": 2, "path": "[4kii.net]哆啦A梦:大雄与天空的理想乡.Doraemon.Nobita's.Sky.Utopia.2023.2160p.HQ.WEB-DL.H265.DDP5.1.2Audio-[简中]-蓝光高清网.mkv", "extension": "mkv", "size": "16902174938" }, { "index": 4, "path": "【蓝光高清网】全球所有4K资源 第一时间发布.html", "extension": "html", "size": "618" }, { "index": 1, "path": "_____padding_file_0_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "2096606" }, { "index": 3, "path": "_____padding_file_1_如果您看到此文件,请升级到BitComet(比特彗星)0.85或以上版本____", "extension": null, "size": "870182" } ], "created_at": 1719173721, "updated_at": 1719173721 }, { "hash": "1f24a17e853bf80b2637a87e9e70e6b7759b5780", "name": "哆啦A梦:大x的jy岛.1080p.国日粤三语.BD中字[最新电影www.66ys.tv].mp4", "size": "1862845913", "magnet_uri": "magnet:?xt=urn:btih:1f24a17e853bf80b2637a87e9e70e6b7759b5780&dn=%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E5%A4%A7x%E7%9A%84jy%E5%B2%9B.1080p.%E5%9B%BD%E6%97%A5%E7%B2%A4%E4%B8%89%E8%AF%AD.BD%E4%B8%AD%E5%AD%97%5B%E6%9C%80%E6%96%B0%E7%94%B5%E5%BD%B1www.66ys.tv%5D.mp4&xl=1862845913", "single_file": true, "files_count": 1, "files": [ { "index": 0, "path": "哆啦A梦:大x的jy岛.1080p.国日粤三语.BD中字[最新电影www.66ys.tv].mp4", "extension": "mp4", "size": "1862845913" } ], "created_at": 1719134227, "updated_at": 1719134227 }, { "hash": "3b222a510e1477c5144c34ca92111e7dd7ff26d2", "name": "[梦蓝字幕组]New Doraemon 哆啦A梦新番[777][2023.09.23][AVC][1080P][GB_JP][MP4].mp4", "size": "573393407", "magnet_uri": "magnet:?xt=urn:btih:3b222a510e1477c5144c34ca92111e7dd7ff26d2&dn=%5B%E6%A2%A6%E8%93%9D%E5%AD%97%E5%B9%95%E7%BB%84%5DNew%20Doraemon%20%E5%93%86%E5%95%A6A%E6%A2%A6%E6%96%B0%E7%95%AA%5B777%5D%5B2023.09.23%5D%5BAVC%5D%5B1080P%5D%5BGB_JP%5D%5BMP4%5D.mp4&xl=573393407", "single_file": true, "files_count": 1, "files": [ { "index": 0, "path": "[梦蓝字幕组]New Doraemon 哆啦A梦新番[777][2023.09.23][AVC][1080P][GB_JP][MP4].mp4", "extension": "mp4", "size": "573393407" } ], "created_at": 1719116467, "updated_at": 1719116467 } ], "total_count": 163, "has_more": true }, "message": "success", "status": 200 } ================================================ FILE: moke/stats.json ================================================ { "data": { "size": "26710807011", "total_count": 3447565, "updated_at": 1719015251, "latest_torrent_hash": "3648baf850d5930510c1f172b534200ebb5496e6", "latest_torrent": { "hash": "3648baf850d5930510c1f172b534200ebb5496e6", "name": "Ubuntu 24.04", "size": "8869638144", "created_at": 1719015251, "updated_at": 1719015251 } }, "message": "success", "status": 200 } ================================================ FILE: next.config.js ================================================ const createNextIntlPlugin = require('next-intl/plugin'); const withNextIntl = createNextIntlPlugin('./i18n'); const mode = process.env.BUILD_MODE ?? 'standalone'; console.log("[Next] build mode:", mode); /** @type {import('next').NextConfig} */ const nextConfig = { output: mode, experimental: { serverComponentsExternalPackages: [ '@node-rs/jieba' ] } } module.exports = withNextIntl(nextConfig); ================================================ FILE: package.json ================================================ { "name": "bitmagnet-next-web", "private": true, "scripts": { "dev": "next dev", "build": "cross-env BUILD_MODE=standalone next build", "start": "next start", "lint": "eslint . --ext .ts,.tsx -c .eslintrc.json --fix" }, "dependencies": { "@apollo/client": "^3.10.4", "@apollo/server": "^4.10.4", "@as-integrations/next": "^3.0.0", "@nextui-org/react": "^2.4.1", "@nextui-org/system": "2.2.1", "@nextui-org/theme": "2.2.5", "@node-rs/jieba": "^1.10.3", "@react-aria/ssr": "3.9.4", "@react-aria/visually-hidden": "3.8.12", "@tsparticles/react": "^3.0.0", "@tsparticles/slim": "^3.4.0", "autoprefixer": "10.4.19", "clsx": "2.1.1", "dayjs": "^1.11.11", "embla-carousel": "^8.1.5", "embla-carousel-react": "^8.1.5", "framer-motion": "~11.1.1", "graphql": "^16.8.1", "graphql-tag": "^2.12.6", "intl-messageformat": "^10.5.0", "js-cookie": "^3.0.5", "next": "14.2.3", "next-intl": "^3.14.1", "next-themes": "^0.2.1", "pg": "^8.12.0", "postcss": "8.4.38", "react": "18.3.1", "react-dom": "18.3.1", "react-responsive": "^10.0.0", "tailwind-variants": "0.1.20", "tailwindcss": "3.4.3", "zod": "^3.23.8" }, "devDependencies": { "@types/js-cookie": "^3.0.6", "@types/node": "20.5.7", "@types/pg": "^8.11.6", "@types/react": "18.3.2", "@types/react-dom": "18.3.0", "@typescript-eslint/eslint-plugin": "^7.10.0", "@typescript-eslint/parser": "^7.10.0", "cross-env": "^7.0.3", "eslint": "^8.56.0", "eslint-config-next": "14.2.1", "eslint-config-prettier": "^8.2.0", "eslint-plugin-import": "^2.26.0", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.23.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-unused-imports": "^3.2.0", "typescript": "5.4.5" } } ================================================ FILE: postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================ FILE: styles/file-type-icon/icons.css ================================================ .file-type-icon { display: inline-block; width: 1em; height: 18px; font-size: 1.3em; flex-shrink: 0; align-self: flex-start; vertical-align: middle; margin-right: 4px; background-image: var(--icon); background-repeat: no-repeat; background-size: 100% auto; background-position: center; } :root { --icon-folder: url(./file-folder.svg); --icon-file: url(./page-facing-up.svg); --icon-image: url(./framed-picture.svg); --icon-video: url(./film-frames.svg); --icon-audio: url(./musical-note.svg); --icon-book: url(./books.svg); --icon-web: url(./globe-with-meridians.svg); --icon-archive: url(./package.svg); --icon-disk: url(./optical-disk.svg); --icon-executable: url(./gear.svg); --icon-subtitle: url(./scroll.svg); } .file-type-icon[data-icon="folder"] { --icon: var(--icon-folder); } .file-type-icon[data-icon="file"] { --icon: var(--icon-file); } .file-type-icon[data-icon="image"] { --icon: var(--icon-image); } .file-type-icon[data-icon="video"] { --icon: var(--icon-video); } .file-type-icon[data-icon="audio"] { --icon: var(--icon-audio); } .file-type-icon[data-icon="book"] { --icon: var(--icon-book); } .file-type-icon[data-icon="web"] { --icon: var(--icon-web); } .file-type-icon[data-icon="archive"] { --icon: var(--icon-archive); } .file-type-icon[data-icon="disk"] { --icon: var(--icon-disk); } .file-type-icon[data-icon="executable"] { --icon: var(--icon-executable); } .file-type-icon[data-icon="subtitle"] { --icon: var(--icon-subtitle); } ================================================ FILE: styles/globals.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; @import url('./file-type-icon/icons.css'); @import url('./style.css'); ================================================ FILE: styles/style.css ================================================ html ,body { width: 100%; height: 100%; margin: 0; padding: 0; overscroll-behavior: unset; scroll-behavior: smooth; touch-action: manipulation; } input:-internal-autofill-selected, input:-webkit-autofill, input:-webkit-autofill:hover, input:-webkit-autofill:focus, input:-webkit-autofill:active { -webkit-box-shadow: 0 0 2em rgba(255, 255, 255, 0) inset !important; background-color: transparent !important; background-clip: text; } ================================================ FILE: tailwind.config.js ================================================ import {nextui} from '@nextui-org/theme' /** @type {import('tailwindcss').Config} */ module.exports = { content: [ './utils/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', './app/**/*.{js,ts,jsx,tsx,mdx}', './node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}' ], safelist: [ { pattern: /(bg|text)-(red|green|blue|gray)-\d+/, }, ], theme: { extend: { fontFamily: { sans: ["var(--font-sans)"], mono: ["var(--font-mono)"], }, screens: { 'xs': '400px', 'sm': '540px', }, keyframes: { 'fade-in': { '0%': { opacity: 0 }, '100%': { opacity: 1 }, }, 'fade-out': { '0%': { opacity: 1 }, '100%': { opacity: 0 }, }, 'fade-in-up': { '0%': { opacity: 0, transform: 'translateY(-10px)' }, '100%': { opacity: 1, transform: 'translateY(0)' }, }, 'pop': { '0%': { transform: 'scale(1)' }, '50%': { transform: 'scale(1.05)' }, '100%': { transform: 'scale(1)' }, }, }, animation: { 'fade-in': 'fade-in 0.3s ease-in-out', 'fade-out': 'fade-out 0.3s ease-out forwards', 'fade-in-up': 'fade-in-up 0.3s ease-in-out', 'pop': 'pop 0.4s cubic-bezier(0.4, 0, 0.2, 1)', }, }, }, future: { hoverOnlyWhenSupported: true, }, darkMode: "class", plugins: [nextui()], } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } ================================================ FILE: types/index.ts ================================================ import { SVGProps } from "react"; export type IconSvgProps = SVGProps & { size?: number; }; export type SearchResultsListProps = { torrents: TorrentItemProps[]; total_count: number; has_more: boolean; }; export type TorrentItemProps = { hash: string; name: string; size: number; magnet_uri: string; single_file: boolean; files_count: number; files: { index: number; path: string; size: number; extension: string; }[]; created_at: number; updated_at: number; }; ================================================ FILE: utils/Toast.tsx ================================================ "use client"; import React, { useState, useEffect } from "react"; import { createRoot } from "react-dom/client"; import clsx from "clsx"; import { $env } from "@/utils"; type ToastType = "info" | "success" | "warn" | "error"; interface ToastMessage { id: number; type: ToastType; content: string; duration: number; } interface ToastProps { messages: ToastMessage[]; removeMessage: (id: number) => void; } const iconMap = { info: (
    ), success: (
    ), warn: (
    ), error: (
    ), }; const ToastContainer: React.FC = ({ messages, removeMessage }) => { const [removingMessages, setRemovingMessages] = useState([]); const handleRemove = (id: number) => { setRemovingMessages((prev) => [...prev, id]); setTimeout(() => removeMessage(id), 300); // 300ms matches the fade-out duration }; useEffect(() => { messages.forEach((message) => { setTimeout(() => handleRemove(message.id), message.duration); }); }, [messages]); return (
    {messages.map((message) => (
    {iconMap[message.type]}
    {message.content}
    ))}
    ); }; let toastId = 0; let toastRoot: HTMLDivElement; let root: ReturnType; setTimeout(() => { if ($env.isServer) return; toastRoot = document.querySelector(".__toast-container") || document.createElement("div"); toastRoot.className = "__toast-container"; toastRoot.style.zIndex = "10001"; document.body.appendChild(toastRoot); root = createRoot(toastRoot); }, 0); export const Toast = { messages: [] as ToastMessage[], setMessages(messages: ToastMessage[]) { this.messages = messages; root.render( , ); }, show(type: ToastType, content: string, duration = 2000) { const id = toastId++; const newMessage: ToastMessage = { id, type, content, duration }; const list = [newMessage, ...this.messages].splice(0, 5); this.setMessages(list); }, removeMessage(id: number) { this.setMessages(this.messages.filter((message) => message.id !== id)); }, info(content: string, duration?: number) { this.show("info", content, duration); }, success(content: string, duration?: number) { this.show("success", content, duration); }, warn(content: string, duration?: number) { this.show("warn", content, duration); }, error(content: string, duration?: number) { this.show("error", content, duration); }, }; ================================================ FILE: utils/api.ts ================================================ export function getBaseUrl() { // Check if NEXT_PUBLIC_BASE_URL is set if (process.env.NEXT_PUBLIC_BASE_URL) { return process.env.NEXT_PUBLIC_BASE_URL; } // Fallback to localhost with port 3000 const host = process.env.NEXT_PUBLIC_HOST || "localhost"; const port = parseInt(process.env.PORT || "3000", 10); return `http://${host}:${port}`; } async function apiFetch(endpoint: string, options?: RequestInit): Promise { try { const baseUrl = getBaseUrl(); const url = `${baseUrl}${endpoint}`; const response = await fetch(url, options); if (!response.ok) { throw new Error(`Network response was not ok: ${response.statusText}`); } return await response.json(); } catch (error: any) { console.error(`Failed to fetch: ${error.message}`); throw error; } } export default apiFetch; ================================================ FILE: utils/index.ts ================================================ import dayjs from "dayjs"; import Cookie from "js-cookie"; import { SEARCH_KEYWORD_SPLIT_REGEX } from "@/config/constant"; export function hexToBase64(hexString: string) { const binary = Buffer.from(hexString, "hex"); let base64 = binary.toString("base64"); base64 = base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return base64; } export function base64ToHex(base64String: string) { let base64 = base64String.replace(/-/g, "+").replace(/_/g, "/"); const padding = "=".repeat((4 - (base64.length % 4)) % 4); base64 = base64 + padding; const binary = Buffer.from(base64, "base64"); return binary.toString("hex"); } export function formatByteSize(bytes: number | string) { bytes = Number(bytes); if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const base = 1024; const digitGroups = Math.floor(Math.log(bytes) / Math.log(base)); const convertedSize = (bytes / Math.pow(base, digitGroups)).toFixed(2); return `${convertedSize} ${units[digitGroups]}`; } export function formatDate( ts: number, format = "YYYY-MM-DD HH:mm:ss", utc = false, ) { let dateStr = dayjs.unix(ts).format(format); if (utc) dateStr += " (UTC)"; return dateStr; } export function getSizeColor(size: number | string) { size = Number(size); if (size < 1024 * 1024 * 2) { // < 2MB return "text-gray-400 bg-gray-100"; } else if (size < 1024 * 1024 * 50) { // < 50MB return "text-gray-600 bg-gray-100"; } else if (size < 1024 * 1024 * 200) { // < 200MB return "text-green-600 bg-green-100 opacity-90"; } else if (size < 1024 * 1024 * 1024) { // < 1GB return "text-green-600 bg-green-100"; } else { // > 1GB return "text-red-600 bg-red-100"; } } export function parseHighlight(text: string, highlight: string | string[]) { if (!text || !highlight) { return text; } const keywords = typeof highlight === "string" ? [highlight, ...highlight.split(SEARCH_KEYWORD_SPLIT_REGEX)].filter( (k: string) => k.trim().length >= 2, ) : highlight; // Function to escape HTML special characters to avoid interference function escapeHtml(unsafe: string) { return unsafe.replace(/[&<>"'`=\/]/g, (match) => { return ( { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", "`": "`", "/": "/", "=": "=", }[match] || match ); }); } // Function to highlight the keywords function highlightKeywords(text: string, keyword: string) { const regex = new RegExp(`(${escapeHtml(keyword)})(?![^<>]*>)`, "gi"); return text.replace( regex, `$1`, ); } let highlightedText = text; keywords.forEach((keyword) => { highlightedText = highlightKeywords(highlightedText, keyword); }); return highlightedText; } export function setClipboard(text: string) { if (navigator.clipboard) { navigator.clipboard.writeText(text); } else { const textarea = document.createElement("textarea"); textarea.value = text; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); } return true; } /* get resource link from "WhatsLinks" https://whatslink.info/ */ export interface GetLinkInfoFromWhatsLinkResponse { error: string; type: string; // The content type for the link // The type of the content corresponding to the link, Possible values: unknown, folder, video, text, image, audio, archive, font, document file_type: | "unknown" | "folder" | "video" | "text" | "image" | "audio" | "archive" | "font" | "document"; name: string; // The name of the content corresponding to the link size: number; // The total size of the content corresponding to the link count: number; // The number of included files corresponding to the link screenshots: | null | { time: number; // Position of the screenshot within the content screenshot: string; // The URL of the screenshot image }[]; // List of content screenshots corresponding to the link } export async function getLinkInfoFromWhatsLink( link: string, ): Promise { const res = await fetch(`https://whatslink.info/api/v1/link?url=${link}`, { method: "GET", headers: { "Content-Type": "application/json", }, }); return res.json(); } export const $env = { get isServer() { return typeof window === "undefined"; }, get isMobile() { return ( !this.isServer && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent, ) ); }, get isDesktop() { return !this.isServer && !this.isMobile; }, }; export { Cookie }; export { Toast } from "./Toast";