├── .nvmrc
├── packages
├── server
│ ├── .gitignore
│ ├── src
│ │ ├── prisma
│ │ │ ├── migrations
│ │ │ │ ├── migration_lock.toml
│ │ │ │ └── 20220919003636_init
│ │ │ │ │ └── migration.sql
│ │ │ ├── generated
│ │ │ │ └── prisma
│ │ │ │ │ ├── enums.ts
│ │ │ │ │ ├── models.ts
│ │ │ │ │ ├── browser.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── internal
│ │ │ │ │ ├── prismaNamespaceBrowser.ts
│ │ │ │ │ └── class.ts
│ │ │ │ │ └── commonInputTypes.ts
│ │ │ ├── seed.ts
│ │ │ ├── schema.prisma
│ │ │ └── client.ts
│ │ ├── types
│ │ │ └── index.ts
│ │ ├── routes
│ │ │ └── v1
│ │ │ │ ├── webhook.route.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── user.route.ts
│ │ │ │ ├── preference.route.ts
│ │ │ │ └── bangumi.route.ts
│ │ ├── middlewares
│ │ │ ├── authAdmin.moddleware.ts
│ │ │ ├── githubWebhook.middleware.ts
│ │ │ ├── auth.middleware.ts
│ │ │ └── validator.middleware.ts
│ │ ├── config.ts
│ │ ├── app.ts
│ │ ├── logger.ts
│ │ ├── controllers
│ │ │ ├── bangumiPreference.controller.ts
│ │ │ ├── userPreference.controller.ts
│ │ │ ├── bangumi.controller.ts
│ │ │ └── user.controller.ts
│ │ ├── models
│ │ │ ├── token.model.ts
│ │ │ ├── bangumiPreference.model.ts
│ │ │ ├── userPreference.model.ts
│ │ │ ├── user.model.ts
│ │ │ └── bangumi.model.ts
│ │ └── misc
│ │ │ └── legacyDBMigrate.sql
│ ├── tsconfig.json
│ ├── prisma.config.ts
│ ├── tsconfig.build.json
│ ├── .env.example
│ ├── package.json
│ └── API.md
├── client
│ ├── constants
│ │ ├── user.ts
│ │ ├── storage.ts
│ │ ├── weekday.ts
│ │ └── links.ts
│ ├── pages
│ │ ├── archive
│ │ │ ├── [season].module.css
│ │ │ ├── index.module.css
│ │ │ ├── index.tsx
│ │ │ └── [season].tsx
│ │ ├── index.module.css
│ │ ├── config
│ │ │ ├── index.module.css
│ │ │ └── index.tsx
│ │ ├── _app.tsx
│ │ ├── signup
│ │ │ ├── index.module.css
│ │ │ └── index.tsx
│ │ ├── _document.tsx
│ │ ├── login
│ │ │ ├── index.module.css
│ │ │ └── index.tsx
│ │ ├── me
│ │ │ ├── index.module.css
│ │ │ └── index.tsx
│ │ └── index.tsx
│ ├── public
│ │ ├── favicon.ico
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── mstile-150x150.png
│ │ ├── apple-touch-icon.png
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-384x384.png
│ │ ├── browserconfig.xml
│ │ ├── manifest.json
│ │ └── safari-pinned-tab.svg
│ ├── .vscode
│ │ └── settings.json
│ ├── styles
│ │ ├── variables.css
│ │ ├── layout.css
│ │ └── reset.css
│ ├── components
│ │ ├── common
│ │ │ ├── Container.module.css
│ │ │ ├── Footer.module.css
│ │ │ ├── Container.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── Top.tsx
│ │ │ ├── SearchInput.module.css
│ │ │ ├── Top.module.css
│ │ │ ├── Header.module.css
│ │ │ ├── SearchInput.tsx
│ │ │ └── Header.tsx
│ │ ├── BangumiItemTable.module.css
│ │ ├── layout
│ │ │ └── Layout.tsx
│ │ ├── BangumiLinkItem.tsx
│ │ ├── WeekdayTab.module.css
│ │ ├── WeekdayTab.tsx
│ │ ├── Core.tsx
│ │ ├── BangumiItemTable.tsx
│ │ ├── BangumiItem.module.css
│ │ └── BangumiItem.tsx
│ ├── next-env.d.ts
│ ├── eslint.config.js
│ ├── types
│ │ ├── custom.d.ts
│ │ └── index.ts
│ ├── utils
│ │ ├── quarterToMonth.ts
│ │ ├── formatSeason.ts
│ │ ├── getBroadcastTimeString.ts
│ │ ├── api.ts
│ │ └── bangumiItemUtils.ts
│ ├── images
│ │ ├── clear.svg
│ │ ├── close.svg
│ │ ├── favorite-full.svg
│ │ ├── user.svg
│ │ ├── logout.svg
│ │ └── favorite-empty.svg
│ ├── models
│ │ ├── bangumi.model.ts
│ │ ├── user.model.ts
│ │ ├── preference.model.ts
│ │ └── preferenceLocal.model.ts
│ ├── next.config.js
│ ├── tsconfig.json
│ ├── package.json
│ └── contexts
│ │ ├── userContext.tsx
│ │ └── preferenceContext.tsx
└── shared
│ ├── src
│ ├── types
│ │ ├── User.interface.ts
│ │ ├── Preference.interface.ts
│ │ └── Bangumi.interface.ts
│ └── index.ts
│ ├── tsconfig.json
│ ├── tsconfig.build.json
│ └── package.json
├── .prettierignore
├── .dockerignore
├── .husky
└── pre-commit
├── start.sh
├── tsconfig.json
├── .prettierrc
├── Dockerfile
├── tsconfig.build.json
├── .github
└── workflows
│ └── docker-image.yml
├── README.md
├── .editorconfig
├── .vscode
└── launch.json
├── .gitignore
├── package.json
└── eslint.config.js
/.nvmrc:
--------------------------------------------------------------------------------
1 | v24.11.1
2 |
--------------------------------------------------------------------------------
/packages/server/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist/
2 | .run/
3 | *.d.ts
4 | generated/
5 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | **/node_modules
3 | **/stats.json
4 | .run
5 | .env
6 |
--------------------------------------------------------------------------------
/packages/client/constants/user.ts:
--------------------------------------------------------------------------------
1 | export const PASSWORD_MIN_LENGTH = 8;
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/packages/client/pages/archive/[season].module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | margin: 12px auto;
3 | }
4 |
--------------------------------------------------------------------------------
/packages/shared/src/types/User.interface.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | id: string;
3 | email: string;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/shared/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/packages/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wxt2005/bangumi-list-v3/HEAD/packages/client/public/favicon.ico
--------------------------------------------------------------------------------
/packages/client/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wxt2005/bangumi-list-v3/HEAD/packages/client/public/favicon-16x16.png
--------------------------------------------------------------------------------
/packages/client/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wxt2005/bangumi-list-v3/HEAD/packages/client/public/favicon-32x32.png
--------------------------------------------------------------------------------
/packages/client/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wxt2005/bangumi-list-v3/HEAD/packages/client/public/mstile-150x150.png
--------------------------------------------------------------------------------
/packages/client/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wxt2005/bangumi-list-v3/HEAD/packages/client/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/packages/client/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wxt2005/bangumi-list-v3/HEAD/packages/client/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/packages/client/public/android-chrome-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wxt2005/bangumi-list-v3/HEAD/packages/client/public/android-chrome-384x384.png
--------------------------------------------------------------------------------
/packages/client/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "../../node_modules/typescript/lib",
3 | "typescript.enablePromptUseWorkspaceTsdk": true
4 | }
--------------------------------------------------------------------------------
/packages/shared/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './types/Bangumi.interface';
2 | export * from './types/Preference.interface';
3 | export * from './types/User.interface';
4 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "sqlite"
--------------------------------------------------------------------------------
/packages/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "typeRoots": [
5 | "./src/types", "node_modules/@types"
6 | ]
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | HOST=127.0.0.1 PORT=3001 npm run start -w packages/server &
4 | HOST=127.0.0.1 PORT=3000 API_HOST=http://127.0.0.1:3001 npm run start -w packages/client &
5 |
6 | wait
7 |
8 | exit $?
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.build.json",
3 | "compilerOptions": {
4 | "baseUrl": "./packages",
5 | "skipLibCheck": true,
6 | "paths": {
7 | "bangumi-list-v3-*": ["*/src"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/client/styles/variables.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --color-theme-green: #1abc9c;
3 | --color-theme-blue: #34495e;
4 | --content-max-width: 1024px;
5 | --breakpoint-sm: 640px;
6 | --active-opacity: 0.6;
7 | --disable-opacity: 0.4;
8 | }
9 |
--------------------------------------------------------------------------------
/packages/client/constants/storage.ts:
--------------------------------------------------------------------------------
1 | export const STORAGE_CREDENTIAL_NAME = 'bgmlist:credential';
2 |
3 | export const STORAGE_COMMON_PREFERENCE_NAME = 'bmglist:preference:common';
4 |
5 | export const STORAGE_BANGUMI_PREFERENCE_NAME = 'bgmlist:preference:bangumi';
6 |
--------------------------------------------------------------------------------
/packages/client/components/common/Container.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | max-width: var(--content-max-width);
3 | margin: 0 auto;
4 | padding: 0 12px;
5 | }
6 |
7 | @media (min-width: var(--content-max-width)) {
8 | .root {
9 | padding: 0;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/client/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
6 |
--------------------------------------------------------------------------------
/packages/shared/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.build.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "declaration": true,
6 | "rootDir": "./src",
7 | "outDir": "./dist"
8 | },
9 | "include": [
10 | "src/**/*"
11 | ]
12 | }
--------------------------------------------------------------------------------
/packages/client/constants/weekday.ts:
--------------------------------------------------------------------------------
1 | export const WEEKDAY_CN = [
2 | '周日',
3 | '周一',
4 | '周二',
5 | '周三',
6 | '周四',
7 | '周五',
8 | '周六',
9 | ];
10 |
11 | export const WEEKDAY_JP = [
12 | '日曜',
13 | '月曜',
14 | '火曜',
15 | '水曜',
16 | '木曜',
17 | '金曜',
18 | '土曜',
19 | ];
20 |
--------------------------------------------------------------------------------
/packages/client/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #1abc9c
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/packages/client/styles/layout.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding: 0;
3 | margin: 0;
4 | color: var(--color-theme-blue);
5 | font-family: system-ui ,sans-serif;
6 | min-width: 320px;
7 | }
8 |
9 | a:hover,
10 | a:active {
11 | color: var(--color-theme-green);
12 | }
13 |
14 | button {
15 | cursor: pointer;
16 | }
17 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false,
4 | "semi": true,
5 | "singleQuote": true,
6 | "quoteProps": "as-needed",
7 | "jsxSingleQuote": false,
8 | "trailingComma": "es5",
9 | "bracketSpacing": true,
10 | "bracketSameLine": false,
11 | "arrowParens": "always",
12 | "endOfLine": "lf"
13 | }
--------------------------------------------------------------------------------
/packages/client/components/BangumiItemTable.module.css:
--------------------------------------------------------------------------------
1 | .content {
2 | background: #fff;
3 | }
4 |
5 | .item {
6 | width: 100%;
7 | }
8 |
9 | .item + .item {
10 | margin-top: 12px;
11 | }
12 |
13 | .empty {
14 | display: flex;
15 | height: 80px;
16 | justify-content: center;
17 | align-items: center;
18 | }
19 |
--------------------------------------------------------------------------------
/packages/server/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import { UserRole } from '../models/user.model';
2 |
3 | export interface RequestUser {
4 | id: string;
5 | role: UserRole;
6 | token: string;
7 | }
8 |
9 | declare module 'express-serve-static-core' {
10 | export interface Request {
11 | user?: RequestUser;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/server/prisma.config.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 | import { defineConfig, env } from 'prisma/config';
3 |
4 | export default defineConfig({
5 | schema: './src/prisma/schema.prisma',
6 | datasource: {
7 | url: env('DATABASE_URL'),
8 | },
9 | migrations: {
10 | seed: 'ts-node ./src/prisma/seed.ts',
11 | },
12 | });
13 |
--------------------------------------------------------------------------------
/packages/client/eslint.config.js:
--------------------------------------------------------------------------------
1 | const nextPlugin = require('@next/eslint-plugin-next');
2 |
3 | module.exports = [
4 | {
5 | plugins: {
6 | '@next/next': nextPlugin,
7 | },
8 | rules: {
9 | ...nextPlugin.configs.recommended.rules,
10 | ...nextPlugin.configs['core-web-vitals'].rules,
11 | },
12 | },
13 | ];
14 |
--------------------------------------------------------------------------------
/packages/client/types/custom.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare module '*.module.css' {
4 | const classes: { [key: string]: string };
5 | export default classes;
6 | }
7 |
8 | declare module "*.svg" {
9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
10 | const content: any;
11 | export default content;
12 | }
13 |
--------------------------------------------------------------------------------
/packages/client/utils/quarterToMonth.ts:
--------------------------------------------------------------------------------
1 | export default function quarterToMonth(quarter: number): number {
2 | switch (quarter) {
3 | case 1:
4 | return 1;
5 | case 2:
6 | return 4;
7 | case 3:
8 | return 7;
9 | case 4:
10 | return 10;
11 | default:
12 | throw new TypeError('Unknown quarter');
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/packages/server/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.build.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "declaration": true,
6 | "rootDir": "./src",
7 | "outDir": "./dist"
8 | },
9 | "references": [{
10 | "path": "../shared/tsconfig.build.json"
11 | }],
12 | "include": [
13 | "src/**/*"
14 | ]
15 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax=docker/dockerfile:1
2 |
3 | FROM node:24.11.1-bookworm
4 |
5 | ARG GA_ID
6 |
7 | ENV TZ=Asia/Shanghai
8 | ENV NEXT_PUBLIC_GA_ID=${GA_ID}
9 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
10 |
11 | WORKDIR /app
12 | COPY . .
13 |
14 | EXPOSE 3000
15 |
16 | RUN ./build.sh
17 |
18 | CMD [ "/bin/sh", "./start.sh" ]
19 |
--------------------------------------------------------------------------------
/packages/client/components/layout/Layout.tsx:
--------------------------------------------------------------------------------
1 | import Header from '../common/Header';
2 | import Footer from '../common/Footer';
3 |
4 | export default function Layout({
5 | children,
6 | }: {
7 | children: JSX.Element;
8 | }): JSX.Element {
9 | return (
10 | <>
11 |
12 | {children}
13 |
14 | >
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/packages/client/images/clear.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/packages/server/.env.example:
--------------------------------------------------------------------------------
1 | PORT=3001
2 | JWT_SECRET=jwt_secret
3 | GITHUB_WEBHOOK_SECRET=github_webhook_secret
4 | RUNTIME_DIR=.run
5 | LOG_DIR=.run/logs
6 | LOG_FILE=server.log
7 | DB_DIR=.run
8 | DATABASE_URL=file:../../.run/db.db
9 | DATA_DIR=.run
10 | DATA_FILE=data.json
11 | CLIENT_DIST_DIR=../../client/dist
12 | ADMIN_EMAIL=admin@admin.com
13 | ADMIN_PASSWORD=admin123456
14 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "resolveJsonModule": true,
5 | "esModuleInterop": true,
6 | "target": "es2016",
7 | "moduleResolution": "node",
8 | "strict": true,
9 | "sourceMap": true,
10 | "composite": true,
11 | "noEmitOnError": true
12 | },
13 | "exclude": [
14 | "node_modules",
15 | "dist"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/generated/prisma/enums.ts:
--------------------------------------------------------------------------------
1 |
2 | /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3 | /* eslint-disable */
4 | // biome-ignore-all lint: generated file
5 | // @ts-nocheck
6 | /*
7 | * This file exports all enum related types from the schema.
8 | *
9 | * 🟢 You can import this file directly.
10 | */
11 |
12 |
13 |
14 | // This file is empty because there are no enums in the schema.
15 | export {}
16 |
--------------------------------------------------------------------------------
/packages/client/components/common/Footer.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | margin-top: 24px;
3 | display: flex;
4 | justify-content: center;
5 | align-items: center;
6 | padding: 12px 12px 24px;
7 | }
8 |
9 | .root p + p {
10 | margin-left: 0.5em;
11 | }
12 |
13 | .root p + p::before {
14 | content: "|";
15 | font-family: "airal";
16 | margin-right: 0.5em;
17 | text-align: center;
18 | font-size: 14px;;
19 | vertical-align: 1px;
20 | }
21 |
--------------------------------------------------------------------------------
/packages/server/src/routes/v1/webhook.route.ts:
--------------------------------------------------------------------------------
1 | import express, { Request, Response } from 'express';
2 | import * as bangumiController from '../../controllers/bangumi.controller';
3 | import githubWebhook from '../../middlewares/githubWebhook.middleware';
4 |
5 | const router = express.Router();
6 |
7 | router.post('/update', githubWebhook, async (req: Request, res: Response) => {
8 | await bangumiController.update(req, res);
9 | });
10 |
11 | export default router;
12 |
--------------------------------------------------------------------------------
/packages/client/components/common/Container.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 | import styles from './Container.module.css';
4 |
5 | interface Props {
6 | children: React.ReactNode;
7 | className?: string;
8 | }
9 |
10 | export default function BaseContainer(props: Props): JSX.Element {
11 | const rootClassName = classNames(props.className, styles.root);
12 |
13 | return
{props.children}
;
14 | }
15 |
--------------------------------------------------------------------------------
/packages/server/src/middlewares/authAdmin.moddleware.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 | import { UserRole } from '../models/user.model';
3 |
4 | const authAdmin = (req: Request, res: Response, next: NextFunction): void => {
5 | if (!req.user) {
6 | res.sendStatus(401);
7 | return;
8 | }
9 |
10 | if (req.user.role !== UserRole.ADMIN) {
11 | res.sendStatus(401);
12 | return;
13 | }
14 |
15 | next();
16 | };
17 |
18 | export default authAdmin;
19 |
--------------------------------------------------------------------------------
/packages/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "icons": [
4 | {
5 | "src": "/android-chrome-192x192.png",
6 | "sizes": "192x192",
7 | "type": "image/png"
8 | },
9 | {
10 | "src": "/android-chrome-384x384.png",
11 | "sizes": "384x384",
12 | "type": "image/png"
13 | }
14 | ],
15 | "theme_color": "#ffffff",
16 | "background_color": "#ffffff",
17 | "display": "standalone"
18 | }
--------------------------------------------------------------------------------
/packages/server/src/routes/v1/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import bangumiRoute from './bangumi.route';
3 | import userRoute from './user.route';
4 | import preferenceRoute from './preference.route';
5 | import webhookRoute from './webhook.route';
6 |
7 | const router = express.Router();
8 |
9 | router.use('/bangumi', bangumiRoute);
10 | router.use('/user', userRoute);
11 | router.use('/preference', preferenceRoute);
12 | router.use('/webhook', webhookRoute);
13 |
14 | export default router;
15 |
--------------------------------------------------------------------------------
/packages/client/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface LoginResponse {
2 | token: string;
3 | }
4 |
5 | export interface UserUpdateRequest {
6 | oldPassword: string;
7 | newPassword: string;
8 | }
9 |
10 | export enum Status {
11 | INITIAL = 'INITIAL',
12 | PENDING = 'PENDING',
13 | FULFILED = 'FULFILED',
14 | REJECTED = 'REJECTED',
15 | }
16 |
17 | export enum Weekday {
18 | SUNDAY = 0,
19 | MONDAY = 1,
20 | TUESDAY = 2,
21 | WEDNESDAY = 3,
22 | THURSDAY = 4,
23 | FIRDAY = 5,
24 | SATURDAY = 6,
25 | ALL = -1,
26 | }
27 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/generated/prisma/models.ts:
--------------------------------------------------------------------------------
1 |
2 | /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3 | /* eslint-disable */
4 | // biome-ignore-all lint: generated file
5 | // @ts-nocheck
6 | /*
7 | * This is a barrel export file for all models and their related types.
8 | *
9 | * 🟢 You can import this file directly.
10 | */
11 | export type * from './models/BangumiPreference'
12 | export type * from './models/Preference'
13 | export type * from './models/Token'
14 | export type * from './models/User'
15 | export type * from './commonInputTypes'
--------------------------------------------------------------------------------
/packages/client/utils/formatSeason.ts:
--------------------------------------------------------------------------------
1 | export default function formatSeason(season: string): string {
2 | const match = /^(\d{4})q(\d)$/.exec(season);
3 | if (!match) return '';
4 | let monthString = '';
5 | switch (match[2]) {
6 | case '1':
7 | monthString = '1';
8 | break;
9 | case '2':
10 | monthString = '4';
11 | break;
12 | case '3':
13 | monthString = '7';
14 | break;
15 | case '4':
16 | monthString = '10';
17 | break;
18 | default:
19 | return '';
20 | }
21 | return `${match[1]}年${monthString}月`;
22 | }
23 |
--------------------------------------------------------------------------------
/packages/shared/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bangumi-list-v3-shared",
3 | "version": "1.0.0",
4 | "main": "dist/index.js",
5 | "description": "",
6 | "scripts": {
7 | "clean": "rimraf dist *.tsbuildinfo",
8 | "tsc": "tsc -b tsconfig.build.json",
9 | "build": "npm-run-all clean tsc"
10 | },
11 | "author": "Botao (https://github.com/wxt2005)",
12 | "repository": {
13 | "type": "git",
14 | "url": "https://github.com/wxt2005/bangumi-list-v3.git",
15 | "directory": "packages/shared"
16 | },
17 | "license": "MIT",
18 | "private": true
19 | }
20 |
--------------------------------------------------------------------------------
/packages/client/images/close.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/packages/client/pages/index.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | }
3 |
4 | .header {
5 | display: flex;
6 | width: 100%;
7 | margin-bottom: 12px;
8 | justify-content: space-between;
9 | }
10 |
11 | .settingBtn {
12 | transition: all 0.3s ease;
13 | padding: 0 12px;
14 | vertical-align: middle;
15 | border-radius: 12px;
16 | display: flex;
17 | align-items: center;
18 | text-align: center;
19 | }
20 |
21 | .settingBtn:hover {
22 | color: var(--color-theme-green);
23 | box-shadow: 4px 12px 40px 6px rgba(0, 0, 0, 0.09);
24 | }
25 |
26 | .status {
27 | padding: 24px;
28 | text-align: center;
29 | }
30 |
--------------------------------------------------------------------------------
/packages/client/pages/archive/index.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 12px;
3 | }
4 |
5 | .heading {
6 | font-size: 40px;
7 | padding-left: 12px;
8 | }
9 |
10 | .sectionBox {
11 | display: grid;
12 | grid-template-columns: repeat(3, 1fr);
13 | padding: 16px 0;
14 |
15 | }
16 |
17 | @media (max-width: 640px) {
18 | .sectionBox {
19 | grid-template-columns: repeat(2, 1fr);
20 | }
21 | }
22 |
23 | .section {
24 | padding: 16px;
25 | }
26 |
27 | .sectionHeader {
28 | margin-bottom: 8px;
29 | font-size: 32px;
30 | }
31 |
32 | .sectionList li {
33 | margin-bottom: 4px;
34 | font-size: 18px;
35 | }
36 |
--------------------------------------------------------------------------------
/packages/client/components/common/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react';
2 | import styles from './Footer.module.css';
3 |
4 | export default function Footer(): JSX.Element {
5 | const currentYear = useMemo(() => new Date().getFullYear(), []);
6 | return (
7 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/packages/client/constants/links.ts:
--------------------------------------------------------------------------------
1 | import { BangumiDomain, MikanDomain } from 'bangumi-list-v3-shared';
2 |
3 | export const SUBMIT_PROBLEM_LINK = encodeURI(
4 | 'mailto:wise.home5941@fastmail.com?subject=番组放送-错误提交'
5 | );
6 |
7 | export const bangumiTemplates = {
8 | [BangumiDomain.BANGUMI_TV]: 'https://bangumi.tv/subject/{{id}}',
9 | [BangumiDomain.BGM_TV]: 'https://bgm.tv/subject/{{id}}',
10 | [BangumiDomain.CHII_IN]: 'https://chii.in/subject/{{id}}',
11 | };
12 |
13 | export const mikanTemplates = {
14 | [MikanDomain.MIKANANI_ME]: 'https://mikanani.me/Home/Bangumi/{{id}}',
15 | [MikanDomain.MIKANIME_TV]: 'https://mikanime.tv/Home/Bangumi/{{id}}',
16 | };
17 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 |
7 | jobs:
8 |
9 | build:
10 |
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Set up Docker Buildx
15 | uses: docker/setup-buildx-action@v3
16 | - uses: docker/login-action@v3
17 | with:
18 | registry: ghcr.io
19 | username: ${{ github.actor }}
20 | password: ${{ secrets.GITHUB_TOKEN }}
21 | - name: Build and push
22 | uses: docker/build-push-action@v6
23 | with:
24 | push: true
25 | tags: ghcr.io/wxt2005/bangumi-list-v3:latest
26 | build-args: |
27 | GA_ID=${{ secrets.GA_ID }}
28 |
29 |
--------------------------------------------------------------------------------
/packages/client/components/BangumiLinkItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { BangumiSite, SiteMeta } from 'bangumi-list-v3-shared';
3 |
4 | interface Props {
5 | newTab?: boolean;
6 | site: BangumiSite;
7 | siteMeta?: SiteMeta;
8 | }
9 |
10 | export default function BangumiLinkItem(props: Props): JSX.Element {
11 | const { newTab = true, site, siteMeta = {} } = props;
12 | const title = siteMeta[site.site]?.title ?? '未知';
13 | const urlTemplate = siteMeta[site.site]?.urlTemplate ?? '';
14 | const href = urlTemplate.replace(/\{\{id\}\}/g, site.id);
15 | const target = newTab ? '_blank' : '_self';
16 |
17 | return (
18 |
19 | {title}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | import userModel, { UserRole } from '../models/user.model';
2 | import prisma from './client';
3 |
4 | async function main() {
5 | const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
6 | const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
7 | if (!ADMIN_EMAIL || !ADMIN_PASSWORD) return;
8 | await userModel.addUser(
9 | {
10 | email: ADMIN_EMAIL,
11 | password: ADMIN_PASSWORD,
12 | },
13 | UserRole.ADMIN
14 | );
15 |
16 | console.log(`Admin user ${ADMIN_EMAIL} created.`);
17 | }
18 |
19 | main()
20 | .then(async () => {
21 | await prisma.$disconnect();
22 | })
23 | .catch(async (e) => {
24 | console.error(e);
25 | await prisma.$disconnect();
26 | process.exit(1);
27 | });
28 |
--------------------------------------------------------------------------------
/packages/client/components/common/Top.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import SearchInput from './SearchInput';
3 | import styles from './Top.module.css';
4 |
5 | interface Props {
6 | title?: string;
7 | onSearchInput?: (text: string) => void;
8 | }
9 |
10 | export default function Top(props: Props): JSX.Element {
11 | const { title = '每日放送', onSearchInput } = props;
12 | const handleSearchInput = (value: string) => {
13 | onSearchInput && onSearchInput(value);
14 | };
15 |
16 | return (
17 |
18 |
{title}
19 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/packages/server/src/config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 |
3 | export const HOST = process.env.HOST || '';
4 | export const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
5 |
6 | export const RUNTIME_DIR =
7 | process.env.RUNTIME_DIR || path.resolve(process.cwd(), '.run');
8 |
9 | export const LOG_DIR = process.env.LOG_DIR || path.resolve(RUNTIME_DIR, 'logs');
10 |
11 | export const LOG_FILE = process.env.LOG_FILE || 'server.log';
12 |
13 | export const DB_DIR = process.env.DB_DIR || RUNTIME_DIR;
14 |
15 | export const DATA_DIR = process.env.DATA_DIR || RUNTIME_DIR;
16 |
17 | export const DATA_FILE = process.env.DATA_FILE || 'data.json';
18 |
19 | export const CLIENT_DIST_DIR =
20 | process.env.CLIENT_DIST_DIR || path.resolve(__dirname, '../../client/dist');
21 |
--------------------------------------------------------------------------------
/packages/shared/src/types/Preference.interface.ts:
--------------------------------------------------------------------------------
1 | export enum BangumiDomain {
2 | BANGUMI_TV = 'bangumi.tv',
3 | BGM_TV = 'bgm.tv',
4 | CHII_IN = 'chii.in',
5 | }
6 |
7 | export enum MikanDomain {
8 | MIKANANI_ME = 'mikanani.me',
9 | MIKANIME_TV = 'mikanime.tv',
10 | }
11 |
12 | export interface CommonPreference {
13 | newOnly: boolean;
14 | watchingOnly: boolean;
15 | hoistWatching: boolean;
16 | bangumiDomain: BangumiDomain;
17 | mikanDomain: MikanDomain;
18 | }
19 |
20 | export interface VersionedCommonPreference extends CommonPreference {
21 | version: number;
22 | }
23 |
24 | export interface BangumiPreference {
25 | watching: string[];
26 | }
27 |
28 | export interface VersionedBangumiPreference extends BangumiPreference {
29 | version: number;
30 | }
31 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client"
3 | output = "./generated/prisma"
4 | }
5 |
6 | datasource db {
7 | provider = "sqlite"
8 | }
9 |
10 | model BangumiPreference {
11 | userID String @id
12 | watching String?
13 | createdAt DateTime
14 | updatedAt DateTime
15 | }
16 |
17 | model Preference {
18 | userID String @id
19 | common String
20 | createdAt DateTime
21 | updatedAt DateTime
22 | }
23 |
24 | model Token {
25 | userID String
26 | token String
27 |
28 | @@id([userID, token])
29 | }
30 |
31 | model User {
32 | id String @id
33 | email String @unique(map: "sqlite_autoindex_user_2")
34 | password String
35 | role Int
36 | createdAt DateTime
37 | updatedAt DateTime
38 | }
39 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # bangumi-list-v3
2 |
3 | ## Development
4 |
5 | ```bash
6 | $ npm install
7 | $ cp packages/server/.env.example packages/server/.env
8 | $ npm run dev
9 | ```
10 |
11 | * Client pages hosted on `http://localhost:3000/`
12 | * Server API hosted on `http://localhost:3000/api/v1/`. Please check [API Doc](./packages/server/API.md)
13 |
14 | ## Install dependency
15 |
16 | ```bash
17 | $ npm install local-dependency -w packages/client
18 | $ npm install shared-dependency
19 | ```
20 |
21 | ## Release
22 |
23 | ```bash
24 | $ npm run build
25 | $ npm run start
26 | ```
27 |
28 | ## Docker
29 |
30 | ```bash
31 | $ docker build --tag wxt2005/bangumi-list-v3:latest --build-arg GA_ID=UA-xxx .
32 | $ docker run -p 3000:3000 --env-file .env -v /path/to/run:/app/.run -d wxt2005/bangumi-list-v3
33 | ```
34 |
--------------------------------------------------------------------------------
/packages/server/src/app.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import apiV1Routes from './routes/v1';
3 | import bangumiModel from './models/bangumi.model';
4 | import fse from 'fs-extra';
5 | import pinoHttp from 'pino-http';
6 | import logger from './logger';
7 | import { PORT, HOST, RUNTIME_DIR, LOG_DIR } from './config';
8 | import './types';
9 |
10 | (async function main() {
11 | await fse.ensureDir(RUNTIME_DIR);
12 | await fse.ensureDir(LOG_DIR);
13 |
14 | await bangumiModel.update(false);
15 |
16 | const app = express();
17 | app.use(pinoHttp({ logger }));
18 | app.use(express.json());
19 | app.use(express.urlencoded({ extended: true }));
20 | app.use('/api/v1', apiV1Routes);
21 | app.listen(PORT, HOST);
22 |
23 | logger.info('Server running on host %s, port %d', HOST, PORT);
24 | })();
25 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/client.ts:
--------------------------------------------------------------------------------
1 | import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
2 | import { resolve } from 'node:path';
3 | import { PrismaClient } from './generated/prisma/client';
4 |
5 | function resolveDatabaseUrl(url: string | undefined): string {
6 | if (!url) {
7 | return 'file:./dev.db';
8 | }
9 |
10 | if (url.startsWith('file:')) {
11 | const filePath = url.replace('file:', '');
12 | return `file:${resolve(__dirname, filePath)}`;
13 | }
14 |
15 | return url;
16 | }
17 |
18 | // Create the libSQL adapter with resolved database URL
19 | const adapter = new PrismaBetterSqlite3({
20 | url: resolveDatabaseUrl(process.env.DATABASE_URL),
21 | });
22 |
23 | // Create a single instance of PrismaClient with the adapter
24 | const prisma = new PrismaClient({ adapter });
25 |
26 | export default prisma;
27 |
--------------------------------------------------------------------------------
/packages/client/models/bangumi.model.ts:
--------------------------------------------------------------------------------
1 | import api from '../utils/api';
2 | import {
3 | SeasonList,
4 | SiteType,
5 | SiteMeta,
6 | ItemList,
7 | } from 'bangumi-list-v3-shared';
8 |
9 | export async function getSeasonList(start = ''): Promise {
10 | return await api.request('GET', 'bangumi/season', {
11 | ...(start && { start }),
12 | });
13 | }
14 |
15 | export async function getSites(type?: SiteType): Promise {
16 | return await api.request('GET', 'bangumi/site', {
17 | type,
18 | });
19 | }
20 |
21 | export async function getArchive(season: string): Promise {
22 | return await api.request('GET', `bangumi/archive/${season}`);
23 | }
24 |
25 | export async function getOnair(): Promise {
26 | return await api.request('GET', `bangumi/onair`);
27 | }
28 |
--------------------------------------------------------------------------------
/packages/server/src/logger.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import pino from 'pino';
3 | import { LOG_DIR, LOG_FILE } from './config';
4 |
5 | export function createLogger(): pino.Logger {
6 | const TIME_FORMAT = 'SYS:yyyy-mm-dd HH:MM:ss o';
7 |
8 | if (process.env.NODE_ENV === 'production') {
9 | return pino(
10 | {
11 | transport: {
12 | target: 'pino-pretty',
13 | options: {
14 | translateTime: TIME_FORMAT,
15 | },
16 | },
17 | },
18 | pino.destination(path.resolve(LOG_DIR, LOG_FILE))
19 | );
20 | }
21 |
22 | return pino({
23 | transport: {
24 | target: 'pino-pretty',
25 | options: {
26 | translateTime: TIME_FORMAT,
27 | colorize: true,
28 | ignore: 'hostname,pid',
29 | },
30 | },
31 | });
32 | }
33 |
34 | export default createLogger();
35 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.js]
13 | quote_type = single
14 |
15 | [*.ts]
16 | quote_type = single
17 |
18 | # Use 4 spaces for the Python files
19 | [*.py]
20 | indent_size = 4
21 | max_line_length = 80
22 |
23 | # The JSON files contain newlines inconsistently
24 | [*.json]
25 | insert_final_newline = ignore
26 |
27 | # Minified JavaScript files shouldn't be changed
28 | [**.min.js]
29 | indent_style = ignore
30 | insert_final_newline = ignore
31 |
32 | # Makefiles always use tabs for indentation
33 | [Makefile]
34 | indent_style = tab
35 |
36 | # Batch files use tabs for indentation
37 | [*.bat]
38 | indent_style = tab
39 |
40 | [*.md]
41 | trim_trailing_whitespace = false
42 |
43 |
--------------------------------------------------------------------------------
/packages/client/pages/config/index.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 12px;
3 | }
4 |
5 | .heading {
6 | font-size: 40px;
7 | padding-left: 12px;
8 | }
9 |
10 | .form {
11 | font-size: 18px;
12 | display: flex;
13 | flex-direction: column;
14 | padding: 12px;
15 | }
16 |
17 | .form > div {
18 | display: flex;
19 | align-items: center;
20 | justify-content: space-between;
21 | margin-bottom: 12px;
22 | }
23 |
24 | .form label {
25 | margin-right: 12px;
26 | }
27 |
28 | .confirmButton {
29 | background: var(--color-theme-green);
30 | color: #fff;
31 | border-radius: 4px;
32 | padding: 6px 12px;
33 | transition: opacity 0.3s ease;
34 | max-width: 240px;
35 | width: 100%;
36 | margin: 24px auto 0;
37 | }
38 |
39 | @media (max-width: 640px) {
40 | .confirmButton {
41 | max-width: none;
42 | }
43 | }
44 |
45 | .confirmButton:hover,
46 | .confirmButton:active {
47 | opacity: var(--active-opacity);
48 | }
49 |
--------------------------------------------------------------------------------
/packages/client/next.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | /**
4 | * @type {import('next').NextConfig}
5 | */
6 | const nextConfig = {
7 | async rewrites() {
8 | return [
9 | {
10 | source: '/api/:path*',
11 | destination: `${
12 | process.env.API_HOST || 'http://127.0.0.1:3001'
13 | }/api/:path*`,
14 | },
15 | ];
16 | },
17 | webpack(config, { defaultLoaders }) {
18 | config.module.rules.push({
19 | test: /\.(ts|tsx)$/,
20 | include: [path.resolve(__dirname, '../shared')],
21 | use: [defaultLoaders.babel],
22 | });
23 | config.module.rules.push({
24 | test: /\.svg$/,
25 | use: [
26 | {
27 | loader: '@svgr/webpack',
28 | options: {
29 | dimensions: false,
30 | },
31 | },
32 | ],
33 | });
34 | return config;
35 | },
36 | reactStrictMode: true,
37 | };
38 |
39 | module.exports = nextConfig;
40 |
--------------------------------------------------------------------------------
/packages/client/components/common/SearchInput.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | position: relative;
3 | width: 240px;
4 | }
5 |
6 | .input {
7 | border: 1px solid var(--color-theme-blue);
8 | border-radius: 6px;
9 | height: 40px;
10 | padding-left: 12px;
11 | padding-right: 12px;
12 | font-size: 16px;
13 | transition: all 0.3s ease;
14 | width: 100%;
15 | background: #fff;
16 | }
17 |
18 | @media (max-width: 640px) {
19 | .root {
20 | width: 100%;
21 | margin-top: 6px;
22 | }
23 | }
24 |
25 | .input:hover,
26 | .input:active,
27 | .input:focus,
28 | .input:focus-visible {
29 | outline: 0;
30 | border-color: var(--color-theme-green);
31 | }
32 |
33 | .clearButton {
34 | position: absolute;
35 | right: 5px;
36 | top: 5px;
37 | width: 30px;
38 | height: 30px;
39 | transition: opacity 0.3s ease;
40 | }
41 |
42 | .clearButton svg {
43 | fill: var(--color-theme-blue);
44 | }
45 |
46 | .clearButton:hover,
47 | .clearButton.active {
48 | opacity: var(--active-opacity);
49 | }
50 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "pwa-node",
9 | "request": "attach",
10 | "name": "Attach Server"
11 | },
12 | {
13 | "type": "pwa-node",
14 | "request": "launch",
15 | "name": "Launch Server",
16 | "runtimeExecutable": "npm",
17 | "runtimeArgs": [
18 | "run-script",
19 | "dev"
20 | ],
21 | "cwd": "${workspaceRoot}/packages/server",
22 | "skipFiles": [
23 | "/**"
24 | ],
25 | "outFiles": [
26 | "${workspaceFolder}/packages/server/dist/**/*.js"
27 | ],
28 | "resolveSourceMapLocations": [
29 | "${workspaceFolder}/**",
30 | "!**/node_modules/**"
31 | ],
32 | "console": "integratedTerminal"
33 | }
34 | ]
35 | }
--------------------------------------------------------------------------------
/packages/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "preserve",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noEmit": true,
12 | "incremental": true,
13 | "isolatedModules": true,
14 | "target": "esnext",
15 | "module": "esnext",
16 | "skipLibCheck": true,
17 | "esModuleInterop": true,
18 | "moduleResolution": "node",
19 | "resolveJsonModule": true,
20 | "baseUrl": "..",
21 | "paths": {
22 | "bangumi-list-v3-*": [
23 | "*/src"
24 | ]
25 | },
26 | "strict": false
27 | },
28 | "references": [
29 | {
30 | "path": "../shared/tsconfig.build.json"
31 | }
32 | ],
33 | "typeRoots": [
34 | "./types",
35 | "../../node_modules/@types"
36 | ],
37 | "include": [
38 | "next-env.d.ts",
39 | "**/*.ts",
40 | "**/*.tsx",
41 | ".next/types/**/*.ts"
42 | ],
43 | "exclude": [
44 | "node_modules"
45 | ]
46 | }
47 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/migrations/20220919003636_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "BangumiPreference" (
3 | "userID" TEXT NOT NULL PRIMARY KEY,
4 | "watching" TEXT,
5 | "createdAt" DATETIME NOT NULL,
6 | "updatedAt" DATETIME NOT NULL
7 | );
8 |
9 | -- CreateTable
10 | CREATE TABLE "Preference" (
11 | "userID" TEXT NOT NULL PRIMARY KEY,
12 | "common" TEXT NOT NULL,
13 | "createdAt" DATETIME NOT NULL,
14 | "updatedAt" DATETIME NOT NULL
15 | );
16 |
17 | -- CreateTable
18 | CREATE TABLE "Token" (
19 | "userID" TEXT NOT NULL,
20 | "token" TEXT NOT NULL,
21 |
22 | PRIMARY KEY ("userID", "token")
23 | );
24 |
25 | -- CreateTable
26 | CREATE TABLE "User" (
27 | "id" TEXT NOT NULL PRIMARY KEY,
28 | "email" TEXT NOT NULL,
29 | "password" TEXT NOT NULL,
30 | "role" INTEGER NOT NULL,
31 | "createdAt" DATETIME NOT NULL,
32 | "updatedAt" DATETIME NOT NULL
33 | );
34 |
35 | -- CreateIndex
36 | Pragma writable_schema=1;
37 | CREATE UNIQUE INDEX "sqlite_autoindex_user_2" ON "User"("email");
38 | Pragma writable_schema=0;
39 |
--------------------------------------------------------------------------------
/packages/client/components/WeekdayTab.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 6px;
3 | background: #f5f7f8;
4 | display: flex;
5 | border-radius: 12px;
6 | }
7 |
8 | .item {
9 | padding: 10px 12px 12px 12px;
10 | overflow: hidden;
11 | border-radius: 12px;
12 | margin-right: 6px;
13 | transition: all 0.3s ease;
14 | }
15 |
16 | .item.today {
17 | font-weight: 700;
18 | }
19 |
20 | .item:hover {
21 | color: var(--color-theme-green);
22 | background: #fff;
23 | }
24 |
25 | .item:last-child {
26 | margin-right: 0;
27 | }
28 |
29 | @media (max-width: 640px) {
30 | .item {
31 | padding: 4px 6px 6px 6px;
32 | flex-grow: 1;
33 | }
34 | }
35 |
36 | @media (max-width: 375px) {
37 | .item {
38 | font-size: 14px;
39 | }
40 | }
41 |
42 | .item.active {
43 | background: #fff;
44 | color: var(--color-theme-green);
45 | }
46 |
47 | .item[disabled] {
48 | cursor: default;
49 | pointer-events: none;
50 | }
51 |
52 | .item.active[disabled] {
53 | background: #eee;
54 | }
55 |
56 | .item[disabled]:hover {
57 | background: transparent;
58 | color: inherit;
59 | }
60 |
--------------------------------------------------------------------------------
/packages/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bangumi-list-v3-client",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "clean": "rimraf dist *.tsbuildinfo",
8 | "dev": "next dev",
9 | "build": "next build",
10 | "start": "next start",
11 | "lint": "next lint"
12 | },
13 | "author": "Botao (https://github.com/wxt2005)",
14 | "repository": {
15 | "type": "git",
16 | "url": "https://github.com/wxt2005/bangumi-list-v3.git",
17 | "directory": "packages/client"
18 | },
19 | "license": "MIT",
20 | "private": true,
21 | "browserslist": [
22 | "defaults"
23 | ],
24 | "dependencies": {
25 | "@svgr/webpack": "^8.1.0",
26 | "axios": "^1.13.2",
27 | "classnames": "^2.5.1",
28 | "date-fns": "^4.1.0",
29 | "next": "^14.2.33",
30 | "nextjs-google-analytics": "^2.3.7",
31 | "react": "^18.3.1",
32 | "react-dom": "^18.3.1",
33 | "swr": "^2.3.7"
34 | },
35 | "devDependencies": {
36 | "@types/react": "18.3.12",
37 | "eslint-config-next": "^14.2.33"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/packages/client/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import Layout from '../components/layout/Layout';
2 | import { UserProvider } from '../contexts/userContext';
3 | import { PreferenceProvider } from '../contexts/preferenceContext';
4 | import { GoogleAnalytics } from 'nextjs-google-analytics';
5 | import Core from '../components/Core';
6 | import '../styles/variables.css';
7 | import '../styles/reset.css';
8 | import '../styles/layout.css';
9 |
10 | export default function MyApp({
11 | Component,
12 | pageProps,
13 | }: {
14 | Component: React.ElementType;
15 | pageProps: Record;
16 | }): JSX.Element | null {
17 | const gaMeasurementId = process.env.NEXT_PUBLIC_GA_ID || '';
18 |
19 | return (
20 | <>
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | {gaMeasurementId && (
31 |
32 | )}
33 | >
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/packages/server/src/controllers/bangumiPreference.controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 | import bangumiPreferenceModel from '../models/bangumiPreference.model';
3 |
4 | export async function get(req: Request, res: Response): Promise {
5 | if (!req.user) {
6 | res.sendStatus(401);
7 | return;
8 | }
9 |
10 | const userID = req.user.id;
11 | let data;
12 | try {
13 | data = await bangumiPreferenceModel.get(userID);
14 | } catch (error) {
15 | console.error(error);
16 | res.sendStatus(500);
17 | return;
18 | }
19 |
20 | res.send(data || bangumiPreferenceModel.getDefaultPreference());
21 | }
22 |
23 | export async function update(req: Request, res: Response): Promise {
24 | if (!req.user) {
25 | res.sendStatus(401);
26 | return;
27 | }
28 |
29 | const userID = req.user.id;
30 | const newData = req.body || {};
31 | let data;
32 | try {
33 | data = await bangumiPreferenceModel.update(userID, newData);
34 | } catch (error) {
35 | console.error(error);
36 | res.sendStatus(500);
37 | return;
38 | }
39 |
40 | res.send(data || bangumiPreferenceModel.getDefaultPreference());
41 | }
42 |
--------------------------------------------------------------------------------
/packages/client/styles/reset.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | h1, h2, h3, h4, h5, h6, p, span {
6 | padding: unset;
7 | margin: unset;
8 | font-weight: unset;
9 | }
10 |
11 | ul, ol {
12 | list-style: none;
13 | margin: unset;
14 | padding: unset;
15 | }
16 |
17 | a {
18 | color: unset;
19 | text-decoration: unset;
20 | }
21 |
22 | a:focus-visible {
23 | border-color: unset;
24 | }
25 |
26 | button {
27 | font-size: inherit;
28 | color: unset;
29 | border: unset;
30 | background-color: unset;
31 | margin: unset;
32 | padding: unset;
33 | display: inline;
34 | height: initial;
35 | -webkit-appearance: none;
36 | }
37 |
38 | button:focus-visible {
39 | border-color: unset;
40 | }
41 |
42 | dl, dt, dd {
43 | margin: unset;
44 | }
45 |
46 | input[type=search]::-ms-clear { display: none; width : 0; height: 0; }
47 | input[type=search]::-ms-reveal { display: none; width : 0; height: 0; }
48 | input[type="search"]::-webkit-search-decoration,
49 | input[type="search"]::-webkit-search-cancel-button,
50 | input[type="search"]::-webkit-search-results-button,
51 | input[type="search"]::-webkit-search-results-decoration { display: none; }
52 |
--------------------------------------------------------------------------------
/packages/server/src/controllers/userPreference.controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 | import userPreferenceModel from '../models/userPreference.model';
3 |
4 | export async function getCommon(req: Request, res: Response): Promise {
5 | if (!req.user) {
6 | res.sendStatus(401);
7 | return;
8 | }
9 |
10 | const userID = req.user.id;
11 |
12 | let data;
13 | try {
14 | data = await userPreferenceModel.getCommon(userID);
15 | } catch (error) {
16 | console.error(error);
17 | res.sendStatus(500);
18 | return;
19 | }
20 |
21 | res.send(data || userPreferenceModel.getDefaultPreference());
22 | }
23 |
24 | export async function updateCommon(req: Request, res: Response): Promise {
25 | if (!req.user) {
26 | res.sendStatus(401);
27 | return;
28 | }
29 |
30 | const userID = req.user.id;
31 | const newData = req.body || {};
32 |
33 | let data;
34 | try {
35 | data = await userPreferenceModel.updateCommon(userID, newData);
36 | } catch (error) {
37 | console.error(error);
38 | res.sendStatus(500);
39 | return;
40 | }
41 |
42 | res.send(data || userPreferenceModel.getDefaultPreference());
43 | }
44 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/generated/prisma/browser.ts:
--------------------------------------------------------------------------------
1 |
2 | /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3 | /* eslint-disable */
4 | // biome-ignore-all lint: generated file
5 | // @ts-nocheck
6 | /*
7 | * This file should be your main import to use Prisma-related types and utilities in a browser.
8 | * Use it to get access to models, enums, and input types.
9 | *
10 | * This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
11 | * See `client.ts` for the standard, server-side entry point.
12 | *
13 | * 🟢 You can import this file directly.
14 | */
15 |
16 | import * as Prisma from './internal/prismaNamespaceBrowser'
17 | export { Prisma }
18 | export * as $Enums from './enums'
19 | export * from './enums';
20 | /**
21 | * Model BangumiPreference
22 | *
23 | */
24 | export type BangumiPreference = Prisma.BangumiPreferenceModel
25 | /**
26 | * Model Preference
27 | *
28 | */
29 | export type Preference = Prisma.PreferenceModel
30 | /**
31 | * Model Token
32 | *
33 | */
34 | export type Token = Prisma.TokenModel
35 | /**
36 | * Model User
37 | *
38 | */
39 | export type User = Prisma.UserModel
40 |
--------------------------------------------------------------------------------
/packages/client/components/common/Top.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: flex;
3 | margin: 12px auto;
4 | align-content: initial;
5 | align-items: flex-end;
6 | }
7 |
8 | @media (max-width: 640px) {
9 | .root {
10 | padding: 0;
11 | flex-direction: column;
12 | align-items: flex-start;
13 | margin: 0 0 12px 0;
14 | }
15 | }
16 |
17 | .heading {
18 | font-size: 40px;
19 | flex-grow: 1;
20 | padding-left: 12px;
21 | }
22 |
23 | @media (max-width: 640px) {
24 | .heading {
25 | font-size: 36px;
26 | margin-top: 6px;
27 | padding-left: 0;
28 | }
29 | }
30 |
31 | .searchForm {
32 |
33 | }
34 |
35 | .searchInput {
36 | border: 1px solid var(--color-theme-blue);
37 | border-radius: 6px;
38 | height: 40px;
39 | padding-left: 12px;
40 | padding-right: 12px;
41 | font-size: 16px;
42 | transition: all 0.3s ease;
43 | }
44 |
45 | @media (max-width: 640px) {
46 | .searchForm {
47 | width: 100%;
48 | margin-top: 6px;
49 | }
50 |
51 | .searchInput {
52 | width: 100%;
53 | }
54 | }
55 |
56 | .search:hover,
57 | .search:active,
58 | .search:focus,
59 | .search:focus-visible {
60 | outline: 0;
61 | border-color: var(--color-theme-green);
62 | }
63 |
--------------------------------------------------------------------------------
/packages/client/images/favorite-full.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
8 | ]>
9 |
19 |
--------------------------------------------------------------------------------
/packages/server/src/models/token.model.ts:
--------------------------------------------------------------------------------
1 | import prisma from '../prisma/client';
2 |
3 | class TokenModel {
4 | public async add(userID: string, token: string): Promise {
5 | await prisma.token.create({
6 | data: {
7 | userID,
8 | token,
9 | },
10 | });
11 | }
12 |
13 | public async remove(userID: string, token: string): Promise {
14 | await prisma.token.delete({
15 | where: {
16 | userID_token: {
17 | userID,
18 | token,
19 | },
20 | },
21 | });
22 | }
23 |
24 | public async validate(userID: string, token: string): Promise {
25 | let isValid = false;
26 | try {
27 | const row = await prisma.token.findUnique({
28 | where: {
29 | userID_token: {
30 | userID,
31 | token,
32 | },
33 | },
34 | });
35 | if (row) isValid = true;
36 | } catch (error) {
37 | console.error(error);
38 | }
39 |
40 | return isValid;
41 | }
42 |
43 | public async clearTokens(userID: string): Promise {
44 | await prisma.token.deleteMany({
45 | where: {
46 | userID,
47 | },
48 | });
49 | }
50 | }
51 |
52 | export default new TokenModel();
53 |
--------------------------------------------------------------------------------
/packages/client/utils/getBroadcastTimeString.ts:
--------------------------------------------------------------------------------
1 | import { format } from 'date-fns';
2 | import { zhCN } from 'date-fns/locale/zh-CN';
3 | import { Item, SiteMeta, SiteType } from 'bangumi-list-v3-shared';
4 |
5 | function broadcastToTimeString(broadcast?: string, begin?: string): string {
6 | let time = '';
7 | if (broadcast) {
8 | time = broadcast.split('/')[1];
9 | } else if (begin) {
10 | time = begin;
11 | }
12 | if (!time) return '';
13 | return format(new Date(time), 'eee HH:mm', {
14 | locale: zhCN,
15 | });
16 | }
17 |
18 | export default function getBroadcastTimeString(
19 | item: Item,
20 | siteMeta: SiteMeta
21 | ): { jp: string; cn: string } {
22 | const { sites } = item;
23 | const jpString = broadcastToTimeString(item.broadcast, item.begin);
24 | let cnString = '';
25 | for (const site of sites) {
26 | const { site: siteKey } = site;
27 | if (!siteMeta[siteKey]) continue;
28 | const { type, regions = [] } = siteMeta[siteKey];
29 | if (type === SiteType.ONAIR && regions.includes('CN')) {
30 | cnString = broadcastToTimeString(site.broadcast, item.begin);
31 | if (cnString) {
32 | break;
33 | }
34 | }
35 | }
36 | return {
37 | jp: jpString,
38 | cn: cnString,
39 | };
40 | }
41 |
--------------------------------------------------------------------------------
/packages/server/src/middlewares/githubWebhook.middleware.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 | import crypto from 'crypto';
3 | import logger from '../logger';
4 |
5 | const SIGN_HEADER_NAME = 'X-Hub-Signature-256';
6 | const SIGN_HASH_ALGORITHM = 'sha256';
7 |
8 | const githubWebhook = (
9 | req: Request,
10 | res: Response,
11 | next: NextFunction
12 | ): void => {
13 | const signHeader = req.get(SIGN_HEADER_NAME);
14 | const secret = process.env.GITHUB_WEBHOOK_SECRET || '';
15 | if (!secret) {
16 | logger.error('Empty secret');
17 | res.sendStatus(401);
18 | return;
19 | }
20 | if (!signHeader) {
21 | logger.error('Empty sign header');
22 | res.sendStatus(401);
23 | return;
24 | }
25 |
26 | const sign = Buffer.from(signHeader, 'utf8');
27 | const hmac = crypto.createHmac(SIGN_HASH_ALGORITHM, secret);
28 | const digest = Buffer.from(
29 | SIGN_HASH_ALGORITHM +
30 | '=' +
31 | hmac.update(JSON.stringify(req.body)).digest('hex'),
32 | 'utf8'
33 | );
34 | if (sign.length !== digest.length || !crypto.timingSafeEqual(digest, sign)) {
35 | console.error('Mismatch');
36 | logger.error('Signature mismatch');
37 | res.sendStatus(401);
38 | return;
39 | }
40 |
41 | next();
42 | };
43 |
44 | export default githubWebhook;
45 |
--------------------------------------------------------------------------------
/packages/server/src/routes/v1/user.route.ts:
--------------------------------------------------------------------------------
1 | import express, { Request, Response } from 'express';
2 | import * as userController from '../../controllers/user.controller';
3 | import auth from '../../middlewares/auth.middleware';
4 | import {
5 | signupUserValidationRules,
6 | loginUserValidationRules,
7 | updateUserValidationRules,
8 | validate,
9 | } from '../../middlewares/validator.middleware';
10 |
11 | const router = express.Router();
12 |
13 | router.get('/me', auth, async (req: Request, res: Response) => {
14 | await userController.getMe(req, res);
15 | });
16 |
17 | router.patch(
18 | '/me',
19 | auth,
20 | updateUserValidationRules(),
21 | validate,
22 | async (req: Request, res: Response) => {
23 | await userController.update(req, res);
24 | }
25 | );
26 |
27 | router.post(
28 | '/signup',
29 | signupUserValidationRules(),
30 | validate,
31 | async (req: Request, res: Response) => {
32 | await userController.create(req, res);
33 | }
34 | );
35 |
36 | router.post(
37 | '/login',
38 | loginUserValidationRules(),
39 | validate,
40 | async (req: Request, res: Response) => {
41 | await userController.login(req, res);
42 | }
43 | );
44 |
45 | router.post('/logout', auth, async (req: Request, res: Response) => {
46 | await userController.logout(req, res);
47 | });
48 |
49 | export default router;
50 |
--------------------------------------------------------------------------------
/packages/server/src/routes/v1/preference.route.ts:
--------------------------------------------------------------------------------
1 | import express, { Request, Response } from 'express';
2 | import * as userPreferenceController from '../../controllers/userPreference.controller';
3 | import * as bangumiPreferenceController from '../../controllers/bangumiPreference.controller';
4 | import auth from '../../middlewares/auth.middleware';
5 | import {
6 | commonPreferenceValidationRules,
7 | updateBangumiPreferenceValidationRules,
8 | validate,
9 | } from '../../middlewares/validator.middleware';
10 |
11 | const router = express.Router();
12 |
13 | router.get('/common', auth, async (req: Request, res: Response) => {
14 | await userPreferenceController.getCommon(req, res);
15 | });
16 |
17 | router.patch(
18 | '/common',
19 | auth,
20 | commonPreferenceValidationRules(),
21 | validate,
22 | async (req: Request, res: Response) => {
23 | await userPreferenceController.updateCommon(req, res);
24 | }
25 | );
26 |
27 | router.get('/bangumi', auth, validate, async (req: Request, res: Response) => {
28 | await bangumiPreferenceController.get(req, res);
29 | });
30 |
31 | router.patch(
32 | '/bangumi',
33 | auth,
34 | updateBangumiPreferenceValidationRules(),
35 | validate,
36 | async (req: Request, res: Response) => {
37 | await bangumiPreferenceController.update(req, res);
38 | }
39 | );
40 |
41 | export default router;
42 |
--------------------------------------------------------------------------------
/packages/shared/src/types/Bangumi.interface.ts:
--------------------------------------------------------------------------------
1 | export enum SiteType {
2 | INFO = 'info',
3 | ONAIR = 'onair',
4 | RESOURCE = 'resource',
5 | }
6 |
7 | export interface SiteItem {
8 | title: string;
9 | urlTemplate: string;
10 | type: SiteType;
11 | regions?: string[];
12 | }
13 |
14 | export interface SiteMeta {
15 | [key: string]: SiteItem;
16 | }
17 |
18 | export enum BangumiType {
19 | TV = 'tv',
20 | WEB = 'web',
21 | MOVIE = 'movie',
22 | OVA = 'ova',
23 | }
24 |
25 | export interface BangumiSite {
26 | site: string;
27 | id: string;
28 | url?: string;
29 | begin?: string;
30 | broadcast?: string;
31 | comment?: string;
32 | }
33 |
34 | export interface TitleTranslate {
35 | [key: string]: string[];
36 | }
37 |
38 | export interface Item {
39 | id?: string;
40 | title: string;
41 | titleTranslate?: TitleTranslate;
42 | pinyinTitles?: string[];
43 | type: BangumiType;
44 | lang: string;
45 | officialSite: string;
46 | begin: string;
47 | broadcast?: string;
48 | end: string;
49 | comment?: string;
50 | sites: BangumiSite[];
51 | }
52 |
53 | export interface ItemList {
54 | items: Item[];
55 | }
56 |
57 | export interface Data {
58 | siteMeta: SiteMeta;
59 | items: Item[];
60 | version?: number;
61 | }
62 |
63 | export interface SeasonList {
64 | version: number;
65 | items: string[];
66 | }
67 |
--------------------------------------------------------------------------------
/packages/client/models/user.model.ts:
--------------------------------------------------------------------------------
1 | import { LoginResponse, UserUpdateRequest } from '../types';
2 | import { User } from 'bangumi-list-v3-shared';
3 | import api from '../utils/api';
4 |
5 | export async function signup(email: string, password: string): Promise {
6 | return await api.request('POST', 'user/signup', undefined, {
7 | email,
8 | password,
9 | });
10 | }
11 |
12 | export async function login(
13 | email: string,
14 | password: string,
15 | save = true
16 | ): Promise {
17 | const { token } = await api.request(
18 | 'POST',
19 | 'user/login',
20 | undefined,
21 | {
22 | email,
23 | password,
24 | }
25 | );
26 | api.setCredential(token);
27 | api.saveCredential(!save);
28 | }
29 |
30 | export async function logout(): Promise {
31 | try {
32 | await api.request('POST', 'user/logout', undefined, undefined, true);
33 | } finally {
34 | api.removeCredential();
35 | }
36 | }
37 |
38 | export async function getUser(): Promise {
39 | return await api.request('GET', 'user/me', undefined, undefined, true);
40 | }
41 |
42 | export async function updateUser(newData: UserUpdateRequest): Promise {
43 | return await api.request(
44 | 'PATCH',
45 | 'user/me',
46 | undefined,
47 | {
48 | ...newData,
49 | },
50 | true
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/packages/client/images/user.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
52 |
--------------------------------------------------------------------------------
/packages/client/pages/signup/index.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | max-width: 840px;
3 | margin: 24px auto;
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | }
8 |
9 | .title {
10 | font-size: 40px;
11 | }
12 |
13 | .form {
14 | margin-top: 12px;
15 | display: flex;
16 | flex-direction: column;
17 | align-items: flex-start;
18 | }
19 |
20 | @media (max-width: 640px) {
21 | .form {
22 | align-items: stretch;
23 | }
24 | }
25 |
26 | .form label {
27 | margin-bottom: 6px;
28 | }
29 |
30 | .input {
31 | font-size: 16px;
32 | padding: 6px;
33 | width: 200px;
34 | }
35 |
36 | .input:not(:focus):not(:placeholder-shown):invalid {
37 | border: 2px solid red;
38 | }
39 |
40 | .input + label {
41 | margin-top: 24px;
42 | }
43 |
44 | .hint {
45 | font-size: 14px;
46 | color: #787c7b;
47 | }
48 |
49 | .error,
50 | .success {
51 | font-size: 14px;
52 | margin-top: 12px;
53 | }
54 |
55 | .success {
56 | color: var(--color-theme-green);
57 | }
58 |
59 | .submit {
60 | margin-top: 12px;
61 | background: var(--color-theme-green);
62 | color: #fff;
63 | border-radius: 4px;
64 | padding: 6px 12px;
65 | transition: opacity 0.3s ease;
66 | font-size: 20px;
67 | }
68 |
69 | .submit:active,
70 | .submit:hover {
71 | opacity: var(--active-opacity);
72 | }
73 |
74 | .submit:disabled {
75 | opacity: var(--disable-opacity);
76 | pointer-events: none;
77 | }
78 |
--------------------------------------------------------------------------------
/packages/client/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from 'next/document';
2 |
3 | export default function Document() {
4 | return (
5 |
6 |
7 |
8 |
9 |
13 |
17 |
18 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/packages/client/models/preference.model.ts:
--------------------------------------------------------------------------------
1 | import api from '../utils/api';
2 | import {
3 | VersionedCommonPreference,
4 | VersionedBangumiPreference,
5 | BangumiPreference,
6 | CommonPreference,
7 | } from 'bangumi-list-v3-shared';
8 |
9 | export async function getCommonPreference(): Promise {
10 | return await api.request(
11 | 'GET',
12 | '/preference/common',
13 | undefined,
14 | undefined,
15 | true
16 | );
17 | }
18 |
19 | export async function updateCommonPreference(
20 | newData: Partial
21 | ): Promise {
22 | return await api.request(
23 | 'PATCH',
24 | 'preference/common',
25 | undefined,
26 | {
27 | ...newData,
28 | },
29 | true
30 | );
31 | }
32 |
33 | export async function getBangumiPreference(): Promise {
34 | return await api.request(
35 | 'GET',
36 | 'preference/bangumi',
37 | undefined,
38 | undefined,
39 | true
40 | );
41 | }
42 |
43 | export async function updateBangumiPreference(
44 | newData: Partial
45 | ): Promise {
46 | return await api.request(
47 | 'PATCH',
48 | 'preference/bangumi',
49 | undefined,
50 | {
51 | ...newData,
52 | },
53 | true
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/packages/client/pages/login/index.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | max-width: 840px;
3 | margin: 24px auto;
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | }
8 |
9 | .title {
10 | font-size: 40px;
11 | }
12 |
13 | .form {
14 | margin-top: 12px;
15 | display: flex;
16 | flex-direction: column;
17 | align-items: flex-start;
18 | }
19 |
20 | @media (max-width: 640px) {
21 | .form {
22 | align-items: stretch;
23 | }
24 | }
25 |
26 | .form label {
27 | margin-bottom: 6px;
28 | }
29 |
30 | .input {
31 | font-size: 16px;
32 | padding: 6px;
33 | width: 200px;
34 | }
35 |
36 | .input:not(:focus):not(:placeholder-shown):invalid {
37 | border: 2px solid red;
38 | }
39 |
40 | .input + label {
41 | margin-top: 24px;
42 | }
43 |
44 | .hint {
45 | font-size: 14px;
46 | color: #787c7b;
47 | }
48 |
49 | .saveBox {
50 | margin-top: 12px;
51 | }
52 |
53 | .error,
54 | .success {
55 | font-size: 14px;
56 | margin-top: 12px;
57 | }
58 |
59 | .success {
60 | color: var(--color-theme-green);
61 | }
62 |
63 | .submit {
64 | margin-top: 12px;
65 | background: var(--color-theme-green);
66 | color: #fff;
67 | border-radius: 4px;
68 | padding: 6px 12px;
69 | transition: opacity 0.3s ease;
70 | font-size: 20px;
71 | }
72 |
73 | .submit:hover,
74 | .submit:active {
75 | opacity: var(--active-opacity);
76 | }
77 |
78 | .submit:disabled {
79 | opacity: var(--disable-opacity);
80 | pointer-events: none;
81 | }
82 |
--------------------------------------------------------------------------------
/packages/client/pages/me/index.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | max-width: 840px;
3 | margin: 24px auto;
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | }
8 |
9 | .title {
10 | font-size: 40px;
11 | }
12 |
13 | .form {
14 | margin-top: 12px;
15 | display: flex;
16 | flex-direction: column;
17 | align-items: flex-start;
18 | }
19 |
20 | @media (max-width: 640px) {
21 | .form {
22 | align-items: stretch;
23 | }
24 | }
25 |
26 | .form label {
27 | margin-bottom: 6px;
28 | }
29 |
30 | .input {
31 | font-size: 16px;
32 | padding: 6px;
33 | width: 200px;
34 | }
35 |
36 | .input:not(:focus):not(:placeholder-shown):invalid {
37 | border: 2px solid red;
38 | }
39 |
40 | .input + label {
41 | margin-top: 24px;
42 | }
43 |
44 | .hint {
45 | font-size: 14px;
46 | color: #787c7b;
47 | }
48 |
49 | .saveBox {
50 | margin-top: 12px;
51 | }
52 |
53 | .error,
54 | .success {
55 | font-size: 14px;
56 | margin-top: 12px;
57 | }
58 |
59 | .success {
60 | color: var(--color-theme-green);
61 | }
62 |
63 | .submit {
64 | margin-top: 12px;
65 | background: var(--color-theme-green);
66 | color: #fff;
67 | border-radius: 4px;
68 | padding: 6px 12px;
69 | transition: opacity 0.3s ease;
70 | font-size: 20px;
71 | }
72 |
73 | .submit:hover,
74 | .submit:active {
75 | opacity: var(--active-opacity);
76 | }
77 |
78 | .submit:disabled {
79 | opacity: var(--disable-opacity);
80 | pointer-events: none;
81 | }
82 |
--------------------------------------------------------------------------------
/packages/server/src/routes/v1/bangumi.route.ts:
--------------------------------------------------------------------------------
1 | import express, { Request, Response } from 'express';
2 | import * as bangumiController from '../../controllers/bangumi.controller';
3 | import auth from '../../middlewares/auth.middleware';
4 | import authAdmin from '../../middlewares/authAdmin.moddleware';
5 | import {
6 | bangumiSeasonValidationRules,
7 | bangumiArchiveValidationRules,
8 | bangumiSiteValidationRules,
9 | validate,
10 | } from '../../middlewares/validator.middleware';
11 |
12 | const router = express.Router();
13 |
14 | router.post('/update', auth, authAdmin, async (req: Request, res: Response) => {
15 | await bangumiController.update(req, res);
16 | });
17 |
18 | router.get(
19 | '/season',
20 | bangumiSeasonValidationRules(),
21 | validate,
22 | async (req: Request, res: Response) => {
23 | await bangumiController.season(req, res);
24 | }
25 | );
26 |
27 | router.get(
28 | '/archive/:season',
29 | bangumiArchiveValidationRules(),
30 | validate,
31 | async (req: Request, res: Response) => {
32 | await bangumiController.getArchive(req, res);
33 | }
34 | );
35 |
36 | router.get('/onair', async (req: Request, res: Response) => {
37 | await bangumiController.getOnAir(req, res);
38 | });
39 |
40 | router.get(
41 | '/site',
42 | bangumiSiteValidationRules(),
43 | validate,
44 | async (req: Request, res: Response) => {
45 | await bangumiController.site(req, res);
46 | }
47 | );
48 |
49 | export default router;
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | ### Node ###
3 | # Logs
4 | logs
5 | *.log
6 | npm-debug.log*
7 | yarn-debug.log*
8 | yarn-error.log*
9 | lerna-debug.log*
10 | .pnpm-debug.log*
11 |
12 | # Diagnostic reports (https://nodejs.org/api/report.html)
13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
14 |
15 | # Runtime data
16 | pids
17 | *.pid
18 | *.seed
19 | *.pid.lock
20 |
21 | # Directory for instrumented libs generated by jscoverage/JSCover
22 | lib-cov
23 |
24 | # Coverage directory used by tools like istanbul
25 | coverage
26 | *.lcov
27 |
28 | # nyc test coverage
29 | .nyc_output
30 |
31 | # Dependency directories
32 | node_modules/
33 | jspm_packages/
34 |
35 | # Snowpack dependency directory (https://snowpack.dev/)
36 | web_modules/
37 |
38 | # TypeScript cache
39 | *.tsbuildinfo
40 |
41 | # Optional npm cache directory
42 | .npm
43 |
44 | # Optional eslint cache
45 | .eslintcache
46 |
47 | # Optional REPL history
48 | .node_repl_history
49 |
50 | # Yarn Integrity file
51 | .yarn-integrity
52 |
53 | # dotenv environment variables file
54 | .env
55 | .env.test
56 | .env.production
57 |
58 | # parcel-bundler cache (https://parceljs.org/)
59 | .cache
60 | .parcel-cache
61 |
62 | # yarn v2
63 | .yarn/cache
64 | .yarn/unplugged
65 | .yarn/build-state.yml
66 | .yarn/install-state.gz
67 | .pnp.*
68 |
69 | ### Node Patch ###
70 | # Serverless Webpack directories
71 | .webpack/
72 |
73 | .DS_*
74 | **/*.backup.*
75 | **/*.back.*
76 |
77 | .run/
78 | data/
79 | dist/
80 | .env
81 | stats.json
82 | .next
83 |
--------------------------------------------------------------------------------
/packages/client/images/logout.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
58 |
--------------------------------------------------------------------------------
/packages/server/src/middlewares/auth.middleware.ts:
--------------------------------------------------------------------------------
1 | import jwt, { JwtPayload, TokenExpiredError } from 'jsonwebtoken';
2 | import { Request, Response, NextFunction } from 'express';
3 | import tokenModel from '../models/token.model';
4 | import { RequestUser } from '../types';
5 | import logger from '../logger';
6 |
7 | const auth = async (
8 | req: Request,
9 | res: Response,
10 | next: NextFunction
11 | ): Promise => {
12 | const authHeader = req.headers.authorization as string;
13 |
14 | if (!authHeader) {
15 | res.sendStatus(401);
16 | return;
17 | }
18 |
19 | const match = /^Bearer\s(.+)$/.exec(authHeader);
20 | if (!match) {
21 | res.sendStatus(401);
22 | return;
23 | }
24 |
25 | const token = match[1];
26 | let user: RequestUser;
27 |
28 | try {
29 | const decoded = jwt.verify(
30 | token,
31 | process.env.JWT_SECRET as string
32 | ) as JwtPayload;
33 | user = {
34 | id: decoded.userID,
35 | role: decoded.userRole,
36 | token,
37 | };
38 | } catch (error) {
39 | if (error instanceof TokenExpiredError) {
40 | const decoded = jwt.decode(token) as JwtPayload;
41 | try {
42 | await tokenModel.remove(decoded.userID, token);
43 | } catch (error) {
44 | logger.error(error || {}, 'Remove token failed');
45 | }
46 | }
47 | console.error(error);
48 | res.sendStatus(401);
49 | return;
50 | }
51 |
52 | const isValid = await tokenModel.validate(user.id, token);
53 | if (!isValid) {
54 | res.sendStatus(401);
55 | return;
56 | }
57 |
58 | req.user = user;
59 | next();
60 | };
61 |
62 | export default auth;
63 |
--------------------------------------------------------------------------------
/packages/client/components/WeekdayTab.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 | import { Weekday } from '../types';
4 | import styles from './WeekdayTab.module.css';
5 |
6 | const tabItems: [Weekday, string][] = [
7 | [Weekday.MONDAY, '周一'],
8 | [Weekday.TUESDAY, '周二'],
9 | [Weekday.WEDNESDAY, '周三'],
10 | [Weekday.THURSDAY, '周四'],
11 | [Weekday.FIRDAY, '周五'],
12 | [Weekday.SATURDAY, '周六'],
13 | [Weekday.SUNDAY, '周日'],
14 | [Weekday.ALL, '全部'],
15 | ];
16 |
17 | interface Props {
18 | activated?: Weekday;
19 | onClick?: (tab: Weekday) => void;
20 | disabled?: boolean;
21 | className?: string;
22 | }
23 |
24 | export default function WeekdayTab(props: Props): JSX.Element {
25 | const { disabled = false } = props;
26 | const buttons = tabItems.map(([tab, text]) => {
27 | const isActivated = tab === props.activated;
28 | const isToday = new Date().getDay() === tab;
29 | const itemClassName = classNames(styles.item, {
30 | [styles.active]: isActivated,
31 | [styles.today]: isToday,
32 | });
33 | const buttonText = isToday ? '今天' : text;
34 |
35 | return (
36 |
49 | );
50 | });
51 | const rootClassName = classNames(props.className, styles.root);
52 |
53 | return (
54 |
55 | {buttons}
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/packages/client/models/preferenceLocal.model.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | VersionedCommonPreference,
3 | VersionedBangumiPreference,
4 | BangumiPreference,
5 | CommonPreference,
6 | } from 'bangumi-list-v3-shared';
7 | import {
8 | STORAGE_BANGUMI_PREFERENCE_NAME,
9 | STORAGE_COMMON_PREFERENCE_NAME,
10 | } from '../constants/storage';
11 |
12 | export async function getCommonPreferenceLocal(): Promise {
13 | try {
14 | const str = localStorage.getItem(STORAGE_COMMON_PREFERENCE_NAME);
15 | if (!str) {
16 | return null;
17 | }
18 | const obj: VersionedCommonPreference = JSON.parse(str);
19 | return obj;
20 | } catch (error) {
21 | console.error(error);
22 | return null;
23 | }
24 | }
25 |
26 | export async function updateCommonPreferenceLocal(
27 | data: CommonPreference
28 | ): Promise {
29 | try {
30 | localStorage.setItem(STORAGE_COMMON_PREFERENCE_NAME, JSON.stringify(data));
31 | return Promise.resolve(true);
32 | } catch (error) {
33 | console.error(error);
34 | return Promise.resolve(false);
35 | }
36 | }
37 |
38 | export async function getBangumiPreferenceLocal(): Promise {
39 | try {
40 | const str = localStorage.getItem(STORAGE_BANGUMI_PREFERENCE_NAME);
41 | if (!str) {
42 | return null;
43 | }
44 | const obj: VersionedBangumiPreference = JSON.parse(str);
45 | return obj;
46 | } catch (error) {
47 | console.error(error);
48 | return null;
49 | }
50 | }
51 |
52 | export async function updateBangumiPreferenceLocal(
53 | data: BangumiPreference
54 | ): Promise {
55 | try {
56 | localStorage.setItem(STORAGE_BANGUMI_PREFERENCE_NAME, JSON.stringify(data));
57 | return Promise.resolve(true);
58 | } catch (error) {
59 | console.error(error);
60 | return Promise.resolve(false);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/packages/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bangumi-list-v3-server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "dist/app.js",
6 | "scripts": {
7 | "dev:base": "cross-env NODE_ENV=development ts-node-dev --respawn --rs --transpile-only --project tsconfig.build.json -r tsconfig-paths/register -r dotenv/config",
8 | "dev": "npm run dev:base -- src/app.ts",
9 | "dev:inspect": "npm run dev:base -- --inspect -- src/app.ts",
10 | "clean": "rimraf dist *.tsbuildinfo",
11 | "tsc": "tsc -b tsconfig.build.json",
12 | "build": "npm-run-all clean tsc",
13 | "start": "cross-env NODE_ENV=production node -r dotenv/config .",
14 | "prisma:generate": "npx prisma generate",
15 | "prisma:migrate:reset": "npx prisma migrate reset"
16 | },
17 | "author": "Botao (https://github.com/wxt2005)",
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/wxt2005/bangumi-list-v3.git",
21 | "directory": "packages/server"
22 | },
23 | "license": "MIT",
24 | "private": true,
25 | "dependencies": {
26 | "@libsql/client": "^0.15.15",
27 | "@prisma/adapter-better-sqlite3": "^7.1.0",
28 | "@prisma/client": "^7.1.0",
29 | "axios": "^1.13.2",
30 | "bcrypt": "^6.0.0",
31 | "express": "^5.2.1",
32 | "express-validator": "^7.3.1",
33 | "fs-extra": "^11.3.2",
34 | "jsonwebtoken": "^9.0.3",
35 | "lodash": "^4.17.21",
36 | "md5": "^2.3.0",
37 | "moment": "^2.30.1",
38 | "pino-http": "^11.0.0",
39 | "pino-pretty": "^13.1.3",
40 | "pinyin": "^4.0.0",
41 | "prisma": "^7.1.0"
42 | },
43 | "devDependencies": {
44 | "@types/bcrypt": "^6.0.0",
45 | "@types/better-sqlite3": "^7.6.13",
46 | "@types/express": "^5.0.6",
47 | "@types/fs-extra": "^11.0.4",
48 | "@types/jsonwebtoken": "^9.0.10",
49 | "@types/lodash": "^4.17.21",
50 | "@types/md5": "^2.3.6"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/packages/client/images/favorite-empty.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
51 |
--------------------------------------------------------------------------------
/packages/client/components/common/Header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | background: var(--color-theme-blue);
3 | color: #fff;
4 | height: 50px;
5 | font-size: 16px;
6 | }
7 |
8 | .container {
9 | height: 100%;
10 | margin: 0 auto;
11 | display: flex;
12 | align-items: center;
13 | }
14 |
15 | @media (max-width: 375px) {
16 | .header {
17 | font-size: 14px;
18 | }
19 | }
20 |
21 | .title {
22 | font-size: 24px;
23 | flex-grow: 0;
24 | flex-shrink: 0;
25 | margin-right: 12px;
26 | padding-left: 12px;
27 | }
28 |
29 | @media (max-width: 640px) {
30 | .title {
31 | padding-left: 0;
32 | }
33 | }
34 |
35 | @media (max-width: 375px) {
36 | .title {
37 | font-size: 18px;
38 | margin-right: 6px;
39 | }
40 | }
41 |
42 | .navMenu {
43 | flex-grow: 1;
44 | flex-shrink: 0;
45 | }
46 |
47 | .navUser {
48 | flex-grow: 0;
49 | flex-shrink: 0;
50 | }
51 |
52 | .navMenuList,
53 | .navUserList {
54 | display: flex;
55 | align-items: center;
56 | height: 100%;
57 | }
58 |
59 | .navMenuList a,
60 | .navUserList a {
61 | padding: 0 6px;
62 | }
63 |
64 | .button {
65 | display: flex;
66 | justify-content: center;
67 | align-items: center;
68 | padding: 0 6px;
69 | }
70 |
71 | .button:hover {
72 | color: var(--color-theme-green);
73 | }
74 |
75 | .icon {
76 | width: 16px;
77 | height: 16px;
78 | fill: #fff;
79 | margin-right: 4px;
80 | }
81 |
82 | .navUserList .button:hover .icon {
83 | fill: var(--color-theme-green);
84 | }
85 |
86 | @media (max-width: 640px) {
87 | .navUserList {
88 | margin-right: -6px;
89 | }
90 |
91 | .button {
92 | padding: 6px 12px;
93 | }
94 |
95 | .icon {
96 | margin: 0;
97 | }
98 |
99 | .iconText {
100 | clip: rect(1px, 1px, 1px, 1px);
101 | clip-path: inset(50%);
102 | height: 1px;
103 | width: 1px;
104 | margin: -1px;
105 | overflow: hidden;
106 | padding: 0;
107 | position: absolute;
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/generated/prisma/client.ts:
--------------------------------------------------------------------------------
1 |
2 | /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3 | /* eslint-disable */
4 | // biome-ignore-all lint: generated file
5 | // @ts-nocheck
6 | /*
7 | * This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
8 | * If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
9 | *
10 | * 🟢 You can import this file directly.
11 | */
12 |
13 | import * as process from 'node:process'
14 | import * as path from 'node:path'
15 |
16 | import * as runtime from "@prisma/client/runtime/client"
17 | import * as $Enums from "./enums"
18 | import * as $Class from "./internal/class"
19 | import * as Prisma from "./internal/prismaNamespace"
20 |
21 | export * as $Enums from './enums'
22 | export * from "./enums"
23 | /**
24 | * ## Prisma Client
25 | *
26 | * Type-safe database client for TypeScript
27 | * @example
28 | * ```
29 | * const prisma = new PrismaClient()
30 | * // Fetch zero or more BangumiPreferences
31 | * const bangumiPreferences = await prisma.bangumiPreference.findMany()
32 | * ```
33 | *
34 | * Read more in our [docs](https://pris.ly/d/client).
35 | */
36 | export const PrismaClient = $Class.getPrismaClientClass()
37 | export type PrismaClient = $Class.PrismaClient
38 | export { Prisma }
39 |
40 | /**
41 | * Model BangumiPreference
42 | *
43 | */
44 | export type BangumiPreference = Prisma.BangumiPreferenceModel
45 | /**
46 | * Model Preference
47 | *
48 | */
49 | export type Preference = Prisma.PreferenceModel
50 | /**
51 | * Model Token
52 | *
53 | */
54 | export type Token = Prisma.TokenModel
55 | /**
56 | * Model User
57 | *
58 | */
59 | export type User = Prisma.UserModel
60 |
--------------------------------------------------------------------------------
/packages/client/components/common/SearchInput.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState } from 'react';
2 | import classNames from 'classnames';
3 | import ClearIcon from '../../images/clear.svg';
4 | import styles from './SearchInput.module.css';
5 |
6 | interface Props {
7 | className?: string;
8 | placeholder?: string;
9 | showClear?: boolean;
10 | onInput?: (value: string) => void;
11 | onSubmit?: (value: string) => void;
12 | }
13 |
14 | export default function SearchInput(props: Props): JSX.Element {
15 | const [value, setValue] = useState('');
16 | const { className, placeholder, showClear = true, onInput, onSubmit } = props;
17 | const rootClassName = classNames(styles.root, className);
18 | const inputRef = useRef(null);
19 | const handleSearchInput: React.FormEventHandler = (
20 | event
21 | ) => {
22 | const text = event.currentTarget.value;
23 | onInput && onInput(text);
24 | setValue(text);
25 | };
26 | const handleSearchFormSubmit: React.FormEventHandler = (
27 | e
28 | ) => {
29 | e.preventDefault();
30 | onSubmit && onSubmit(value);
31 | inputRef.current && inputRef.current.blur();
32 | };
33 | const handleClearClick = () => {
34 | setValue('');
35 | onInput && onInput('');
36 | };
37 | const showClearButton = showClear && !!value;
38 |
39 | return (
40 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/packages/client/contexts/userContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useReducer } from 'react';
2 |
3 | export const UserContext = createContext(null);
4 | export const UserDispatchContext =
5 | createContext | null>(null);
6 |
7 | export interface UserState {
8 | isLogin: boolean;
9 | id: string | null;
10 | email: string | null;
11 | }
12 |
13 | const initialState: UserState = {
14 | isLogin: false,
15 | id: null,
16 | email: null,
17 | };
18 |
19 | export type UserAction =
20 | | { type: 'LOGIN'; id: string; email: string }
21 | | { type: 'LOGOUT' };
22 |
23 | function userReducer(state: UserState, action: UserAction): UserState {
24 | switch (action.type) {
25 | case 'LOGIN': {
26 | return {
27 | ...state,
28 | isLogin: true,
29 | id: action.id,
30 | email: action.email,
31 | };
32 | }
33 | case 'LOGOUT': {
34 | return {
35 | ...state,
36 | isLogin: false,
37 | id: null,
38 | email: null,
39 | };
40 | }
41 | default: {
42 | throw Error('Unknown action');
43 | }
44 | }
45 | }
46 |
47 | export function UserProvider({
48 | children,
49 | }: {
50 | children: React.ReactNode;
51 | }): JSX.Element {
52 | const [state, dispatch] = useReducer(userReducer, initialState);
53 |
54 | return (
55 |
56 |
57 | {children}
58 |
59 |
60 | );
61 | }
62 |
63 | export function useUser(): UserState {
64 | const userContext = useContext(UserContext);
65 | if (!userContext) {
66 | throw Error('userContext should not be null');
67 | }
68 | return userContext;
69 | }
70 |
71 | export function useUserDispatch(): React.Dispatch {
72 | const dispatch = useContext(UserDispatchContext);
73 | if (typeof dispatch !== 'function') {
74 | throw Error('userDispatch should be a function');
75 | }
76 | return dispatch;
77 | }
78 |
--------------------------------------------------------------------------------
/packages/server/src/controllers/bangumi.controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 | import bangumiModel from '../models/bangumi.model';
3 | import { SiteType } from 'bangumi-list-v3-shared';
4 | import moment from 'moment';
5 |
6 | export async function update(req: Request, res: Response): Promise {
7 | try {
8 | await bangumiModel.update();
9 | } catch (e) {
10 | console.error(e);
11 | res.sendStatus(500);
12 | return;
13 | }
14 | res.sendStatus(201);
15 | }
16 |
17 | export async function season(req: Request, res: Response): Promise {
18 | const version = bangumiModel.version;
19 | let items = [...bangumiModel.seasons];
20 | const { start } = req.query;
21 | const startIndex = items.findIndex((item) => item === start);
22 | if (startIndex !== -1) {
23 | items = items.slice(startIndex);
24 | }
25 |
26 | res.send({
27 | version,
28 | items,
29 | });
30 | }
31 |
32 | export async function site(req: Request, res: Response): Promise {
33 | const { type } = req.query;
34 | let result = {};
35 | if (type) {
36 | result = { ...bangumiModel.siteMap[type as SiteType] };
37 | } else {
38 | result = { ...bangumiModel.data?.siteMeta };
39 | }
40 | res.send(result);
41 | }
42 |
43 | export async function getArchive(req: Request, res: Response): Promise {
44 | const { season } = req.params;
45 | const { seasonIds, itemEntities } = bangumiModel;
46 | if (!seasonIds[season]) {
47 | res.send({ items: [] });
48 | return;
49 | }
50 | res.send({
51 | items: seasonIds[season].map((id) => itemEntities[id]),
52 | });
53 | }
54 |
55 | export async function getOnAir(req: Request, res: Response): Promise {
56 | const { noEndDateIds, itemEntities } = bangumiModel;
57 | const now = moment();
58 |
59 | res.send({
60 | items: noEndDateIds
61 | .map((id) => itemEntities[id])
62 | .filter((item) => {
63 | const { begin } = item;
64 | const beginDate = moment(begin);
65 | return beginDate.isBefore(now);
66 | }),
67 | });
68 | }
69 |
--------------------------------------------------------------------------------
/packages/server/src/misc/legacyDBMigrate.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "BangumiPreference_new" (
3 | "userID" TEXT NOT NULL PRIMARY KEY,
4 | "watching" TEXT,
5 | "createdAt" DATETIME NOT NULL,
6 | "updatedAt" DATETIME NOT NULL
7 | );
8 |
9 | -- CreateTable
10 | CREATE TABLE "Preference_new" (
11 | "userID" TEXT NOT NULL PRIMARY KEY,
12 | "common" TEXT NOT NULL,
13 | "createdAt" DATETIME NOT NULL,
14 | "updatedAt" DATETIME NOT NULL
15 | );
16 |
17 | -- CreateTable
18 | CREATE TABLE "Token_new" (
19 | "userID" TEXT NOT NULL,
20 | "token" TEXT NOT NULL,
21 |
22 | PRIMARY KEY ("userID", "token")
23 | );
24 |
25 | -- CreateTable
26 | CREATE TABLE "User_new" (
27 | "id" TEXT NOT NULL PRIMARY KEY,
28 | "email" TEXT NOT NULL,
29 | "password" TEXT NOT NULL,
30 | "role" INTEGER NOT NULL,
31 | "createdAt" DATETIME NOT NULL,
32 | "updatedAt" DATETIME NOT NULL
33 | );
34 |
35 | -- RedefineTables
36 | PRAGMA foreign_keys=OFF;
37 | INSERT INTO "User_new" ("createdAt", "email", "id", "password", "role", "updatedAt") SELECT "created_at", "email", "id", "password", "role", "updated_at" FROM "users";
38 | DROP TABLE "users";
39 | ALTER TABLE "User_new" RENAME TO "User";
40 | Pragma writable_schema=1;
41 | CREATE UNIQUE INDEX "sqlite_autoindex_user_2" ON "User"("email");
42 | Pragma writable_schema=0;
43 |
44 | INSERT INTO "BangumiPreference_new" ("createdAt", "updatedAt", "userID", "watching") SELECT "created_at", "updated_at", "user_id", "watching" FROM "bangumiPreference";
45 | DROP TABLE "bangumiPreference";
46 | ALTER TABLE "BangumiPreference_new" RENAME TO "BangumiPreference";
47 |
48 | INSERT INTO "Preference_new" ("common", "createdAt", "updatedAt", "userID") SELECT "common", "created_at", "updated_at", "user_id" FROM "preference";
49 | DROP TABLE "preference";
50 | ALTER TABLE "Preference_new" RENAME TO "Preference";
51 |
52 | INSERT INTO "Token_new" ("userID", "token") SELECT "user_id", "token" FROM "tokens";
53 | DROP TABLE "tokens";
54 | ALTER TABLE "Token_new" RENAME TO "Token";
55 |
56 | PRAGMA foreign_key_check;
57 | PRAGMA foreign_keys=ON;
58 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bangumi-list-v3",
3 | "version": "1.0.0",
4 | "description": "Monorepo for bangumi-list v3 server & client",
5 | "homepage": "https://github.com/wxt2005/bangumi-list-v3",
6 | "bugs": {
7 | "url": "https://github.com/wxt2005/bangumi-list-v3/issues"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/wxt2005/bangumi-list-v3.git"
12 | },
13 | "main": "index.js",
14 | "scripts": {
15 | "clean": "npm run clean --workspaces --if-present",
16 | "build": "npm run build --workspaces --if-present",
17 | "build:shared": "npm run build -w packages/shared",
18 | "dev:client": "npm run dev -w packages/client",
19 | "dev:server": "npm run dev -w packages/server",
20 | "dev": "npm-run-all -l build:shared -p dev:server dev:client",
21 | "lint": "eslint .",
22 | "lint:fix": "eslint --fix .",
23 | "prepare": "husky install",
24 | "start": "npm run start -w packages/server"
25 | },
26 | "author": "Botao (https://github.com/wxt2005)",
27 | "license": "MIT",
28 | "workspaces": [
29 | "packages/shared",
30 | "packages/server",
31 | "packages/client"
32 | ],
33 | "private": true,
34 | "devDependencies": {
35 | "@eslint/js": "^9.39.1",
36 | "@types/node": "^24.10.1",
37 | "@typescript-eslint/eslint-plugin": "^8.48.1",
38 | "@typescript-eslint/parser": "^8.48.1",
39 | "eslint": "^9.39.1",
40 | "eslint-config-prettier": "^10.1.8",
41 | "eslint-plugin-import": "^2.32.0",
42 | "eslint-plugin-prettier": "^5.5.4",
43 | "husky": "^9.1.7",
44 | "lint-staged": "^16.2.7",
45 | "prettier": "^3.7.4",
46 | "ts-node": "^10.9.2",
47 | "ts-node-dev": "^2.0.0",
48 | "tsconfig-paths": "^4.2.0",
49 | "typescript": "^5.9.3"
50 | },
51 | "dependencies": {
52 | "cross-env": "^10.1.0",
53 | "dotenv": "^17.2.3",
54 | "npm-run-all": "^4.1.5",
55 | "rimraf": "^6.1.2"
56 | },
57 | "engines": {
58 | "node": ">= 16",
59 | "npm": ">= 7"
60 | },
61 | "lint-staged": {
62 | "*.{js,jsx,ts,tsx}": "eslint --fix"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/packages/client/pages/archive/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { GetServerSideProps } from 'next';
3 | import Link from 'next/link';
4 | import { getSeasonList } from '../../models/bangumi.model';
5 | import quarterToMonth from '../../utils/quarterToMonth';
6 | import Container from '../../components/common/Container';
7 | import Head from 'next/head';
8 | import styles from './index.module.css';
9 |
10 | export default function ArchiveModal({
11 | seasons,
12 | }: {
13 | seasons: string[];
14 | }): JSX.Element {
15 | const yearOptions: string[] = [];
16 | const yearQuarterOptions: { [key: string]: string[] } = {};
17 |
18 | for (const season of seasons) {
19 | const match = /^(\d{4})q(\d)$/.exec(season);
20 | if (!match) continue;
21 | const [, year, quarter] = match;
22 | if (!yearQuarterOptions[year]) {
23 | yearOptions.unshift(year); // revese years
24 | yearQuarterOptions[year] = [];
25 | }
26 | yearQuarterOptions[year].push(quarter);
27 | }
28 |
29 | return (
30 | <>
31 |
32 | 历史数据 | 番组放送
33 |
34 |
35 | 历史数据
36 |
37 | {yearOptions.map((year) => (
38 |
39 | {year}年
40 |
41 | {yearQuarterOptions[year].map((quarter) => (
42 | -
43 | {`${year}年${quarterToMonth(
46 | parseInt(quarter, 10)
47 | )}月`}
48 |
49 | ))}
50 |
51 |
52 | ))}
53 |
54 |
55 | >
56 | );
57 | }
58 |
59 | export const getServerSideProps: GetServerSideProps = async () => {
60 | const { items } = await getSeasonList();
61 |
62 | return {
63 | props: {
64 | seasons: items,
65 | },
66 | };
67 | };
68 |
--------------------------------------------------------------------------------
/packages/client/components/Core.tsx:
--------------------------------------------------------------------------------
1 | import { AxiosError } from 'axios';
2 | import { useEffect } from 'react';
3 | import api from '../utils/api';
4 | import { getUser } from '../models/user.model';
5 | import {
6 | getCommonPreference,
7 | getBangumiPreference,
8 | } from '../models/preference.model';
9 | import {
10 | getCommonPreferenceLocal,
11 | getBangumiPreferenceLocal,
12 | } from '../models/preferenceLocal.model';
13 | import { useUserDispatch } from '../contexts/userContext';
14 | import { usePreferenceDispatch } from '../contexts/preferenceContext';
15 | import type {
16 | VersionedBangumiPreference,
17 | VersionedCommonPreference,
18 | } from 'bangumi-list-v3-shared';
19 |
20 | export default function Core({
21 | children,
22 | }: {
23 | children: React.ReactNode;
24 | }): JSX.Element {
25 | const userDispatch = useUserDispatch();
26 | const preferenceDispatch = usePreferenceDispatch();
27 |
28 | useEffect(() => {
29 | (async () => {
30 | let commonPreference: VersionedCommonPreference | null = null;
31 | let bangumiPreference: VersionedBangumiPreference | null = null;
32 |
33 | if (api.hasCredential()) {
34 | try {
35 | const { id, email } = await getUser();
36 | userDispatch({ type: 'LOGIN', id, email });
37 | commonPreference = await getCommonPreference();
38 | bangumiPreference = await getBangumiPreference();
39 | } catch (error) {
40 | if (error instanceof AxiosError && error.response?.status === 401) {
41 | api.removeCredential();
42 | } else {
43 | console.error(error);
44 | }
45 | }
46 | } else {
47 | try {
48 | commonPreference = await getCommonPreferenceLocal();
49 | bangumiPreference = await getBangumiPreferenceLocal();
50 | } catch (error) {
51 | console.error(error);
52 | }
53 | }
54 |
55 | if (commonPreference) {
56 | preferenceDispatch({
57 | type: 'SET_COMMON_PREFERENCE',
58 | payload: commonPreference,
59 | });
60 | }
61 | if (bangumiPreference) {
62 | preferenceDispatch({
63 | type: 'SET_BANGUMI_PREFERENCE',
64 | payload: bangumiPreference,
65 | });
66 | }
67 | })();
68 | }, []);
69 |
70 | return <> {children} >;
71 | }
72 |
--------------------------------------------------------------------------------
/packages/client/utils/api.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosInstance, Method } from 'axios';
2 | import { STORAGE_CREDENTIAL_NAME } from '../constants/storage';
3 | class API {
4 | private instance: AxiosInstance;
5 | private credentialToken?: string;
6 |
7 | constructor() {
8 | this.instance = axios.create({
9 | baseURL: `${process.env.API_HOST || ''}/api/v1/`,
10 | timeout: 5000,
11 | });
12 | this.loadCredential();
13 | }
14 |
15 | private loadCredential() {
16 | let token = '';
17 | try {
18 | token =
19 | localStorage.getItem(STORAGE_CREDENTIAL_NAME) ||
20 | sessionStorage.getItem(STORAGE_CREDENTIAL_NAME) ||
21 | '';
22 | } catch (e) {
23 | // ignore
24 | }
25 | if (token) {
26 | this.credentialToken = token;
27 | }
28 | }
29 |
30 | public setCredential(token: string) {
31 | this.credentialToken = token;
32 | }
33 |
34 | public saveCredential(sessionOnly = false) {
35 | if (!this.credentialToken) return;
36 | if (sessionOnly) {
37 | sessionStorage.setItem(STORAGE_CREDENTIAL_NAME, this.credentialToken);
38 | } else {
39 | localStorage.setItem(STORAGE_CREDENTIAL_NAME, this.credentialToken);
40 | }
41 | }
42 |
43 | public removeCredential() {
44 | this.credentialToken = undefined;
45 | try {
46 | localStorage.removeItem(STORAGE_CREDENTIAL_NAME);
47 | sessionStorage.removeItem(STORAGE_CREDENTIAL_NAME);
48 | } catch (e) {
49 | // ignore
50 | }
51 | }
52 |
53 | public hasCredential(): boolean {
54 | return !!this.credentialToken;
55 | }
56 |
57 | private getRequestHeaders(needCredential = false): Record {
58 | const headers: Record = {};
59 | if (needCredential) {
60 | headers['Authorization'] = `Bearer ${this.credentialToken || ''}`;
61 | }
62 | return headers;
63 | }
64 |
65 | public async request(
66 | method: Method,
67 | endpoint: string,
68 | params?: Record,
69 | data?: Record,
70 | needCredential = false
71 | ): Promise {
72 | const response = await this.instance.request({
73 | url: endpoint,
74 | method,
75 | params,
76 | data,
77 | headers: this.getRequestHeaders(needCredential),
78 | });
79 | return response.data as Type;
80 | }
81 | }
82 |
83 | export default new API();
84 |
--------------------------------------------------------------------------------
/packages/server/src/models/bangumiPreference.model.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BangumiPreference,
3 | VersionedBangumiPreference,
4 | } from 'bangumi-list-v3-shared';
5 | import prisma from '../prisma/client';
6 |
7 | const DEFAULT_BANGUMI_PREFERENCE: BangumiPreference = {
8 | watching: [],
9 | };
10 |
11 | class BangumiPreferenceModel {
12 | public async get(userID: string): Promise {
13 | if (!userID) throw new Error('User ID missing');
14 | const row = await prisma.bangumiPreference.findUnique({
15 | where: {
16 | userID,
17 | },
18 | });
19 | if (!row) return null;
20 | try {
21 | const watching = row.watching ? row.watching.split(',') : [];
22 | return { watching, version: row.updatedAt.getTime() || 0 };
23 | } catch (error) {
24 | console.error(error);
25 | return null;
26 | }
27 | }
28 |
29 | public async update(
30 | userID: string,
31 | updateData: Partial
32 | ): Promise {
33 | if (!userID) throw new Error('User ID missing');
34 |
35 | const previousData = await this.get(userID);
36 | const shouldCreateNew = previousData === null;
37 | const now = new Date();
38 |
39 | if (shouldCreateNew) {
40 | const newData = generateUpdatedData(updateData);
41 | await prisma.bangumiPreference.create({
42 | data: {
43 | userID,
44 | watching: newData.watching.join(','),
45 | createdAt: now,
46 | updatedAt: now,
47 | },
48 | });
49 | return { ...newData, version: now.getTime() };
50 | } else {
51 | const newData = generateUpdatedData(updateData, previousData);
52 | await prisma.bangumiPreference.update({
53 | where: {
54 | userID,
55 | },
56 | data: {
57 | watching: newData.watching.join(','),
58 | updatedAt: now,
59 | },
60 | });
61 | return { ...newData, version: now.getTime() };
62 | }
63 | }
64 |
65 | public getDefaultPreference(): BangumiPreference {
66 | return { ...DEFAULT_BANGUMI_PREFERENCE };
67 | }
68 | }
69 |
70 | function generateUpdatedData(
71 | newData: Partial,
72 | previousData?: BangumiPreference
73 | ): BangumiPreference {
74 | const updatedData = {
75 | ...(previousData || DEFAULT_BANGUMI_PREFERENCE),
76 | ...newData,
77 | };
78 |
79 | return updatedData;
80 | }
81 |
82 | export default new BangumiPreferenceModel();
83 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | const js = require('@eslint/js');
2 | const tsPlugin = require('@typescript-eslint/eslint-plugin');
3 | const tsParser = require('@typescript-eslint/parser');
4 | const importPlugin = require('eslint-plugin-import');
5 | const prettierPlugin = require('eslint-plugin-prettier');
6 | const prettierConfig = require('eslint-config-prettier');
7 |
8 | module.exports = [
9 | {
10 | ignores: [
11 | '**/dist/**',
12 | '**/node_modules/**',
13 | '**/.next/**',
14 | '**/build/**',
15 | '**/*.tsbuildinfo',
16 | ],
17 | },
18 | js.configs.recommended,
19 | {
20 | files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
21 | languageOptions: {
22 | parser: tsParser,
23 | parserOptions: {
24 | ecmaVersion: 12,
25 | sourceType: 'module',
26 | ecmaFeatures: {
27 | jsx: true,
28 | },
29 | },
30 | globals: {
31 | // Node.js globals
32 | console: 'readonly',
33 | process: 'readonly',
34 | Buffer: 'readonly',
35 | __dirname: 'readonly',
36 | __filename: 'readonly',
37 | module: 'readonly',
38 | require: 'readonly',
39 | exports: 'readonly',
40 | setTimeout: 'readonly',
41 | clearTimeout: 'readonly',
42 | setInterval: 'readonly',
43 | clearInterval: 'readonly',
44 | setImmediate: 'readonly',
45 | clearImmediate: 'readonly',
46 | // Browser globals
47 | window: 'readonly',
48 | document: 'readonly',
49 | localStorage: 'readonly',
50 | sessionStorage: 'readonly',
51 | navigator: 'readonly',
52 | location: 'readonly',
53 | fetch: 'readonly',
54 | HTMLElement: 'readonly',
55 | HTMLInputElement: 'readonly',
56 | HTMLFormElement: 'readonly',
57 | HTMLSelectElement: 'readonly',
58 | Element: 'readonly',
59 | Event: 'readonly',
60 | EventTarget: 'readonly',
61 | // TypeScript/React globals
62 | JSX: 'readonly',
63 | React: 'readonly',
64 | },
65 | },
66 | plugins: {
67 | '@typescript-eslint': tsPlugin,
68 | import: importPlugin,
69 | prettier: prettierPlugin,
70 | },
71 | rules: {
72 | ...tsPlugin.configs.recommended.rules,
73 | ...prettierConfig.rules,
74 | 'prettier/prettier': 'error',
75 | },
76 | },
77 | {
78 | files: ['**/*.js'],
79 | rules: {
80 | '@typescript-eslint/no-var-requires': 'off',
81 | '@typescript-eslint/no-require-imports': 'off',
82 | },
83 | },
84 | ];
85 |
--------------------------------------------------------------------------------
/packages/client/components/BangumiItemTable.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import BangumiItem from './BangumiItem';
3 | import type { Item, SiteMeta } from 'bangumi-list-v3-shared';
4 | import { useUser } from '../contexts/userContext';
5 | import {
6 | usePreference,
7 | usePreferenceDispatch,
8 | } from '../contexts/preferenceContext';
9 | import { updateBangumiPreference } from '../models/preference.model';
10 | import { updateBangumiPreferenceLocal } from '../models/preferenceLocal.model';
11 | import styles from './BangumiItemTable.module.css';
12 |
13 | interface Props {
14 | items?: Item[];
15 | siteMeta?: SiteMeta;
16 | isArchive?: boolean;
17 | emptyText?: string;
18 | }
19 |
20 | export default function BangumiItemTable(props: Props): JSX.Element {
21 | const {
22 | items = [],
23 | siteMeta = {},
24 | isArchive = false,
25 | emptyText = '暂无',
26 | } = props;
27 | const {
28 | bangumi: { watching, version },
29 | bangumi,
30 | } = usePreference();
31 | const { isLogin } = useUser();
32 | const lastBangumiPreferenceVersion = useRef(version);
33 | const preferenceDispatch = usePreferenceDispatch();
34 |
35 | useEffect(() => {
36 | if (version > lastBangumiPreferenceVersion.current) {
37 | if (isLogin) {
38 | updateBangumiPreference(bangumi);
39 | } else {
40 | updateBangumiPreferenceLocal(bangumi);
41 | }
42 | lastBangumiPreferenceVersion.current = version;
43 | }
44 | }, [version]);
45 |
46 | const itemNodes = [];
47 | for (const item of items) {
48 | const id = item.id || '';
49 | const isWatching = watching.includes(id);
50 | itemNodes.push(
51 | {
59 | if (isWatching) {
60 | preferenceDispatch({
61 | type: 'DEL_BANGUMI_WATCHING',
62 | payload: id,
63 | });
64 | } else {
65 | preferenceDispatch({
66 | type: 'ADD_BANGUMI_WATCHING',
67 | payload: id,
68 | });
69 | }
70 | }}
71 | />
72 | );
73 | }
74 |
75 | return (
76 |
77 | {itemNodes.length ? (
78 | itemNodes
79 | ) : (
80 |
81 | {emptyText}
82 |
83 | )}
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/packages/client/utils/bangumiItemUtils.ts:
--------------------------------------------------------------------------------
1 | import { Item } from 'bangumi-list-v3-shared';
2 | import { Weekday } from '../types';
3 | import { isSameQuarter } from 'date-fns';
4 |
5 | export const getBroadcastDate = (item: Item): Date => {
6 | const { begin, broadcast } = item;
7 | if (broadcast) {
8 | return new Date(broadcast.split('/')[1]);
9 | }
10 |
11 | return new Date(begin);
12 | };
13 |
14 | export const newBangumiFilter = (item: Item): boolean => {
15 | return isSameQuarter(new Date(), new Date(item.begin));
16 | };
17 |
18 | export const watchingFilter =
19 | (watchingIds: string[]) =>
20 | (item: Item): boolean => {
21 | return !!item.id && watchingIds.includes(item.id);
22 | };
23 |
24 | export const weekdayFilter =
25 | (currentTab: Weekday) =>
26 | (item: Item): boolean => {
27 | if (currentTab === Weekday.ALL) return true;
28 | const broadcastWeekday = getBroadcastDate(item).getDay();
29 | return currentTab === broadcastWeekday;
30 | };
31 |
32 | export const searchFilter =
33 | (searchText: string) =>
34 | (item: Item): boolean => {
35 | searchText = searchText.toLowerCase();
36 | const itemTitles = [item.title];
37 | for (const titles of Object.values(item.titleTranslate || {})) {
38 | Array.prototype.push.apply(itemTitles, titles);
39 | }
40 | if (item.pinyinTitles) {
41 | Array.prototype.push.apply(itemTitles, item.pinyinTitles);
42 | }
43 |
44 | return itemTitles.some((text) => text.toLowerCase().includes(searchText));
45 | };
46 |
47 | export const itemSortCompare = (first: Item, second: Item): number => {
48 | const firstBroadcastWeekday = getBroadcastDate(first);
49 | const secondBroadcastWeekday = getBroadcastDate(second);
50 | if (firstBroadcastWeekday.getHours() === secondBroadcastWeekday.getHours()) {
51 | if (
52 | firstBroadcastWeekday.getMinutes() === secondBroadcastWeekday.getMinutes()
53 | ) {
54 | return 0;
55 | }
56 |
57 | return firstBroadcastWeekday.getMinutes() <
58 | secondBroadcastWeekday.getMinutes()
59 | ? -1
60 | : 1;
61 | }
62 | return firstBroadcastWeekday.getHours() < secondBroadcastWeekday.getHours()
63 | ? -1
64 | : 1;
65 | };
66 |
67 | export const hoistWatchingItems = (
68 | items: Item[],
69 | watchingIds: string[]
70 | ): Item[] => {
71 | const watching: Item[] = [];
72 | const notWatching: Item[] = [];
73 | for (const item of items) {
74 | if (!item.id) continue;
75 | if (watchingIds.includes(item.id)) {
76 | watching.push(item);
77 | } else {
78 | notWatching.push(item);
79 | }
80 | }
81 | return [...watching, ...notWatching];
82 | };
83 |
--------------------------------------------------------------------------------
/packages/client/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
43 |
--------------------------------------------------------------------------------
/packages/client/components/common/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 | import { SUBMIT_PROBLEM_LINK } from '../../constants/links';
4 | import { useUser, useUserDispatch } from '../../contexts/userContext';
5 | import UserIcon from '../../images/user.svg';
6 | import LogoutIcon from '../../images/logout.svg';
7 | import { logout } from '../../models/user.model';
8 | import Container from './Container';
9 | import styles from './Header.module.css';
10 |
11 | export default function Header(): JSX.Element {
12 | const { isLogin, email: userEmail } = useUser();
13 | const userDispatch = useUserDispatch();
14 |
15 | const handleLogout = async () => {
16 | await logout();
17 | userDispatch({ type: 'LOGOUT' });
18 | window.location.href = '/'; // Use hard reload
19 | };
20 |
21 | return (
22 |
23 |
24 |
25 | 番组放送
26 |
27 |
42 |
73 |
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/packages/server/src/middlewares/validator.middleware.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 | import {
3 | body,
4 | query,
5 | param,
6 | validationResult,
7 | checkSchema,
8 | ValidationChain,
9 | } from 'express-validator';
10 | import { SiteType, BangumiDomain } from 'bangumi-list-v3-shared';
11 |
12 | const SEASON_REGEXP = /^\d{4}q[1234]$/;
13 |
14 | export const signupUserValidationRules = (): ValidationChain[] => [
15 | body('email').isEmail(),
16 | body('password').isLength({ min: 6, max: 72 }).isAscii(),
17 | ];
18 |
19 | export const loginUserValidationRules = (): ValidationChain[] => [
20 | body('email').isEmail(),
21 | body('password').isLength({ min: 3, max: 72 }).isAscii(),
22 | ];
23 |
24 | export const updateUserValidationRules = (): ValidationChain[] => [
25 | body('oldPassword').isLength({ min: 6, max: 72 }).isAscii(),
26 | body('newPassword').isLength({ min: 6, max: 72 }).isAscii(),
27 | ];
28 |
29 | export const bangumiSeasonValidationRules = (): ValidationChain[] => [
30 | query('start').optional().matches(SEASON_REGEXP),
31 | ];
32 |
33 | export const bangumiSiteValidationRules = (): ValidationChain[] => [
34 | query('type').optional().isIn(Object.values(SiteType)),
35 | ];
36 |
37 | export const bangumiArchiveValidationRules = (): ValidationChain[] => [
38 | param('season').matches(SEASON_REGEXP),
39 | ];
40 |
41 | export const commonPreferenceValidationRules = (): ValidationChain[] =>
42 | checkSchema({
43 | newOnly: {
44 | optional: true,
45 | isBoolean: true,
46 | },
47 | bangumiDomain: {
48 | optional: true,
49 | isIn: {
50 | options: [Object.values(BangumiDomain)],
51 | },
52 | },
53 | });
54 |
55 | export const updateBangumiPreferenceValidationRules = (): ValidationChain[] =>
56 | checkSchema({
57 | watching: {
58 | optional: true,
59 | isArray: true,
60 | },
61 | });
62 |
63 | export const validate = (
64 | req: Request,
65 | res: Response,
66 | next: NextFunction
67 | ): void => {
68 | const errors = validationResult(req);
69 | if (errors.isEmpty()) {
70 | next();
71 | return;
72 | }
73 | const extractedErrors: { [key: string]: string }[] = [];
74 | errors.array().map((err) => {
75 | // In express-validator v7, 'param' is now 'path' for field errors
76 | const field: string =
77 | 'path' in err
78 | ? err.path
79 | : 'param' in err
80 | ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
81 | (err as any).param
82 | : 'unknown';
83 | extractedErrors.push({ [field]: err.msg });
84 | });
85 |
86 | res.status(422).json({
87 | errors: extractedErrors,
88 | });
89 | };
90 |
--------------------------------------------------------------------------------
/packages/server/src/models/userPreference.model.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CommonPreference,
3 | VersionedCommonPreference,
4 | BangumiDomain,
5 | MikanDomain,
6 | } from 'bangumi-list-v3-shared';
7 | import prisma from '../prisma/client';
8 |
9 | const DEFAULT_COMMON_PREFERENCE: CommonPreference = {
10 | newOnly: false,
11 | watchingOnly: false,
12 | hoistWatching: false,
13 | bangumiDomain: BangumiDomain.BGM_TV,
14 | mikanDomain: MikanDomain.MIKANANI_ME,
15 | };
16 |
17 | class UserPreferenceModel {
18 | public async getCommon(
19 | userID: string
20 | ): Promise {
21 | if (!userID) throw new Error('User ID missing');
22 | const row = await prisma.preference.findFirst({
23 | where: {
24 | userID,
25 | },
26 | });
27 | if (!row) return null;
28 | try {
29 | return {
30 | ...DEFAULT_COMMON_PREFERENCE,
31 | ...JSON.parse(row.common),
32 | version: row.updatedAt.getTime() || 0,
33 | };
34 | } catch (error) {
35 | console.error(error);
36 | return null;
37 | }
38 | }
39 |
40 | public async updateCommon(
41 | userID: string,
42 | updateData: Partial
43 | ): Promise {
44 | if (!userID) throw new Error('User ID missing');
45 | const previousData = await this.getCommon(userID);
46 | const shouldCreateNew = previousData === null;
47 | const now = new Date();
48 |
49 | if (shouldCreateNew) {
50 | const newData = generateUpdatedData(updateData);
51 | const newDataString = JSON.stringify(newData);
52 | await prisma.preference.create({
53 | data: {
54 | userID,
55 | common: newDataString,
56 | createdAt: now,
57 | updatedAt: now,
58 | },
59 | });
60 | return { ...newData, version: now.getTime() };
61 | } else {
62 | const newData = generateUpdatedData({
63 | ...previousData,
64 | ...updateData,
65 | });
66 | const newDataString = JSON.stringify(newData);
67 | await prisma.preference.update({
68 | where: {
69 | userID,
70 | },
71 | data: {
72 | common: newDataString,
73 | updatedAt: now,
74 | },
75 | });
76 | return { ...newData, version: now.getTime() };
77 | }
78 | }
79 |
80 | public getDefaultPreference(): CommonPreference {
81 | return { ...DEFAULT_COMMON_PREFERENCE };
82 | }
83 | }
84 |
85 | function generateUpdatedData(
86 | newData: Partial
87 | ): CommonPreference {
88 | const updatedData: Record = { ...DEFAULT_COMMON_PREFERENCE };
89 | for (const [key, value] of Object.entries(newData)) {
90 | if (!(key in updatedData)) continue;
91 | updatedData[key] = value;
92 | }
93 | return updatedData as unknown as CommonPreference;
94 | }
95 |
96 | export default new UserPreferenceModel();
97 |
--------------------------------------------------------------------------------
/packages/client/pages/archive/[season].tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useMemo } from 'react';
2 | import { GetServerSideProps } from 'next';
3 | import Top from '../../components/common/Top';
4 | import BangumiItemTable from '../../components/BangumiItemTable';
5 | import { getArchive, getSites } from '../../models/bangumi.model';
6 | import formatSeason from '../../utils/formatSeason';
7 | import { searchFilter, itemSortCompare } from '../../utils/bangumiItemUtils';
8 | import { usePreference } from '../../contexts/preferenceContext';
9 | import type { Item, SiteMeta } from 'bangumi-list-v3-shared';
10 | import { bangumiTemplates, mikanTemplates } from '../../constants/links';
11 | import Container from '../../components/common/Container';
12 | import Head from 'next/head';
13 | import styles from './[season].module.css';
14 |
15 | export default function ArchivePage({
16 | items = [],
17 | siteMeta = {},
18 | topTitle = '',
19 | }: {
20 | items?: Item[];
21 | siteMeta?: SiteMeta;
22 | topTitle?: string;
23 | }): JSX.Element | null {
24 | const [searchText, setSearchText] = useState('');
25 | const isInSearch = !!searchText;
26 | const filteredItems = useMemo(() => {
27 | const filteredItems = items.filter(searchFilter(searchText));
28 | filteredItems.sort(itemSortCompare);
29 | return filteredItems;
30 | }, [items, isInSearch, searchText]);
31 | const handleSearchInput = (text: string) => {
32 | setSearchText(text);
33 | };
34 | const {
35 | common: { bangumiDomain, mikanDomain },
36 | } = usePreference();
37 |
38 | const modifiedSiteMeta = useMemo(() => {
39 | return {
40 | ...siteMeta,
41 | bangumi: {
42 | ...siteMeta.bangumi,
43 | urlTemplate: bangumiTemplates[bangumiDomain],
44 | },
45 | mikan: {
46 | ...siteMeta.mikan,
47 | urlTemplate: mikanTemplates[mikanDomain],
48 | },
49 | };
50 | }, [siteMeta, bangumiDomain]);
51 |
52 | const metaTitle = `${topTitle}番组 | 番组放送`;
53 |
54 | return (
55 | <>
56 |
57 | {metaTitle}
58 |
59 |
60 |
61 |
67 |
68 | >
69 | );
70 | }
71 |
72 | export const getServerSideProps: GetServerSideProps = async function (context) {
73 | const { season } = context.query;
74 | if (typeof season !== 'string') {
75 | return {
76 | props: {
77 | items: [],
78 | topTitle: '',
79 | },
80 | };
81 | }
82 |
83 | const [archiveData, siteMeta] = await Promise.all([
84 | getArchive(season),
85 | getSites(),
86 | ]);
87 |
88 | return {
89 | props: {
90 | items: archiveData.items,
91 | siteMeta,
92 | topTitle: formatSeason(season),
93 | },
94 | };
95 | };
96 |
--------------------------------------------------------------------------------
/packages/client/pages/signup/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { signup } from '../../models/user.model';
3 | import { useRouter } from 'next/router';
4 | import { PASSWORD_MIN_LENGTH } from '../../constants/user';
5 | import Container from '../../components/common/Container';
6 | import Head from 'next/head';
7 | import styles from './index.module.css';
8 |
9 | export default function SignupPage(): JSX.Element {
10 | const router = useRouter();
11 | const [email, setEmail] = useState('');
12 | const [password, setPassword] = useState('');
13 | const [loading, setLoading] = useState(false);
14 | const [showError, setShowError] = useState(false);
15 | const [showSuccess, setShowSuccess] = useState(false);
16 | const handleEmailChange: React.FormEventHandler = (e) => {
17 | setEmail(e.currentTarget.value);
18 | };
19 | const handlePasswordChange: React.FormEventHandler = (
20 | e
21 | ) => {
22 | setPassword(e.currentTarget.value);
23 | };
24 | const handleSubmit: React.FormEventHandler = async (e) => {
25 | e.preventDefault();
26 | setLoading(true);
27 | try {
28 | await signup(email, password);
29 | setShowSuccess(true);
30 | setTimeout(() => {
31 | router.replace('/login');
32 | }, 1500);
33 | } catch (e) {
34 | setShowError(true);
35 | setLoading(false);
36 | }
37 | };
38 |
39 | return (
40 | <>
41 |
42 | 注册 | 番组放送
43 |
44 |
45 | 注册
46 |
88 |
89 | >
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/packages/client/pages/me/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { PASSWORD_MIN_LENGTH } from '../../constants/user';
3 | import { useUser } from '../../contexts/userContext';
4 | import { updateUser } from '../../models/user.model';
5 | import { useRouter } from 'next/router';
6 | import Container from '../../components/common/Container';
7 | import Head from 'next/head';
8 | import styles from './index.module.css';
9 |
10 | export default function LoginPage(): JSX.Element {
11 | const [oldPassword, setOldPassword] = useState('');
12 | const [newPassword, setNewPassword] = useState('');
13 | const [loading, setLoading] = useState(false);
14 | const [showError, setShowError] = useState(false);
15 | const [showSuccess, setShowSuccess] = useState(false);
16 | const { email: userEmail } = useUser();
17 | const router = useRouter();
18 |
19 | const handleOldPasswordChange: React.FormEventHandler = (
20 | e
21 | ) => {
22 | setOldPassword(e.currentTarget.value);
23 | };
24 | const handleNewPasswordChange: React.FormEventHandler = (
25 | e
26 | ) => {
27 | setNewPassword(e.currentTarget.value);
28 | };
29 | const handleSubmit: React.FormEventHandler = async (e) => {
30 | e.preventDefault();
31 | setLoading(true);
32 | try {
33 | await updateUser({
34 | oldPassword,
35 | newPassword,
36 | });
37 | setShowSuccess(true);
38 | router.push('/');
39 | } catch (e) {
40 | setShowError(true);
41 | setLoading(false);
42 | }
43 | };
44 |
45 | return (
46 | <>
47 |
48 | 用户中心 | 番组放送
49 |
50 |
51 | 用户中心
52 |
100 |
101 | >
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/packages/server/src/controllers/user.controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 | import userModel, { UserFull } from '../models/user.model';
3 | import { User } from 'bangumi-list-v3-shared';
4 | import jwt from 'jsonwebtoken';
5 | import tokenModel from '../models/token.model';
6 | import logger from '../logger';
7 |
8 | export async function create(req: Request, res: Response): Promise {
9 | const { email, password } = req.body;
10 |
11 | let isEmailValid = false;
12 | try {
13 | isEmailValid = await userModel.checkEmailValid(email);
14 | } catch (error) {
15 | res.status(500).send({ message: 'Create user failed' });
16 | return;
17 | }
18 | if (!isEmailValid) {
19 | res.status(500).send({ message: 'Email is invalid' });
20 | return;
21 | }
22 |
23 | let id;
24 | try {
25 | id = await userModel.addUser({
26 | email,
27 | password,
28 | });
29 | } catch (error) {
30 | res.status(500).send({ message: 'Create user failed' });
31 | return;
32 | }
33 |
34 | res.status(201).send({ id });
35 | }
36 |
37 | export async function getMe(req: Request, res: Response): Promise {
38 | if (!req.user) {
39 | res.sendStatus(401);
40 | return;
41 | }
42 |
43 | const userID = req.user.id;
44 |
45 | let data: UserFull | null;
46 | try {
47 | data = await userModel.getUser(userID);
48 | } catch (e) {
49 | console.error(e);
50 | res.sendStatus(500);
51 | return;
52 | }
53 |
54 | if (!data) {
55 | res.sendStatus(404);
56 | return;
57 | }
58 |
59 | const user: User = {
60 | email: data.email,
61 | id: data.id,
62 | };
63 |
64 | res.send(user);
65 | }
66 |
67 | export async function login(req: Request, res: Response): Promise {
68 | const { email, password } = req.body;
69 | let user: UserFull | null;
70 | try {
71 | user = await userModel.verifyUserByEmail(email, password);
72 | } catch (e) {
73 | res.sendStatus(401);
74 | return;
75 | }
76 |
77 | if (user === null) {
78 | res.sendStatus(401);
79 | return;
80 | }
81 |
82 | const token = jwt.sign(
83 | { userID: user.id, userRole: user.role },
84 | process.env.JWT_SECRET as string,
85 | { expiresIn: '1y' }
86 | );
87 |
88 | try {
89 | await tokenModel.add(user.id, token);
90 | } catch (error) {
91 | logger.error(error || {}, 'Add token failed');
92 | res.sendStatus(500);
93 | return;
94 | }
95 |
96 | res.status(200).send({ token });
97 | }
98 |
99 | export async function logout(req: Request, res: Response): Promise {
100 | if (!req.user) {
101 | res.sendStatus(401);
102 | return;
103 | }
104 |
105 | try {
106 | await tokenModel.remove(req.user.id, req.user.token);
107 | } catch (error) {
108 | logger.error(error || {}, 'Remove token failed');
109 | }
110 |
111 | res.sendStatus(200);
112 | }
113 |
114 | export async function update(req: Request, res: Response): Promise {
115 | if (!req.user) {
116 | res.sendStatus(401);
117 | return;
118 | }
119 |
120 | const userID = req.user.id;
121 | const { oldPassword, newPassword } = req.body;
122 | let isOldPasswordCorrect = false;
123 | try {
124 | const user = await userModel.verifyUserByID(userID, oldPassword);
125 | if (user) isOldPasswordCorrect = true;
126 | } catch (error) {
127 | console.error(error);
128 | res.sendStatus(500);
129 | return;
130 | }
131 |
132 | if (!isOldPasswordCorrect) {
133 | res.sendStatus(401);
134 | return;
135 | }
136 |
137 | try {
138 | await userModel.changePassword(userID, newPassword);
139 | } catch (error) {
140 | console.error(error);
141 | res.sendStatus(500);
142 | return;
143 | }
144 |
145 | res.sendStatus(200);
146 | }
147 |
--------------------------------------------------------------------------------
/packages/client/contexts/preferenceContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useReducer } from 'react';
2 | import {
3 | VersionedCommonPreference,
4 | VersionedBangumiPreference,
5 | BangumiDomain,
6 | MikanDomain,
7 | } from 'bangumi-list-v3-shared';
8 |
9 | export const PreferenceContext = createContext(null);
10 | export const PreferenceDispatchContext =
11 | createContext | null>(null);
12 |
13 | export interface PreferenceState {
14 | common: {
15 | version: number;
16 | newOnly: boolean;
17 | watchingOnly: boolean;
18 | hoistWatching: boolean;
19 | bangumiDomain: BangumiDomain;
20 | mikanDomain: MikanDomain;
21 | };
22 | bangumi: {
23 | version: number;
24 | watching: string[];
25 | };
26 | }
27 |
28 | const initialState: PreferenceState = {
29 | common: {
30 | version: 0,
31 | newOnly: false,
32 | watchingOnly: false,
33 | hoistWatching: false,
34 | bangumiDomain: BangumiDomain.BANGUMI_TV,
35 | mikanDomain: MikanDomain.MIKANANI_ME,
36 | },
37 | bangumi: {
38 | version: 0,
39 | watching: [],
40 | },
41 | };
42 |
43 | export type PreferenceAction =
44 | | { type: 'SET_COMMON_PREFERENCE'; payload: VersionedCommonPreference }
45 | | { type: 'SET_BANGUMI_PREFERENCE'; payload: VersionedBangumiPreference }
46 | | { type: 'ADD_BANGUMI_WATCHING'; payload: string }
47 | | { type: 'DEL_BANGUMI_WATCHING'; payload: string };
48 |
49 | function preferenceReducer(state: PreferenceState, action: PreferenceAction) {
50 | switch (action.type) {
51 | case 'SET_COMMON_PREFERENCE': {
52 | return {
53 | ...state,
54 | common: {
55 | ...state.common,
56 | ...action.payload,
57 | version: Date.now(),
58 | },
59 | };
60 | }
61 | case 'SET_BANGUMI_PREFERENCE': {
62 | return {
63 | ...state,
64 | bangumi: {
65 | ...state.bangumi,
66 | ...action.payload,
67 | version: Date.now(),
68 | },
69 | };
70 | }
71 | case 'ADD_BANGUMI_WATCHING': {
72 | return {
73 | ...state,
74 | bangumi: {
75 | ...state.bangumi,
76 | watching: [...state.bangumi.watching, action.payload],
77 | version: Date.now(),
78 | },
79 | };
80 | }
81 | case 'DEL_BANGUMI_WATCHING': {
82 | return {
83 | ...state,
84 | bangumi: {
85 | ...state.bangumi,
86 | watching: state.bangumi.watching.filter(
87 | (id) => id !== action.payload
88 | ),
89 | version: Date.now(),
90 | },
91 | };
92 | }
93 | default: {
94 | throw Error('Unknown action');
95 | }
96 | }
97 | }
98 |
99 | export function PreferenceProvider({
100 | children,
101 | }: {
102 | children: React.ReactNode;
103 | }): JSX.Element {
104 | const [state, dispatch] = useReducer(preferenceReducer, initialState);
105 |
106 | return (
107 |
108 |
109 | {children}
110 |
111 |
112 | );
113 | }
114 |
115 | export function usePreference(): PreferenceState {
116 | const context = useContext(PreferenceContext);
117 | if (!context) {
118 | throw new Error('preferenceContext should not be null');
119 | }
120 | return context;
121 | }
122 |
123 | export function usePreferenceDispatch(): React.Dispatch {
124 | const dispatch = useContext(PreferenceDispatchContext);
125 | if (typeof dispatch !== 'function') {
126 | throw new Error('preferenceDispatch not be null');
127 | }
128 | return dispatch;
129 | }
130 |
--------------------------------------------------------------------------------
/packages/client/pages/login/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useUserDispatch } from '../../contexts/userContext';
3 | import { login, getUser } from '../../models/user.model';
4 | import Container from '../../components/common/Container';
5 | import Head from 'next/head';
6 | import styles from './index.module.css';
7 |
8 | export default function LoginPage(): JSX.Element {
9 | const [email, setEmail] = useState('');
10 | const [password, setPassword] = useState('');
11 | const [disableSaveStatus, setDisableSaveStatus] = useState(false);
12 | const [loading, setLoading] = useState(false);
13 | const [showError, setShowError] = useState(false);
14 | const [showSuccess, setShowSuccess] = useState(false);
15 | const userDispatch = useUserDispatch();
16 | const handleEmailChange: React.FormEventHandler = (e) => {
17 | setEmail(e.currentTarget.value);
18 | };
19 | const handlePasswordChange: React.FormEventHandler = (
20 | e
21 | ) => {
22 | setPassword(e.currentTarget.value);
23 | };
24 | const handleDisableSaveStatusChange: React.FormEventHandler<
25 | HTMLInputElement
26 | > = (e) => {
27 | setDisableSaveStatus(e.currentTarget.checked);
28 | };
29 | const handleSubmit: React.FormEventHandler = async (e) => {
30 | e.preventDefault();
31 | setLoading(true);
32 | try {
33 | await login(email, password, !disableSaveStatus);
34 | const { id: loggedInId, email: loggedInEmail } = await getUser();
35 | userDispatch({ type: 'LOGIN', id: loggedInId, email: loggedInEmail });
36 | setShowSuccess(true);
37 | window.location.replace('/'); // Use hard reload
38 | } catch (e) {
39 | setShowError(true);
40 | setLoading(false);
41 | }
42 | };
43 |
44 | return (
45 | <>
46 |
47 | 登录 | 番组放送
48 |
49 |
50 | 登录
51 |
98 |
99 | >
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts:
--------------------------------------------------------------------------------
1 |
2 | /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3 | /* eslint-disable */
4 | // biome-ignore-all lint: generated file
5 | // @ts-nocheck
6 | /*
7 | * WARNING: This is an internal file that is subject to change!
8 | *
9 | * 🛑 Under no circumstances should you import this file directly! 🛑
10 | *
11 | * All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
12 | * While this enables partial backward compatibility, it is not part of the stable public API.
13 | *
14 | * If you are looking for your Models, Enums, and Input Types, please import them from the respective
15 | * model files in the `model` directory!
16 | */
17 |
18 | import * as runtime from "@prisma/client/runtime/index-browser"
19 |
20 | export type * from '../models'
21 | export type * from './prismaNamespace'
22 |
23 | export const Decimal = runtime.Decimal
24 |
25 |
26 | export const NullTypes = {
27 | DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
28 | JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
29 | AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
30 | }
31 | /**
32 | * Helper for filtering JSON entries that have `null` on the database (empty on the db)
33 | *
34 | * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
35 | */
36 | export const DbNull = runtime.DbNull
37 |
38 | /**
39 | * Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
40 | *
41 | * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
42 | */
43 | export const JsonNull = runtime.JsonNull
44 |
45 | /**
46 | * Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
47 | *
48 | * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
49 | */
50 | export const AnyNull = runtime.AnyNull
51 |
52 |
53 | export const ModelName = {
54 | BangumiPreference: 'BangumiPreference',
55 | Preference: 'Preference',
56 | Token: 'Token',
57 | User: 'User'
58 | } as const
59 |
60 | export type ModelName = (typeof ModelName)[keyof typeof ModelName]
61 |
62 | /*
63 | * Enums
64 | */
65 |
66 | export const TransactionIsolationLevel = {
67 | Serializable: 'Serializable'
68 | } as const
69 |
70 | export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
71 |
72 |
73 | export const BangumiPreferenceScalarFieldEnum = {
74 | userID: 'userID',
75 | watching: 'watching',
76 | createdAt: 'createdAt',
77 | updatedAt: 'updatedAt'
78 | } as const
79 |
80 | export type BangumiPreferenceScalarFieldEnum = (typeof BangumiPreferenceScalarFieldEnum)[keyof typeof BangumiPreferenceScalarFieldEnum]
81 |
82 |
83 | export const PreferenceScalarFieldEnum = {
84 | userID: 'userID',
85 | common: 'common',
86 | createdAt: 'createdAt',
87 | updatedAt: 'updatedAt'
88 | } as const
89 |
90 | export type PreferenceScalarFieldEnum = (typeof PreferenceScalarFieldEnum)[keyof typeof PreferenceScalarFieldEnum]
91 |
92 |
93 | export const TokenScalarFieldEnum = {
94 | userID: 'userID',
95 | token: 'token'
96 | } as const
97 |
98 | export type TokenScalarFieldEnum = (typeof TokenScalarFieldEnum)[keyof typeof TokenScalarFieldEnum]
99 |
100 |
101 | export const UserScalarFieldEnum = {
102 | id: 'id',
103 | email: 'email',
104 | password: 'password',
105 | role: 'role',
106 | createdAt: 'createdAt',
107 | updatedAt: 'updatedAt'
108 | } as const
109 |
110 | export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
111 |
112 |
113 | export const SortOrder = {
114 | asc: 'asc',
115 | desc: 'desc'
116 | } as const
117 |
118 | export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
119 |
120 |
121 | export const NullsOrder = {
122 | first: 'first',
123 | last: 'last'
124 | } as const
125 |
126 | export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
127 |
128 |
--------------------------------------------------------------------------------
/packages/server/src/models/user.model.ts:
--------------------------------------------------------------------------------
1 | import bcrypt from 'bcrypt';
2 | import crypto from 'crypto';
3 | import { User } from 'bangumi-list-v3-shared';
4 | import tokenModel from './token.model';
5 | import { User as UserRow } from '../prisma/generated/prisma/client';
6 | import prisma from '../prisma/client';
7 |
8 | const saltRounds = 10;
9 |
10 | export enum UserRole {
11 | REGULAR = 0,
12 | ADMIN = 999,
13 | }
14 | export interface NewUserData {
15 | email: string;
16 | password: string;
17 | }
18 |
19 | export interface UpdateUserData {
20 | password?: string;
21 | }
22 | export interface UserFull extends User {
23 | password: string;
24 | role: UserRole;
25 | createdAt: number;
26 | updatedAt: number;
27 | }
28 |
29 | class UserModel {
30 | public async addUser(
31 | newUserData: NewUserData,
32 | role: UserRole = UserRole.REGULAR
33 | ): Promise {
34 | const { email, password } = newUserData;
35 | const passwordHash = await bcrypt.hash(password, saltRounds);
36 | const now = new Date();
37 | const userID = crypto.randomBytes(16).toString('hex');
38 | try {
39 | await prisma.user.create({
40 | data: {
41 | id: userID,
42 | email,
43 | password: passwordHash,
44 | role,
45 | createdAt: now,
46 | updatedAt: now,
47 | },
48 | });
49 | } catch (error) {
50 | console.error(error);
51 | throw error;
52 | }
53 | return userID;
54 | }
55 |
56 | public async getUser(userID: string): Promise {
57 | const row = await prisma.user.findUnique({
58 | where: {
59 | id: userID,
60 | },
61 | });
62 | if (!row) return null;
63 |
64 | return {
65 | id: row.id,
66 | role: row.role,
67 | email: row.email,
68 | password: row.password,
69 | createdAt: row.createdAt.getTime(),
70 | updatedAt: row.updatedAt.getTime(),
71 | };
72 | }
73 |
74 | public async checkEmailValid(email: string): Promise {
75 | if (!email) return false;
76 | const row = await prisma.user.findFirst({
77 | where: {
78 | email,
79 | },
80 | });
81 | if (row) return false;
82 | return true;
83 | }
84 |
85 | public async verifyUserByEmail(
86 | email: string,
87 | password: string
88 | ): Promise {
89 | let row: UserRow | null;
90 | try {
91 | row = await prisma.user.findFirst({
92 | where: {
93 | email,
94 | },
95 | });
96 | } catch (error) {
97 | console.error(error);
98 | return null;
99 | }
100 | if (!row) return null;
101 | const isSuccess = await bcrypt.compare(password, row.password || '');
102 | if (!isSuccess) return null;
103 | return {
104 | id: row.id,
105 | role: row.role,
106 | email: row.email,
107 | password: row.password,
108 | createdAt: row.createdAt.getTime(),
109 | updatedAt: row.updatedAt.getTime(),
110 | };
111 | }
112 |
113 | public async verifyUserByID(
114 | id: string,
115 | password: string
116 | ): Promise {
117 | let row: UserRow | null;
118 | try {
119 | row = await prisma.user.findFirst({
120 | where: {
121 | id,
122 | },
123 | });
124 | } catch (error) {
125 | console.error(error);
126 | return null;
127 | }
128 | if (!row) return null;
129 | const isSuccess = await bcrypt.compare(password, row.password || '');
130 | if (!isSuccess) return null;
131 | return {
132 | id: row.id,
133 | role: row.role,
134 | email: row.email,
135 | password: row.password,
136 | createdAt: row.createdAt.getTime(),
137 | updatedAt: row.updatedAt.getTime(),
138 | };
139 | }
140 |
141 | public async changePassword(
142 | userID: string,
143 | newPassword: string
144 | ): Promise {
145 | const now = new Date();
146 | const passwordHash = await bcrypt.hash(newPassword, saltRounds);
147 | await prisma.user.update({
148 | where: {
149 | id: userID,
150 | },
151 | data: {
152 | password: passwordHash,
153 | updatedAt: now,
154 | },
155 | });
156 |
157 | await tokenModel.clearTokens(userID);
158 | }
159 | }
160 |
161 | export default new UserModel();
162 |
--------------------------------------------------------------------------------
/packages/client/components/BangumiItem.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | background: #fff;
3 | box-shadow: 4px 12px 40px 6px rgba(0, 0, 0, 0.09);
4 | transform: translateY(0);
5 | perspective: 3200px;
6 | padding: 24px;
7 | border-radius: 12px;
8 | }
9 |
10 | @media (max-width: 640px) {
11 | .root {
12 | padding: 12px;
13 | padding-bottom: 6px;
14 | }
15 | }
16 |
17 | .header {
18 | display: block;
19 | position: relative;
20 | }
21 |
22 | @media (max-width: 640px) {
23 | .header {
24 | display: flex;
25 | }
26 | }
27 |
28 | .newMark {
29 | color: #6e6e73;
30 | border: 1px solid #6e6e73;
31 | border-radius: 18px;
32 | padding: 2px 6px;
33 | font-size: 10px;
34 | line-height: 1.2;
35 | height: 20px;
36 | display: inline-block;
37 | position: relative;
38 | top: -2px;
39 | flex-grow: 0;
40 | flex-shrink: 0;
41 | }
42 |
43 | .favButton {
44 | transition: opacity 0.3s ease;
45 | position: absolute;
46 | right: 0;
47 | top: 4px;
48 | }
49 |
50 | .favButton:hover,
51 | .favButton:active {
52 | opacity: var(--active-opacity);
53 | }
54 |
55 | .favIcon {
56 | width: 20px;
57 | height: 20px;
58 | fill: #6e6e73;
59 | }
60 |
61 | @media (max-width: 640px) {
62 | .favIcon {
63 | top: 2px;
64 | }
65 | }
66 |
67 | .titleBox {
68 | display: block;
69 | line-height: 1.4;
70 | padding-right: 32px;
71 | }
72 |
73 | @media (max-width: 640px) {
74 | .titleBox {
75 | padding-right: 24px;
76 | word-break: break-all;
77 | }
78 | }
79 |
80 | .title {
81 | font-size: 20px;
82 | font-weight: 700;
83 | display: inline;
84 | margin-right: 6px;
85 | }
86 |
87 | .subTitle {
88 | font-size: 14px;
89 | color: #6e6e73;
90 | margin-right: 6px;
91 | }
92 |
93 | @media (max-width: 640px) {
94 | .subTitle {
95 | margin-left: 0;
96 | }
97 | }
98 |
99 | .inner {
100 | margin-top: 12px;
101 | display: grid;
102 | grid-template-columns: 1fr 1fr 24px 2fr;
103 | grid-template-rows: 1fr auto;
104 | grid-template-areas:
105 | "jpTime cnTime divider onair"
106 | "start info divider resource";
107 | row-gap: 6px;
108 | }
109 |
110 | .inner:after {
111 | content: " ";
112 | display: block;
113 | grid-area: divider;
114 | width: 1px;
115 | height: 100%;
116 | background: #d2d2d7;
117 | }
118 |
119 | @media (max-width: 665px) {
120 | .inner {
121 | grid-template-columns: repeat(2, 1fr);
122 | grid-template-rows: repeat(3, 1fr);
123 | grid-template-areas:
124 | "jpTime cnTime"
125 | "start info"
126 | "resource resource"
127 | "onair onair";
128 | }
129 | }
130 |
131 | @media (max-width: 375px) {
132 | .inner {
133 | grid-template-columns: repeat(2, 1fr);
134 | grid-template-rows: repeat(4, 1fr);
135 | grid-template-areas:
136 | "jpTime cnTime"
137 | "start start"
138 | "info info"
139 | "resource resource"
140 | "onair onair";
141 | }
142 | }
143 |
144 | @media (max-width: 330px) {
145 | .inner {
146 | grid-template-columns: 1fr;
147 | grid-template-rows: repeat(5, 1fr);
148 | grid-template-areas:
149 | "jpTime"
150 | "cnTime"
151 | "start"
152 | "info"
153 | "resource"
154 | "onair";
155 | }
156 | }
157 |
158 | .meta {
159 | display: flex;
160 | flex-wrap: wrap;
161 | flex-basis: 50%;
162 | flex-grow: 0;
163 | flex-shrink: 0;
164 | }
165 |
166 | .inner > div {
167 | display: flex;
168 | line-height: 1.2;
169 | }
170 |
171 | .jpTime {
172 | grid-area: jpTime;
173 | }
174 |
175 | .cnTime {
176 | grid-area: cnTime;
177 | }
178 |
179 | .start {
180 | grid-area: start;
181 | }
182 |
183 | .resource {
184 | grid-area: resource;
185 | }
186 |
187 | .onair {
188 | grid-area: onair;
189 | }
190 |
191 | .info {
192 | grid-area: info;
193 | }
194 |
195 | .inner dt {
196 | flex-grow: 0;
197 | flex-shrink: 0;
198 | margin-right: 6px;
199 | line-height: 1.4;
200 | }
201 |
202 | .inner dt::after {
203 | content: ":";
204 | }
205 |
206 | .inner dd {
207 | line-height: 1.4;
208 | }
209 |
210 | .inner dd ul {
211 | display: flex;
212 | flex-wrap: wrap;
213 | }
214 |
215 | .inner dd ul li {
216 | display: flex;
217 | align-items: center;
218 | margin-right: 6px;
219 | }
220 |
221 | .inner dd ul li:last-child {
222 | margin-right: 0;
223 | }
224 |
225 | .inner dd ul li:after {
226 | content: "/";
227 | font-size: 18px;
228 | margin-left: 6px;
229 | line-height: 1;
230 | font-family: "Arial";
231 | }
232 |
233 | .inner dd ul li:last-child:after {
234 | content: none;
235 | }
236 |
--------------------------------------------------------------------------------
/packages/server/API.md:
--------------------------------------------------------------------------------
1 | # API
2 |
3 | ## Bangumi
4 |
5 | ### POST /api/v1/bangumi/update
6 |
7 | ### GET /api/v1/bangumi/season
8 |
9 | ```
10 | ?start=2013q4
11 | ```
12 |
13 | ```json
14 | {
15 | "version": 1633008766334,
16 | "items": [
17 | "2021q1",
18 | "2021q2"
19 | ]
20 | }
21 | ```
22 |
23 | ### GET /api/v1/bangumi/site
24 |
25 | ```
26 | ?type=onair
27 | ```
28 |
29 |
30 | ```json
31 | {
32 | "bangumi": {
33 | "title": "番组计划",
34 | "urlTemplate": "https://bangumi.tv/subject/{{id}}",
35 | "type": "info"
36 | },
37 | "acfun": {
38 | "title": "AcFun",
39 | "urlTemplate": "https://www.acfun.cn/bangumi/aa{{id}}",
40 | "regions": ["CN"],
41 | "type": "onair"
42 | }
43 | }
44 | ```
45 |
46 | ### GET /api/v1/bangumi/archive/:season
47 |
48 | * `:season`: 2021q1, 2020q2...
49 |
50 | ```json
51 | {
52 | "id": "ba98fe6f3ebf0d1f5cfd1c2249c2b229",
53 | "title": "ぐんまちゃん",
54 | "titleTranslate": {
55 | "zh-Hans": [
56 | "群马酱"
57 | ]
58 | },
59 | "type": "tv",
60 | "lang": "ja",
61 | "officialSite": "https://gunmachan-official.jp/animation/",
62 | "begin": "2021-10-02T23:00:00.000Z",
63 | "broadcast": "R/2021-10-02T23:00:00.000Z/P7D",
64 | "end": "",
65 | "comment": "",
66 | "sites": [
67 | {
68 | "site": "bangumi",
69 | "id": "341168",
70 | "url": "https://bgm.tv/subject/297954"
71 | },
72 | {
73 | "site": "gamer",
74 | "id": "112046",
75 | "begin": "2021-04-10T18:38:00.000Z",
76 | "broadcast": "R/2021-04-10T18:38:00.000Z/P7D"
77 | },
78 | {
79 | "site": "muse_hk",
80 | "id": "PLuxqoToY7Uch24ToiTn-Yl1UyX7r-RFzB",
81 | "begin": "2021-04-10T18:38:00.000Z",
82 | "broadcast": "R/2021-04-10T18:38:00.000Z/P7D"
83 | }
84 | ]
85 | }
86 | ```
87 |
88 | ### GET /api/v1/bangumi/onair
89 |
90 | ```json
91 | {
92 | "id": "ba98fe6f3ebf0d1f5cfd1c2249c2b229",
93 | "title": "ぐんまちゃん",
94 | "titleTranslate": {
95 | "zh-Hans": [
96 | "群马酱"
97 | ]
98 | },
99 | "type": "tv",
100 | "lang": "ja",
101 | "officialSite": "https://gunmachan-official.jp/animation/",
102 | "begin": "2021-10-02T23:00:00.000Z",
103 | "broadcast": "R/2021-10-02T23:00:00.000Z/P7D",
104 | "end": "",
105 | "comment": "",
106 | "sites": [
107 | {
108 | "site": "bangumi",
109 | "id": "341168",
110 | "url": "https://bgm.tv/subject/297954"
111 | },
112 | {
113 | "site": "gamer",
114 | "id": "112046",
115 | "begin": "2021-04-10T18:38:00.000Z",
116 | "broadcast": "R/2021-04-10T18:38:00.000Z/P7D"
117 | },
118 | {
119 | "site": "muse_hk",
120 | "id": "PLuxqoToY7Uch24ToiTn-Yl1UyX7r-RFzB",
121 | "begin": "2021-04-10T18:38:00.000Z",
122 | "broadcast": "R/2021-04-10T18:38:00.000Z/P7D"
123 | }
124 | ]
125 | }
126 | ```
127 |
128 | ## User
129 |
130 | ### POST /api/v1/user/signup
131 |
132 | ```json
133 | {
134 | "email": "mail@example.com",
135 | "password": "passowrd"
136 | }
137 | ```
138 |
139 | ### POST /api/v1/user/login
140 |
141 | ```json
142 | {
143 | "email": "mail@example.com",
144 | "password": "passowrd"
145 | }
146 | ```
147 |
148 | ```json
149 | {
150 | "token": "xxx"
151 | }
152 | ```
153 |
154 | ### POST /api/v1/user/logout
155 |
156 | ### GET /api/v1/user/me
157 |
158 | ```json
159 | {
160 | "id": "c6943395537863ad80b325882e7993e7",
161 | "email": "mail@example.com"
162 | }
163 | ```
164 |
165 | ### PATCH /api/v1/user/me
166 |
167 | ```json
168 | {
169 | "oldPassword": "xxx",
170 | "newPassword": "xxx"
171 | }
172 | ```
173 |
174 | ## Preference
175 |
176 | ### GET /api/v1/preference/common
177 |
178 | ```json
179 | {
180 | "newOnly": false,
181 | "autoSwitch": false,
182 | "newTab": false,
183 | "japaneseTitle": false,
184 | "watchingOnly": false,
185 | "nextDay": 24,
186 | "bangumiDomain": "bangumi.tv",
187 | "hiddenSite": [
188 | "acfun"
189 | ]
190 | }
191 | ```
192 |
193 | ### PATCH /api/v1/preference/common
194 |
195 | ```json
196 | {
197 | "newOnly": false
198 | }
199 | ```
200 |
201 | ```json
202 | {
203 | "hiddenSite": [
204 | "acfun"
205 | ]
206 | }
207 | ```
208 |
209 | ### GET /api/v1/preference/bangumi
210 |
211 | ```
212 | ?season=2013q4
213 | ```
214 |
215 | ```json
216 | {
217 | "watching": [
218 | 111,
219 | 222
220 | ],
221 | "hidden": [
222 | 111,
223 | 222
224 | ]
225 | }
226 | ```
227 |
228 | ### PATCH /api/v1/preference/bangumi
229 |
230 | ```
231 | ?season=2013q4
232 | ```
233 |
234 | ```json
235 | {
236 | "watching": [
237 | 111
238 | ]
239 | }
240 | ```
241 |
--------------------------------------------------------------------------------
/packages/client/components/BangumiItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 | import type { Item, SiteMeta } from 'bangumi-list-v3-shared';
4 | import { SiteType } from 'bangumi-list-v3-shared';
5 | import getBroadcastTimeString from '../utils/getBroadcastTimeString';
6 | import { get } from 'lodash';
7 | import { format, isSameQuarter } from 'date-fns';
8 | import BangumiLinkItem from './BangumiLinkItem';
9 | import FavIconEmpty from '../images/favorite-empty.svg';
10 | import FavIconFull from '../images/favorite-full.svg';
11 | import styles from './BangumiItem.module.css';
12 |
13 | interface Props {
14 | className?: string;
15 | item: Item;
16 | siteMeta?: SiteMeta;
17 | isArchive?: boolean;
18 | isWatching?: boolean;
19 | onWatchingClick?: () => void;
20 | }
21 |
22 | export default function BangumiItem(props: Props): JSX.Element {
23 | const {
24 | className,
25 | item,
26 | siteMeta = {},
27 | isArchive = false,
28 | isWatching = false,
29 | onWatchingClick,
30 | } = props;
31 | const rootClassName = classNames(className, styles.root);
32 | const broadcastTimeString = getBroadcastTimeString(item, siteMeta);
33 | const titleCN = get(item, 'titleTranslate.zh-Hans[0]', '');
34 | const nowDate = new Date();
35 | const beginDate = new Date(item.begin);
36 | const beginString = format(beginDate, 'yyyy-MM-dd');
37 | const isNew = isSameQuarter(nowDate, beginDate);
38 | const infoSites = [];
39 | const onairSites = [];
40 | const resourceSites = [];
41 | for (const site of item.sites) {
42 | if (!siteMeta[site.site]) continue;
43 | const node = (
44 |
45 |
46 |
47 | );
48 | switch (siteMeta[site.site].type) {
49 | case SiteType.INFO:
50 | infoSites.push(node);
51 | break;
52 | case SiteType.RESOURCE:
53 | resourceSites.push(node);
54 | break;
55 | case SiteType.ONAIR:
56 | onairSites.push(node);
57 | break;
58 | default:
59 | continue;
60 | }
61 | }
62 | const handleWatchingClick = () => {
63 | onWatchingClick && onWatchingClick();
64 | };
65 |
66 | return (
67 |
68 |
94 |
95 |
96 |
- 日本
97 | - {broadcastTimeString.jp || '暂无'}
98 |
99 |
100 |
- 大陆
101 | - {broadcastTimeString.cn || '暂无'}
102 |
103 |
104 |
- 开播
105 | - {beginString}
106 |
107 |
108 |
- 信息
109 |
-
110 |
111 | {item.officialSite ? (
112 | -
113 |
114 | 官网
115 |
116 |
117 | ) : null}
118 | {infoSites}
119 |
120 |
121 |
122 |
123 |
- 配信
124 |
- {onairSites.length ? : '暂无'}
125 |
126 |
127 |
- 下载
128 |
- {resourceSites.length ? : '暂无'}
129 |
130 |
131 |
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/packages/client/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import type { Weekday } from '../types';
2 | import React, { useEffect, useState, useMemo } from 'react';
3 | import Top from '../components/common/Top';
4 | import BangumiItemTable from '../components/BangumiItemTable';
5 | import WeekdayTab from '../components/WeekdayTab';
6 | import {
7 | newBangumiFilter,
8 | watchingFilter,
9 | weekdayFilter,
10 | searchFilter,
11 | itemSortCompare,
12 | hoistWatchingItems,
13 | } from '../utils/bangumiItemUtils';
14 | import { usePreference } from '../contexts/preferenceContext';
15 | import Link from 'next/link';
16 | import { getOnair, getSites } from '../models/bangumi.model';
17 | import { bangumiTemplates, mikanTemplates } from '../constants/links';
18 | import Container from '../components/common/Container';
19 | import Head from 'next/head';
20 | import useSWR from 'swr';
21 | import styles from './index.module.css';
22 |
23 | export default function OnAirPage(): JSX.Element {
24 | const {
25 | data: onairData,
26 | error: onairError,
27 | isLoading: onairIsLoading,
28 | } = useSWR('bangumi/onair', () => getOnair());
29 | const {
30 | data: siteData,
31 | error: siteError,
32 | isLoading: siteIsLoading,
33 | } = useSWR('bangumi/site', () => getSites());
34 | const [currentTab, setCurrentTab] = useState(new Date().getDay());
35 | const [searchText, setSearchText] = useState('');
36 | const {
37 | common: {
38 | newOnly,
39 | watchingOnly,
40 | hoistWatching,
41 | bangumiDomain,
42 | mikanDomain,
43 | },
44 | bangumi: { watching },
45 | } = usePreference();
46 | const [hoistWatchingIds, setHoistWatchingIds] = useState(
47 | null
48 | );
49 | const isInSearch = !!searchText;
50 | const filteredItems = useMemo(() => {
51 | const items = onairData?.items || [];
52 | if (!items.length) return [];
53 | let filteredItems = [];
54 | if (isInSearch) {
55 | filteredItems = items.filter(searchFilter(searchText));
56 | } else {
57 | filteredItems = items.filter(weekdayFilter(currentTab));
58 | if (watchingOnly) {
59 | filteredItems = filteredItems.filter(watchingFilter(watching));
60 | } else if (newOnly) {
61 | filteredItems = filteredItems.filter(newBangumiFilter);
62 | }
63 | }
64 | filteredItems.sort(itemSortCompare);
65 | if (hoistWatching && hoistWatchingIds) {
66 | filteredItems = hoistWatchingItems(filteredItems, hoistWatchingIds);
67 | }
68 | return filteredItems;
69 | }, [
70 | onairData,
71 | isInSearch,
72 | searchText,
73 | currentTab,
74 | watching,
75 | watchingOnly,
76 | newOnly,
77 | hoistWatching,
78 | hoistWatchingIds,
79 | ]);
80 | const modifiedSiteMeta = useMemo(() => {
81 | if (!siteData) return {};
82 |
83 | return {
84 | ...siteData,
85 | bangumi: {
86 | ...siteData.bangumi,
87 | urlTemplate: bangumiTemplates[bangumiDomain],
88 | },
89 | mikan: {
90 | ...siteData.mikan,
91 | urlTemplate: mikanTemplates[mikanDomain],
92 | },
93 | };
94 | }, [siteData, bangumiDomain]);
95 |
96 | const handleTabClick = (tab: Weekday) => {
97 | setCurrentTab(tab);
98 | };
99 | const handleSearchInput = (text: string) => {
100 | setSearchText(text);
101 | };
102 |
103 | useEffect(() => {
104 | if (hoistWatching) {
105 | setHoistWatchingIds(watching);
106 | } else {
107 | setHoistWatchingIds([]);
108 | }
109 | }, [currentTab, hoistWatching]);
110 |
111 | let content = null;
112 | if (!!onairError || !!siteError) {
113 | content = 获取数据失败,请稍后重试
;
114 | } else if (onairIsLoading || siteIsLoading) {
115 | content = 加载中……
;
116 | } else {
117 | content = (
118 |
123 | );
124 | }
125 |
126 | return (
127 | <>
128 |
129 | 每日放送 | 番组放送
130 |
131 |
132 |
133 |
134 |
139 |
140 | 设置
141 |
142 |
143 | {content}
144 |
145 | >
146 | );
147 | }
148 |
--------------------------------------------------------------------------------
/packages/server/src/models/bangumi.model.ts:
--------------------------------------------------------------------------------
1 | import { stat } from 'fs/promises';
2 | import fs, { constants } from 'fs';
3 | import fse from 'fs-extra';
4 | import path from 'path';
5 | import {
6 | BangumiSite,
7 | Data,
8 | Item,
9 | SiteItem,
10 | SiteType,
11 | } from 'bangumi-list-v3-shared';
12 | import moment from 'moment';
13 | import axios, { AxiosResponse } from 'axios';
14 | import { Stream } from 'stream';
15 | import md5 from 'md5';
16 | import { DATA_DIR, DATA_FILE } from '../config';
17 | import { flatten } from 'lodash';
18 | import pinyin from 'pinyin';
19 |
20 | export interface SiteMap {
21 | [SiteType.INFO]?: {
22 | [key: string]: SiteItem;
23 | };
24 | [SiteType.ONAIR]?: {
25 | [key: string]: SiteItem;
26 | };
27 | [SiteType.RESOURCE]?: {
28 | [key: string]: SiteItem;
29 | };
30 | }
31 |
32 | class BangumiModel {
33 | public seasons: string[] = [];
34 | public seasonIds: { [key: string]: string[] } = {};
35 | public noEndDateIds: string[] = [];
36 | public itemEntities: { [key: string]: Item } = {};
37 | public siteMap: SiteMap = {};
38 | public data?: Data;
39 | public version = 0;
40 |
41 | private dataPath: string;
42 | private dataFolderPath: string;
43 | private dataURL =
44 | 'https://raw.githubusercontent.com/bangumi-data/bangumi-data/master/dist/data.json';
45 |
46 | constructor() {
47 | this.dataFolderPath = DATA_DIR;
48 | this.dataPath = path.resolve(DATA_DIR, DATA_FILE);
49 | }
50 |
51 | get isLoaded(): boolean {
52 | return !!this.version;
53 | }
54 |
55 | public async update(force = true) {
56 | const newDataPath = this.dataPath + `.${Date.now()}`;
57 | let skip = false;
58 | await fse.ensureDir(this.dataFolderPath);
59 | if (!force) {
60 | try {
61 | await fse.access(this.dataPath, constants.R_OK);
62 | skip = true;
63 | } catch (e) {
64 | // ignore
65 | }
66 | }
67 |
68 | if (!skip) {
69 | const resp: AxiosResponse = await axios({
70 | url: this.dataURL,
71 | method: 'GET',
72 | responseType: 'stream',
73 | });
74 | await new Promise((resolve) => {
75 | resp.data.on('end', async () => {
76 | await fse.rename(newDataPath, this.dataPath);
77 | resolve(undefined);
78 | });
79 | resp.data.pipe(fs.createWriteStream(newDataPath));
80 | });
81 | }
82 |
83 | await this.read();
84 | this.process();
85 | }
86 |
87 | private async read() {
88 | const statRes = await stat(this.dataPath);
89 | const newData = await fse.readJSON(this.dataPath);
90 | this.version = Math.floor(statRes.mtimeMs);
91 | this.data = { version: this.version, ...newData };
92 | }
93 |
94 | private process() {
95 | if (!this.data) return;
96 | const { items, siteMeta } = this.data;
97 |
98 | const seasons: Set = new Set();
99 | const seasonIds: { [key: string]: string[] } = {};
100 | const noEndDateIds: string[] = [];
101 | const itemEntities: { [key: string]: Item } = {};
102 | for (const item of items) {
103 | const { begin, end } = item;
104 | const beginDate = moment(begin);
105 | const season = beginDate.format('YYYY[q]Q');
106 | seasons.add(season);
107 | if (!seasonIds[season]) {
108 | seasonIds[season] = [];
109 | }
110 | const id = generateItemID(item);
111 | item.id = generateItemID(item);
112 | if (!end) {
113 | noEndDateIds.push(id);
114 | }
115 | seasonIds[season].push(id);
116 | item.sites.sort(siteSortCompare);
117 | generatePinyinTitltes(item);
118 | itemEntities[id] = item;
119 | }
120 | this.seasons = Array.from(seasons);
121 | this.seasonIds = seasonIds;
122 | this.noEndDateIds = noEndDateIds;
123 | this.itemEntities = itemEntities;
124 |
125 | // Sites
126 | const siteMap: SiteMap = {};
127 | for (const [siteName, site] of Object.entries(siteMeta)) {
128 | const { type } = site;
129 | if (!siteMap[type]) {
130 | siteMap[type] = {};
131 | }
132 | (siteMap[type] || {})[siteName] = site;
133 | }
134 | this.siteMap = siteMap;
135 | }
136 | }
137 |
138 | function generateItemID(item: Item): string {
139 | const { title, begin } = item;
140 | const beginDate = moment(begin);
141 | const idString = `${beginDate.format('YYYY-MM')}${title}`;
142 | return md5(idString);
143 | }
144 |
145 | function siteSortCompare(first: BangumiSite, second: BangumiSite): number {
146 | return first.site < second.site ? -1 : 1;
147 | }
148 |
149 | function generatePinyinTitltes(item: Item) {
150 | if (!item.titleTranslate || !item.titleTranslate['zh-Hans']) return;
151 | const pinyinTitles = [];
152 | for (const title of item.titleTranslate['zh-Hans']) {
153 | pinyinTitles.push(
154 | flatten(
155 | pinyin(title, {
156 | style: pinyin.STYLE_NORMAL,
157 | })
158 | ).join(''),
159 | flatten(
160 | pinyin(title, {
161 | style: pinyin.STYLE_FIRST_LETTER,
162 | })
163 | ).join('')
164 | );
165 | }
166 | item.pinyinTitles = pinyinTitles;
167 | }
168 |
169 | export default new BangumiModel();
170 |
--------------------------------------------------------------------------------
/packages/client/pages/config/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FormEventHandler, useState, useEffect } from 'react';
2 | import { BangumiDomain, MikanDomain } from 'bangumi-list-v3-shared';
3 | import {
4 | usePreference,
5 | usePreferenceDispatch,
6 | } from '../../contexts/preferenceContext';
7 | import { useUser } from '../../contexts/userContext';
8 | import { updateCommonPreference } from '../../models/preference.model';
9 | import { updateCommonPreferenceLocal } from '../../models/preferenceLocal.model';
10 | import { useRouter } from 'next/router';
11 | import Container from '../../components/common/Container';
12 | import Head from 'next/head';
13 | import styles from './index.module.css';
14 |
15 | const bangumiDomainOptions = [
16 | BangumiDomain.BANGUMI_TV,
17 | BangumiDomain.BGM_TV,
18 | BangumiDomain.CHII_IN,
19 | ];
20 |
21 | const mikanDomainOptions = [MikanDomain.MIKANANI_ME, MikanDomain.MIKANIME_TV];
22 |
23 | export default function ConfigPage(): JSX.Element | null {
24 | const [newOnly, setNewOnly] = useState(false);
25 | const [watchingOnly, setWatchingOnly] = useState(false);
26 | const [hoistWatching, setHoistWatching] = useState(false);
27 | const [bangumiDomain, setBangumiDomain] = useState(
28 | BangumiDomain.BANGUMI_TV
29 | );
30 | const [mikanDomain, setMikanDomain] = useState(
31 | MikanDomain.MIKANANI_ME
32 | );
33 | const preferenceDispatch = usePreferenceDispatch();
34 | const { common: commonPreference } = usePreference();
35 | const { isLogin } = useUser();
36 | const router = useRouter();
37 | useEffect(() => {
38 | setNewOnly(commonPreference.newOnly);
39 | setWatchingOnly(commonPreference.watchingOnly);
40 | setHoistWatching(commonPreference.hoistWatching);
41 | setBangumiDomain(commonPreference.bangumiDomain);
42 | setMikanDomain(commonPreference.mikanDomain);
43 | }, [commonPreference]);
44 |
45 | const handleNewOnlyChange: React.FormEventHandler = (e) => {
46 | setNewOnly(e.currentTarget.checked);
47 | };
48 | const handleWatchingOnlyChange: React.FormEventHandler = (
49 | e
50 | ) => {
51 | setWatchingOnly(e.currentTarget.checked);
52 | };
53 | const handleHoistWatchingChange: React.FormEventHandler = (
54 | e
55 | ) => {
56 | setHoistWatching(e.currentTarget.checked);
57 | };
58 | const handleBangumiDomainChange: FormEventHandler = (
59 | e
60 | ) => {
61 | setBangumiDomain(e.currentTarget.value as BangumiDomain);
62 | };
63 | const handleMikanDomainChange: FormEventHandler = (e) => {
64 | setMikanDomain(e.currentTarget.value as MikanDomain);
65 | };
66 | const handleFormSubmit: React.FormEventHandler = (e) => {
67 | e.preventDefault();
68 | const newPreference = {
69 | newOnly,
70 | watchingOnly,
71 | hoistWatching,
72 | bangumiDomain,
73 | mikanDomain,
74 | version: Date.now(),
75 | };
76 |
77 | preferenceDispatch({
78 | type: 'SET_COMMON_PREFERENCE',
79 | payload: newPreference,
80 | });
81 |
82 | router.push('/');
83 | if (isLogin) {
84 | updateCommonPreference(newPreference);
85 | } else {
86 | updateCommonPreferenceLocal(newPreference);
87 | }
88 | };
89 |
90 | return (
91 | <>
92 |
93 | 设置 | 番组放送
94 |
95 |
96 | 设置
97 |
157 |
158 | >
159 | );
160 | }
161 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/generated/prisma/internal/class.ts:
--------------------------------------------------------------------------------
1 |
2 | /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3 | /* eslint-disable */
4 | // biome-ignore-all lint: generated file
5 | // @ts-nocheck
6 | /*
7 | * WARNING: This is an internal file that is subject to change!
8 | *
9 | * 🛑 Under no circumstances should you import this file directly! 🛑
10 | *
11 | * Please import the `PrismaClient` class from the `client.ts` file instead.
12 | */
13 |
14 | import * as runtime from "@prisma/client/runtime/client"
15 | import type * as Prisma from "./prismaNamespace"
16 |
17 |
18 | const config: runtime.GetPrismaClientConfig = {
19 | "previewFeatures": [],
20 | "clientVersion": "7.1.0",
21 | "engineVersion": "ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba",
22 | "activeProvider": "sqlite",
23 | "inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"./generated/prisma\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nmodel BangumiPreference {\n userID String @id\n watching String?\n createdAt DateTime\n updatedAt DateTime\n}\n\nmodel Preference {\n userID String @id\n common String\n createdAt DateTime\n updatedAt DateTime\n}\n\nmodel Token {\n userID String\n token String\n\n @@id([userID, token])\n}\n\nmodel User {\n id String @id\n email String @unique(map: \"sqlite_autoindex_user_2\")\n password String\n role Int\n createdAt DateTime\n updatedAt DateTime\n}\n",
24 | "runtimeDataModel": {
25 | "models": {},
26 | "enums": {},
27 | "types": {}
28 | }
29 | }
30 |
31 | config.runtimeDataModel = JSON.parse("{\"models\":{\"BangumiPreference\":{\"fields\":[{\"name\":\"userID\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"watching\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Preference\":{\"fields\":[{\"name\":\"userID\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"common\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Token\":{\"fields\":[{\"name\":\"userID\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"token\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"role\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}")
32 |
33 | async function decodeBase64AsWasm(wasmBase64: string): Promise {
34 | const { Buffer } = await import('node:buffer')
35 | const wasmArray = Buffer.from(wasmBase64, 'base64')
36 | return new WebAssembly.Module(wasmArray)
37 | }
38 |
39 | config.compilerWasm = {
40 | getRuntime: async () => await import("@prisma/client/runtime/query_compiler_bg.sqlite.js"),
41 |
42 | getQueryCompilerWasmModule: async () => {
43 | const { wasm } = await import("@prisma/client/runtime/query_compiler_bg.sqlite.wasm-base64.js")
44 | return await decodeBase64AsWasm(wasm)
45 | }
46 | }
47 |
48 |
49 |
50 | export type LogOptions =
51 | 'log' extends keyof ClientOptions ? ClientOptions['log'] extends Array ? Prisma.GetEvents : never : never
52 |
53 | export interface PrismaClientConstructor {
54 | /**
55 | * ## Prisma Client
56 | *
57 | * Type-safe database client for TypeScript
58 | * @example
59 | * ```
60 | * const prisma = new PrismaClient()
61 | * // Fetch zero or more BangumiPreferences
62 | * const bangumiPreferences = await prisma.bangumiPreference.findMany()
63 | * ```
64 | *
65 | * Read more in our [docs](https://pris.ly/d/client).
66 | */
67 |
68 | new <
69 | Options extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions,
70 | LogOpts extends LogOptions = LogOptions,
71 | OmitOpts extends Prisma.PrismaClientOptions['omit'] = Options extends { omit: infer U } ? U : Prisma.PrismaClientOptions['omit'],
72 | ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
73 | >(options: Prisma.Subset ): PrismaClient
74 | }
75 |
76 | /**
77 | * ## Prisma Client
78 | *
79 | * Type-safe database client for TypeScript
80 | * @example
81 | * ```
82 | * const prisma = new PrismaClient()
83 | * // Fetch zero or more BangumiPreferences
84 | * const bangumiPreferences = await prisma.bangumiPreference.findMany()
85 | * ```
86 | *
87 | * Read more in our [docs](https://pris.ly/d/client).
88 | */
89 |
90 | export interface PrismaClient<
91 | in LogOpts extends Prisma.LogLevel = never,
92 | in out OmitOpts extends Prisma.PrismaClientOptions['omit'] = undefined,
93 | in out ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
94 | > {
95 | [K: symbol]: { types: Prisma.TypeMap['other'] }
96 |
97 | $on(eventType: V, callback: (event: V extends 'query' ? Prisma.QueryEvent : Prisma.LogEvent) => void): PrismaClient;
98 |
99 | /**
100 | * Connect with the database
101 | */
102 | $connect(): runtime.Types.Utils.JsPromise;
103 |
104 | /**
105 | * Disconnect from the database
106 | */
107 | $disconnect(): runtime.Types.Utils.JsPromise;
108 |
109 | /**
110 | * Executes a prepared raw query and returns the number of affected rows.
111 | * @example
112 | * ```
113 | * const result = await prisma.$executeRaw`UPDATE User SET cool = ${true} WHERE email = ${'user@email.com'};`
114 | * ```
115 | *
116 | * Read more in our [docs](https://pris.ly/d/raw-queries).
117 | */
118 | $executeRaw(query: TemplateStringsArray | Prisma.Sql, ...values: any[]): Prisma.PrismaPromise;
119 |
120 | /**
121 | * Executes a raw query and returns the number of affected rows.
122 | * Susceptible to SQL injections, see documentation.
123 | * @example
124 | * ```
125 | * const result = await prisma.$executeRawUnsafe('UPDATE User SET cool = $1 WHERE email = $2 ;', true, 'user@email.com')
126 | * ```
127 | *
128 | * Read more in our [docs](https://pris.ly/d/raw-queries).
129 | */
130 | $executeRawUnsafe(query: string, ...values: any[]): Prisma.PrismaPromise;
131 |
132 | /**
133 | * Performs a prepared raw query and returns the `SELECT` data.
134 | * @example
135 | * ```
136 | * const result = await prisma.$queryRaw`SELECT * FROM User WHERE id = ${1} OR email = ${'user@email.com'};`
137 | * ```
138 | *
139 | * Read more in our [docs](https://pris.ly/d/raw-queries).
140 | */
141 | $queryRaw(query: TemplateStringsArray | Prisma.Sql, ...values: any[]): Prisma.PrismaPromise;
142 |
143 | /**
144 | * Performs a raw query and returns the `SELECT` data.
145 | * Susceptible to SQL injections, see documentation.
146 | * @example
147 | * ```
148 | * const result = await prisma.$queryRawUnsafe('SELECT * FROM User WHERE id = $1 OR email = $2;', 1, 'user@email.com')
149 | * ```
150 | *
151 | * Read more in our [docs](https://pris.ly/d/raw-queries).
152 | */
153 | $queryRawUnsafe(query: string, ...values: any[]): Prisma.PrismaPromise;
154 |
155 |
156 | /**
157 | * Allows the running of a sequence of read/write operations that are guaranteed to either succeed or fail as a whole.
158 | * @example
159 | * ```
160 | * const [george, bob, alice] = await prisma.$transaction([
161 | * prisma.user.create({ data: { name: 'George' } }),
162 | * prisma.user.create({ data: { name: 'Bob' } }),
163 | * prisma.user.create({ data: { name: 'Alice' } }),
164 | * ])
165 | * ```
166 | *
167 | * Read more in our [docs](https://www.prisma.io/docs/concepts/components/prisma-client/transactions).
168 | */
169 | $transaction[]>(arg: [...P], options?: { isolationLevel?: Prisma.TransactionIsolationLevel }): runtime.Types.Utils.JsPromise>
170 |
171 | $transaction(fn: (prisma: Omit) => runtime.Types.Utils.JsPromise, options?: { maxWait?: number, timeout?: number, isolationLevel?: Prisma.TransactionIsolationLevel }): runtime.Types.Utils.JsPromise
172 |
173 | $extends: runtime.Types.Extensions.ExtendsHook<"extends", Prisma.TypeMapCb, ExtArgs, runtime.Types.Utils.Call, {
174 | extArgs: ExtArgs
175 | }>>
176 |
177 | /**
178 | * `prisma.bangumiPreference`: Exposes CRUD operations for the **BangumiPreference** model.
179 | * Example usage:
180 | * ```ts
181 | * // Fetch zero or more BangumiPreferences
182 | * const bangumiPreferences = await prisma.bangumiPreference.findMany()
183 | * ```
184 | */
185 | get bangumiPreference(): Prisma.BangumiPreferenceDelegate;
186 |
187 | /**
188 | * `prisma.preference`: Exposes CRUD operations for the **Preference** model.
189 | * Example usage:
190 | * ```ts
191 | * // Fetch zero or more Preferences
192 | * const preferences = await prisma.preference.findMany()
193 | * ```
194 | */
195 | get preference(): Prisma.PreferenceDelegate;
196 |
197 | /**
198 | * `prisma.token`: Exposes CRUD operations for the **Token** model.
199 | * Example usage:
200 | * ```ts
201 | * // Fetch zero or more Tokens
202 | * const tokens = await prisma.token.findMany()
203 | * ```
204 | */
205 | get token(): Prisma.TokenDelegate;
206 |
207 | /**
208 | * `prisma.user`: Exposes CRUD operations for the **User** model.
209 | * Example usage:
210 | * ```ts
211 | * // Fetch zero or more Users
212 | * const users = await prisma.user.findMany()
213 | * ```
214 | */
215 | get user(): Prisma.UserDelegate;
216 | }
217 |
218 | export function getPrismaClientClass(): PrismaClientConstructor {
219 | return runtime.getPrismaClient(config) as unknown as PrismaClientConstructor
220 | }
221 |
--------------------------------------------------------------------------------
/packages/server/src/prisma/generated/prisma/commonInputTypes.ts:
--------------------------------------------------------------------------------
1 |
2 | /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3 | /* eslint-disable */
4 | // biome-ignore-all lint: generated file
5 | // @ts-nocheck
6 | /*
7 | * This file exports various common sort, input & filter types that are not directly linked to a particular model.
8 | *
9 | * 🟢 You can import this file directly.
10 | */
11 |
12 | import type * as runtime from "@prisma/client/runtime/client"
13 | import * as $Enums from "./enums"
14 | import type * as Prisma from "./internal/prismaNamespace"
15 |
16 |
17 | export type StringFilter<$PrismaModel = never> = {
18 | equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
19 | in?: string[]
20 | notIn?: string[]
21 | lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
22 | lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
23 | gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
24 | gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
25 | contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
26 | startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
27 | endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
28 | not?: Prisma.NestedStringFilter<$PrismaModel> | string
29 | }
30 |
31 | export type StringNullableFilter<$PrismaModel = never> = {
32 | equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
33 | in?: string[] | null
34 | notIn?: string[] | null
35 | lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
36 | lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
37 | gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
38 | gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
39 | contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
40 | startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
41 | endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
42 | not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
43 | }
44 |
45 | export type DateTimeFilter<$PrismaModel = never> = {
46 | equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
47 | in?: Date[] | string[]
48 | notIn?: Date[] | string[]
49 | lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
50 | lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
51 | gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
52 | gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
53 | not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
54 | }
55 |
56 | export type SortOrderInput = {
57 | sort: Prisma.SortOrder
58 | nulls?: Prisma.NullsOrder
59 | }
60 |
61 | export type StringWithAggregatesFilter<$PrismaModel = never> = {
62 | equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
63 | in?: string[]
64 | notIn?: string[]
65 | lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
66 | lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
67 | gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
68 | gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
69 | contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
70 | startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
71 | endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
72 | not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
73 | _count?: Prisma.NestedIntFilter<$PrismaModel>
74 | _min?: Prisma.NestedStringFilter<$PrismaModel>
75 | _max?: Prisma.NestedStringFilter<$PrismaModel>
76 | }
77 |
78 | export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
79 | equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
80 | in?: string[] | null
81 | notIn?: string[] | null
82 | lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
83 | lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
84 | gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
85 | gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
86 | contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
87 | startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
88 | endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
89 | not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
90 | _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
91 | _min?: Prisma.NestedStringNullableFilter<$PrismaModel>
92 | _max?: Prisma.NestedStringNullableFilter<$PrismaModel>
93 | }
94 |
95 | export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
96 | equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
97 | in?: Date[] | string[]
98 | notIn?: Date[] | string[]
99 | lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
100 | lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
101 | gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
102 | gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
103 | not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
104 | _count?: Prisma.NestedIntFilter<$PrismaModel>
105 | _min?: Prisma.NestedDateTimeFilter<$PrismaModel>
106 | _max?: Prisma.NestedDateTimeFilter<$PrismaModel>
107 | }
108 |
109 | export type IntFilter<$PrismaModel = never> = {
110 | equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
111 | in?: number[]
112 | notIn?: number[]
113 | lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
114 | lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
115 | gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
116 | gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
117 | not?: Prisma.NestedIntFilter<$PrismaModel> | number
118 | }
119 |
120 | export type IntWithAggregatesFilter<$PrismaModel = never> = {
121 | equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
122 | in?: number[]
123 | notIn?: number[]
124 | lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
125 | lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
126 | gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
127 | gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
128 | not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
129 | _count?: Prisma.NestedIntFilter<$PrismaModel>
130 | _avg?: Prisma.NestedFloatFilter<$PrismaModel>
131 | _sum?: Prisma.NestedIntFilter<$PrismaModel>
132 | _min?: Prisma.NestedIntFilter<$PrismaModel>
133 | _max?: Prisma.NestedIntFilter<$PrismaModel>
134 | }
135 |
136 | export type NestedStringFilter<$PrismaModel = never> = {
137 | equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
138 | in?: string[]
139 | notIn?: string[]
140 | lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
141 | lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
142 | gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
143 | gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
144 | contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
145 | startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
146 | endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
147 | not?: Prisma.NestedStringFilter<$PrismaModel> | string
148 | }
149 |
150 | export type NestedStringNullableFilter<$PrismaModel = never> = {
151 | equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
152 | in?: string[] | null
153 | notIn?: string[] | null
154 | lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
155 | lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
156 | gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
157 | gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
158 | contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
159 | startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
160 | endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
161 | not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
162 | }
163 |
164 | export type NestedDateTimeFilter<$PrismaModel = never> = {
165 | equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
166 | in?: Date[] | string[]
167 | notIn?: Date[] | string[]
168 | lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
169 | lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
170 | gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
171 | gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
172 | not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
173 | }
174 |
175 | export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
176 | equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
177 | in?: string[]
178 | notIn?: string[]
179 | lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
180 | lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
181 | gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
182 | gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
183 | contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
184 | startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
185 | endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
186 | not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
187 | _count?: Prisma.NestedIntFilter<$PrismaModel>
188 | _min?: Prisma.NestedStringFilter<$PrismaModel>
189 | _max?: Prisma.NestedStringFilter<$PrismaModel>
190 | }
191 |
192 | export type NestedIntFilter<$PrismaModel = never> = {
193 | equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
194 | in?: number[]
195 | notIn?: number[]
196 | lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
197 | lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
198 | gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
199 | gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
200 | not?: Prisma.NestedIntFilter<$PrismaModel> | number
201 | }
202 |
203 | export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
204 | equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
205 | in?: string[] | null
206 | notIn?: string[] | null
207 | lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
208 | lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
209 | gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
210 | gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
211 | contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
212 | startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
213 | endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
214 | not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
215 | _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
216 | _min?: Prisma.NestedStringNullableFilter<$PrismaModel>
217 | _max?: Prisma.NestedStringNullableFilter<$PrismaModel>
218 | }
219 |
220 | export type NestedIntNullableFilter<$PrismaModel = never> = {
221 | equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
222 | in?: number[] | null
223 | notIn?: number[] | null
224 | lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
225 | lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
226 | gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
227 | gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
228 | not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
229 | }
230 |
231 | export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
232 | equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
233 | in?: Date[] | string[]
234 | notIn?: Date[] | string[]
235 | lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
236 | lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
237 | gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
238 | gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
239 | not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
240 | _count?: Prisma.NestedIntFilter<$PrismaModel>
241 | _min?: Prisma.NestedDateTimeFilter<$PrismaModel>
242 | _max?: Prisma.NestedDateTimeFilter<$PrismaModel>
243 | }
244 |
245 | export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
246 | equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
247 | in?: number[]
248 | notIn?: number[]
249 | lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
250 | lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
251 | gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
252 | gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
253 | not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
254 | _count?: Prisma.NestedIntFilter<$PrismaModel>
255 | _avg?: Prisma.NestedFloatFilter<$PrismaModel>
256 | _sum?: Prisma.NestedIntFilter<$PrismaModel>
257 | _min?: Prisma.NestedIntFilter<$PrismaModel>
258 | _max?: Prisma.NestedIntFilter<$PrismaModel>
259 | }
260 |
261 | export type NestedFloatFilter<$PrismaModel = never> = {
262 | equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
263 | in?: number[]
264 | notIn?: number[]
265 | lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
266 | lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
267 | gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
268 | gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
269 | not?: Prisma.NestedFloatFilter<$PrismaModel> | number
270 | }
271 |
272 |
273 |
--------------------------------------------------------------------------------