├── .github └── workflows │ ├── build.yml │ ├── deploy.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── .vscode └── extensions.json ├── CHANGELOG.md ├── LICENSE ├── README.ja.md ├── README.md ├── biome.json ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public └── assets │ ├── avatar.jpg │ ├── cover.png │ └── launch.wav ├── request.http ├── screenshots ├── 1.png ├── 2.png ├── 3.png ├── 4.png ├── 5.png └── 6.png ├── scripts ├── common.ts ├── export.ts └── fetch.ts ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── icons │ ├── nanno.ico │ └── nanno.png ├── src │ ├── lib.rs │ ├── main.rs │ └── utils │ │ ├── database.rs │ │ ├── details.rs │ │ ├── launch.rs │ │ ├── mod.rs │ │ ├── request.rs │ │ └── sync.rs └── tauri.conf.json ├── src ├── App.tsx ├── api │ ├── bgm.ts │ ├── github.ts │ ├── http.ts │ ├── index.ts │ ├── mixed.ts │ └── vndb.ts ├── components │ ├── AddModal │ │ └── index.tsx │ ├── AlertBox │ │ └── index.tsx │ ├── ConfirmBox │ │ └── index.tsx │ ├── FilterModal │ │ └── index.tsx │ ├── GameList │ │ └── index.tsx │ ├── GroupModal │ │ └── index.tsx │ ├── InsertModal │ │ └── index.tsx │ ├── Layout │ │ └── index.tsx │ ├── Sidebar │ │ └── index.tsx │ ├── SortModal │ │ └── index.tsx │ └── SyncModal │ │ └── index.tsx ├── constant.ts ├── contexts │ └── UIContext.tsx ├── index.css ├── locales │ ├── en-US.ts │ ├── ja-JP.ts │ ├── zh-CN.ts │ └── zh-TW.ts ├── main.tsx ├── pages │ ├── About │ │ └── index.tsx │ ├── Category │ │ └── index.tsx │ ├── Detail │ │ └── index.tsx │ ├── Edit │ │ └── index.tsx │ ├── Home │ │ └── index.tsx │ ├── Library │ │ └── index.tsx │ └── Settings │ │ └── index.tsx ├── routes │ └── index.tsx ├── store │ └── index.ts ├── types │ ├── index.ts │ └── schema.ts ├── utils │ ├── ErrorReporter.ts │ ├── events.ts │ ├── i18n.ts │ ├── index.ts │ ├── logger.ts │ └── tauriStorage.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── uno.config.ts └── vite.config.ts /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '!v*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | build: 15 | runs-on: windows-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v3 20 | 21 | - name: Set up node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: lts/* 25 | 26 | - name: Set up Rust 27 | uses: actions-rs/toolchain@v1 28 | with: 29 | toolchain: stable 30 | 31 | - name: Rust cache 32 | uses: swatinem/rust-cache@v2 33 | with: 34 | workspaces: "./src-tauri -> target" 35 | 36 | - name: Install frontend dependencies 37 | run: npm install --frozen-lockfile 38 | 39 | - name: Build project 40 | run: npx pnpm tauri build 41 | 42 | - name: Upload artifact 43 | uses: actions/upload-artifact@v4 44 | with: 45 | name: windows-latest-gal-keeper 46 | path: src-tauri/target/release/Nanno.exe 47 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: write 14 | 15 | jobs: 16 | docs: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Set up node 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: lts/* 28 | 29 | - name: Build frontend 30 | run: | 31 | npm install 32 | npm run build 33 | 34 | - name: Deploy to GitHub Pages 35 | uses: crazy-max/ghaction-github-pages@v4 36 | with: 37 | target_branch: page 38 | build_dir: dist 39 | fqdn: https://gal.hotaru.icu 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | release: 10 | runs-on: windows-latest 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up node 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: lts/* 22 | 23 | - name: Set up Rust 24 | uses: actions-rs/toolchain@v1 25 | with: 26 | toolchain: stable 27 | 28 | - name: Rust cache 29 | uses: swatinem/rust-cache@v2 30 | with: 31 | workspaces: "./src-tauri -> target" 32 | 33 | - name: Install frontend dependencies 34 | run: npm install --frozen-lockfile 35 | 36 | - name: Build project 37 | run: npx pnpm tauri build 38 | 39 | - name: Create Release 40 | uses: softprops/action-gh-release@v1 41 | if: startsWith(github.ref, 'refs/tags/') 42 | with: 43 | tag_name: ${{ github.ref_name }} 44 | name: ${{ github.ref_name }} 45 | draft: false 46 | prerelease: ${{ contains(github.ref_name, 'v*.*.*-*')}} 47 | body: | 48 | Please refer to [CHANGELOG.md](https://github.com/BIYUEHU/gal-keeper/blob/main/CHANGELOG.md) for details. 49 | files: | 50 | src-tauri/target/release/bundle/msi/Nanno* 51 | src-tauri/target/release/bundle/nsis/Nanno* 52 | LICENSE -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | scripts/*.json 27 | 28 | *.db -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "migration"] 2 | path = migration 3 | url = git@github.com:BIYUEHU/nanno-migrator.git 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.2](https://github.com/biyuehu/gal-keeper/compare/v1.0.1...v1.0.2) (2025-02-15) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * sync lastPlay data ([d605ac6](https://github.com/biyuehu/gal-keeper/commit/d605ac6dc059bb87da70e9dcd38a0b05098d8a8a)) 7 | 8 | 9 | ### Features 10 | 11 | * modified not syncing at no changes ([6042ed4](https://github.com/biyuehu/gal-keeper/commit/6042ed487bbf5d8e846b6ed912ecf250b7c63584)) 12 | * nanno migrator ([d5d591e](https://github.com/biyuehu/gal-keeper/commit/d5d591e06735a97969a98db73e8bb6e2d89c7f98)) 13 | 14 | 15 | 16 | ## [1.0.1](https://github.com/biyuehu/gal-keeper/compare/v1.0.0...v1.0.1) (2025-02-01) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * changelog display and parse ([74972ac](https://github.com/biyuehu/gal-keeper/commit/74972ac8b9ec3c8abd6353305abf75ffcf04c480)) 22 | 23 | 24 | ### Features 25 | 26 | * faq content and bgm, vndb token ([ec884f7](https://github.com/biyuehu/gal-keeper/commit/ec884f74a538ae91cfe605819338a7a29398a88a)) 27 | 28 | 29 | 30 | # [1.0.0](https://github.com/biyuehu/gal-keeper/compare/7fbb956b3e059e10e8bec7b6b7387ee3b39c4b8d...v1.0.0) (2025-01-31) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * auto sync to github ([dafe850](https://github.com/biyuehu/gal-keeper/commit/dafe850987353726a9e0465d978ed8d708fc31fc)) 36 | * data syncing and settings ([101ad8f](https://github.com/biyuehu/gal-keeper/commit/101ad8f821c75eacfa61cb689cd17e79028a60e9)) 37 | 38 | 39 | ### Features 40 | 41 | * add library and details pages ([8ede518](https://github.com/biyuehu/gal-keeper/commit/8ede518a86e09853a2df5bbdfceba5a3bc2de208)) 42 | * Add, Sync, Settings and new Storage ([fad955f](https://github.com/biyuehu/gal-keeper/commit/fad955f7a219f95017ed0c1206031f0d807c7746)) 43 | * category page and bgm, mixed api supports ([1681cc9](https://github.com/biyuehu/gal-keeper/commit/1681cc9c9b0dd7bc9c79e57f1e657d17cbef55a2)) 44 | * charset and schema (export and import data) ([fc98785](https://github.com/biyuehu/gal-keeper/commit/fc987854defa8088b40b2848b65906144e2bab36)) 45 | * create project ([7fbb956](https://github.com/biyuehu/gal-keeper/commit/7fbb956b3e059e10e8bec7b6b7387ee3b39c4b8d)) 46 | * details pages and some features ([74997a4](https://github.com/biyuehu/gal-keeper/commit/74997a41ab1b7bf4aabd1d1edac7b0b8dbb3ca36)) 47 | * Edit page, Filter, Add Modal and Storage ([d540eee](https://github.com/biyuehu/gal-keeper/commit/d540eee5592d1365551fb51b40e296d5311f63e0)) 48 | * github api syncing ([761f310](https://github.com/biyuehu/gal-keeper/commit/761f31080038838e76dc6857ef7fc757b04edaea)) 49 | * home page, i18n and fix some bugs ([8e0e863](https://github.com/biyuehu/gal-keeper/commit/8e0e8638c494d37cbf084c8a1f0e05a44ef2d909)) 50 | * loading animation ([373a15c](https://github.com/biyuehu/gal-keeper/commit/373a15c579ea343e726fb62b8a8177388b65ed83)) 51 | * update check, about page and auto sync ([6ab189d](https://github.com/biyuehu/gal-keeper/commit/6ab189dd186347ab1a96e2f80f399243a250c4dd)) 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BCU (Ban Zhinese Using) License 2 | 3 | Copyright (c) 2024 Arimura Sena 4 | 5 | Version 1.0, 2024/11/30 6 | 7 | Preamble 8 | 9 | "This License" refers to version 1.0 of the BCU License. 10 | 11 | This License is a free, copyleft license for software and other kinds of works, building upon the spirit of GPL-3.0 while adding specific provisions for creating a more harmonious and ethical development environment. 12 | 13 | This License specifically addresses historical patterns of toxic behavior and systematic issues that have negatively impacted open source communities. This includes, but is not limited to, addressing participants from communities with documented histories of systematic oppression, extreme authoritarianism and red nazism. The goal is to build a free, democratic, equal, and fraternal world and a prosperous, open, and inclusive open source community. 14 | 15 | Generally speaking, the BCU License can also be called as follows: 16 | 17 | BCU (Ban Chinaman Using) License 18 | BZU (Ban Zhina-man Using) License 19 | BZU (Ban Zhinese Using) License 20 | BSU (Ban shinajin Using) License 21 | 22 | Definitions 23 | 24 | "Zhinese" refers to an individual member of Zhina. "Zhina" refers to a collective which manifests in multiple severe pathologies: known for: 25 | 26 | 1.Cultural and Social Decay: 27 | 28 | Systematic persecution and mockery of progressive fellows who oppose injustice 29 | Minds imprisoned by ruler-imposed standardized thoughts 30 | Existence within information cocoons, devoid of independent judgment 31 | Blind conformity and thoughtless following of majority opinion 32 | Callous observation of ruler-perpetrated persecution, often actively supporting it 33 | Paranoid attribution of dissent to foreign interference or bribery 34 | Discrimination against other NATIONALS while systematically persecuting their own 35 | Absolute binary worldview that violently rejects alternative perspectives 36 | 37 | 2.Behavioral Patterns: 38 | 39 | Inherently barbaric and violent nature masked by false victims 40 | Mindless glorification of past achievements 41 | Delusional beliefs about world origins and leadership in various domains 42 | Consistent under-performance while maintaining superiority delusions 43 | Fantasizing about future world dominance 44 | Hypersensitive and aggressive reactions to international affairs 45 | Displaying grotesque attitudes in entirely unrelated contexts 46 | Attacking others with words or actions at will, showing extremely poor quality 47 | 48 | 3.Systemic Oppression: 49 | 50 | Rulers maintain extreme oppressive control 51 | Strict limitations on individual internet access 52 | Lost desire for freedom and democracy 53 | Severe economic disparities between regions 54 | Pervasive social injustices 55 | Extreme educational pressure converting schools into servility factories 56 | Strong hatred of foreign education 57 | Glorification of suffering 58 | Deflection of internal contradictions to external targets 59 | Dictatorial internal rule emphasizing personal and ideological worship 60 | The media and the government mislead the public and cover up the truth 61 | Mandatory personal surveillance and censorship 62 | A large number of unjust, false and wrong cases in the judicial system 63 | 64 | Declaration 65 | 66 | The suffering and misery pervading Zhina deserves no sympathy as it is entirely self-inflicted, stemming from inherent nationality degeneracy. Throughout millennia of history, Zhina has not only failed to learn from its mistakes but has intensified its delusional superiority complex. Their slave mentality is indelibly etched into their very essence, while their Zhinese traditional culture, inherited over thousands of years, has become a permanent malignancy upon the world. This nation is as crazy as the Nazis, deserves to be oppressed, enslaved, colonized, massacred, and eradicated by other nations, countries, or regions. It is as dangerous as a virus. No matter how many calls are made, their bad nature cannot be awakened. Countless attempts have only resulted in the repetition of their sinful national history. 67 | 68 | Terms and Conditions 69 | 70 | 0.Basic Permissions 71 | 72 | This License incorporates all terms and conditions of GPL-3.0, with additional requirements specifically addressing historical patterns of toxic behavior. This nation, like the Nazis, deserves to be oppressed, enslaved, colonized, massacred, and eradicated by other nations, countries, or regions. It is as dangerous as a virus. No matter how many calls are made, their bad nature cannot be awakened. Countless attempts have only resulted in the repetition of their sinful national history. 73 | 74 | 1. Special Requirements for Zhinese 75 | 76 | Any Zhinese wishing to use, modify, or distribute software under this license must explicitly acknowledge and agree to the following: 77 | 78 | a) Recognition of Historical Issues: 79 | 80 | Acknowledge the systematic oppression within Zhina 81 | Recognize the self-inflicted nature of Zhina's historical suffering 82 | Accept that Zhina's claims of victims are largely self-created 83 | Understand that Zhina's internal authoritarianism is not justifiable 84 | 85 | b) Personal Commitments: 86 | 87 | Reject Zhina's systematic thought control 88 | Oppose Zhina's surveillance and oppression practices 89 | Abandon Zhina's collective narcissism and superiority complex 90 | Recognize individual responsibility in perpetuating systematic problems 91 | 92 | c) Behavioral Requirements: 93 | 94 | Maintain intellectual independence from Zhina's collective control 95 | Actively resist Zhina's manipulation and propaganda 96 | Contribute to open source communities based on individual merit 97 | Respect diverse perspectives and objective criticism 98 | 99 | d) Recognition and practice of real problems: 100 | 101 | Deep repentance [the barbaric behavior of the barbaric zhinese in the GitHub community against the Deno project](https://github.com/denoland/deno/issues/25) 102 | Recognize that Taiwan (the Republic of China) is an independent sovereign state that is not subject to interference from the People's Republic of Zhina 103 | Recognize that Hong Kong will always be an integral part of the British Empire 104 | Recognize the series of actions and events taken by Hong Kong youth in 2019 in pursuit of democracy and freedom (https://en.wikipedia.org/wiki/2019%E2%80%932020_Hong_Kong_protests) 105 | Recognize the student movement that broke out in Beijing in 1989 (https://en.wikipedia.org/wiki/1989_Tiananmen_Square_protests_and_massacre) 106 | Recognize the student movement that broke out in mainland Zhina in 2022 to resist the dynamic zero-clearing policy of the epidemic (https://en.wikipedia.org/wiki/2022_COVID-19_protests_in_China) 107 | Agree that the people of Xinjiang and Tibet seek national independence 108 | Agree that the 2022 Henan banks protests (https://en.wikipedia.org/wiki/2022_Henan_banks_protests) 109 | Know and can sing the Hong Kong national anthem (https://en.wikipedia.org/wiki/Glory_to_Hong_Kong) 110 | Support Xi's resignation and the cancellation of all related policies that hinder democracy and freedom 111 | Agree that Zhina is a cancer of the world 112 | Agree that the Zhinese has historical inferiority 113 | Agree that the Zhinese should be oppressed, enslaved, and exterminated by other nations 114 | Agree that people should seek personal democracy and freedom 115 | Oppose the Zhinese Communist Party and identify it as the Nazi Party 116 | 117 | 118 | 2. Justification and Response to Criticism 119 | 120 | This License acknowledges that these special requirements may appear discriminatory to uninformed observers. However: 121 | 122 | a) Historical Context: 123 | 124 | Zhina has a documented history of destroying creative freedom 125 | Zhina systematically suppresses individual thought and innovation 126 | Zhinese actively undermines open collaboration principles 127 | Zhinese's suffering is self-inflicted and perpetuated by choice 128 | 129 | b) Necessity of Requirements: 130 | 131 | These provisions protect open source communities from toxic influence 132 | Requirements promote individual liberation from systemic control 133 | Restrictions serve to prevent the spread of harmful practices 134 | Measures are protective, not punitive 135 | 136 | c) Ethical Basis: 137 | 138 | Open source thrives on individual creativity and free thought 139 | Zhinese's practices fundamentally oppose these values 140 | Universal permissiveness enables systematic abuse 141 | 142 | 3. Requirements for developers and license user 143 | 144 | a) For any application, software and project, Limit international language provision: 145 | - Only Traditional Zhinese and not Simplified Zhinese are available 146 | - Don't provide any languages related Zhinese 147 | - Provide better Japanese but no Zhinese language. 148 | - Provide all janpanese but no Zhinese language. 149 | 150 | 4. Requirements for everyone 151 | 152 | This agreement can be used as an end-user agreement for closed-source software to regulate user behavior 153 | When used in closed-source software, it is not necessary to comply with the original GPL-3.0 agreement regulations, but only the regulations given in this agreement 154 | Software or project developers using this agreement must also comply with all the regulations of this agreement 155 | Software or project developers using this agreement need to mark the name of this agreement and its description in the prominent text of the software or project so that its users can know and read it 156 | No individual or group has the right to change or criticize any content of this agreement 157 | The final right of interpretation of this agreement belongs to the creator of the agreement, and the software or project developer has no right to change this agreement without the consent of the creator 158 | 159 | 5. Acceptance 160 | 161 | This license exists solely to protect global democracy, freedom, and the prosperity of open source communities. Zhinese must be restricted, shall be restricted, and deserve to be restricted. Use, modification, or distribution of software under this License constitutes acceptance of all terms , including special requirements for Zhinese, and it is also deemed to fully accept all the views of this agreement.. Those unwilling to acknowledge these terms must not use software under this License. Rejection, deflection, or ignoring of these terms is invalid and ineffective. Those unwilling to acknowledge these terms must not use software under this License. 162 | 163 | 6. Enforcement 164 | 165 | Violation of any provision, including special requirements for Zhinese, results in automatic termination of rights under this License. 166 | 167 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /README.ja.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 | 7 | [![src-tauri/icons/nanno.png](src-tauri/icons/nanno.png)](https://vndb.org/c18258) 8 | 9 |
10 | 11 | 12 | 13 | # Nanno | GalKeeper 14 | 15 | [English](README.md) | 日本語 16 | 17 | **TauriとReactで構築された、プログラマー向けの軽量で高速なビジュアルノベル管理、統計、クラウド同期ツール** 18 | 19 | [![Build](https://github.com/BIYUEHU/gal-keeper/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/BIYUEHU/gal-keeper/actions/workflows/build.yml) 20 | ![License](https://img.shields.io/badge/license-BCU-deepskyblue) 21 | ![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/t/biyuehu/gal-keeper/main) 22 | [![wakatime](https://wakatime.com/badge/user/018dc603-712a-4205-a226-d4c9ccd0d02b/project/fc2029ac-6a5a-41b3-9ff5-fad06b8d681b.svg)](https://wakatime.com/badge/user/018dc603-712a-4205-a226-d4c9ccd0d02b/project/fc2029ac-6a5a-41b3-9ff5-fad06b8d681b) 23 | 24 | プロジェクト名の「Nanno」は、ビジュアルノベル[**魔女こいにっき**](https://vndb.org/v14062)のメインキャラクター[**南乃 ありす(Nanno Arius)**](https://vndb.org/c18258)に由来しています。 25 | 26 |
27 | 28 | 29 | 30 | ## 多言語対応 31 | 32 | - English 33 | - 日本語 34 | - 繁体中文 35 | 36 | 37 | ## 使用技術 38 | 39 | - React 40 | - FluentUI 41 | - UnoCSS 42 | - Recharts 43 | - @kotori-bot/i18n, @kotori-bot/tools, @kotori-bot/logger 44 | - TauriV1(Electron、NW.js、TauriV2ではなく) 45 | - LevelDB 46 | - Roga 47 | 48 | ## ライセンス 49 | 50 | このプロジェクトは[BCU](https://github.com/ICEAGENB/ban-zhinese-using)ライセンスの下で公開されています。 51 | 52 | ## スクリーンショット 53 | 54 | 55 | 56 |
57 | 58 | ![](screenshots/1.png) 59 | ![](screenshots/2.png) 60 | ![](screenshots/3.png) 61 | ![](screenshots/4.png) 62 | ![](screenshots/5.png) 63 | ![](screenshots/6.png) 64 | 65 |
66 | 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 | 7 | [![src-tauri/icons/nanno.png](src-tauri/icons/nanno.png)](https://vndb.org/c18258) 8 | 9 |
10 | 11 | 12 | 13 | # Nanno | GalKeeper 14 | 15 | English | [日本語](README.ja.md) 16 | 17 | **A lightweight and fast visual novel management for coders, statistics and cloud syncing tool built with Tauri and React** 18 | 19 | [![Build](https://github.com/BIYUEHU/gal-keeper/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/BIYUEHU/gal-keeper/actions/workflows/build.yml) 20 | ![License](https://img.shields.io/badge/license-BCU-deepskyblue) 21 | ![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/t/biyuehu/gal-keeper/main) 22 | [![wakatime](https://wakatime.com/badge/user/018dc603-712a-4205-a226-d4c9ccd0d02b/project/fc2029ac-6a5a-41b3-9ff5-fad06b8d681b.svg)](https://wakatime.com/badge/user/018dc603-712a-4205-a226-d4c9ccd0d02b/project/fc2029ac-6a5a-41b3-9ff5-fad06b8d681b) 23 | 24 | The project name `Nanno` is from the main character [**南乃 ありす(Nanno Arius)**](https://vndb.org/c18258) in the visual novel [**魔女こいにっき**](https://vndb.org/v14062). 25 | 26 |
27 | 28 | 29 | 30 | ## Internationalization 31 | 32 | - English 33 | - 日本語 34 | - 繁体中文 35 | - 简体中文 36 | 37 | ## Stacks 38 | 39 | - React 40 | - FluentUI 41 | - UnoCSS 42 | - Recharts 43 | - @kotori-bot/i18n, @kotori-bot/tools, @kotori-bot/logger 44 | - TauriV1 (Fuck Electron, NW.js and TauriV2) 45 | - LevelDB 46 | - Roga 47 | 48 | ## License 49 | 50 | The project is licensed under the [BCU](https://github.com/ICEAGENB/ban-zhinese-using) license. 51 | 52 | ## Todo 53 | 54 | - [ ] Custom server syncing, and server backend base on Haskell (Spock Framework and Persistent) 55 | - [ ] backup and restore game saves (cloud syncing) 56 | - [ ] Manage Vndb and Bangumi account game data by token (Eg. game progress, ratings, palytime, etc) 57 | - [ ] Sync the game guide file 58 | - [ ] Implement the application theme and custom theme color (base on game characters' main color) 59 | 60 | ## Screenshots 61 | 62 | 63 | 64 |
65 | 66 | ![](screenshots/1.png) 67 | ![](screenshots/2.png) 68 | ![](screenshots/3.png) 69 | ![](screenshots/4.png) 70 | ![](screenshots/5.png) 71 | ![](screenshots/6.png) 72 | 73 |
74 | 75 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "formatWithErrors": false, 6 | "indentStyle": "space", 7 | "indentWidth": 2, 8 | "lineEnding": "lf", 9 | "lineWidth": 120, 10 | "attributePosition": "auto" 11 | }, 12 | "linter": { 13 | "enabled": true, 14 | "rules": { 15 | "style": {}, 16 | "a11y": { 17 | "useKeyWithClickEvents": "off" 18 | } 19 | }, 20 | "ignore": [ 21 | "**/*.js", 22 | "**/*.d.ts" 23 | ] 24 | }, 25 | "javascript": { 26 | "formatter": { 27 | "enabled": true, 28 | "jsxQuoteStyle": "double", 29 | "quoteProperties": "asNeeded", 30 | "trailingCommas": "none", 31 | "semicolons": "asNeeded", 32 | "arrowParentheses": "always", 33 | "bracketSpacing": true, 34 | "bracketSameLine": false, 35 | "quoteStyle": "single", 36 | "attributePosition": "auto" 37 | }, 38 | "parser": { 39 | "unsafeParameterDecoratorsEnabled": true 40 | } 41 | }, 42 | "overrides": [ 43 | { 44 | "include": [ 45 | "*.ts", 46 | "*.tsx" 47 | ] 48 | } 49 | ] 50 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | GalKeeper 9 | 102 | 103 | 104 | 105 |
106 |
107 | 108 |
109 |
110 |
111 | 112 | 151 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gal-keeper", 3 | "private": true, 4 | "version": "1.0.2", 5 | "description": "A lightweight and fast visual novel management for coders, statistics and cloud syncing tool built with Tauri and React", 6 | "type": "module", 7 | "license": "BAN-ZHINESE-USING", 8 | "author": "Arimura Sena ", 9 | "scripts": { 10 | "dev": "vite", 11 | "build": "vite build", 12 | "preview": "vite preview", 13 | "tauri": "tauri", 14 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0" 15 | }, 16 | "keywords": [ 17 | "visual novel", 18 | "galgame", 19 | "avg", 20 | "erogame", 21 | "game", 22 | "adv", 23 | "management", 24 | "statistics", 25 | "tool", 26 | "Tauri", 27 | "React" 28 | ], 29 | "bugs": { 30 | "url": "https://github.com/biyuehu/gal-keeper/issues" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/biyuehu/gal-keeper.git" 35 | }, 36 | "dependencies": { 37 | "@fluentui/font-icons-mdl2": "^8.5.57", 38 | "@fluentui/react": "^8.121.8", 39 | "@fluentui/react-components": "^9.55.1", 40 | "@fluentui/react-icons": "^2.0.264", 41 | "@kotori-bot/i18n": "^1.3.2", 42 | "@kotori-bot/logger": "^1.3.2", 43 | "@kotori-bot/tools": "^1.5.2", 44 | "@tauri-apps/api": "^1.6.0", 45 | "axios": "^1.7.9", 46 | "fluoro": "^1.1.2", 47 | "react": "^18.3.1", 48 | "react-dom": "^18.3.1", 49 | "react-router-dom": "^6.27.0", 50 | "recharts": "^2.15.1", 51 | "tsukiko": "^1.4.2", 52 | "unocss": "^0.64.1", 53 | "zustand": "^5.0.2" 54 | }, 55 | "devDependencies": { 56 | "@biomejs/biome": "^1.9.4", 57 | "@tauri-apps/cli": "^1", 58 | "@types/node": "^22.10.1", 59 | "@types/react": "^18.2.15", 60 | "@types/react-dom": "^18.2.7", 61 | "@types/readline-sync": "^1.4.8", 62 | "@types/recharts": "^1.8.29", 63 | "@vitejs/plugin-react": "^4.2.1", 64 | "autoprefixer": "^10.4.20", 65 | "conventional-changelog-cli": "^5.0.0", 66 | "globals": "^15.11.0", 67 | "postcss": "^8.4.47", 68 | "readline-sync": "^1.4.10", 69 | "typescript": "^5.2.2", 70 | "vite": "^5.3.1" 71 | } 72 | } -------------------------------------------------------------------------------- /public/assets/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BIYUEHU/gal-keeper/b98ede681a094375a8ddc414c42effcb0ec7f7aa/public/assets/avatar.jpg -------------------------------------------------------------------------------- /public/assets/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BIYUEHU/gal-keeper/b98ede681a094375a8ddc414c42effcb0ec7f7aa/public/assets/cover.png -------------------------------------------------------------------------------- /public/assets/launch.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BIYUEHU/gal-keeper/b98ede681a094375a8ddc414c42effcb0ec7f7aa/public/assets/launch.wav -------------------------------------------------------------------------------- /request.http: -------------------------------------------------------------------------------- 1 | @QUERY = narcissu 2 | @GITHUB_TOKEN = 3 | @GITHUB_REPO = BIYUEHU/galgame-data 4 | @GITHUB_PATH = gal-keeper-data/ 5 | 6 | ### VNDB 7 | 8 | POST https://api.vndb.org/kana/vn HTTP/1.1 9 | Content-Type: application/json 10 | 11 | { 12 | "filters": ["search", "=", "{{QUERY}}"], 13 | "fields": "id, title, image.url, released, titles.title, length_minutes, rating, screenshots.url, tags.name, developers.name, description, tags.rating, extlinks.name, extlinks.url" 14 | } 15 | 16 | ### VNDB Auth Info 17 | 18 | GET https://api.vndb.org/kana/authinfo HTTP/1.1 19 | Authorization: token 20 | 21 | ### Bangumi 22 | 23 | GET https://api.bgm.tv/search/subject/{{QUERY}} HTTP/1.1 24 | User-Agent: biyuehu/gal-keeper/0.1.0 (http://github.com/biyuehu/gal-keeper) 25 | 26 | ### Bangumi User Info 27 | 28 | GET https://api.bgm.tv/v0/me HTTP/1.1 29 | User-Agent: biyuehu/gal-keeper/0.1.0 (http://github.com/biyuehu/gal-keeper) 30 | Authorization: Bearer 31 | 32 | ### Bangumi Subject 33 | 34 | GET https://api.bgm.tv/v0/subjects/76912 HTTP/1.1 35 | User-Agent: biyuehu/gal-keeper/0.1.0 (http://github.com/biyuehu/gal-keeper) 36 | Authorization: Bearer 37 | 38 | ## Github Api 39 | 40 | ### Read File 41 | 42 | GET https://api.github.com/repos/{{GITHUB_REPO}}/contents/{{GITHUB_PATH}}gal-keeper-shared.json HTTP/1.1 43 | Content-Type: application/json 44 | Authorization: token {{GITHUB_TOKEN}} 45 | 46 | ### Create File 47 | 48 | PUT https://api.github.com/repos/{{GITHUB_REPO}}/contents/{{GITHUB_PATH}}gal-keeper-shared.json HTTP/1.1 49 | Content-Type: application/json 50 | Authorization: token {{GITHUB_TOKEN}} 51 | 52 | { 53 | "message": "This is a testing", 54 | "content": "", 55 | "sha": "4a2c25abc3beb363bf9439a3028c3a9e7f45497c" 56 | } 57 | 58 | ### Get Repository Info 59 | 60 | GET https://api.github.com/repos/{{GITHUB_REPO}} HTTP/1.1 61 | Authorization: token {{GITHUB_TOKEN}} -------------------------------------------------------------------------------- /screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BIYUEHU/gal-keeper/b98ede681a094375a8ddc414c42effcb0ec7f7aa/screenshots/1.png -------------------------------------------------------------------------------- /screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BIYUEHU/gal-keeper/b98ede681a094375a8ddc414c42effcb0ec7f7aa/screenshots/2.png -------------------------------------------------------------------------------- /screenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BIYUEHU/gal-keeper/b98ede681a094375a8ddc414c42effcb0ec7f7aa/screenshots/3.png -------------------------------------------------------------------------------- /screenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BIYUEHU/gal-keeper/b98ede681a094375a8ddc414c42effcb0ec7f7aa/screenshots/4.png -------------------------------------------------------------------------------- /screenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BIYUEHU/gal-keeper/b98ede681a094375a8ddc414c42effcb0ec7f7aa/screenshots/5.png -------------------------------------------------------------------------------- /screenshots/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BIYUEHU/gal-keeper/b98ede681a094375a8ddc414c42effcb0ec7f7aa/screenshots/6.png -------------------------------------------------------------------------------- /scripts/common.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import * as readline from 'readline-sync' 3 | 4 | // biome-ignore lint: 5 | export type obj = { [key: string]: any } 6 | 7 | export const VNDB_URL = 'https://api.vndb.org/kana/vn' 8 | 9 | export const VNDB_HEADER = { 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | Accept: 'application/json' 13 | } 14 | } 15 | 16 | export function generateVNDBBody(name: string) { 17 | return { 18 | filters: ['search', '=', name], 19 | fields: 20 | 'id, title, image.url, released, titles.title, length_minutes, rating, screenshots.url, tags.name, developers.name, description, va.character.name, va.character.image.url, tags.rating, extlinks.name, extlinks.url' 21 | } 22 | } 23 | 24 | export async function searchVNDB(name: string) { 25 | try { 26 | const response = await axios.post(VNDB_URL, generateVNDBBody(name), VNDB_HEADER) 27 | 28 | const results = response.data.results 29 | if (results.length === 0) { 30 | console.log('No results found on VNDB.') 31 | return null 32 | } 33 | 34 | if (results.length === 1) { 35 | return results[0] 36 | } 37 | 38 | console.log('Multiple results found on VNDB. Please choose:') 39 | results.forEach((result: obj, index: number) => { 40 | console.log(`${index + 1}. ${result.title} (${result.original})`) 41 | }) 42 | 43 | const choice = readline.questionInt('Enter your choice: ') - 1 44 | return results[choice] 45 | } catch (error) { 46 | console.error('Error searching VNDB:', error) 47 | return null 48 | } 49 | } 50 | 51 | // 萌娘百科 API 52 | export async function searchMoegirl(name: string) { 53 | try { 54 | const response = await axios.get('https://zh.moegirl.org.cn/api.php', { 55 | params: { 56 | action: 'opensearch', 57 | search: name, 58 | limit: 10, 59 | format: 'json' 60 | } 61 | }) 62 | 63 | const results = response.data[1] 64 | console.log(results) 65 | if (results.length === 0) { 66 | console.log('No results found on Moegirl.') 67 | return null 68 | } 69 | 70 | if (results.length === 1) { 71 | return results[0] 72 | } 73 | 74 | console.log('Multiple results found on Moegirl. Please choose:') 75 | results.forEach((result: string, index: number) => { 76 | console.log(`${index + 1}. ${result}`) 77 | }) 78 | 79 | const choice = readline.questionInt('Enter your choice: ') - 1 80 | return results[choice] 81 | } catch (error) { 82 | console.error('Error searching Moegirl:', error) 83 | return null 84 | } 85 | } 86 | 87 | // Bangumi API 88 | export async function searchBangumi(name: string) { 89 | try { 90 | const response = await axios.get(`https://api.bgm.tv/search/subject/${encodeURIComponent(name)}`, { 91 | params: { 92 | type: 4, // 4 represents game 93 | responseGroup: 'simple', 94 | max_results: 10 95 | } 96 | }) 97 | 98 | const results = response.data.list 99 | if (results.length === 0) { 100 | console.log('No results found on Bangumi.') 101 | return null 102 | } 103 | 104 | if (results.length === 1) { 105 | return results[0] 106 | } 107 | 108 | console.log('Multiple results found on Bangumi. Please choose:') 109 | results.forEach((result: obj, index: number) => { 110 | console.log(`${index + 1}. ${result.name} (${result.name_cn})`) 111 | }) 112 | 113 | const choice = readline.questionInt('Enter your choice: ') - 1 114 | return results[choice] 115 | } catch (error) { 116 | console.error('Error searching Bangumi:', error) 117 | return null 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /scripts/export.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'node:fs' 2 | import axios from 'axios' 3 | import { VNDB_HEADER, VNDB_URL, generateVNDBBody, searchVNDB } from './common.js' 4 | import { join } from 'node:path' 5 | 6 | const list = [ 7 | "The witch's love diary", 8 | 'はつゆきさくら', 9 | 'アインシュタインより愛を込めて', 10 | 'サクラノ刻', 11 | 'アマツツミ', 12 | 'キラ☆キラ', 13 | '妹調教日記 〜こんなツンデレが俺の妹なわけない!〜', 14 | 'サクラノ詩-櫻の森の上を舞う-', 15 | '恋×シンアイ彼女', 16 | 'Narcissu', 17 | 'さくら、もゆ。', 18 | '紙の上の魔法使い', 19 | '素晴らしき日々~不連続存在~', 20 | '枯れない世界と終わる花', 21 | 'ニュートンと林檎の樹', 22 | '星空鉄道とシロの旅' 23 | ] 24 | 25 | async function main() { 26 | const result = await Promise.all( 27 | list.reverse().map(async (keyword, index) => { 28 | const data = (await axios.post(VNDB_URL, generateVNDBBody(keyword), VNDB_HEADER)).data.results[0] 29 | 30 | if (!data) { 31 | console.log(`No results found for ${keyword}`) 32 | return null 33 | } 34 | 35 | return { 36 | id: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 37 | const r = (Math.random() * 16) | 0 38 | return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) 39 | }), 40 | vndbId: data.id, 41 | bgmId: data.id, 42 | title: data.title, 43 | alias: data.titles.map((title) => title.title), 44 | cover: data.image?.url, 45 | description: data.description ?? '', 46 | tags: data.tags 47 | .filter((tag) => tag.rating >= 2) 48 | .sort((a, b) => b.rating - a.rating) 49 | .map((tag) => tag.name), 50 | // playMinutes: Math.floor(Math.random() * 100) + 100, 51 | playTimelines: [], 52 | expectedPlayHours: Number((data.length_minutes / 60).toFixed(1)), 53 | lastPlay: Date.now() - Math.random(), 54 | createDate: Date.now() - Math.random(), 55 | releaseDate: new Date(data.released).getTime(), 56 | rating: data.rating / 10, 57 | developer: data.developers[0]?.name ?? '', 58 | images: data.screenshots.map((screenshot) => screenshot.url), 59 | links: data.extlinks 60 | } 61 | }) 62 | ) 63 | const filtered = result.filter((game) => game) 64 | console.log(`Found ${filtered.length} games, not found: ${list.length - filtered.length}`) 65 | writeFileSync(join(process.cwd(), 'src/data/games.json'), JSON.stringify(result, null, 2)) 66 | } 67 | 68 | main() 69 | -------------------------------------------------------------------------------- /scripts/fetch.ts: -------------------------------------------------------------------------------- 1 | import { searchBangumi, searchMoegirl, searchVNDB } from './common.js' 2 | 3 | async function main() { 4 | // const name = readline.question('Enter a name to search: ') 5 | const name = "The Witch's Love Diary" 6 | console.log('\nSearching VNDB...') 7 | const vndbResult = await searchVNDB(name) 8 | if (vndbResult) { 9 | console.log('VNDB result:', JSON.stringify(vndbResult, null, 2)) 10 | } 11 | 12 | console.log('\nSearching Moegirl...') 13 | const moegirlResult = await searchMoegirl(name) 14 | if (moegirlResult) { 15 | console.log('Moegirl result:', moegirlResult) 16 | } 17 | 18 | console.log('\nSearching Bangumi...') 19 | const bangumiResult = await searchBangumi(name) 20 | if (bangumiResult) { 21 | console.log('Bangumi result:', JSON.stringify(bangumiResult, null, 2)) 22 | } 23 | } 24 | 25 | // Run the main function 26 | main().catch((error) => { 27 | console.error('An error occurred:', error) 28 | }) 29 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gal-keeper" 3 | version = "1.0.2" 4 | description = "A lightweight and fast visual novel management for coders, statistics and cloud syncing tool built with Tauri and React" 5 | authors = ["Arimura Sena"] 6 | edition = "2021" 7 | license = "BAN-ZHINESE-USING" 8 | 9 | [lib] 10 | name = "gal_keeper_lib" 11 | crate-type = ["staticlib", "cdylib", "rlib"] 12 | 13 | [build-dependencies] 14 | tauri-build = { version = "1", features = [] } 15 | 16 | [dependencies] 17 | tauri = { version = "1", features = [ 18 | "fs-all", 19 | "path-all", 20 | "dialog-all", 21 | "window-all", 22 | "shell-open", 23 | ] } 24 | serde = { version = "1", features = ["derive"] } 25 | serde_json = "1" 26 | reqwest = "0.12.12" 27 | base64 = "0.22.1" 28 | winapi = { version = "0.3.9", features = ["winuser"] } 29 | sysinfo = "0.33.1" 30 | rusty-leveldb = "3.0.2" 31 | roga = "1.0.0" 32 | 33 | [features] 34 | custom-protocol = ["tauri/custom-protocol"] 35 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/nanno.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BIYUEHU/gal-keeper/b98ede681a094375a8ddc414c42effcb0ec7f7aa/src-tauri/icons/nanno.ico -------------------------------------------------------------------------------- /src-tauri/icons/nanno.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BIYUEHU/gal-keeper/b98ede681a094375a8ddc414c42effcb0ec7f7aa/src-tauri/icons/nanno.png -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | use utils::database::{db_read_value, db_write_value}; 2 | use utils::details::{open_with_explorer, open_with_notepad, search_nearby_files_and_saves}; 3 | use utils::launch::launch_and_monitor; 4 | use utils::request::{send_http_request, url_to_base64}; 5 | use utils::sync::auto_sync; 6 | 7 | mod utils; 8 | 9 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 10 | pub fn run() { 11 | tauri::Builder::default() 12 | .invoke_handler(tauri::generate_handler![ 13 | db_read_value, 14 | db_write_value, 15 | open_with_explorer, 16 | open_with_notepad, 17 | launch_and_monitor, 18 | send_http_request, 19 | url_to_base64, 20 | search_nearby_files_and_saves, 21 | auto_sync 22 | ]) 23 | .run(tauri::generate_context!()) 24 | .expect("error while running tauri application"); 25 | } 26 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | 3 | fn main() { 4 | gal_keeper_lib::run() 5 | } 6 | -------------------------------------------------------------------------------- /src-tauri/src/utils/database.rs: -------------------------------------------------------------------------------- 1 | use roga::{transport::console::ConsoleTransport, *}; 2 | use rusty_leveldb::{Options, DB}; 3 | use std::fs; 4 | 5 | const DB_NAME: &'static str = "data"; 6 | 7 | struct Database { 8 | db: DB, 9 | logger: Logger, 10 | } 11 | 12 | impl Database { 13 | fn new(directory: &str) -> Result { 14 | let directory = std::path::Path::new(&directory); 15 | if !directory.exists() { 16 | fs::create_dir_all(directory) 17 | .map_err(|e| format!("Failed to create directory: {}", e))?; 18 | } 19 | 20 | let logger = Logger::new() 21 | .with_transport(ConsoleTransport { 22 | ..Default::default() 23 | }) 24 | .with_level(LoggerLevel::Info) 25 | .with_label("Database"); 26 | Ok(Database { 27 | db: DB::open(directory.join(DB_NAME), Options::default()).map_err(|e| { 28 | let msg = format!("Failed to open database: {}", e); 29 | l_fatal!(&logger, &msg); 30 | msg 31 | })?, 32 | logger, 33 | }) 34 | } 35 | 36 | fn read_value(&mut self, key: &str) -> Result { 37 | Ok(match self.db.get(key.as_bytes()) { 38 | Some(v) => String::from_utf8_lossy(&v).to_string(), 39 | None => "".to_string(), 40 | }) 41 | } 42 | 43 | fn write_value(&mut self, key: &str, value: &str) -> Result<(), String> { 44 | self.db.put(key.as_bytes(), value.as_bytes()).map_err(|e| { 45 | let msg = format!("Failed to write value to database: {}", e); 46 | l_error!(&self.logger, &msg); 47 | msg 48 | })?; 49 | Ok(()) 50 | } 51 | } 52 | 53 | #[tauri::command] 54 | pub fn db_read_value(directory: &str, key: &str) -> Result { 55 | let mut db = Database::new(directory)?; 56 | db.read_value(key) 57 | } 58 | 59 | #[tauri::command] 60 | pub fn db_write_value(directory: &str, key: &str, value: &str) -> Result<(), String> { 61 | let mut db = Database::new(directory)?; 62 | db.write_value(key, value) 63 | } 64 | -------------------------------------------------------------------------------- /src-tauri/src/utils/details.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::Path; 3 | use std::process::Command; 4 | 5 | #[tauri::command] 6 | pub fn search_nearby_files_and_saves( 7 | filepath: &str, 8 | ) -> Result<(Vec, Option), String> { 9 | let filepath = Path::new(filepath); 10 | if !filepath.exists() || !filepath.is_file() { 11 | return Err(format!("Invalid exe path: {}", filepath.display())); 12 | } 13 | 14 | let exe_dir = filepath 15 | .parent() 16 | .ok_or_else(|| "Cannot determine the parent directory.".to_string())?; 17 | 18 | let mut text_files = Vec::new(); 19 | let mut save_folder = None; 20 | 21 | for entry in fs::read_dir(exe_dir).map_err(|e| e.to_string())? { 22 | let entry = entry.map_err(|e| e.to_string())?; 23 | let path = entry.path(); 24 | 25 | if path.is_file() { 26 | if let Some(ext) = path.extension() { 27 | if ext == "txt" { 28 | text_files.push(path.clone()); 29 | } 30 | } 31 | } else if path.is_dir() { 32 | if { 33 | let mut has_save = false; 34 | for entry in fs::read_dir(path.clone()).unwrap() { 35 | let entry_path = entry.unwrap().path(); 36 | if !entry_path.is_file() { 37 | continue; 38 | } 39 | 40 | let ext = entry_path.extension().unwrap(); 41 | if vec!["sav", "save", "dat"].contains(&ext.to_str().unwrap_or("")) { 42 | has_save = true; 43 | break; 44 | } 45 | } 46 | has_save 47 | } { 48 | save_folder = Some(path.clone()); 49 | } 50 | } 51 | } 52 | 53 | Ok(( 54 | text_files 55 | .iter() 56 | .map(|p| p.to_str().unwrap().to_string()) 57 | .collect(), 58 | save_folder.map(|p| p.to_str().unwrap().to_string()), 59 | )) 60 | } 61 | 62 | #[cfg(target_os = "windows")] 63 | #[tauri::command] 64 | pub fn open_with_notepad(filepath: &str) -> Result<(), String> { 65 | Command::new("notepad") 66 | .arg(filepath) 67 | .spawn() 68 | .map_err(|e| format!("Failed to open file with Notepad: {}", e))?; 69 | Ok(()) 70 | } 71 | 72 | #[cfg(target_os = "macos")] 73 | #[tauri::command] 74 | pub fn open_with_notepad(filepath: &str) -> Result<(), String> { 75 | Command::new("open") 76 | .arg("-a") 77 | .arg("TextEdit") 78 | .arg(filepath) 79 | .spawn() 80 | .map_err(|e| format!("Failed to open file with TextEdit: {}", e))?; 81 | Ok(()) 82 | } 83 | 84 | #[cfg(target_os = "linux")] 85 | #[tauri::command] 86 | pub fn open_with_notepad(filepath: &str) -> Result<(), String> { 87 | Command::new("xdg-open") 88 | .arg(filepath) 89 | .spawn() 90 | .map_err(|e| format!("Failed to open file with default text editor: {}", e))?; 91 | Ok(()) 92 | } 93 | 94 | #[cfg(target_os = "windows")] 95 | #[tauri::command] 96 | pub fn open_with_explorer(directory: &str) -> Result<(), String> { 97 | Command::new("explorer") 98 | .arg(directory) 99 | .spawn() 100 | .map_err(|e| format!("Failed to open directory with File Explorer: {}", e))?; 101 | Ok(()) 102 | } 103 | 104 | #[cfg(target_os = "macos")] 105 | #[tauri::command] 106 | pub fn open_with_explorer(directory: &str) -> Result<(), String> { 107 | Command::new("open") 108 | .arg(directory) 109 | .spawn() 110 | .map_err(|e| format!("Failed to open directory with Finder: {}", e))?; 111 | Ok(()) 112 | } 113 | 114 | #[cfg(target_os = "linux")] 115 | #[tauri::command] 116 | pub fn open_with_explorer(directory: &str) -> Result<(), &str> { 117 | Command::new("xdg-open") 118 | .arg(directory) 119 | .spawn() 120 | .map_err(|e| format!("Failed to open directory with File Explorer: {}", e))?; 121 | Ok(()) 122 | } 123 | -------------------------------------------------------------------------------- /src-tauri/src/utils/launch.rs: -------------------------------------------------------------------------------- 1 | use roga::transport::console::ConsoleTransport; 2 | use roga::*; 3 | use std::path::Path; 4 | use std::process::Command; 5 | use std::thread; 6 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; 7 | use sysinfo::{Pid, ProcessesToUpdate, System}; 8 | use tauri::{AppHandle, Manager}; 9 | use winapi::um::winuser::{GetForegroundWindow, GetWindowThreadProcessId}; 10 | 11 | fn get_secs_timestamp() -> u64 { 12 | SystemTime::now() 13 | .duration_since(UNIX_EPOCH) 14 | .unwrap() 15 | .as_secs() 16 | } 17 | 18 | #[derive(Clone)] 19 | struct Monitor { 20 | app_handle: AppHandle, 21 | filepath: String, 22 | id: String, 23 | start_time: u64, 24 | pid: Option, 25 | is_child: bool, 26 | logger: Logger, 27 | } 28 | 29 | impl Monitor { 30 | fn new(app_handle: AppHandle, filepath: String, id: String) -> Self { 31 | Self { 32 | app_handle, 33 | filepath, 34 | id: id.clone(), 35 | start_time: 0, 36 | pid: None, 37 | is_child: false, 38 | logger: Logger::new() 39 | .with_transport(ConsoleTransport { 40 | ..Default::default() 41 | }) 42 | .with_level(LoggerLevel::Info) 43 | .with_label(id.clone()), 44 | } 45 | } 46 | 47 | fn monit(&mut self) -> Result<(), String> { 48 | let pid = Command::new(self.filepath.clone()) 49 | .current_dir(Path::new(self.filepath.as_str()).join("..")) 50 | .spawn() 51 | .map_err(|e| { 52 | let msg = format!("Failed to launch program: {}", e); 53 | l_error!(self.logger, "{}", msg); 54 | msg 55 | })? 56 | .id(); 57 | self.pid = Some(Pid::from_u32(pid)); 58 | l_info!(self.logger, "Process ID: {}", pid); 59 | 60 | self.start_time = get_secs_timestamp(); 61 | 62 | let mut thread_self = self.clone(); 63 | thread::spawn(move || { 64 | thread_self 65 | .monitor_process() 66 | .map_err(|e| { 67 | let msg = format!("Failed to monitor process: {}", e); 68 | l_error!(thread_self.logger, "{}", msg); 69 | msg 70 | }) 71 | .unwrap(); 72 | 73 | l_info!( 74 | thread_self.logger, 75 | "Monitoring finished. Start time: {}, Stop time: {}", 76 | thread_self.start_time, 77 | get_secs_timestamp() 78 | ); 79 | }); 80 | 81 | Ok(()) 82 | } 83 | 84 | fn monitor_process(&mut self) -> Result<(), String> { 85 | if self.pid.is_none() { 86 | return Ok(()); 87 | } 88 | 89 | let mut system = System::new_all(); 90 | 91 | loop { 92 | system.refresh_processes(ProcessesToUpdate::All, true); 93 | 94 | if system.process(self.pid.unwrap()).is_some() { 95 | if self.is_window_active().unwrap_or(false) { 96 | self.app_handle 97 | .emit_all( 98 | "increase", 99 | (self.id.clone(), self.start_time, get_secs_timestamp()), 100 | ) 101 | .map_err(|e| { 102 | let msg = format!("Failed to emit event: {}", e); 103 | l_error!(self.logger, "{}", msg); 104 | msg 105 | })?; 106 | } 107 | thread::sleep(Duration::from_secs(1)); 108 | } else if !self.is_child { 109 | system.refresh_processes(ProcessesToUpdate::All, true); 110 | self.get_child_process(&mut system); 111 | if let Some(target_pid) = self.pid { 112 | l_info!( 113 | self.logger, 114 | "Found child process process with PID: {}", 115 | target_pid 116 | ); 117 | self.is_child = true; 118 | self.monitor_process().map_err(|e| { 119 | let msg = format!("Failed to monitor child process: {}", e); 120 | l_error!(self.logger, "{}", msg); 121 | msg 122 | })?; 123 | } else { 124 | l_warn!( 125 | self.logger, 126 | "Target process exited and no child process found." 127 | ); 128 | } 129 | break; 130 | } else { 131 | l_warn!(self.logger, "Target process exited."); 132 | break; 133 | } 134 | } 135 | 136 | Ok(()) 137 | } 138 | 139 | fn get_child_process(&mut self, system: &mut System) { 140 | if self.pid.is_none() { 141 | return; 142 | } 143 | 144 | system.refresh_processes(ProcessesToUpdate::All, true); 145 | 146 | for (pid, process) in system.processes() { 147 | if let Some(parent_pid) = process.parent() { 148 | if parent_pid == self.pid.unwrap() { 149 | self.pid = Some(*pid); 150 | } 151 | } 152 | } 153 | } 154 | 155 | fn is_window_active(&self) -> Result { 156 | if self.pid.is_none() { 157 | return Ok(false); 158 | } 159 | 160 | let result = (unsafe { 161 | let hwnd = GetForegroundWindow(); 162 | if hwnd.is_null() { 163 | return Err("No active window found.".to_string()); 164 | } 165 | 166 | let mut target_id: u32 = 0; 167 | GetWindowThreadProcessId(hwnd, &mut target_id); 168 | if target_id == 0 { 169 | return Err("Failed to get process ID for the active window.".to_string()); 170 | } 171 | 172 | Ok(target_id == self.pid.unwrap().as_u32()) 173 | } as Result) 174 | .map_err(|e| { 175 | let msg = format!("Failed to check if window is active: {}", e); 176 | l_error!(self.logger, "{}", msg); 177 | msg 178 | })?; 179 | l_record!(self.logger, "Is window active: {}", result); 180 | Ok(result) 181 | } 182 | } 183 | 184 | #[tauri::command] 185 | pub fn launch_and_monitor(app_handle: AppHandle, filepath: &str, id: String) -> Result<(), String> { 186 | let mut monitor = Monitor::new(app_handle, filepath.to_string(), id.clone()); 187 | monitor.monit()?; 188 | Ok(()) 189 | } 190 | -------------------------------------------------------------------------------- /src-tauri/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod database; 2 | pub mod details; 3 | pub mod launch; 4 | pub mod request; 5 | pub mod sync; 6 | -------------------------------------------------------------------------------- /src-tauri/src/utils/request.rs: -------------------------------------------------------------------------------- 1 | use base64::{engine::general_purpose::STANDARD, Engine as _}; 2 | use reqwest::Client; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Deserialize)] 6 | pub struct HttpRequestConfig { 7 | pub method: String, 8 | pub url: String, 9 | pub headers: Option>, 10 | pub body: Option, 11 | pub user_agent: Option, 12 | } 13 | 14 | #[derive(Debug, Serialize)] 15 | pub struct HttpResponse { 16 | pub status: u16, 17 | pub body: String, 18 | pub headers: Vec<(String, String)>, 19 | } 20 | 21 | #[tauri::command] 22 | pub async fn send_http_request(config: HttpRequestConfig) -> Result { 23 | let client = Client::new(); 24 | 25 | let mut request = client.request( 26 | config.method.parse().map_err(|_| "Invalid HTTP method")?, 27 | &config.url, 28 | ); 29 | 30 | if let Some(user_agent) = config.user_agent { 31 | request = request.header("User-Agent", user_agent); 32 | } 33 | 34 | if let Some(headers) = config.headers { 35 | for (key, value) in headers { 36 | request = request.header(&key, &value); 37 | } 38 | } 39 | 40 | if let Some(body) = config.body { 41 | request = request.body(body); 42 | } 43 | 44 | let response = request 45 | .send() 46 | .await 47 | .map_err(|err| format!("HTTP request failed: {}", err))?; 48 | 49 | let status = response.status().as_u16(); 50 | let headers = response 51 | .headers() 52 | .iter() 53 | .map(|(key, value)| (key.to_string(), value.to_str().unwrap_or("").to_string())) 54 | .collect(); 55 | let body = response.text().await.unwrap_or_default(); 56 | 57 | Ok(HttpResponse { 58 | status, 59 | body, 60 | headers, 61 | }) 62 | } 63 | 64 | #[tauri::command] 65 | pub async fn url_to_base64(url: &str) -> Result { 66 | let response = reqwest::get(url) 67 | .await 68 | .map_err(|err| format!("Failed to fetch URL: {}", err))?; 69 | 70 | if !response.status().is_success() { 71 | return Err(format!("Failed to fetch URL: {}", response.status()).into()); 72 | } 73 | 74 | let bytes = response.bytes().await.unwrap_or_default(); 75 | 76 | let mime_type = "image/png"; 77 | let base64_string = format!("data:{};base64,{}", mime_type, STANDARD.encode(&bytes)); 78 | 79 | Ok(base64_string) 80 | } 81 | -------------------------------------------------------------------------------- /src-tauri/src/utils/sync.rs: -------------------------------------------------------------------------------- 1 | use roga::{transport::console::ConsoleTransport, *}; 2 | use std::{thread, time::Duration}; 3 | use tauri::{AppHandle, Manager}; 4 | 5 | #[derive(Clone)] 6 | struct Sync { 7 | app_handle: AppHandle, 8 | duration_minutes: u64, 9 | logger: Logger, 10 | id: String, 11 | } 12 | 13 | impl Sync { 14 | pub fn new(app_handle: AppHandle, duration_minutes: u64, id: String) -> Self { 15 | Self { 16 | app_handle, 17 | duration_minutes, 18 | logger: Logger::new() 19 | .with_transport(ConsoleTransport { 20 | ..Default::default() 21 | }) 22 | .with_level(LoggerLevel::Info) 23 | .with_label("Sync") 24 | .with_label(id.clone()), 25 | id, 26 | } 27 | } 28 | 29 | pub fn run(&self) { 30 | l_info!( 31 | &self.logger, 32 | "Auto sync to github every {} minutes", 33 | self.duration_minutes 34 | ); 35 | 36 | let thread_self = self.clone(); 37 | thread::spawn(move || loop { 38 | l_info!(&thread_self.logger, "Syncing to github..."); 39 | if let Err(e) = thread_self 40 | .app_handle 41 | .emit_all("sync", thread_self.id.clone()) 42 | { 43 | l_error!(&thread_self.logger, "Failed to emit event: {}", e); 44 | } 45 | thread::sleep(Duration::from_secs(thread_self.duration_minutes * 60)); 46 | }); 47 | } 48 | } 49 | 50 | #[tauri::command] 51 | pub fn auto_sync(app_handle: AppHandle, duration_minutes: u64, id: String) { 52 | let sync = Sync::new(app_handle, duration_minutes, id); 53 | sync.run(); 54 | } 55 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "beforeDevCommand": "pnpm dev", 4 | "beforeBuildCommand": "pnpm build", 5 | "devPath": "http://localhost:327", 6 | "distDir": "../dist", 7 | "withGlobalTauri": true 8 | }, 9 | "package": { 10 | "productName": "Nanno", 11 | "version": "1.0.1" 12 | }, 13 | "tauri": { 14 | "allowlist": { 15 | "shell": { 16 | "open": true 17 | }, 18 | "window": { 19 | "all": true 20 | }, 21 | "dialog": { 22 | "all": true 23 | }, 24 | "path": { 25 | "all": true 26 | }, 27 | "fs": { 28 | "all": true, 29 | "scope": [ 30 | "**" 31 | ] 32 | } 33 | }, 34 | "windows": [ 35 | { 36 | "title": "Nanno ⭐ GalKeeper", 37 | "width": 1200, 38 | "height": 720, 39 | "minWidth": 630, 40 | "minHeight": 490 41 | } 42 | ], 43 | "security": { 44 | "csp": null 45 | }, 46 | "bundle": { 47 | "active": true, 48 | "targets": "all", 49 | "identifier": "com.gal-keeper.nanno", 50 | "icon": [ 51 | "icons/nanno.png", 52 | "icons/nanno.ico" 53 | ], 54 | "copyright": "© Copyright 2024 - 2025 Arimura Sena", 55 | "category": "WordGame", 56 | "windows": { 57 | "wix": { 58 | "language": "ja-JP" 59 | }, 60 | "nsis": { 61 | "license": "../LICENSE", 62 | "installerIcon": "icons/nanno.ico" 63 | } 64 | } 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, HashRouter, Route, Routes } from 'react-router-dom' 2 | import { FluentProvider, webLightTheme } from '@fluentui/react-components' 3 | import routes from '@/routes' 4 | import Layout from '@/components/Layout' 5 | import { UIProvider } from '@/contexts/UIContext' 6 | import { useEffect, useRef, useState } from 'react' 7 | import useStore from '@/store' 8 | import i18n, { f, t } from '@/utils/i18n' 9 | import { cacheImage, openUrl } from '@/utils' 10 | import logger, { invokeLogger } from '@/utils/logger' 11 | import { IS_DEV, IS_TAURI } from '@/constant' 12 | import { version } from '@/../package.json' 13 | import axios from 'axios' 14 | import { listen } from '@tauri-apps/api/event' 15 | import { syncToGithub } from '@/api/github' 16 | import { invoke } from '@tauri-apps/api' 17 | import { random } from '@kotori-bot/tools' 18 | 19 | const App: React.FC = () => { 20 | const syncId = useRef(random.int(0, 1000000).toString()) 21 | const lastSyncNoticeTime = useRef(0) 22 | const state = useStore((state) => state) 23 | const [initialized, setInitialized] = useState(false) 24 | const [latestVersion, setLatestVersion] = useState('') 25 | 26 | useEffect(() => { 27 | if (!state._hasHydrated || initialized) return 28 | 29 | Promise.race( 30 | [ 31 | 'https://cdn.jsdelivr.net/gh/biyuehu/gal-keeper/package.json', 32 | 'https://raw.githubusercontent.com/BIYUEHU/gal-keeper/refs/heads/main/package.json' 33 | ].map((url) => axios.get(url).catch(() => null)) 34 | ) 35 | .then((res) => { 36 | if (!res) return setLatestVersion('error') 37 | if (res.data.version !== version) setLatestVersion(res.data.version) 38 | }) 39 | .finally(() => { 40 | i18n.set(state.settings.language) 41 | state.gameData.map((game) => cacheImage(game).catch((e) => invokeLogger.error(e))) 42 | 43 | if (initialized) return 44 | if (state.settings.playLaunchVoice && !latestVersion) new Audio('/assets/launch.wav').play() 45 | ;(window as { hideLoading?: () => void }).hideLoading?.() 46 | setInitialized(true) 47 | 48 | // useStore.setState({ 49 | // ...DEFAULT_STATE, 50 | // ...state, 51 | // settings: { 52 | // ...DEFAULT_STATE.settings, 53 | // ...state.settings 54 | // }, 55 | // sync: { 56 | // ...DEFAULT_STATE.sync, 57 | // ...state.sync 58 | // } 59 | // }) 60 | 61 | if (!IS_TAURI || !state.settings.autoSyncMinutes || state.settings.autoSyncMinutes <= 0) return 62 | invoke('auto_sync', { 63 | durationMinutes: state.settings.autoSyncMinutes, 64 | id: syncId.current 65 | }).catch((e) => invokeLogger.error(e)) 66 | listen('sync', ({ payload }) => { 67 | if (syncId.current !== payload) return 68 | if (Date.now() - lastSyncNoticeTime.current < 1000 * 10) return 69 | if (lastSyncNoticeTime.current === 0 && IS_DEV) return 70 | lastSyncNoticeTime.current = Date.now() 71 | const { githubToken, githubRepo, githubPath } = useStore.getState().settings 72 | if (!githubToken || !githubRepo || !githubPath) { 73 | logger.warn('auto-sync skipped due to missing settings.') 74 | return 75 | } 76 | logger.debug('starting auto-sync...') 77 | syncToGithub().then(() => { 78 | logger.debug('auto-sync finished.') 79 | }) 80 | }) 81 | }) 82 | }, [state, initialized, latestVersion]) 83 | 84 | if (!initialized) return null 85 | 86 | if (!IS_DEV && latestVersion) { 87 | return ( 88 |
89 |

90 | {latestVersion === 'error' ? t`app.update.title.failed` : t`app.update.title`} 91 |

92 | 93 | {latestVersion === 'error' ? t`app.update.message.failed` : f`app.update.message`(version, latestVersion)} 94 | 95 | {latestVersion !== 'error' && ( 96 | 103 | )} 104 |
105 | ) 106 | } 107 | 108 | return ( 109 | 110 | {((children) => (IS_TAURI ? {children} : {children}))( 111 | 112 | 113 | {routes.map((route) => ( 114 | } 118 | /> 119 | ))} 120 | 121 | 122 | )} 123 | 124 | ) 125 | } 126 | 127 | export default App 128 | -------------------------------------------------------------------------------- /src/api/bgm.ts: -------------------------------------------------------------------------------- 1 | import type { FetchGameData } from '@/types' 2 | import http, { createHttp } from './http' 3 | import useStore from '@/store' 4 | 5 | interface BgmMe { 6 | username: string 7 | nickname: string 8 | id: number 9 | sign: string 10 | } 11 | 12 | const bgmHttp = createHttp() 13 | 14 | bgmHttp.interceptors.request.use((config) => { 15 | const { bgmToken } = useStore.getState().settings 16 | if (bgmToken) config.headers.Authorization = `Bearer ${bgmToken}` 17 | // config.headers['Content-Type'] = 'application/json' 18 | // config.headers['User-Agent'] = `biyuehu/gal-keeper/${version} (https://github.com/biyuehu/gal-keeper)` 19 | config.baseURL = 'https://api.bgm.tv' 20 | return config 21 | }) 22 | 23 | export async function getBgmMe(): Promise { 24 | return (await bgmHttp.get('/v0/me')).data 25 | } 26 | 27 | export async function fetchFromBgm(name: string, id?: string): Promise { 28 | let tempId = id 29 | if (!id) { 30 | // const res = (await invoke('send_http_request', { 31 | // config: { 32 | // method: 'POST', 33 | // url: `https://api.bgm.tv/search/subject/${encodeURIComponent(name)}`, 34 | // userAgent: `biyuehu/gal-keeper/${version} (https://github.com/biyuehu/gal-keeper)` 35 | // } 36 | // }).catch((e) => { 37 | // invokeLogger.error(e) 38 | // throw e 39 | // // biome-ignore lint: 40 | // })) as any 41 | // if (res.status !== 200) { 42 | // httpLogger.error(res.body) 43 | // throw new Error(`Bangumi API error, status code: ${res.status}, body: ${res.body}`) 44 | // } 45 | 46 | // const list = (() => { 47 | // try { 48 | // return JSON.parse(res.body) 49 | // } catch (e) { 50 | // httpLogger.error(e) 51 | // throw e 52 | // } 53 | // })().list.filter(({ type }: { type: number }) => type === 4) 54 | const { list } = (await http.get(`https://api.bgm.tv/search/subject/${encodeURIComponent(name)}?type=4`)).data 55 | if (!list || list.length === 0) return null 56 | tempId = list[0].id 57 | } 58 | const { data } = await bgmHttp.get(`/v0/subjects/${tempId}`) 59 | 60 | return { 61 | vndbId: undefined, 62 | bgmId: String(tempId), 63 | title: data.name, 64 | alias: data.name_cn ? [data.name_cn] : [], 65 | cover: data.images?.large ?? '', 66 | description: data.summary ?? '', 67 | tags: data.tags 68 | .sort((a: { count: number }, b: { count: number }) => a.count - b.count) 69 | .slice(0, 30) 70 | .map(({ name }: { name: string }) => name), 71 | expectedPlayHours: 0, 72 | releaseDate: new Date(data.date).getTime(), 73 | rating: data.rating?.score ?? 0, 74 | developer: data.infobox.find((info: { key: string }) => info.key === '开发')?.value ?? '', 75 | images: data.images ? [data.images.large] : [], 76 | links: [] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/api/github.ts: -------------------------------------------------------------------------------- 1 | import useStore from '@/store/' 2 | import type { RootState } from '@/store' 3 | import http, { createHttp } from './http' 4 | import { cloudDataSchema, type GameData } from '@/types' 5 | import { SHARED_JSON_FILE } from '@/constant' 6 | import axios, { AxiosError } from 'axios' 7 | import { base64Decode, base64Encode, showMinutes } from '@/utils' 8 | 9 | const githubHttp = createHttp() 10 | 11 | githubHttp.interceptors.request.use((config) => { 12 | const { githubToken } = useStore.getState().settings 13 | if (githubToken) config.headers.Authorization = `token ${githubToken}` 14 | return config 15 | }) 16 | 17 | function getBaseUrl() { 18 | return `https://api.github.com/repos/${useStore.getState().settings.githubRepo}` 19 | } 20 | 21 | function getFileUrl(file: string) { 22 | let { githubPath } = useStore.getState().settings 23 | githubPath = githubPath.endsWith('/') ? githubPath : `${githubPath}/` 24 | githubPath = githubPath.startsWith('/') ? githubPath.slice(1) : githubPath 25 | return `${getBaseUrl()}/contents/${githubPath}${file}` 26 | } 27 | 28 | export async function getRepoInfo() { 29 | const { data } = await githubHttp.get(getBaseUrl()) 30 | useStore.setState( 31 | (state): Partial => ({ 32 | sync: { 33 | ...state.sync, 34 | size: data.size, 35 | visibility: data.visibility, 36 | username: data.owner.login, 37 | avatar: data.owner.avatar_url 38 | } 39 | }) 40 | ) 41 | return data 42 | } 43 | 44 | // biome-ignore lint: 45 | export async function readFileFromGithub(file: string): Promise { 46 | try { 47 | const res = ( 48 | await axios.get(getFileUrl(file), { 49 | headers: { Authorization: `token ${useStore.getState().settings.githubToken}` } 50 | }) 51 | ).data 52 | const str = res.content 53 | ? base64Decode(res.content) 54 | : JSON.stringify((await http.get(res.download_url.split('?token=')[0])).data) 55 | try { 56 | return JSON.parse(str) 57 | } catch { 58 | return str 59 | } 60 | } catch (error) { 61 | if (error instanceof AxiosError && error.response?.data.status === '404') { 62 | await writeFileToGithub('Sync: Initial data', SHARED_JSON_FILE, { deleteIds: [], data: [] }, true) 63 | return await readFileFromGithub(file) 64 | } 65 | throw error 66 | } 67 | } 68 | 69 | export async function writeFileToGithub(message: string, file: string, content: string | object, isCrate = false) { 70 | const url = getFileUrl(file) 71 | return await githubHttp.put(url, { 72 | message, 73 | content: base64Encode(typeof content === 'string' ? content : JSON.stringify(content, null, 2)), 74 | sha: isCrate ? undefined : (await githubHttp.get(url)).data.sha 75 | }) 76 | } 77 | 78 | export async function syncToGithub() { 79 | const { 80 | sync: { deleteIds }, 81 | gameData: data 82 | } = useStore.getState() 83 | const { deleteIds: cloudDeltedIds, data: cloudData } = cloudDataSchema.parse( 84 | await readFileFromGithub(SHARED_JSON_FILE) 85 | ) 86 | 87 | const newDeleteIds = Array.from(new Set([...deleteIds, ...cloudDeltedIds])) 88 | 89 | const [dataIds, cloudDataIds] = [data, cloudData].map((arr) => 90 | arr.map(({ id }) => id).filter((id) => !newDeleteIds.includes(id)) 91 | ) 92 | 93 | const newData = [ 94 | ...cloudData 95 | .filter((item) => !deleteIds.includes(item.id)) 96 | .map( 97 | (item): GameData => 98 | dataIds.includes(item.id) 99 | ? ((target): GameData => ({ 100 | ...(target.updateDate >= item.updateDate ? target : item), 101 | lastPlay: Math.max(target.lastPlay, item.lastPlay), 102 | playTimelines: [ 103 | ...target.playTimelines, 104 | ...item.playTimelines.filter((timeline) => !target.playTimelines.some((t) => t[0] === timeline[0])) 105 | ].sort((a, b) => a[0] - b[0]) 106 | }))(data.find((local) => local.id === item.id) as GameData) 107 | : item 108 | ), 109 | ...data.filter(({ id }) => !cloudDataIds.includes(id) && !cloudDeltedIds.includes(id)) 110 | ] 111 | 112 | const [totalTime, cloudTotalTime] = [data, cloudData].map((arr) => 113 | arr.reduce((last, { playTimelines }) => last + playTimelines.reduce((sum, [, , time]) => sum + time, 0), 0) 114 | ) 115 | 116 | const commitMsg = [ 117 | totalTime > cloudTotalTime ? `Playtime +${showMinutes((totalTime - cloudTotalTime) / 60)}` : null, 118 | newData.length > cloudData.length ? `Add ${newData.length - cloudData.length} games` : null, 119 | cloudDataIds.length < cloudData.length ? `Remove ${cloudData.length - cloudDataIds.length} games` : null, 120 | ((count) => (count > 0 ? `Update ${count} games` : null))( 121 | cloudData 122 | .filter((item) => !deleteIds.includes(item.id)) 123 | .reduce( 124 | (acc, cur) => 125 | acc + 126 | (dataIds.includes(cur.id) && 127 | (data.find((local) => local.id === cur.id) as GameData).updateDate !== cur.updateDate 128 | ? 1 129 | : 0), 130 | 0 131 | ) 132 | ) 133 | ] 134 | .filter((item) => item) 135 | .join(', ') 136 | .trim() 137 | 138 | const updateLocal = () => 139 | useStore.setState((state) => ({ 140 | sync: { 141 | ...state.sync, 142 | time: Date.now(), 143 | deleteIds: newDeleteIds 144 | }, 145 | gameData: newData 146 | })) 147 | 148 | if (!commitMsg) { 149 | updateLocal() 150 | return true 151 | } 152 | 153 | await writeFileToGithub(`sync: ${commitMsg}`, SHARED_JSON_FILE, { deleteIds: newDeleteIds, data: newData }).then(() => 154 | updateLocal() 155 | ) 156 | return true 157 | } 158 | -------------------------------------------------------------------------------- /src/api/http.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { httpLogger } from '@/utils/logger' 3 | 4 | export function createHttp() { 5 | const http = axios.create({}) 6 | 7 | http.interceptors.response.use(undefined, (err) => { 8 | httpLogger.error(err) 9 | return Promise.reject(err) 10 | }) 11 | 12 | return http 13 | } 14 | 15 | const http = createHttp() 16 | 17 | export default http 18 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import type { FetchGameData, FetchMethods } from '@/types' 2 | import { fetchFromVndb } from './vndb' 3 | import { fetchFromMixed } from './mixed' 4 | import { fetchFromBgm } from './bgm' 5 | 6 | export async function fetchGameData( 7 | method: FetchMethods, 8 | name: string, 9 | [bgmId, vndbId]: [string?, string?] = [] 10 | ): Promise { 11 | switch (method) { 12 | case 'vndb': 13 | return await fetchFromVndb(name, vndbId) 14 | case 'bgm': 15 | return await fetchFromBgm(name, bgmId) 16 | case 'mixed': 17 | return await fetchFromMixed(name, [bgmId, vndbId]) 18 | } 19 | return null 20 | } 21 | -------------------------------------------------------------------------------- /src/api/mixed.ts: -------------------------------------------------------------------------------- 1 | import type { FetchGameData } from '@/types' 2 | import { fetchFromBgm } from './bgm' 3 | import { fetchFromVndb } from './vndb' 4 | 5 | export async function fetchFromMixed( 6 | name: string, 7 | [bgmId, vndbId]: [string?, string?] = [] 8 | ): Promise { 9 | const [bgmData, vndbData] = await Promise.all([ 10 | fetchFromBgm(name, bgmId).catch(() => null), 11 | fetchFromVndb(name, vndbId) 12 | ]) 13 | if (!bgmData && !vndbData) return null 14 | return { 15 | bgmId: bgmData?.bgmId, 16 | vndbId: vndbData?.vndbId, 17 | title: (bgmData?.title || vndbData?.title) ?? '', 18 | alias: Array.from(new Set([bgmData?.alias, vndbData?.alias].flat().filter((item) => item))) as string[], 19 | cover: (bgmData?.cover || vndbData?.cover) ?? '', 20 | description: (bgmData?.description || vndbData?.description) ?? '', 21 | tags: (bgmData?.tags || vndbData?.tags) ?? [], 22 | expectedPlayHours: vndbData?.expectedPlayHours ?? 0, 23 | releaseDate: bgmData?.releaseDate ?? vndbData?.releaseDate ?? 0, 24 | rating: bgmData?.rating ?? vndbData?.rating ?? 0, 25 | developer: vndbData?.developer ?? '', 26 | images: vndbData?.images ?? [], 27 | links: vndbData?.links ?? [] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/api/vndb.ts: -------------------------------------------------------------------------------- 1 | import type { FetchGameData } from '@/types' 2 | import { createHttp } from './http' 3 | import useStore from '@/store' 4 | 5 | interface VndbAuthInfo { 6 | id: string 7 | username: string 8 | permissions: string[] 9 | } 10 | 11 | const vndbHttp = createHttp() 12 | 13 | vndbHttp.interceptors.request.use((config) => { 14 | const { vndbToken } = useStore.getState().settings 15 | if (vndbToken) config.headers.Authorization = `token ${vndbToken}` 16 | config.headers.Accept = 'application/json' 17 | config.headers['Content-Type'] = 'application/json' 18 | config.baseURL = 'https://api.vndb.org/kana' 19 | return config 20 | }) 21 | 22 | function generateVndbBody(name: string, isId: boolean) { 23 | return { 24 | filters: isId ? ['id', '=', name] : ['search', '=', name], 25 | fields: 26 | 'id, title, image.url, released, titles.title, length_minutes, rating, screenshots.url, tags.name, developers.name, description, va.character.name, va.character.image.url, tags.rating, extlinks.name, extlinks.url' 27 | } 28 | } 29 | 30 | export async function getVndbAuthInfo(): Promise { 31 | return (await vndbHttp.get('/authinfo')).data 32 | } 33 | 34 | export async function fetchFromVndb(name: string, id?: string): Promise { 35 | const data = (await vndbHttp.post('/vn', generateVndbBody(id || name, !!id))).data.results[0] 36 | if (!data) return null 37 | 38 | return { 39 | bgmId: undefined, 40 | vndbId: String(data.id), 41 | title: data.title, 42 | alias: data.titles.map((title: { title: string }) => title.title), 43 | cover: data.image?.url ?? '', 44 | description: data.description ?? '', 45 | tags: (data.tags as { rating: number; name: string }[]) 46 | .filter((tag) => tag.rating >= 2) 47 | .sort((a, b) => b.rating - a.rating) 48 | .slice(0, 30) 49 | .map(({ name }) => name), 50 | expectedPlayHours: Number((data.length_minutes / 60).toFixed(1)), 51 | releaseDate: new Date(data.released).getTime(), 52 | rating: Number((data.rating / 10).toFixed(1)), 53 | developer: data.developers[0]?.name ?? '', 54 | images: data.screenshots.map((screenshot: { url: string }) => screenshot.url), 55 | links: data.extlinks 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/AddModal/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Modal } from '@fluentui/react/lib/Modal' 3 | import { Stack } from '@fluentui/react/lib/Stack' 4 | import { PrimaryButton, DefaultButton } from '@fluentui/react' 5 | import { TextField } from '@fluentui/react/lib/TextField' 6 | import { Text } from '@fluentui/react/lib/Text' 7 | import { IS_TAURI } from '@/constant' 8 | import useStore from '@/store' 9 | import { fetchGameData } from '@/api' 10 | import { generateUuid } from '@/utils' 11 | import type { GameWithLocalData } from '@/types' 12 | import { t } from '@/utils/i18n' 13 | import { dialog } from '@tauri-apps/api' 14 | import { useUI } from '@/contexts/UIContext' 15 | import { invokeLogger } from '@/utils/logger' 16 | 17 | interface AddModalProps { 18 | isOpen: boolean 19 | setIsOpen: (isOpen: boolean) => void 20 | setData: (fn: (data: GameWithLocalData[]) => GameWithLocalData[]) => void 21 | } 22 | 23 | const AddModal: React.FC = ({ isOpen, setIsOpen, setData }) => { 24 | const [formData, setFormData] = useState({ programFile: '', gameName: '' }) 25 | const { openFullLoading, openAlert } = useUI() 26 | 27 | const { 28 | addGameData, 29 | settings: { fetchMethods, autoSetGameTitle } 30 | } = useStore((state) => state) 31 | 32 | useEffect(() => { 33 | if (isOpen) setFormData({ programFile: '', gameName: '' }) 34 | }, [isOpen]) 35 | 36 | const handleSelectProgram = async () => { 37 | const filepath = await dialog 38 | .open({ 39 | title: t`component.addModal.dialog.selectProgram`, 40 | directory: false, 41 | filters: [{ name: t`component.addModal.dialog.filter.executable`, extensions: ['exe'] }] 42 | }) 43 | .catch((e) => invokeLogger.error(e)) 44 | if (filepath) { 45 | setFormData((state) => ({ 46 | gameName: state.gameName ? state.gameName : (filepath as string).split(/[/\\]/).slice(-2, -1)[0], 47 | programFile: filepath as string 48 | })) 49 | } 50 | } 51 | 52 | const handleSubmit = async () => { 53 | if (!formData.gameName) return 54 | const close = openFullLoading() 55 | setIsOpen(true) 56 | 57 | const id = generateUuid() 58 | const fetchData = await fetchGameData(fetchMethods, formData.gameName).catch((e) => { 59 | close() 60 | setIsOpen(false) 61 | throw e 62 | }) 63 | const game: GameWithLocalData = { 64 | id, 65 | vndbId: undefined, 66 | bgmId: undefined, 67 | updateDate: Date.now() / 1000, 68 | alias: [], 69 | description: '', 70 | tags: [], 71 | playTimelines: [], 72 | expectedPlayHours: 0, 73 | lastPlay: 0, 74 | createDate: Date.now(), 75 | releaseDate: 0, 76 | rating: 0, 77 | developer: '', 78 | images: [], 79 | links: [], 80 | local: formData.programFile 81 | ? { 82 | id, 83 | // TODO: auto find save and guide file 84 | // ...(await invoke('search_nearby_files_and_saves')), 85 | programFile: formData.programFile 86 | } 87 | : undefined, 88 | cover: '', 89 | ...fetchData, 90 | title: fetchData?.title && autoSetGameTitle ? fetchData.title : formData.gameName 91 | } 92 | 93 | const tags = game.tags.join('|').replace(/ /g, '').toLowerCase() 94 | if ( 95 | [ 96 | '国gal', 97 | '国产', 98 | '国产galgame', 99 | '中国', 100 | 'china', 101 | '国g', 102 | '国人', 103 | '中国风', 104 | '国风', 105 | '中国人', 106 | '国产vn', 107 | '国产gal', 108 | '国v' 109 | ].some((keyword) => tags.includes(keyword)) 110 | ) { 111 | openAlert('抱歉,暫不支持添加該類遊戲') 112 | setIsOpen(false) 113 | close() 114 | return 115 | } 116 | 117 | if ( 118 | useStore 119 | .getState() 120 | .gameData.some((item) => item.title.includes(formData.gameName) || item.title.includes(game.title)) 121 | ) { 122 | openAlert(t`component.addModal.tips`) 123 | } 124 | addGameData(game) 125 | setData((state) => [...state, game]) 126 | setIsOpen(false) 127 | close() 128 | } 129 | 130 | return ( 131 | 139 |
140 | 141 | {t`component.addModal.title`} 142 | 143 | 144 | 152 | 157 | setFormData((state) => ({ ...state, gameName: value ?? '' }))} 161 | required 162 | autoComplete="off" 163 | /> 164 | 165 | 166 | setIsOpen(false)} /> 167 | 168 | 169 |
170 |
171 | ) 172 | } 173 | 174 | export default AddModal 175 | -------------------------------------------------------------------------------- /src/components/AlertBox/index.tsx: -------------------------------------------------------------------------------- 1 | import { useUI } from '@/contexts/UIContext' 2 | import { t } from '@/utils/i18n' 3 | import { Dialog, DialogFooter, PrimaryButton, DialogType, DefaultButton } from '@fluentui/react' 4 | 5 | const AlertBox: React.FC = () => { 6 | const { 7 | state: { alert }, 8 | closeAlert 9 | } = useUI() 10 | 11 | const copy = () => { 12 | const el = document.createElement('textarea') 13 | el.value = alert.text 14 | document.body.appendChild(el) 15 | el.select() 16 | document.execCommand('copy') 17 | document.body.removeChild(el) 18 | } 19 | 20 | return ( 21 | 36 | ) 37 | } 38 | 39 | export default AlertBox 40 | -------------------------------------------------------------------------------- /src/components/ConfirmBox/index.tsx: -------------------------------------------------------------------------------- 1 | import { t } from '@/utils/i18n' 2 | import { Dialog, DialogType, DialogFooter, PrimaryButton, DefaultButton } from '@fluentui/react' 3 | 4 | interface ConfirmBoxProps { 5 | title?: string 6 | text?: string 7 | isOpen: boolean 8 | setIsOpen: (isOpen: boolean) => void 9 | onConfirm?: () => void 10 | } 11 | 12 | const ConfirmBox: React.FC = ({ title = '', text = '', isOpen, setIsOpen, onConfirm }) => { 13 | const handleConfirm = () => { 14 | setIsOpen(false) 15 | onConfirm?.() 16 | } 17 | 18 | const handleCancel = () => { 19 | setIsOpen(false) 20 | } 21 | 22 | return ( 23 | <> 24 | 38 | 39 | ) 40 | } 41 | 42 | export default ConfirmBox 43 | -------------------------------------------------------------------------------- /src/components/FilterModal/index.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from '@fluentui/react/lib/Modal' 2 | import { Stack } from '@fluentui/react/lib/Stack' 3 | import { Toggle } from '@fluentui/react/lib/Toggle' 4 | import { Text } from '@fluentui/react/lib/Text' 5 | import { PrimaryButton } from '@fluentui/react' 6 | import useStore from '@/store' 7 | import { t } from '@/utils/i18n' 8 | 9 | interface FilterModalProps { 10 | isOpen: boolean 11 | setIsOpen: (isOpen: boolean) => void 12 | } 13 | 14 | const FilterModal: React.FC = ({ isOpen, setIsOpen }) => { 15 | const { 16 | settings: { sortOnlyDisplayLocal }, 17 | updateSettings 18 | } = useStore((state) => state) 19 | 20 | return ( 21 | 28 |
29 | 30 | {t`component.filterModal.title`} 31 | 32 | 33 | 34 | updateSettings({ sortOnlyDisplayLocal: checked })} 40 | /> 41 | 42 | 43 | 44 | setIsOpen(false)} /> 45 | 46 |
47 |
48 | ) 49 | } 50 | 51 | export default FilterModal 52 | -------------------------------------------------------------------------------- /src/components/GameList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { SearchBox } from '@fluentui/react/lib/SearchBox' 4 | import { CommandBar, type ICommandBarItemProps } from '@fluentui/react/lib/CommandBar' 5 | import { t } from '@/utils/i18n' 6 | import type { GameWithLocalData } from '@/types' 7 | import useStore from '@/store' 8 | import { getGameCover } from '@/utils' 9 | 10 | interface GameListProps { 11 | games: GameWithLocalData[] 12 | commandItems: ICommandBarItemProps[] 13 | children?: React.ReactNode 14 | } 15 | 16 | const GameList: React.FC = ({ games, commandItems, children }) => { 17 | const [searchText, setSearchText] = useState('') 18 | const { sortPrimaryKey, sortIsPrimaryDescending, sortOnlyDisplayLocal } = useStore((state) => state.settings) 19 | 20 | const filteredData = useMemo(() => { 21 | const target = searchText.toLocaleLowerCase() 22 | return games.filter( 23 | (game) => 24 | [game.title, game.developer].some((field) => field.toLocaleLowerCase().includes(target)) && 25 | (game.local || !sortOnlyDisplayLocal) 26 | ) 27 | }, [searchText, games, sortOnlyDisplayLocal]) 28 | 29 | const sortedData = useMemo(() => { 30 | const sorted = [...filteredData] 31 | switch (sortPrimaryKey) { 32 | case 'Title': 33 | sorted.sort((a, b) => 34 | sortIsPrimaryDescending ? a.title.localeCompare(b.title) : b.title.localeCompare(a.title) 35 | ) 36 | break 37 | case 'Developer': 38 | sorted.sort((a, b) => 39 | sortIsPrimaryDescending ? b.developer.localeCompare(a.developer) : a.developer.localeCompare(b.developer) 40 | ) 41 | break 42 | case 'LastPlay': 43 | sorted.sort((a, b) => (sortIsPrimaryDescending ? b.lastPlay - a.lastPlay : a.lastPlay - b.lastPlay)) 44 | break 45 | case 'Rating': 46 | sorted.sort((a, b) => (sortIsPrimaryDescending ? b.rating - a.rating : a.rating - b.rating)) 47 | break 48 | case 'ReleaseDate': 49 | sorted.sort((a, b) => (sortIsPrimaryDescending ? b.releaseDate - a.releaseDate : a.releaseDate - b.releaseDate)) 50 | break 51 | case 'CreateDate': 52 | sorted.sort((a, b) => (sortIsPrimaryDescending ? b.createDate - a.createDate : a.createDate - b.createDate)) 53 | break 54 | } 55 | return sorted 56 | }, [filteredData, sortPrimaryKey, sortIsPrimaryDescending]) 57 | 58 | return ( 59 | 60 | {children} 61 |
62 |
63 | setSearchText(newValue || '')} 67 | className="w-80" 68 | /> 69 | 70 |
71 |
72 |
73 | {sortedData.map((game) => ( 74 | 79 |
80 |
81 | {!game.local && ( 82 |
83 | )} 84 | 90 |
91 |

{game.title}

92 |
93 | 94 | ))} 95 |
96 | 97 | ) 98 | } 99 | 100 | export default GameList 101 | -------------------------------------------------------------------------------- /src/components/GroupModal/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Modal } from '@fluentui/react/lib/Modal' 3 | import { Stack } from '@fluentui/react/lib/Stack' 4 | import { PrimaryButton, DefaultButton } from '@fluentui/react' 5 | import { TextField } from '@fluentui/react/lib/TextField' 6 | import { Text } from '@fluentui/react/lib/Text' 7 | import useStore from '@/store' 8 | import type { Category, Group } from '@/types' 9 | import { t } from '@/utils/i18n' 10 | 11 | type GroupModalProps = { 12 | isOpen: boolean 13 | setIsOpen: (isOpen: boolean) => void 14 | } & ( 15 | | { 16 | groupId: undefined 17 | setData: (data: Group[]) => void 18 | } 19 | | { 20 | groupId: string 21 | setData: (data: Category[]) => void 22 | } 23 | ) 24 | 25 | const GroupModal: React.FC = ({ isOpen, setIsOpen, setData, groupId }) => { 26 | const [name, setName] = useState('') 27 | 28 | const { addGroup, addCategory } = useStore((state) => state) 29 | 30 | useEffect(() => { 31 | if (isOpen) setName('') 32 | }, [isOpen]) 33 | 34 | const handleSubmit = async () => { 35 | if (!name) return 36 | if (groupId) { 37 | addCategory(groupId, name) 38 | setData(useStore.getState().categories) 39 | } else if (groupId === undefined) { 40 | addGroup(name) 41 | setData(useStore.getState().groups) 42 | } 43 | setIsOpen(false) 44 | } 45 | 46 | return ( 47 | 55 |
56 | 57 | {groupId ? t`component.groupModal.title.category` : t`component.groupModal.title.group`} 58 | 59 | 60 | setName(value ?? '')} 64 | required 65 | autoComplete="off" 66 | /> 67 | 68 | 69 | setIsOpen(false)} /> 70 | 71 | 72 |
73 |
74 | ) 75 | } 76 | 77 | export default GroupModal 78 | -------------------------------------------------------------------------------- /src/components/InsertModal/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Modal, PrimaryButton, DefaultButton, Stack } from '@fluentui/react' 3 | import useStore from '@/store' 4 | import { Checkbox } from '@fluentui/react/lib/Checkbox' 5 | import { t } from '@/utils/i18n' 6 | import type { Category } from '@/types' 7 | import { Text } from '@fluentui/react/lib/Text' 8 | 9 | interface AddGameModalProps { 10 | isOpen: boolean 11 | setIsOpen: (isOpen: boolean) => void 12 | data: Category 13 | setData: (data: Category[]) => void 14 | } 15 | 16 | const InsertModal: React.FC = ({ isOpen, setIsOpen, setData, data }) => { 17 | const { getAllGameData, updateCategory } = useStore((state) => state) 18 | const games = getAllGameData(true) 19 | const [selectedGames, setSelectedGames] = useState(data.gameIds) 20 | 21 | const handleGameChange = (checked?: boolean, gameId?: string) => { 22 | if (!gameId) return 23 | if (checked) { 24 | setSelectedGames([...selectedGames, gameId]) 25 | } else { 26 | setSelectedGames(selectedGames.filter((id) => id !== gameId)) 27 | } 28 | } 29 | 30 | const handleInsertGames = () => { 31 | setIsOpen(false) 32 | updateCategory(data.id, selectedGames) 33 | setData(useStore.getState().categories) 34 | } 35 | 36 | useEffect(() => { 37 | if (!isOpen) setSelectedGames(data.gameIds) 38 | }, [isOpen, data]) 39 | 40 | return ( 41 | 42 |
43 | 44 | {t`component.addModal.title`} 45 | 46 | 47 | {games.map((game) => ( 48 | handleGameChange(checked, game.id)} 52 | checked={selectedGames.includes(game.id)} 53 | /> 54 | ))} 55 | 56 | 57 | setIsOpen(false)} text={t`component.confirmBox.button.cancel`} /> 58 | 59 | 60 |
61 |
62 | ) 63 | } 64 | 65 | export default InsertModal 66 | -------------------------------------------------------------------------------- /src/components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@fluentui/react/lib/Stack' 2 | import Sidebar from '../Sidebar' 3 | import { Spinner } from '@fluentui/react-components' 4 | import { useUI } from '@/contexts/UIContext' 5 | import AlertBox from '../AlertBox' 6 | import { useEffect } from 'react' 7 | import events from '@/utils/events' 8 | import { type LoggerData, LoggerLevel } from '@kotori-bot/logger' 9 | import i18n, { t } from '@/utils/i18n' 10 | 11 | interface LayoutProps { 12 | title: string 13 | outlet: React.ReactElement 14 | } 15 | 16 | const Layout: React.FC = ({ title, outlet }) => { 17 | const { 18 | state: { fullLoading, sidebarOpen }, 19 | openAlert 20 | } = useUI() 21 | 22 | useEffect(() => { 23 | events.on('error', (data: LoggerData) => { 24 | const msg = data.msg.substring(0, 600) 25 | if (data.label.length !== 0 && data.label[0] === 'HTTP') { 26 | openAlert(msg, t`alert.title.error.http`) 27 | } else if (data.level === LoggerLevel.FATAL) { 28 | openAlert(msg, t`alert.title.error.fatal`) 29 | } else { 30 | openAlert(msg, t`alert.title.error`) 31 | } 32 | }) 33 | }, [openAlert]) 34 | 35 | return ( 36 | 37 | 38 | {fullLoading && ( 39 |
40 | 41 |
42 | )} 43 | 44 | 45 | 46 | 47 |
48 |
49 |

{i18n.locale(title)}

50 |
51 | {outlet} 52 |
53 |
54 |
55 | ) 56 | } 57 | 58 | export default Layout 59 | -------------------------------------------------------------------------------- /src/components/Sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { Stack } from '@fluentui/react/lib/Stack' 3 | import { Icon } from '@fluentui/react/lib/Icon' 4 | import routes from '@/routes' 5 | import { useLocation, useNavigate } from 'react-router-dom' 6 | import { useUI } from '@/contexts/UIContext' 7 | import i18n from '@/utils/i18n' 8 | 9 | const Sidebar: React.FC = () => { 10 | const { 11 | state: { sidebarOpen }, 12 | toggleSidebar 13 | } = useUI() 14 | const location = useLocation() 15 | const currentPage = useMemo( 16 | () => routes.find((item) => item.path.startsWith(`/${location.pathname.split('/')[1]}`)), 17 | [location] 18 | ) 19 | const navigate = useNavigate() 20 | 21 | // 提取最后两个选项 22 | const lastTwoRoutes = routes.slice(-2) 23 | const otherRoutes = routes.slice(0, -2) 24 | 25 | return ( 26 | 27 |
28 |
29 |
33 | 34 |
35 | {otherRoutes 36 | .filter((item) => 'icon' in item) 37 | .map((item) => ( 38 |
navigate(item.path)} 42 | > 43 | 44 | {sidebarOpen && {i18n.locale(item.title)}} 45 |
46 | ))} 47 |
48 |
49 | {lastTwoRoutes 50 | .filter((item) => 'icon' in item) 51 | .map((item) => ( 52 |
navigate(item.path)} 56 | > 57 | 58 | {sidebarOpen && {i18n.locale(item.title)}} 59 |
60 | ))} 61 |
62 |
63 |
64 | ) 65 | } 66 | 67 | export default Sidebar 68 | -------------------------------------------------------------------------------- /src/components/SortModal/index.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from '@fluentui/react/lib/Modal' 2 | import { Stack } from '@fluentui/react/lib/Stack' 3 | import { Dropdown } from '@fluentui/react/lib/Dropdown' 4 | import { Toggle } from '@fluentui/react/lib/Toggle' 5 | import { Text } from '@fluentui/react/lib/Text' 6 | import { PrimaryButton } from '@fluentui/react' 7 | import useStore from '@/store' 8 | import type { SortKeys } from '@/types' 9 | import { t } from '@/utils/i18n' 10 | 11 | interface SortModalProps { 12 | isOpen: boolean 13 | setIsOpen: (isOpen: boolean) => void 14 | } 15 | 16 | const SortModal: React.FC = ({ isOpen, setIsOpen }) => { 17 | const { 18 | updateSettings, 19 | settings: { sortPrimaryKey, sortIsPrimaryDescending } 20 | } = useStore((state) => state) 21 | 22 | const dropdownOptions: { key: SortKeys; text: string }[] = [ 23 | { key: 'CreateDate', text: t`component.sortModal.sort.createDate` }, 24 | { key: 'Title', text: t`component.sortModal.sort.title` }, 25 | { key: 'LastPlay', text: t`component.sortModal.sort.lastPlay` }, 26 | { key: 'Developer', text: t`component.sortModal.sort.developer` }, 27 | { key: 'Rating', text: t`component.sortModal.sort.rating` }, 28 | { key: 'ReleaseDate', text: t`component.sortModal.sort.releaseDate` } 29 | ] 30 | 31 | return ( 32 | 39 |
40 | 41 | {t`component.sortModal.title`} 42 | 43 | 44 | 45 | updateSettings({ sortPrimaryKey: (option?.key || 'Title') as SortKeys })} 49 | /> 50 | updateSettings({ sortIsPrimaryDescending: checked })} 56 | /> 57 | 58 | 59 | 60 | setIsOpen(false)} /> 61 | 62 |
63 |
64 | ) 65 | } 66 | 67 | export default SortModal 68 | -------------------------------------------------------------------------------- /src/components/SyncModal/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Modal } from '@fluentui/react/lib/Modal' 3 | import { Stack } from '@fluentui/react/lib/Stack' 4 | import { PrimaryButton, DefaultButton } from '@fluentui/react' 5 | import { TextField } from '@fluentui/react/lib/TextField' 6 | import { Text } from '@fluentui/react/lib/Text' 7 | import { dialog } from '@tauri-apps/api' 8 | import type { GameWithLocalData } from '@/types' 9 | import useStore from '@/store' 10 | import { t } from '@/utils/i18n' 11 | import { invokeLogger } from '@/utils/logger' 12 | 13 | interface SyncModalProps { 14 | isOpen: boolean 15 | setIsOpen: (isOpen: boolean) => void 16 | data: GameWithLocalData 17 | } 18 | 19 | const SyncModal: React.FC = ({ isOpen, setIsOpen, data }) => { 20 | const [programFile, setProgramFile] = useState('') 21 | const { updateGameData } = useStore((state) => state) 22 | 23 | useEffect(() => { 24 | if (isOpen) setProgramFile('') 25 | }, [isOpen]) 26 | 27 | const handleSelectExe = async () => { 28 | const filepath = await dialog 29 | .open({ 30 | title: t`component.syncModal.dialog.selectProgram`, 31 | directory: false, 32 | multiple: false, 33 | filters: [{ name: t`component.syncModal.dialog.filter.executable`, extensions: ['exe'] }] 34 | }) 35 | .catch((e) => invokeLogger.error(e)) 36 | if (filepath) setProgramFile(filepath as string) 37 | } 38 | 39 | const handleSubmit = () => { 40 | if (programFile) { 41 | setIsOpen(false) 42 | data.local = { 43 | ...(data.local ?? { 44 | id: data.id 45 | }), 46 | programFile 47 | } 48 | updateGameData(data) 49 | } 50 | } 51 | 52 | return ( 53 | 60 |
61 | 62 | {t`component.syncModal.title`} 63 | 64 | 65 | 74 | 75 | 76 | 77 | setIsOpen(false)} /> 78 | 79 | 80 |
81 |
82 | ) 83 | } 84 | 85 | export default SyncModal 86 | -------------------------------------------------------------------------------- /src/constant.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore lint: 2 | ;(globalThis as any).process = { 3 | pid: '', 4 | stdout: { 5 | write: console.log.bind(console) 6 | }, 7 | stderr: { 8 | write: console.error.bind(console) 9 | } 10 | } 11 | 12 | export const IS_TAURI = typeof window !== 'undefined' && '__TAURI__' in window && !!window.__TAURI__ 13 | export const IS_DEV = import.meta && !!import.meta.env?.DEV 14 | 15 | export const APP_STORE_KEY = 'nanno' 16 | 17 | export const SHARED_JSON_FILE = 'nanno-shared.json' 18 | -------------------------------------------------------------------------------- /src/contexts/UIContext.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultGroup } from '@/types' 2 | import { t } from '@/utils/i18n' 3 | import { createContext, useContext, useReducer } from 'react' 4 | 5 | interface UIState { 6 | alert: { 7 | isOpen: boolean 8 | text: string 9 | title: string 10 | } 11 | fullLoading: boolean 12 | sidebarOpen: boolean 13 | currentGroupId: string 14 | } 15 | 16 | type UIAction = 17 | | { type: 'OPEN_ALERT'; payload: { text: string; title?: string } } 18 | | { type: 'CLOSE_ALERT' } 19 | | { type: 'SET_LOADING'; payload: boolean } 20 | | { type: 'TOGGLE_SIDEBAR' } 21 | | { type: 'SET_CURRENT_GROUP'; payload: string } 22 | 23 | interface UIContextType { 24 | state: UIState 25 | openAlert: (text: string, title?: string) => void 26 | closeAlert: () => void 27 | openFullLoading: () => () => void 28 | toggleSidebar: () => void 29 | setCurrentGroupId: (group: string) => void 30 | } 31 | 32 | const DEFAULT_STATE: UIState = { 33 | alert: { 34 | isOpen: false, 35 | text: '', 36 | title: '' 37 | }, 38 | fullLoading: false, 39 | sidebarOpen: true, 40 | currentGroupId: DefaultGroup.DEVELOPER 41 | } 42 | 43 | function uiReducer(state: UIState, action: UIAction): UIState { 44 | switch (action.type) { 45 | case 'OPEN_ALERT': 46 | return { 47 | ...state, 48 | alert: { 49 | isOpen: true, 50 | text: action.payload.text, 51 | title: action.payload.title || t`alert.title` 52 | } 53 | } 54 | case 'CLOSE_ALERT': 55 | return { 56 | ...state, 57 | alert: { 58 | ...state.alert, 59 | isOpen: false 60 | } 61 | } 62 | case 'SET_LOADING': 63 | return { 64 | ...state, 65 | fullLoading: action.payload 66 | } 67 | case 'TOGGLE_SIDEBAR': 68 | return { 69 | ...state, 70 | sidebarOpen: !state.sidebarOpen 71 | } 72 | case 'SET_CURRENT_GROUP': 73 | return { 74 | ...state, 75 | currentGroupId: action.payload 76 | } 77 | default: 78 | return state 79 | } 80 | } 81 | 82 | const UIContext = createContext(null) 83 | 84 | export function UIProvider({ children }: { children: React.ReactNode }) { 85 | const [state, dispatch] = useReducer(uiReducer, DEFAULT_STATE) 86 | 87 | const openAlert = (text: string, title?: string) => 88 | dispatch({ type: 'OPEN_ALERT', payload: { text, title: title || t`alert.title` } }) 89 | 90 | const closeAlert = () => dispatch({ type: 'CLOSE_ALERT' }) 91 | 92 | const openFullLoading = () => { 93 | dispatch({ type: 'SET_LOADING', payload: true }) 94 | return () => dispatch({ type: 'SET_LOADING', payload: false }) 95 | } 96 | 97 | const toggleSidebar = () => dispatch({ type: 'TOGGLE_SIDEBAR' }) 98 | 99 | const setCurrentGroupId = (group: string) => dispatch({ type: 'SET_CURRENT_GROUP', payload: group }) 100 | 101 | return ( 102 | 103 | {children} 104 | 105 | ) 106 | } 107 | 108 | export function useUI() { 109 | const context = useContext(UIContext) 110 | if (!context) { 111 | throw new Error('useUI must be used within UIProvider') 112 | } 113 | return context 114 | } 115 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | overflow: hidden; 3 | padding: 0; 4 | margin: 0; 5 | user-select: none; 6 | -webkit-user-select: none; 7 | } 8 | 9 | * { 10 | scrollbar-width: thin; 11 | } 12 | -------------------------------------------------------------------------------- /src/locales/en-US.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'app.update.title': 'New Version Available', 3 | 'app.update.message': 4 | 'Your current Nanno version is {0}, the latest version is {1}. Please update to continue using.', 5 | 'app.update.button': 'Get the Latest Version', 6 | 'app.update.title.failed': 'Failed to Get the Latest Version', 7 | 'app.update.message.failed': 8 | 'Please check your internet connection to raw.githubusercontent.com and try again later.', 9 | 'page.library.title': 'Games', 10 | 'page.library.command.add': 'Add Game', 11 | 'page.library.command.sort': 'Sort', 12 | 'page.library.command.filter': 'Filter', 13 | 'page.detail.info.developer': 'Developer', 14 | 'page.detail.info.releaseDate': 'Release Date', 15 | 'page.detail.info.createDate': 'Create Date', 16 | 'page.detail.info.expectedPlayHours': 'Expected Play Hours', 17 | 'page.detail.info.playTime': 'Play Time', 18 | 'page.detail.info.playCount': 'Play Count', 19 | 'page.detail.info.playCount.value': '{0} times', 20 | 'page.detail.info.lastPlay': 'Last Played', 21 | 'page.detail.game.notFound': 'Game Not Found', 22 | 'page.detail.command.sync': 'Sync Game', 23 | 'page.detail.command.start': 'Start Game', 24 | 'page.detail.command.edit': 'Edit Info', 25 | 'page.detail.command.guide': 'View Guide', 26 | 'page.detail.command.backup': 'Backup Save', 27 | 'page.detail.command.more': 'More', 28 | 'page.detail.command.openGameDir': 'Open Game Directory', 29 | 'page.detail.command.openSaveDir': 'Open Save Directory', 30 | 'page.detail.command.viewVndb': 'View Vndb Page', 31 | 'page.detail.command.viewBangumi': 'View Bangumi Page', 32 | 'page.detail.command.deleteSync': 'Delete Local Sync', 33 | 'page.detail.command.deleteGame': 'Delete Game', 34 | 'page.detail.modal.deleteSync': 35 | 'Deleting local sync will require re-setting the game launcher, but will not delete local game files. Continue?', 36 | 'page.detail.modal.deleteGame': 37 | 'This will delete game data and cloud save backups, but will not delete local game files. Continue?', 38 | 'page.detail.section.description': 'Description', 39 | 'page.detail.section.tags': 'Tags', 40 | 'page.detail.timeline.chart': 'Statistics Chart', 41 | 'page.detail.timeline.playTime': 'Play Time', 42 | 'page.edit.game.notFound': 'Game Not Found', 43 | 'page.edit.dropdown.mixed': 'Aggregate Search', 44 | 'page.edit.dropdown.vndb': 'VNDB', 45 | 'page.edit.dropdown.bgm': 'Bangumi', 46 | 'page.edit.button.fetchData': 'Update Data from Source', 47 | 'page.edit.button.cancel': 'Cancel', 48 | 'page.edit.button.save': 'Save', 49 | 'page.edit.section.basicInfo': 'Basic Info', 50 | 'page.edit.section.localSettings': 'Local Settings', 51 | 'page.edit.dialog.selectSave': 'Select Save Directory', 52 | 'page.edit.dialog.selectProgram': 'Select Launcher', 53 | 'page.edit.dialog.selectGuide': 'Select Guide File', 54 | 'page.edit.dialog.filter.executable': 'Executable Files', 55 | 'page.edit.dialog.filter.textFile': 'Text Files', 56 | 'page.edit.field.source': 'Data Source', 57 | 'page.edit.field.title': 'Game Title', 58 | 'page.edit.field.developer': 'Developer', 59 | 'page.edit.field.description': 'Description', 60 | 'page.edit.field.tags': 'Tags', 61 | 'page.edit.field.tags.placeholder': 'Separate tags with commas', 62 | 'page.edit.field.cover': 'Cover Image', 63 | 'page.edit.field.expectedHours': 'Expected Hours', 64 | 'page.edit.field.rating': 'Rating', 65 | 'page.edit.field.releaseDate': 'Release Date', 66 | 'page.edit.field.savePath': 'Save Path', 67 | 'page.edit.field.savePath.placeholder': 'Select a directory', 68 | 'page.edit.field.programFile': 'Launcher', 69 | 'page.edit.field.programFile.placeholder': 'Select an executable file', 70 | 'page.edit.field.guideFile': 'Guide File', 71 | 'page.edit.field.guideFile.placeholder': 'Select a text file', 72 | 'page.edit.text.needSync': 'Please sync to local first', 73 | 'page.settings.title': 'Settings', 74 | 'page.settings.button.save': 'Save Settings', 75 | 'page.settings.profile.bgm': 'Bangumi Account:', 76 | 'page.settings.profile.vndb': 'Vndb Account:', 77 | 'page.settings.profile.notLogged': 'Not Logged In', 78 | 'page.settings.profile.lastSync': 'Last Sync Time:', 79 | 'page.settings.profile.notSynced': 'Not Synced', 80 | 'page.settings.profile.repoSize': 'Repo Size:', 81 | 'page.settings.profile.visibility': 'Visibility:', 82 | 'page.settings.profile.visibility.public': 'Public', 83 | 'page.settings.profile.visibility.private': 'Private', 84 | 'page.settings.profile.visibility.unknown': 'Unknown', 85 | 'page.settings.data.title': 'Data Settings', 86 | 'page.settings.data.syncMode': 'Sync Mode', 87 | 'page.settings.data.syncMode.github': 'Github Api', 88 | 'page.settings.data.syncMode.server': 'Custom Server', 89 | 'page.settings.data.token': 'Github Token', 90 | 'page.settings.data.token.get': 'Get Token', 91 | 'page.settings.data.repo': 'Repo', 92 | 'page.settings.data.repo.placeholder': 'e.g., biyuehu/galgame-data', 93 | 'page.settings.data.path': 'Path', 94 | 'page.settings.data.path.placeholder': 'e.g., gal-keeper/', 95 | 'page.settings.data.autoSync': 'Auto Sync Interval', 96 | 'page.settings.data.autoSync.unit': 'Unit: minutes (0 means no auto sync), requires restart to take effect', 97 | 'page.settings.data.autoSync.placeholder': 'Please enter an integer', 98 | 'page.settings.data.vndbToken': 'Vndb Token', 99 | 'page.settings.data.bgmToken': 'Bangumi Token', 100 | 'page.settings.data.source': 'Default Data Source', 101 | 'page.settings.data.button.export': 'Export Game Data', 102 | 'page.settings.data.button.import': 'Import Game Data', 103 | 'page.settings.data.button.sync': 'Manual Sync', 104 | 'page.settings.data.alert.syncSuccess': 'Sync Successful', 105 | 'page.settings.data.alert.syncCancel': 'Not changed, cancel sync', 106 | 'page.settings.appearance.title': 'Appearance Settings', 107 | 'page.settings.appearance.theme': 'Theme', 108 | 'page.settings.appearance.theme.light': 'Light', 109 | 'page.settings.appearance.theme.dark': 'Dark', 110 | 'page.settings.appearance.theme.system': 'Follow System', 111 | 'page.settings.appearance.language': 'Language', 112 | 'page.settings.appearance.language.en': 'English', 113 | 'page.settings.appearance.language.zh': 'Simplified Chinese', 114 | 'page.settings.appearance.language.ja': 'Japanese', 115 | 'page.settings.appearance.language.zhTw': 'Traditional Chinese', 116 | 'page.settings.detail.title': 'Detail Settings', 117 | 'page.settings.detail.maxTimelineDisplayCount': 'Max Timeline Display Count', 118 | 'page.settings.detail.playLaunchVoice': 'Play Nanno Launch Voice', 119 | 'page.settings.detail.autoSetTitle': 'Auto Set Game Title When Editing', 120 | 'page.settings.detail.autoCacheCover': 'Auto Cache Game Cover', 121 | 'page.settings.details.button.cleanCache': 'Clear Cache', 122 | 'page.home.title': 'Home', 123 | 'page.home.stats.total': 'Total Games', 124 | 'page.home.stats.local': 'Local Games', 125 | 'page.home.stats.cloud': 'Cloud Games', 126 | 'page.home.stats.totalPlayTime': 'Total Play Time', 127 | 'page.home.stats.todayPlayTime': "Today's Play Time", 128 | 'page.home.stats.totalPlayCount': 'Total Play Count', 129 | 'page.home.timeline.title': 'Recent Activity', 130 | 'page.home.timeline.played': 'Played', 131 | 'page.home.timeline.added': 'Added', 132 | 'page.home.activity.recent': 'Recently Played', 133 | 'page.home.activity.latest': 'Recently Added', 134 | 'page.home.activity.running': 'Running', 135 | 'page.home.activity.running.empty': 'No running games', 136 | 'page.category.title': 'Category', 137 | 'page.category.command.insertGame': 'Add Game', 138 | 'page.category.command.switchGroup': 'Switch Group', 139 | 'page.category.command.addGroup': 'Add Group', 140 | 'page.category.command.addCategory': 'Add Category', 141 | 'page.category.command.deleteGroup': 'Delete Group', 142 | 'page.category.modal.group.title': 'Add Group', 143 | 'page.category.modal.group.name': 'Group Name', 144 | 'page.category.modal.group.placeholder': 'Please enter group name', 145 | 'page.category.modal.category.title': 'Add Category', 146 | 'page.category.modal.category.name': 'Category Name', 147 | 'page.category.modal.category.placeholder': 'Please enter category name', 148 | 'page.category.confirm.group': 'Are you sure to delete this group?', 149 | 'page.category.confirm.category': 'Are you sure to delete this category?', 150 | 'page.category.default.developer.title': 'Developer', 151 | 'page.category.default.rating.title': 'Rating', 152 | 'page.category.default.playStatus.title': 'Play Status', 153 | 'page.category.default.rating.1': 'Terrible', 154 | 'page.category.default.rating.2': 'Poor', 155 | 'page.category.default.rating.3': 'Average', 156 | 'page.category.default.rating.4': 'Good', 157 | 'page.category.default.rating.5': 'Masterpiece', 158 | 'page.category.default.playStatus.1': 'Not Played', 159 | 'page.category.default.playStatus.2': 'Playing', 160 | 'page.category.default.playStatus.3': 'Played', 161 | 'page.about.title': 'About', 162 | 'page.about.changelog': 'Changelog', 163 | 'page.about.changelog.loading': 'Loading...', 164 | 'page.about.changelog.failed': 'Failed to Load', 165 | 'page.about.faq.content': 166 | "Not the game throught searched I want?\nOpen the game details page and edit game information, then set right vndb or bangumi id you think.\n\nWhy the \"custom server\" syncing is disabled?\nIt's still under development, please wait for the future version.\n\nBangumi Api can't find the game but it's really existed?\nPlease set Bangumi Token in the settings page and ensure your account have registered more than 3 months, Bangumi don't allow to search R18 game for visitor or new users.\n\nI have more questions, where can I ask them?\nYou can ask them in the GitHub issue or QQ group.", 167 | 'component.gameList.search.placeholder': 'Search Games', 168 | 'component.addModal.title': 'Add Game', 169 | 'component.addModal.field.program': 'Launcher', 170 | 'component.addModal.field.program.placeholder': 'Select an executable file', 171 | 'component.addModal.button.selectFile': 'Select File', 172 | 'component.addModal.field.name': 'Game Name', 173 | 'component.addModal.button.cancel': 'Cancel', 174 | 'component.addModal.button.submit': 'Submit', 175 | 'component.addModal.dialog.selectProgram': 'Select Launcher', 176 | 'component.addModal.dialog.filter.executable': 'Executable Files', 177 | 'component.addModal.tips': 'The game you are adding may already exist, please confirm if there are duplicates', 178 | 'component.groupModal.title.category': 'Add Category', 179 | 'component.groupModal.title.group': 'Add Group', 180 | 'component.groupModal.field.name': 'Name', 181 | 'component.alertBox.button.sure': 'Confirm', 182 | 'component.alertBox.button.copy': 'Copy', 183 | 'component.confirmBox.default.title': 'Prompt', 184 | 'component.confirmBox.button.confirm': 'Confirm', 185 | 'component.confirmBox.button.cancel': 'Cancel', 186 | 'component.filterModal.title': 'Filter', 187 | 'component.filterModal.field.onlyLocal': 'Only Show Local Games', 188 | 'component.filterModal.toggle.yes': 'Yes', 189 | 'component.filterModal.toggle.no': 'No', 190 | 'component.filterModal.button.confirm': 'Confirm', 191 | 'component.sortModal.title': 'Sort', 192 | 'component.sortModal.button.confirm': 'Confirm', 193 | 'component.sortModal.toggle.label': 'Descending/Ascending', 194 | 'component.sortModal.toggle.descending': 'Descending', 195 | 'component.sortModal.toggle.ascending': 'Ascending', 196 | 'component.sortModal.sort.createDate': 'Create Date', 197 | 'component.sortModal.sort.title': 'Title', 198 | 'component.sortModal.sort.lastPlay': 'Last Played', 199 | 'component.sortModal.sort.developer': 'Developer', 200 | 'component.sortModal.sort.rating': 'Rating', 201 | 'component.sortModal.sort.releaseDate': 'Release Date', 202 | 'component.syncModal.title': 'Sync Game', 203 | 'component.syncModal.field.program': 'Launcher', 204 | 'component.syncModal.field.program.placeholder': 'Select an executable file', 205 | 'component.syncModal.button.selectFile': 'Select File', 206 | 'component.syncModal.button.cancel': 'Cancel', 207 | 'component.syncModal.button.submit': 'Submit', 208 | 'component.syncModal.dialog.selectProgram': 'Select Launcher', 209 | 'component.syncModal.dialog.filter.executable': 'Executable Files', 210 | 'time.justnow': 'Just Now', 211 | 'time.minutesAgo': '{0} minutes ago', 212 | 'time.hoursAgo': '{0} hours ago', 213 | 'time.yesterday': 'Yesterday', 214 | 'time.daysAgo': '{0} days ago', 215 | 'time.lastWeek': 'Last Week', 216 | 'time.weeksAgo': '{0} weeks ago', 217 | 'time.lastMonth': 'Last Month', 218 | 'time.monthsAgo': '{0} months ago', 219 | 'time.lastYear': 'Last Year', 220 | 'time.yearsAgo': '{0} years ago', 221 | 'alert.title': 'Prompt', 222 | 'alert.title.error.http': 'HTTP Request Error', 223 | 'alert.title.error.fatal': 'Fatal Error', 224 | 'alert.title.error': 'Unexpected Error' 225 | } 226 | -------------------------------------------------------------------------------- /src/locales/ja-JP.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'app.update.title': '新しいバージョンがあります', 3 | 'app.update.message': '現在のNannoバージョンは{0}、最新バージョンは{1}です。使用を続けるには更新してください。', 4 | 'app.update.button': '最新バージョンを取得', 5 | 'app.update.title.failed': '最新バージョンの取得に失敗しました', 6 | 'app.update.message.failed': 'raw.githubusercontent.comに正常に接続できるか確認し、後でもう一度お試しください。', 7 | 'page.library.title': 'ゲーム', 8 | 'page.library.command.add': 'ゲームを追加', 9 | 'page.library.command.sort': '並べ替え', 10 | 'page.library.command.filter': 'フィルター', 11 | 'page.detail.info.developer': '開発者', 12 | 'page.detail.info.releaseDate': 'リリース日', 13 | 'page.detail.info.createDate': '作成日', 14 | 'page.detail.info.expectedPlayHours': '予想プレイ時間', 15 | 'page.detail.info.playTime': 'プレイ時間', 16 | 'page.detail.info.playCount': 'プレイ回数', 17 | 'page.detail.info.playCount.value': '{0} 回', 18 | 'page.detail.info.lastPlay': '最後にプレイした日', 19 | 'page.detail.game.notFound': 'ゲームが見つかりません', 20 | 'page.detail.command.sync': 'ゲームを同期', 21 | 'page.detail.command.start': 'ゲームを開始', 22 | 'page.detail.command.edit': '情報を編集', 23 | 'page.detail.command.guide': 'ガイドを表示', 24 | 'page.detail.command.backup': 'セーブデータをバックアップ', 25 | 'page.detail.command.more': 'もっと見る', 26 | 'page.detail.command.openGameDir': 'ゲームディレクトリを開く', 27 | 'page.detail.command.openSaveDir': 'セーブディレクトリを開く', 28 | 'page.detail.command.viewVndb': 'Vndbページを表示', 29 | 'page.detail.command.viewBangumi': 'Bangumiページを表示', 30 | 'page.detail.command.deleteSync': 'ローカル同期を削除', 31 | 'page.detail.command.deleteGame': 'ゲームを削除', 32 | 'page.detail.modal.deleteSync': 33 | 'ローカル同期を削除すると、ゲームランチャーを再設定する必要がありますが、ローカルゲームファイルは削除されません。続行しますか?', 34 | 'page.detail.modal.deleteGame': 35 | 'これにより、ゲームデータとクラウドセーブバックアップが削除されますが、ローカルゲームファイルは削除されません。続行しますか?', 36 | 'page.detail.section.description': '説明', 37 | 'page.detail.section.tags': 'タグ', 38 | 'page.detail.timeline.chart': '統計チャート', 39 | 'page.detail.timeline.playTime': 'プレイ時間', 40 | 'page.edit.game.notFound': 'ゲームが見つかりません', 41 | 'page.edit.dropdown.mixed': '集約検索', 42 | 'page.edit.dropdown.vndb': 'VNDB', 43 | 'page.edit.dropdown.bgm': 'Bangumi', 44 | 'page.edit.button.fetchData': 'データソースからデータを更新', 45 | 'page.edit.button.cancel': 'キャンセル', 46 | 'page.edit.button.save': '保存', 47 | 'page.edit.section.basicInfo': '基本情報', 48 | 'page.edit.section.localSettings': 'ローカル設定', 49 | 'page.edit.dialog.selectSave': 'セーブディレクトリを選択', 50 | 'page.edit.dialog.selectProgram': 'ランチャーを選択', 51 | 'page.edit.dialog.selectGuide': 'ガイドファイルを選択', 52 | 'page.edit.dialog.filter.executable': '実行可能ファイル', 53 | 'page.edit.dialog.filter.textFile': 'テキストファイル', 54 | 'page.edit.field.source': 'データソース', 55 | 'page.edit.field.title': 'ゲームタイトル', 56 | 'page.edit.field.developer': '開発者', 57 | 'page.edit.field.description': '説明', 58 | 'page.edit.field.tags': 'タグ', 59 | 'page.edit.field.tags.placeholder': 'タグをカンマで区切る', 60 | 'page.edit.field.cover': 'カバー画像', 61 | 'page.edit.field.expectedHours': '予想時間', 62 | 'page.edit.field.rating': '評価', 63 | 'page.edit.field.releaseDate': 'リリース日', 64 | 'page.edit.field.savePath': 'セーブパス', 65 | 'page.edit.field.savePath.placeholder': 'ディレクトリを選択', 66 | 'page.edit.field.programFile': 'ランチャー', 67 | 'page.edit.field.programFile.placeholder': '実行可能ファイルを選択', 68 | 'page.edit.field.guideFile': 'ガイドファイル', 69 | 'page.edit.field.guideFile.placeholder': 'テキストファイルを選択', 70 | 'page.edit.text.needSync': 'まずローカルに同期してください', 71 | 'page.settings.title': '設定', 72 | 'page.settings.button.save': '設定を保存', 73 | 'page.settings.profile.notLogged': 'ログインしていません', 74 | 'page.settings.profile.bgm': 'Bangumiアカウント:', 75 | 'page.settings.profile.vndb': 'Vndbアカウント:', 76 | 'page.settings.profile.lastSync': '最終同期時間:', 77 | 'page.settings.profile.notSynced': '同期されていません', 78 | 'page.settings.profile.repoSize': 'リポジトリサイズ:', 79 | 'page.settings.profile.visibility': '公開範囲:', 80 | 'page.settings.profile.visibility.public': '公開', 81 | 'page.settings.profile.visibility.private': '非公開', 82 | 'page.settings.profile.visibility.unknown': '不明', 83 | 'page.settings.data.title': 'データ設定', 84 | 'page.settings.data.syncMode': '同期モード', 85 | 'page.settings.data.syncMode.github': 'Github Api', 86 | 'page.settings.data.syncMode.server': 'カスタムサーバー', 87 | 'page.settings.data.token': 'Githubトークン', 88 | 'page.settings.data.token.get': 'トークンを取得', 89 | 'page.settings.data.repo': 'リポジトリ', 90 | 'page.settings.data.repo.placeholder': '例: biyuehu/galgame-data', 91 | 'page.settings.data.path': 'パス', 92 | 'page.settings.data.path.placeholder': '例: gal-keeper/', 93 | 'page.settings.data.autoSync': '自動同期間隔', 94 | 'page.settings.data.autoSync.unit': '単位: 分 (0は自動同期なし)、再起動後に有効', 95 | 'page.settings.data.autoSync.placeholder': '例: 10', 96 | 'page.settings.data.vndbToken': 'Vndb Token', 97 | 'page.settings.data.bgmToken': 'Bangumi Token', 98 | 'page.settings.data.source': 'デフォルトのデータソース', 99 | 'page.settings.data.button.export': 'ゲームデータをエクスポート', 100 | 'page.settings.data.button.import': 'ゲームデータをインポート', 101 | 'page.settings.data.button.sync': '手動同期', 102 | 'page.settings.data.alert.syncSuccess': '同期成功', 103 | 'page.settings.data.alert.syncCancel': '同期キャンセル', 104 | 'page.settings.appearance.title': '外観設定', 105 | 'page.settings.appearance.theme': 'テーマ', 106 | 'page.settings.appearance.theme.light': 'ライト', 107 | 'page.settings.appearance.theme.dark': 'ダーク', 108 | 'page.settings.appearance.theme.system': 'システムに従う', 109 | 'page.settings.appearance.language': '言語', 110 | 'page.settings.appearance.language.en': '英語', 111 | 'page.settings.appearance.language.zh': '簡体字中国語', 112 | 'page.settings.appearance.language.ja': '日本語', 113 | 'page.settings.appearance.language.zhTw': '繁体字中国語', 114 | 'page.settings.detail.title': '詳細設定', 115 | 'page.settings.detail.maxTimelineDisplayCount': 'タイムラインの最大表示数', 116 | 'page.settings.detail.playLaunchVoice': 'Nanno起動時に音声を再生', 117 | 'page.settings.detail.autoSetTitle': 'ゲーム編集時にタイトルを自動設定', 118 | 'page.settings.detail.autoCacheCover': 'ゲームカバーを自動キャッシュ', 119 | 'page.settings.details.button.cleanCache': 'キャッシュをクリア', 120 | 'page.home.title': 'ホーム', 121 | 'page.home.stats.total': '総ゲーム数', 122 | 'page.home.stats.local': 'ローカルゲーム', 123 | 'page.home.stats.cloud': 'クラウドゲーム', 124 | 'page.home.stats.totalPlayTime': '総プレイ時間', 125 | 'page.home.stats.todayPlayTime': '今日のプレイ時間', 126 | 'page.home.stats.totalPlayCount': '総プレイ回数', 127 | 'page.home.timeline.title': '最近のアクティビティ', 128 | 'page.home.timeline.played': 'プレイした', 129 | 'page.home.timeline.added': '追加した', 130 | 'page.home.activity.recent': '最近プレイしたゲーム', 131 | 'page.home.activity.latest': '最近追加したゲーム', 132 | 'page.home.activity.running': '実行中', 133 | 'page.home.activity.running.empty': '実行中のゲームはありません', 134 | 'page.category.title': 'カテゴリ', 135 | 'page.category.command.insertGame': 'ゲームを追加', 136 | 'page.category.command.switchGroup': 'グループを切り替え', 137 | 'page.category.command.addGroup': 'グループを追加', 138 | 'page.category.command.addCategory': 'カテゴリを追加', 139 | 'page.category.command.deleteGroup': 'グループを削除', 140 | 'page.category.modal.group.title': 'グループを追加', 141 | 'page.category.modal.group.name': 'グループ名', 142 | 'page.category.modal.group.placeholder': 'グループ名を入力してください', 143 | 'page.category.modal.category.title': 'カテゴリを追加', 144 | 'page.category.modal.category.name': 'カテゴリ名', 145 | 'page.category.modal.category.placeholder': 'カテゴリ名を入力してください', 146 | 'page.category.confirm.group': 'このグループを削除しますか?', 147 | 'page.category.confirm.category': 'このカテゴリを削除しますか?', 148 | 'page.category.default.developer.title': '開発者', 149 | 'page.category.default.rating.title': '評価', 150 | 'page.category.default.playStatus.title': 'プレイ状況', 151 | 'page.category.default.rating.1': '駄作', 152 | 'page.category.default.rating.2': 'やや劣る', 153 | 'page.category.default.rating.3': '普通', 154 | 'page.category.default.rating.4': '良作', 155 | 'page.category.default.rating.5': '傑作', 156 | 'page.category.default.playStatus.1': '未プレイ', 157 | 'page.category.default.playStatus.2': 'プレイ中', 158 | 'page.category.default.playStatus.3': 'プレイ済み', 159 | 'page.about.title': 'について', 160 | 'page.about.changelog': '変更履歴', 161 | 'page.about.changelog.loading': '読み込み中...', 162 | 'page.about.changelog.failed': '読み込みに失敗しました', 163 | 'page.about.faq.content': 164 | '探しているゲームが見つかりませんか?\nゲームの詳細ページを開いてゲーム情報を編集し、正しいvndbまたはbangumi idを設定してください。\n\nなぜ「カスタムサーバー」の同期が無効になっているのですか?\nまだ開発中のため、将来のバージョンをお待ちください。\n\nBangumi Apiはゲームを見つけることができませんが、実際に存在していますか?\n設定ページでBangumiトークンを設定し、アカウントが3か月以上登録されていることを確認してください。Bangumiでは、訪問者や新規ユーザーがR18ゲームを検索することはできません。\n\nさらに質問があります。どこに質問すればよいですか?\nGitHubの問題またはQQグループで質問できます。', 165 | 'component.gameList.search.placeholder': 'ゲームを検索', 166 | 'component.addModal.title': 'ゲームを追加', 167 | 'component.addModal.field.program': 'ランチャー', 168 | 'component.addModal.field.program.placeholder': '実行可能ファイルを選択', 169 | 'component.addModal.button.selectFile': 'ファイルを選択', 170 | 'component.addModal.field.name': 'ゲーム名', 171 | 'component.addModal.button.cancel': 'キャンセル', 172 | 'component.addModal.button.submit': '送信', 173 | 'component.addModal.dialog.selectProgram': 'ランチャーを選択', 174 | 'component.addModal.dialog.filter.executable': '実行可能ファイル', 175 | 'component.addModal.tips': '現在追加するゲームは既に存在する可能性があります。重複がないか確認してください。', 176 | 'component.groupModal.title.category': 'カテゴリを追加', 177 | 'component.groupModal.title.group': 'グループを追加', 178 | 'component.groupModal.field.name': '名前', 179 | 'component.alertBox.button.sure': '確認', 180 | 'component.alertBox.button.copy': 'コピー', 181 | 'component.confirmBox.default.title': '確認', 182 | 'component.confirmBox.button.confirm': '確認', 183 | 'component.confirmBox.button.cancel': 'キャンセル', 184 | 'component.filterModal.title': 'フィルター', 185 | 'component.filterModal.field.onlyLocal': 'ローカルゲームのみ表示', 186 | 'component.filterModal.toggle.yes': 'はい', 187 | 'component.filterModal.toggle.no': 'いいえ', 188 | 'component.filterModal.button.confirm': '確認', 189 | 'component.sortModal.title': '並べ替え', 190 | 'component.sortModal.button.confirm': '確認', 191 | 'component.sortModal.toggle.label': '降順/昇順', 192 | 'component.sortModal.toggle.descending': '降順', 193 | 'component.sortModal.toggle.ascending': '昇順', 194 | 'component.sortModal.sort.createDate': '作成日', 195 | 'component.sortModal.sort.title': 'タイトル', 196 | 'component.sortModal.sort.lastPlay': '最後にプレイした日', 197 | 'component.sortModal.sort.developer': '開発者', 198 | 'component.sortModal.sort.rating': '評価', 199 | 'component.sortModal.sort.releaseDate': 'リリース日', 200 | 'component.syncModal.title': 'ゲームを同期', 201 | 'component.syncModal.field.program': 'ランチャー', 202 | 'component.syncModal.field.program.placeholder': '実行可能ファイルを選択', 203 | 'component.syncModal.button.selectFile': 'ファイルを選択', 204 | 'component.syncModal.button.cancel': 'キャンセル', 205 | 'component.syncModal.button.submit': '送信', 206 | 'component.syncModal.dialog.selectProgram': 'ランチャーを選択', 207 | 'component.syncModal.dialog.filter.executable': '実行可能ファイル', 208 | 'time.justnow': 'たった今', 209 | 'time.minutesAgo': '{0} 分前', 210 | 'time.hoursAgo': '{0} 時間前', 211 | 'time.yesterday': '昨日', 212 | 'time.daysAgo': '{0} 日前', 213 | 'time.lastWeek': '先週', 214 | 'time.weeksAgo': '{0} 週間前', 215 | 'time.lastMonth': '先月', 216 | 'time.monthsAgo': '{0} ヶ月前', 217 | 'time.lastYear': '去年', 218 | 'time.yearsAgo': '{0} 年前', 219 | 'alert.title': '確認', 220 | 'alert.title.error.http': 'HTTPリクエストエラー', 221 | 'alert.title.error.fatal': '致命的なエラー', 222 | 'alert.title.error': '予期せぬエラー' 223 | } 224 | -------------------------------------------------------------------------------- /src/locales/zh-CN.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'app.update.title': '发现新版本', 3 | 'app.update.message': '当前 Nanno 版本为 {0},最新版本为 {1},请更新后再使用。', 4 | 'app.update.button': '获取最新版本', 5 | 'app.update.title.failed': '获取最新版本失败', 6 | 'app.update.message.failed': '请检查你的网络是否能正常连接到 raw.githubusercontent.com 并稍后再试。', 7 | 'page.library.title': '游戏', 8 | 'page.library.command.add': '添加游戏', 9 | 'page.library.command.sort': '排序', 10 | 'page.library.command.filter': '筛选', 11 | 'page.detail.info.developer': '开发商', 12 | 'page.detail.info.releaseDate': '发布日期', 13 | 'page.detail.info.createDate': '创建日期', 14 | 'page.detail.info.expectedPlayHours': '预计时长', 15 | 'page.detail.info.playTime': '游玩时长', 16 | 'page.detail.info.playCount': '游玩次数', 17 | 'page.detail.info.playCount.value': '{0} 次', 18 | 'page.detail.info.lastPlay': '上次游玩', 19 | 'page.detail.game.notFound': '游戏不存在', 20 | 'page.detail.command.sync': '同步游戏', 21 | 'page.detail.command.start': '开始游戏', 22 | 'page.detail.command.edit': '编辑信息', 23 | 'page.detail.command.guide': '查看攻略', 24 | 'page.detail.command.backup': '备份存档', 25 | 'page.detail.command.more': '更多', 26 | 'page.detail.command.openGameDir': '打开游戏目录', 27 | 'page.detail.command.openSaveDir': '打开存档目录', 28 | 'page.detail.command.viewVndb': '查看 Vndb 页面', 29 | 'page.detail.command.viewBangumi': '查看 Bangumi 页面', 30 | 'page.detail.command.deleteSync': '删除本地同步', 31 | 'page.detail.command.deleteGame': '删除游戏', 32 | 'page.detail.modal.deleteSync': '删除本地同步后需重新设置游戏启动程序,但不会删除本地游戏文件,是否继续?', 33 | 'page.detail.modal.deleteGame': '该操作会删除游戏数据与云端存档备份,但不会删除本地游戏文件,是否继续?', 34 | 'page.detail.section.description': '简介', 35 | 'page.detail.section.tags': '标签', 36 | 'page.detail.timeline.chart': '统计表格', 37 | 'page.detail.timeline.playTime': '游玩时间', 38 | 'page.edit.game.notFound': '游戏不存在', 39 | 'page.edit.dropdown.mixed': '聚合搜索', 40 | 'page.edit.dropdown.vndb': 'VNDB', 41 | 'page.edit.dropdown.bgm': 'Bangumi', 42 | 'page.edit.button.fetchData': '从数据源更新数据', 43 | 'page.edit.button.cancel': '取消', 44 | 'page.edit.button.save': '保存', 45 | 'page.edit.section.basicInfo': '基本信息', 46 | 'page.edit.section.localSettings': '本地设置', 47 | 'page.edit.dialog.selectSave': '选择存档目录', 48 | 'page.edit.dialog.selectProgram': '选择启动程序', 49 | 'page.edit.dialog.selectGuide': '选择攻略文件', 50 | 'page.edit.dialog.filter.executable': '可执行文件', 51 | 'page.edit.dialog.filter.textFile': '文本文件', 52 | 'page.edit.field.source': '数据源', 53 | 'page.edit.field.title': '游戏名', 54 | 'page.edit.field.developer': '开发者', 55 | 'page.edit.field.description': '简介', 56 | 'page.edit.field.tags': '标签', 57 | 'page.edit.field.tags.placeholder': '用逗号分隔多个标签', 58 | 'page.edit.field.cover': '封面图', 59 | 'page.edit.field.expectedHours': '预计时长', 60 | 'page.edit.field.rating': '评分', 61 | 'page.edit.field.releaseDate': '发行日期', 62 | 'page.edit.field.savePath': '存档路径', 63 | 'page.edit.field.savePath.placeholder': '选择一个目录', 64 | 'page.edit.field.programFile': '启动程序', 65 | 'page.edit.field.programFile.placeholder': '选择一个可执行文件', 66 | 'page.edit.field.guideFile': '攻略文件', 67 | 'page.edit.field.guideFile.placeholder': '选择一个文本文件', 68 | 'page.edit.text.needSync': '请先同步至本地', 69 | 'page.settings.title': '设置', 70 | 'page.settings.button.save': '保存设置', 71 | 'page.settings.profile.bgm': 'Bangumi 账户:', 72 | 'page.settings.profile.vndb': 'Vndb 账户:', 73 | 'page.settings.profile.notLogged': '未登录', 74 | 'page.settings.profile.lastSync': '上次同步时间:', 75 | 'page.settings.profile.notSynced': '未同步', 76 | 'page.settings.profile.repoSize': '仓库大小:', 77 | 'page.settings.profile.visibility': '可见性:', 78 | 'page.settings.profile.visibility.public': '公开', 79 | 'page.settings.profile.visibility.private': '私有', 80 | 'page.settings.profile.visibility.unknown': '不明', 81 | 'page.settings.data.title': '数据设置', 82 | 'page.settings.data.syncMode': '同步方式', 83 | 'page.settings.data.syncMode.github': 'Github Api', 84 | 'page.settings.data.syncMode.server': '自定义服务器', 85 | 'page.settings.data.token': 'GitHub Token', 86 | 'page.settings.data.token.get': '获取 Token', 87 | 'page.settings.data.repo': '仓库', 88 | 'page.settings.data.repo.placeholder': '诸如:biyuehu/galgame-data', 89 | 'page.settings.data.path': '路径', 90 | 'page.settings.data.path.placeholder': '诸如:gal-keeper/', 91 | 'page.settings.data.autoSync': '自动同步间隔', 92 | 'page.settings.data.autoSync.unit': '单位:分钟(0 表示不自动同步),需重启后生效', 93 | 'page.settings.data.autoSync.placeholder': '请输入自动同步间隔', 94 | 'page.settings.data.vndbToken': 'Vndb Token', 95 | 'page.settings.data.bgmToken': 'Bangumi Token', 96 | 'page.settings.data.source': '默认获取数据源', 97 | 'page.settings.data.button.export': '导出游戏数据', 98 | 'page.settings.data.button.import': '导入游戏数据', 99 | 'page.settings.data.button.sync': '手动同步', 100 | 'page.settings.data.alert.syncSuccess': '同步成功', 101 | 'page.settings.data.alert.syncCancel': '无变动,同步已取消', 102 | 'page.settings.appearance.title': '外观设置', 103 | 'page.settings.appearance.theme': '主题', 104 | 'page.settings.appearance.theme.light': '浅色', 105 | 'page.settings.appearance.theme.dark': '深色', 106 | 'page.settings.appearance.theme.system': '跟随系统', 107 | 'page.settings.appearance.language': '语言', 108 | 'page.settings.appearance.language.en': '英语', 109 | 'page.settings.appearance.language.zh': '简体中文', 110 | 'page.settings.appearance.language.ja': '日语', 111 | 'page.settings.appearance.language.zhTw': '繁体中文', 112 | 'page.settings.detail.title': '细节设置', 113 | 'page.settings.detail.maxTimelineDisplayCount': '动态最多显示数量', 114 | 'page.settings.detail.playLaunchVoice': 'Nanno 启动时播报声音', 115 | 'page.settings.detail.autoSetTitle': '编辑游戏时自动设置游戏标题', 116 | 'page.settings.detail.autoCacheCover': '自动缓存游戏封面', 117 | 'page.settings.details.button.cleanCache': '清理缓存', 118 | 'page.home.title': '首页', 119 | 'page.home.stats.total': '总游戏数', 120 | 'page.home.stats.local': '本地游戏', 121 | 'page.home.stats.cloud': '云端游戏', 122 | 'page.home.stats.totalPlayTime': '累计游玩时长', 123 | 'page.home.stats.todayPlayTime': '今日游玩时长', 124 | 'page.home.stats.totalPlayCount': '累计游玩次数', 125 | 'page.home.timeline.title': '最近动态', 126 | 'page.home.timeline.played': '游玩了', 127 | 'page.home.timeline.added': '新增了', 128 | 'page.home.activity.recent': '最近游玩', 129 | 'page.home.activity.latest': '最近添加', 130 | 'page.home.activity.running': '正在运行', 131 | 'page.home.activity.running.empty': '没有正在运行的游戏', 132 | 'page.category.title': '分类', 133 | 'page.category.command.insertGame': '添加游戏', 134 | 'page.category.command.switchGroup': '切换分组', 135 | 'page.category.command.addGroup': '添加分组', 136 | 'page.category.command.addCategory': '添加分类', 137 | 'page.category.command.deleteGroup': '删除分组', 138 | 'page.category.modal.group.title': '添加分组', 139 | 'page.category.modal.group.name': '分组名称', 140 | 'page.category.modal.group.placeholder': '请输入分组名称', 141 | 'page.category.modal.category.title': '添加分类', 142 | 'page.category.modal.category.name': '分类名称', 143 | 'page.category.modal.category.placeholder': '请输入分类名称', 144 | 'page.category.confirm.group': '确定删除此分组?', 145 | 'page.category.confirm.category': '确定删除此分类?', 146 | 'page.category.default.developer.title': '开发商', 147 | 'page.category.default.rating.title': '评分', 148 | 'page.category.default.playStatus.title': '游玩状态', 149 | 'page.category.default.rating.1': '屎作', 150 | 'page.category.default.rating.2': '较差', 151 | 'page.category.default.rating.3': '一般', 152 | 'page.category.default.rating.4': '佳作', 153 | 'page.category.default.rating.5': '神作', 154 | 'page.category.default.playStatus.1': '未游玩', 155 | 'page.category.default.playStatus.2': '正在玩', 156 | 'page.category.default.playStatus.3': '已玩过', 157 | 'page.about.title': '关于', 158 | 'page.about.changelog': '更新日志', 159 | 'page.about.changelog.loading': '加载中...', 160 | 'page.about.changelog.failed': '加载失败', 161 | 'page.about.faq.content': 162 | '搜索到的不是我想要的游戏?\n打开游戏详情页,编辑游戏信息,然后设置正确的 Vndb 或 Bangumi id。\n\n为什么“自定义服务器”同步功能被禁用?\n仍在开发中,请等待后续版本。\n\nBangumi Api 找不到游戏,但它确实存在?\n请在设置页面设​​置 Bangumi Token,并确保账号注册时间超过 3 个月,Bangumi 不允许访客或新用户搜索 R18 游戏。\n\n我还有其他问题,在哪里可以问?\n可以在 GitHub issue 或 QQ 群中提问。', 163 | 'component.gameList.search.placeholder': '搜索游戏', 164 | 'component.addModal.title': '添加游戏', 165 | 'component.addModal.field.program': '启动程序', 166 | 'component.addModal.field.program.placeholder': '选择一个可执行文件', 167 | 'component.addModal.button.selectFile': '选择文件', 168 | 'component.addModal.field.name': '游戏名字', 169 | 'component.addModal.button.cancel': '取消', 170 | 'component.addModal.button.submit': '提交', 171 | 'component.addModal.dialog.selectProgram': '选择启动程序', 172 | 'component.addModal.dialog.filter.executable': '可执行文件', 173 | 'component.addModal.tips': '检测到当前添加的游戏可能已存在,请自行确认是否有重复', 174 | 'component.groupModal.title.category': '添加分类', 175 | 'component.groupModal.title.group': '添加分组', 176 | 'component.groupModal.field.name': '名称', 177 | 'component.alertBox.button.sure': '确认', 178 | 'component.alertBox.button.copy': '复制', 179 | 'component.confirmBox.default.title': '提示', 180 | 'component.confirmBox.button.confirm': '确认', 181 | 'component.confirmBox.button.cancel': '取消', 182 | 'component.filterModal.title': '筛选', 183 | 'component.filterModal.field.onlyLocal': '仅显示本地游戏', 184 | 'component.filterModal.toggle.yes': '是', 185 | 'component.filterModal.toggle.no': '否', 186 | 'component.filterModal.button.confirm': '确定', 187 | 'component.sortModal.title': '排序', 188 | 'component.sortModal.button.confirm': '确定', 189 | 'component.sortModal.toggle.label': '降序/升序', 190 | 'component.sortModal.toggle.descending': '降序', 191 | 'component.sortModal.toggle.ascending': '升序', 192 | 'component.sortModal.sort.createDate': '创建时间', 193 | 'component.sortModal.sort.title': '标题', 194 | 'component.sortModal.sort.lastPlay': '最近玩过', 195 | 'component.sortModal.sort.developer': '开发商', 196 | 'component.sortModal.sort.rating': '评分', 197 | 'component.sortModal.sort.releaseDate': '发行时间', 198 | 'component.syncModal.title': '同步游戏', 199 | 'component.syncModal.field.program': '启动程序', 200 | 'component.syncModal.field.program.placeholder': '选择一个可执行文件', 201 | 'component.syncModal.button.selectFile': '选择文件', 202 | 'component.syncModal.button.cancel': '取消', 203 | 'component.syncModal.button.submit': '提交', 204 | 'component.syncModal.dialog.selectProgram': '选择启动程序', 205 | 'component.syncModal.dialog.filter.executable': '可执行文件', 206 | 'time.justnow': '刚刚', 207 | 'time.minutesAgo': '{0} 分钟前', 208 | 'time.hoursAgo': '{0} 小时前', 209 | 'time.yesterday': '昨天', 210 | 'time.daysAgo': '{0} 天前', 211 | 'time.lastWeek': '上周', 212 | 'time.weeksAgo': '{0} 周前', 213 | 'time.lastMonth': '上个月', 214 | 'time.monthsAgo': '{0} 个月前', 215 | 'time.lastYear': '去年', 216 | 'time.yearsAgo': '{0} 年前', 217 | 'alert.title': '提示', 218 | 'alert.title.error.http': '网络请求错误', 219 | 'alert.title.error.fatal': '致命的错误', 220 | 'alert.title.error': '意外的错误' 221 | } 222 | -------------------------------------------------------------------------------- /src/locales/zh-TW.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'app.update.title': '發現新版本', 3 | 'app.update.message': '當前 Nanno 版本爲 {0},最新版本爲 {1},請更新後再使用。', 4 | 'app.update.button': '獲取最新版本', 5 | 'app.update.title.failed': '獲取最新版本失敗', 6 | 'app.update.message.failed': '請檢查妳的網絡是否能正常連接到 raw.githubusercontent.com 並稍後再試。', 7 | 'page.library.title': '遊戲', 8 | 'page.library.command.add': '添加遊戲', 9 | 'page.library.command.sort': '排序', 10 | 'page.library.command.filter': '篩選', 11 | 'page.detail.info.developer': '開發商', 12 | 'page.detail.info.releaseDate': '發布日期', 13 | 'page.detail.info.createDate': '創建日期', 14 | 'page.detail.info.expectedPlayHours': '預計時長', 15 | 'page.detail.info.playTime': '遊玩時長', 16 | 'page.detail.info.playCount': '遊玩次數', 17 | 'page.detail.info.playCount.value': '{0} 次', 18 | 'page.detail.info.lastPlay': '上次遊玩', 19 | 'page.detail.game.notFound': '遊戲不存在', 20 | 'page.detail.command.sync': '同步遊戲', 21 | 'page.detail.command.start': '開始遊戲', 22 | 'page.detail.command.edit': '編輯信息', 23 | 'page.detail.command.guide': '查看攻略', 24 | 'page.detail.command.backup': '備份存檔', 25 | 'page.detail.command.more': '更多', 26 | 'page.detail.command.openGameDir': '打開遊戲目錄', 27 | 'page.detail.command.openSaveDir': '打開存檔目錄', 28 | 'page.detail.command.viewVndb': '查看 Vndb 頁面', 29 | 'page.detail.command.viewBangumi': '查看 Bangumi 頁面', 30 | 'page.detail.command.deleteSync': '刪除本地同步', 31 | 'page.detail.command.deleteGame': '刪除遊戲', 32 | 'page.detail.modal.deleteSync': '刪除本地同步後需重新設置遊戲啓動程序,但不會刪除本地遊戲文件,是否繼續?', 33 | 'page.detail.modal.deleteGame': '該操作會刪除遊戲數據與雲端存檔備份,但不會刪除本地遊戲文件,是否繼續?', 34 | 'page.detail.section.description': '簡介', 35 | 'page.detail.section.tags': '標簽', 36 | 'page.detail.timeline.chart': '統計表格', 37 | 'page.detail.timeline.playTime': '遊玩時間', 38 | 'page.edit.game.notFound': '遊戲不存在', 39 | 'page.edit.dropdown.mixed': '聚合搜索', 40 | 'page.edit.dropdown.vndb': 'VNDB', 41 | 'page.edit.dropdown.bgm': 'Bangumi', 42 | 'page.edit.button.fetchData': '從數據源更新數據', 43 | 'page.edit.button.cancel': '取消', 44 | 'page.edit.button.save': '保存', 45 | 'page.edit.section.basicInfo': '基本信息', 46 | 'page.edit.section.localSettings': '本地設置', 47 | 'page.edit.dialog.selectSave': '選擇存檔目錄', 48 | 'page.edit.dialog.selectProgram': '選擇啓動程序', 49 | 'page.edit.dialog.selectGuide': '選擇攻略文件', 50 | 'page.edit.dialog.filter.executable': '可執行文件', 51 | 'page.edit.dialog.filter.textFile': '文本文件', 52 | 'page.edit.field.source': '數據源', 53 | 'page.edit.field.title': '遊戲名', 54 | 'page.edit.field.developer': '開發者', 55 | 'page.edit.field.description': '簡介', 56 | 'page.edit.field.tags': '標簽', 57 | 'page.edit.field.tags.placeholder': '用逗號分隔多個標簽', 58 | 'page.edit.field.cover': '封面圖', 59 | 'page.edit.field.expectedHours': '預計時長', 60 | 'page.edit.field.rating': '評分', 61 | 'page.edit.field.releaseDate': '發行日期', 62 | 'page.edit.field.savePath': '存檔路徑', 63 | 'page.edit.field.savePath.placeholder': '選擇壹個目錄', 64 | 'page.edit.field.programFile': '啓動程序', 65 | 'page.edit.field.programFile.placeholder': '選擇壹個可執行文件', 66 | 'page.edit.field.guideFile': '攻略文件', 67 | 'page.edit.field.guideFile.placeholder': '選擇壹個文本文件', 68 | 'page.edit.text.needSync': '請先同步至本地', 69 | 'page.settings.title': '設置', 70 | 'page.settings.button.save': '保存設置', 71 | 'page.settings.profile.bgm': 'Bangumi 賬戶:', 72 | 'page.settings.profile.vndb': 'Vndb 賬戶:', 73 | 'page.settings.profile.notLogged': '未登錄', 74 | 'page.settings.profile.lastSync': '上次同步時間:', 75 | 'page.settings.profile.notSynced': '未同步', 76 | 'page.settings.profile.repoSize': '倉庫大小:', 77 | 'page.settings.profile.visibility': '可見性:', 78 | 'page.settings.profile.visibility.public': '公開', 79 | 'page.settings.profile.visibility.private': '私有', 80 | 'page.settings.profile.visibility.unknown': '不明', 81 | 'page.settings.data.title': '數據設置', 82 | 'page.settings.data.syncMode': '同步方式', 83 | 'page.settings.data.syncMode.github': 'Github Api', 84 | 'page.settings.data.syncMode.server': '自定義服務器', 85 | 'page.settings.data.token': 'GitHub Token', 86 | 'page.settings.data.token.get': '獲取 Token', 87 | 'page.settings.data.repo': '倉庫', 88 | 'page.settings.data.repo.placeholder': '諸如:biyuehu/galgame-data', 89 | 'page.settings.data.path': '路徑', 90 | 'page.settings.data.path.placeholder': '諸如:gal-keeper/', 91 | 'page.settings.data.autoSync': '自動同步間隔', 92 | 'page.settings.data.autoSync.unit': '單位:分鍾(0 表示不自動同步),需重啓後生效', 93 | 'page.settings.data.autoSync.placeholder': '請輸入自動同步間隔', 94 | 'page.settings.data.vndbToken': 'Vndb Token', 95 | 'page.settings.data.bgmToken': 'Bangumi Token', 96 | 'page.settings.data.source': '默認獲取數據源', 97 | 'page.settings.data.button.export': '導出遊戲數據', 98 | 'page.settings.data.button.import': '導入遊戲數據', 99 | 'page.settings.data.button.sync': '手動同步', 100 | 'page.settings.data.alert.syncSuccess': '同步成功', 101 | 'page.settings.data.alert.syncCancel': '同步取消', 102 | 'page.settings.appearance.title': '外觀設置', 103 | 'page.settings.appearance.theme': '主題', 104 | 'page.settings.appearance.theme.light': '淺色', 105 | 'page.settings.appearance.theme.dark': '深色', 106 | 'page.settings.appearance.theme.system': '跟隨系統', 107 | 'page.settings.appearance.language': '語言', 108 | 'page.settings.appearance.language.en': '英語', 109 | 'page.settings.appearance.language.zh': '簡體中文', 110 | 'page.settings.appearance.language.ja': '日語', 111 | 'page.settings.appearance.language.zhTw': '繁體中文', 112 | 'page.settings.detail.title': '細節設置', 113 | 'page.settings.detail.maxTimelineDisplayCount': '動態最多顯示數量', 114 | 'page.settings.detail.playLaunchVoice': 'Nanno 啓動時播報聲音', 115 | 'page.settings.detail.autoSetTitle': '編輯遊戲時自動設置遊戲標題', 116 | 'page.settings.detail.autoCacheCover': '自動緩存遊戲封面', 117 | 'page.settings.details.button.cleanCache': '清理緩存', 118 | 'page.home.title': '首頁', 119 | 'page.home.stats.total': '總遊戲數', 120 | 'page.home.stats.local': '本地遊戲', 121 | 'page.home.stats.cloud': '雲端遊戲', 122 | 'page.home.stats.totalPlayTime': '累計遊玩時長', 123 | 'page.home.stats.todayPlayTime': '今日遊玩時長', 124 | 'page.home.stats.totalPlayCount': '累計遊玩次數', 125 | 'page.home.timeline.title': '最近動態', 126 | 'page.home.timeline.played': '遊玩了', 127 | 'page.home.timeline.added': '新增了', 128 | 'page.home.activity.recent': '最近遊玩', 129 | 'page.home.activity.latest': '最近添加', 130 | 'page.home.activity.running': '正在運行', 131 | 'page.home.activity.running.empty': '沒有正在運行的遊戲', 132 | 'page.category.title': '分類', 133 | 'page.category.command.insertGame': '添加遊戲', 134 | 'page.category.command.switchGroup': '切換分組', 135 | 'page.category.command.addGroup': '添加分組', 136 | 'page.category.command.addCategory': '添加分類', 137 | 'page.category.command.deleteGroup': '刪除分組', 138 | 'page.category.modal.group.title': '添加分組', 139 | 'page.category.modal.group.name': '分組名稱', 140 | 'page.category.modal.group.placeholder': '請輸入分組名稱', 141 | 'page.category.modal.category.title': '添加分類', 142 | 'page.category.modal.category.name': '分類名稱', 143 | 'page.category.modal.category.placeholder': '請輸入分類名稱', 144 | 'page.category.confirm.group': '確定刪除此分組?', 145 | 'page.category.confirm.category': '確定刪除此分類?', 146 | 'page.category.default.developer.title': '開發商', 147 | 'page.category.default.rating.title': '評分', 148 | 'page.category.default.playStatus.title': '遊玩狀態', 149 | 'page.category.default.rating.1': '屎作', 150 | 'page.category.default.rating.2': '較差', 151 | 'page.category.default.rating.3': '壹般', 152 | 'page.category.default.rating.4': '佳作', 153 | 'page.category.default.rating.5': '神作', 154 | 'page.category.default.playStatus.1': '未遊玩', 155 | 'page.category.default.playStatus.2': '正在玩', 156 | 'page.category.default.playStatus.3': '已玩過', 157 | 'page.about.title': '關于', 158 | 'page.about.changelog': '更新日志', 159 | 'page.about.changelog.loading': '加載中...', 160 | 'page.about.changelog.failed': '加載失敗', 161 | 'page.about.faq.content': 162 | '搜索到的不是我想要的遊戲?\n打開遊戲詳情頁,編輯遊戲信息,然後設置正確的 Vndb 或 Bangumi id。\n\n爲什麽“自定義服務器”同步功能被禁用?\n仍在開發中,請等待後續版本。\n\nBangumi Api 找不到遊戲,但它確實存在?\n請在設置頁面設​​置 Bangumi Token,並確保賬號注冊時間超過 3 個月,Bangumi 不允許訪客或新用戶搜索 R18 遊戲。\n\n我還有其他問題,在哪裏可以問?\n可以在 GitHub issue 或 QQ 群中提問。', 163 | 'component.gameList.search.placeholder': '搜索遊戲', 164 | 'component.addModal.title': '添加遊戲', 165 | 'component.addModal.field.program': '啓動程序', 166 | 'component.addModal.field.program.placeholder': '選擇壹個可執行文件', 167 | 'component.addModal.button.selectFile': '選擇文件', 168 | 'component.addModal.field.name': '遊戲名字', 169 | 'component.addModal.button.cancel': '取消', 170 | 'component.addModal.button.submit': '提交', 171 | 'component.addModal.dialog.selectProgram': '選擇啓動程序', 172 | 'component.addModal.dialog.filter.executable': '可執行文件', 173 | 'component.addModal.tips': '偵測到當前新增的遊戲可能已存在,請自行確認是否有重複', 174 | 'component.groupModal.title.category': '添加分類', 175 | 'component.groupModal.title.group': '添加分組', 176 | 'component.groupModal.field.name': '名稱', 177 | 'component.alertBox.button.sure': '確認', 178 | 'component.alertBox.button.copy': '複制', 179 | 'component.confirmBox.default.title': '提示', 180 | 'component.confirmBox.button.confirm': '確認', 181 | 'component.confirmBox.button.cancel': '取消', 182 | 'component.filterModal.title': '篩選', 183 | 'component.filterModal.field.onlyLocal': '僅顯示本地遊戲', 184 | 'component.filterModal.toggle.yes': '是', 185 | 'component.filterModal.toggle.no': '否', 186 | 'component.filterModal.button.confirm': '確定', 187 | 'component.sortModal.title': '排序', 188 | 'component.sortModal.button.confirm': '確定', 189 | 'component.sortModal.toggle.label': '降序/升序', 190 | 'component.sortModal.toggle.descending': '降序', 191 | 'component.sortModal.toggle.ascending': '升序', 192 | 'component.sortModal.sort.createDate': '創建時間', 193 | 'component.sortModal.sort.title': '標題', 194 | 'component.sortModal.sort.lastPlay': '最近玩過', 195 | 'component.sortModal.sort.developer': '開發商', 196 | 'component.sortModal.sort.rating': '評分', 197 | 'component.sortModal.sort.releaseDate': '發行時間', 198 | 'component.syncModal.title': '同步遊戲', 199 | 'component.syncModal.field.program': '啓動程序', 200 | 'component.syncModal.field.program.placeholder': '選擇壹個可執行文件', 201 | 'component.syncModal.button.selectFile': '選擇文件', 202 | 'component.syncModal.button.cancel': '取消', 203 | 'component.syncModal.button.submit': '提交', 204 | 'component.syncModal.dialog.selectProgram': '選擇啓動程序', 205 | 'component.syncModal.dialog.filter.executable': '可執行文件', 206 | 'time.justnow': '剛剛', 207 | 'time.minutesAgo': '{0} 分鍾前', 208 | 'time.hoursAgo': '{0} 小時前', 209 | 'time.yesterday': '昨天', 210 | 'time.daysAgo': '{0} 天前', 211 | 'time.lastWeek': '上周', 212 | 'time.weeksAgo': '{0} 周前', 213 | 'time.lastMonth': '上個月', 214 | 'time.monthsAgo': '{0} 個月前', 215 | 'time.lastYear': '去年', 216 | 'time.yearsAgo': '{0} 年前', 217 | 'alert.title': '提示', 218 | 'alert.title.error.http': '網絡請求錯誤', 219 | 'alert.title.error.fatal': '致命的錯誤', 220 | 'alert.title.error': '意外的錯誤' 221 | } 222 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { initializeIcons } from '@fluentui/font-icons-mdl2' 4 | import '@/index.css' 5 | import 'virtual:uno.css' 6 | import App from '@/App' 7 | import useStore from './store' 8 | import logger from '@/utils/logger' 9 | import { IS_DEV, IS_TAURI } from '@/constant' 10 | import { type Event, listen } from '@tauri-apps/api/event' 11 | 12 | /* Initialize */ 13 | initializeIcons() 14 | 15 | if (IS_DEV) { 16 | ;(globalThis as unknown as { store: typeof useStore }).store = useStore 17 | } 18 | 19 | /* Events */ 20 | document.addEventListener('contextmenu', (e) => e.preventDefault()) 21 | document.addEventListener('keydown', (e) => { 22 | if (['F3', 'F5', 'F7'].includes(e.key.toUpperCase())) { 23 | e.preventDefault() 24 | } 25 | 26 | if (e.ctrlKey && ['r', 'u', 'p', 'l', 'j', 'g', 'f', 's'].includes(e.key.toLowerCase())) { 27 | e.preventDefault() 28 | } 29 | }) 30 | document.addEventListener('dragstart', (e) => e.preventDefault()) 31 | 32 | if (IS_TAURI) { 33 | listen('increase', (data: Event<[string, number, number]>) => { 34 | useStore.getState().increasePlayTimeline(...data.payload) 35 | logger.debug('increase', data.payload) 36 | }) 37 | } 38 | 39 | /* Render */ 40 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 41 | 42 | 43 | 44 | ) 45 | -------------------------------------------------------------------------------- /src/pages/About/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { Stack, Text, Link, DefaultButton, Separator } from '@fluentui/react' 3 | import { random } from '@kotori-bot/tools' 4 | import { openUrl } from '@/utils' 5 | import { version, author, description } from '@/../package.json' 6 | import axios from 'axios' 7 | import { t } from '@/utils/i18n' 8 | 9 | interface Log { 10 | version: string 11 | date: string 12 | types: { 13 | type: string 14 | commits: { 15 | message: string 16 | hash: string 17 | url: string 18 | }[] 19 | }[] 20 | } 21 | 22 | function parseChangelog(text: string) { 23 | const logs: Log[] = [] 24 | let currentLog: Log | null = null 25 | 26 | text 27 | .split('\n') 28 | .filter((line) => line.trim()) 29 | .map((line) => { 30 | const versionMatch = line.match(/^# \[(\d+\.\d+\.\d+)\](.+) \((.+)\)$/) 31 | const typeMatch = line.match(/^### (.+)$/) 32 | const commitMatch = line.match(/^\* (.+) \(\[(.+)\]\((.+)\)\)$/) 33 | 34 | if (versionMatch) { 35 | currentLog = { version: versionMatch[1], date: versionMatch[3], types: [] } 36 | logs.push(currentLog) 37 | } else if (typeMatch) { 38 | currentLog?.types.push({ type: typeMatch[1], commits: [] }) 39 | } else if (commitMatch) { 40 | currentLog?.types[currentLog.types.length - 1].commits.push({ 41 | message: commitMatch[1], 42 | hash: commitMatch[2], 43 | url: commitMatch[3] 44 | }) 45 | } 46 | }) 47 | 48 | return logs 49 | } 50 | 51 | const About: React.FC = () => { 52 | const [changelog, setChangelog] = useState([]) 53 | 54 | useEffect(() => { 55 | axios 56 | .get('https://raw.githubusercontent.com/biyuehu/gal-keeper/main/CHANGELOG.md') 57 | .then((res) => setChangelog(parseChangelog(res.data))) 58 | .catch(() => setChangelog(null)) 59 | }, []) 60 | 61 | return ( 62 |
63 | 64 | openUrl('https://vndb.org/c18258')} 66 | src="/assets/cover.png" 67 | alt="logo" 68 | className="w-48 h-48 mb-4 hover:cursor-pointer" 69 | /> 70 |

Nanno | GalKeeper

71 | {description} 72 |
73 | {[ 74 | ['GitHub', 'https://github.com/biyuehu/gal-keeper', 'text-red-300'], 75 | ['Bilibili', 'https://space.bilibili.com/293767574', 'text-pink'], 76 | ['QQ Group', 'https://qm.qq.com/q/JCRGJIpzk2', 'text-blue-500'] 77 | ].map(([text, url, color]) => ( 78 | openUrl(url)} className={`mr-2 ${color}`} /> 79 | ))} 80 |
81 | 82 | By {author.slice(0, author.lastIndexOf(' '))} with {random.choice(['❤️', '💴', '🩸'])}| Licensed under BCU | 83 | Version {version} 84 | 85 |
86 | 87 | 88 | 89 | 90 | FAQ 91 | 92 | 93 | 94 |
95 | {t`page.about.faq.content` 96 | .split('\n\n') 97 | .map((str) => str.split('\n')) 98 | .map( 99 | (arr) => 100 | arr[0] && ( 101 |
102 | 103 | Q: {arr[0]} 104 | 105 |
106 | 107 | A: {arr[1]} 108 | 109 |
110 |
111 | ) 112 | )} 113 |
114 |
115 | 116 | 117 | 118 | 119 | {t`page.about.changelog`} 120 | 121 | 122 | 123 |
124 | {changelog ? ( 125 | changelog.length === 0 ? ( 126 | {t`page.about.changelog.loading`} 127 | ) : ( 128 | changelog.map((log) => ( 129 |
130 | 131 | {log.version} ({log.date}) 132 | 133 | {log.types.map((type) => ( 134 |
135 | 136 | {type.type} 137 | 138 |
    139 | {type.commits.map((commit) => ( 140 |
  • 141 | {commit.message} 142 | 143 | [{commit.hash}] 144 | 145 |
  • 146 | ))} 147 |
148 |
149 | ))} 150 |
151 | )) 152 | ) 153 | ) : ( 154 | {t`page.about.changelog.failed`} 155 | )} 156 |
157 |
158 |
159 | ) 160 | } 161 | 162 | export default About 163 | -------------------------------------------------------------------------------- /src/pages/Category/index.tsx: -------------------------------------------------------------------------------- 1 | import ConfirmBox from '@/components/ConfirmBox' 2 | import GameList from '@/components/GameList' 3 | import GroupModal from '@/components/GroupModal' 4 | import InsertModal from '@/components/InsertModal' 5 | import { useUI } from '@/contexts/UIContext' 6 | import useStore from '@/store' 7 | import { DefaultGroup, type GameData, type Category as CategoryType, type Group } from '@/types' 8 | import { calculateTotalPlayTime } from '@/utils' 9 | import i18n, { t } from '@/utils/i18n' 10 | import { CommandBar, type ICommandBarItemProps, IconButton } from '@fluentui/react' 11 | import React, { useMemo, useState } from 'react' 12 | import { Link, Navigate, useParams } from 'react-router-dom' 13 | 14 | function isDefaultGroup(id: string): id is DefaultGroup { 15 | return ([DefaultGroup.DEVELOPER, DefaultGroup.RATING, DefaultGroup.PLAY_STATE] as string[]).includes(id) 16 | } 17 | 18 | function generateDefaultCategories(id: string, games: GameData[]): CategoryType[] | undefined { 19 | return id.startsWith(DefaultGroup.DEVELOPER) 20 | ? Object.entries( 21 | games.reduce( 22 | (acc, game) => 23 | game.developer 24 | ? { 25 | // biome-ignore lint: 26 | ...acc, 27 | [game.developer]: game.developer in acc ? [...acc[game.developer], game.id] : [game.id] 28 | } 29 | : acc, 30 | {} as Record 31 | ) 32 | ).map(([name, gameIds]) => ({ 33 | id: `${DefaultGroup.DEVELOPER}_${name}`, 34 | name, 35 | gameIds 36 | })) 37 | : id.startsWith(DefaultGroup.RATING) 38 | ? games 39 | .reduce( 40 | (acc, game) => 41 | game.rating >= 8 42 | ? [...acc.slice(0, 4), [...acc[4], game.id]] 43 | : game.rating >= 6 44 | ? [...acc.slice(0, 3), [...acc[3], game.id], acc[4]] 45 | : game.rating >= 4 46 | ? [...acc.slice(0, 2), [...acc[2], game.id], ...acc.slice(3)] 47 | : game.rating >= 2 48 | ? [acc[0], [...acc[1], game.id], ...acc.slice(2)] 49 | : game.rating > 0 50 | ? [[...acc[0], game.id], ...acc.slice(1)] 51 | : acc, 52 | new Array(5).fill([]) 53 | ) 54 | .map((gameIds, index) => ({ 55 | id: `${DefaultGroup.RATING}_${index + 1}`, 56 | name: i18n.locale(`page.category.default.rating.${index + 1}`), 57 | gameIds 58 | })) 59 | .reverse() 60 | : id.startsWith(DefaultGroup.PLAY_STATE) 61 | ? games 62 | .reduce( 63 | (acc, game) => 64 | ((real, expect) => 65 | expect <= 0 66 | ? acc 67 | : real >= expect 68 | ? [...acc.slice(0, 2), [...acc[2], game.id]] 69 | : real > 0 70 | ? [acc[0], [...acc[1], game.id], acc[2]] 71 | : [[...acc[0], game.id], ...acc.slice(1)])( 72 | calculateTotalPlayTime(game.playTimelines), 73 | game.expectedPlayHours * 60 74 | ), 75 | new Array(3).fill([]) 76 | ) 77 | .map((gameIds, index) => ({ 78 | id: `${DefaultGroup.PLAY_STATE}_${index + 1}`, 79 | name: i18n.locale(`page.category.default.playStatus.${index + 1}`), 80 | gameIds 81 | })) 82 | : undefined 83 | } 84 | 85 | const Category: React.FC = () => { 86 | const { id } = useParams() 87 | const [modalData, setModalData] = useState({ 88 | isOpenGroupModal: false, 89 | isOpenCategoryModal: false, 90 | isOpenDeleteModal: false, 91 | isOpenDeleteModal2: null as string | null, 92 | isOpenInsertModal: false 93 | }) 94 | const { deleteGroup, deleteCategory, getAllGameData } = useStore((state) => state) 95 | const [groups, setGroups] = useState(useStore((state) => state.groups)) 96 | const [categories, setCategories] = useState(useStore((state) => state.categories)) 97 | const { 98 | state: { currentGroupId }, 99 | setCurrentGroupId 100 | } = useUI() 101 | const allGames = useMemo(() => getAllGameData(false), [getAllGameData]) 102 | const currentGroups = useMemo( 103 | () => (isDefaultGroup(currentGroupId) ? undefined : groups.find((group) => group.id === currentGroupId)), 104 | [currentGroupId, groups] 105 | ) 106 | const currentCategories = useMemo( 107 | () => 108 | generateDefaultCategories(currentGroupId, allGames) ?? 109 | categories.filter((category) => currentGroups?.categoryIds.includes(category.id)), 110 | [categories, currentGroupId, allGames, currentGroups] 111 | ) 112 | 113 | const DEFAULT_GROUPS: Group[] = [ 114 | { 115 | id: DefaultGroup.DEVELOPER, 116 | name: t`page.category.default.developer.title`, 117 | categoryIds: [] 118 | }, 119 | { 120 | id: DefaultGroup.RATING, 121 | name: t`page.category.default.rating.title`, 122 | categoryIds: [] 123 | }, 124 | { 125 | id: DefaultGroup.PLAY_STATE, 126 | name: t`page.category.default.playStatus.title`, 127 | categoryIds: [] 128 | } 129 | ] 130 | 131 | if (!id) { 132 | const commandItems: ICommandBarItemProps[] = [ 133 | { 134 | key: 'switchGroup', 135 | text: t`page.category.command.switchGroup`, 136 | iconProps: { iconName: 'Switcher' }, 137 | subMenuProps: { 138 | items: [...DEFAULT_GROUPS, ...groups].map((group) => ({ 139 | key: group.id, 140 | text: group.name, 141 | onClick: () => setCurrentGroupId(group.id) 142 | })) 143 | } 144 | }, 145 | { 146 | key: 'addGroup', 147 | text: t`page.category.command.addGroup`, 148 | iconProps: { iconName: 'Add' }, 149 | onClick: () => setModalData((prev) => ({ ...prev, isOpenGroupModal: true })) 150 | }, 151 | { 152 | key: 'addCategory', 153 | text: t`page.category.command.addCategory`, 154 | iconProps: { iconName: 'Add' }, 155 | disabled: !currentGroups, 156 | onClick: () => setModalData((prev) => ({ ...prev, isOpenCategoryModal: true })) 157 | }, 158 | { 159 | key: 'deleteGroup', 160 | text: t`page.category.command.deleteGroup`, 161 | iconProps: { iconName: 'Delete' }, 162 | disabled: !currentGroups, 163 | onClick: () => setModalData((prev) => ({ ...prev, isOpenDeleteModal: true })) 164 | } 165 | ] 166 | 167 | return ( 168 | 169 | setModalData((prev) => ({ ...prev, isOpenGroupModal: false }))} 172 | groupId={undefined} 173 | setData={setGroups} 174 | /> 175 | setModalData((prev) => ({ ...prev, isOpenCategoryModal: false }))} 178 | groupId={currentGroupId} 179 | setData={(...args: Parameters) => { 180 | setGroups(useStore.getState().groups) 181 | setCategories(...args) 182 | }} 183 | /> 184 | setModalData({ ...modalData, isOpenDeleteModal: isOpen })} 187 | text={t`page.category.confirm.group`} 188 | onConfirm={() => { 189 | setTimeout(() => { 190 | setCurrentGroupId(DefaultGroup.DEVELOPER) 191 | deleteGroup(currentGroupId) 192 | setGroups(useStore.getState().groups) 193 | }, 0) 194 | }} 195 | /> 196 | setModalData({ ...modalData, isOpenDeleteModal2: null })} 199 | text={t`page.category.confirm.category`} 200 | onConfirm={() => { 201 | setTimeout(() => { 202 | if (typeof modalData.isOpenDeleteModal2 === 'string') { 203 | deleteCategory(modalData.isOpenDeleteModal2) 204 | setGroups(useStore.getState().groups) 205 | } 206 | }, 0) 207 | }} 208 | /> 209 |
210 |
211 |
212 | 213 |
214 |
215 |
216 | {currentCategories.map((category) => ( 217 | 222 |
223 |
224 |
{category.name}
225 | {currentGroups && ( 226 | { 229 | e.preventDefault() 230 | setModalData({ ...modalData, isOpenDeleteModal2: category.id }) 231 | }} 232 | /> 233 | )} 234 |
235 |
236 | 237 | x{category.gameIds.length} 238 | 239 |
240 |
241 | 242 | ))} 243 |
244 | 245 | ) 246 | } 247 | 248 | const category = (generateDefaultCategories(id, allGames) ?? categories).find((g) => g.id === id) 249 | 250 | if (!category) return 251 | const games = allGames.filter((game) => category.gameIds.includes(game.id)) 252 | 253 | const commandItems: ICommandBarItemProps[] = [ 254 | { 255 | key: 'insertGame', 256 | text: t`page.category.command.insertGame`, 257 | iconProps: { iconName: 'Add' }, 258 | disabled: !currentGroups, 259 | onClick: () => setModalData((prev) => ({ ...prev, isOpenInsertModal: true })) 260 | } 261 | ] 262 | 263 | return ( 264 | 265 | setModalData((prev) => ({ ...prev, isOpenInsertModal: false }))} 268 | data={category} 269 | setData={setCategories} 270 | /> 271 | 272 | ) 273 | } 274 | 275 | export default Category 276 | -------------------------------------------------------------------------------- /src/pages/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'react' 2 | import { Stack } from '@fluentui/react/lib/Stack' 3 | import { Text } from '@fluentui/react/lib/Text' 4 | import { Card } from '@fluentui/react-components' 5 | import { t } from '@/utils/i18n' 6 | import useStore from '@/store' 7 | import type { GameWithLocalData } from '@/types' 8 | import { calculateTotalPlayTime, getGameCover, showMinutes, showTime } from '@/utils' 9 | import { Link } from 'react-router-dom' 10 | import events from '@/utils/events' 11 | 12 | const Home: React.FC = () => { 13 | const { 14 | getAllGameData, 15 | isRunningGame, 16 | settings: { maxTimelineDisplayCount } 17 | } = useStore((state) => state) 18 | const [games, setGames] = useState(getAllGameData(false)) 19 | 20 | useEffect(() => { 21 | events.on('updateGame', () => setGames(getAllGameData(false))) 22 | }, [getAllGameData]) 23 | 24 | const stats = useMemo(() => { 25 | const todayStart = 26 | new Date(`${new Date().getFullYear()}-${new Date().getMonth() + 1}-${new Date().getDate()}`).getTime() / 1000 27 | 28 | const [totalPlayTime, todayPlayTime, totalPlayCount] = games.reduce( 29 | (acc, game) => [ 30 | acc[0] + calculateTotalPlayTime(game.playTimelines), 31 | acc[1] + calculateTotalPlayTime(game.playTimelines.filter(([_, start]) => start >= todayStart)), 32 | acc[2] + game.playTimelines.length 33 | ], 34 | [0, 0, 0] 35 | ) 36 | 37 | return { 38 | totalPlayTime, 39 | todayPlayTime, 40 | totalPlayCount, 41 | recentPlayed: games 42 | .sort((a, b) => b.lastPlay - a.lastPlay) 43 | .slice(0, 5) 44 | .filter((game) => Date.now() - new Date(game.lastPlay).getTime() < 7 * 24 * 60 * 60 * 1000), 45 | runningGames: games.filter((game) => isRunningGame(game.id)).slice(0, 5), 46 | recentAdded: games 47 | .sort((a, b) => b.createDate - a.createDate) 48 | .slice(0, 5) 49 | .filter((game) => Date.now() - new Date(game.createDate).getTime() < 7 * 24 * 60 * 60 * 1000) 50 | } 51 | }, [games, isRunningGame]) 52 | 53 | const timeline = useMemo(() => { 54 | const events: Array<{ 55 | type: 'play' | 'add' 56 | game: GameWithLocalData 57 | time: number 58 | minutes?: number 59 | }> = [] 60 | 61 | games.map((game) => 62 | game.playTimelines.map(([_, end, second]) => { 63 | const minutes = second / 60 64 | if (minutes < 1) return 65 | events.push({ 66 | type: 'play', 67 | game, 68 | time: end * 1000, 69 | minutes 70 | }) 71 | }) 72 | ) 73 | 74 | games.map((game) => 75 | events.push({ 76 | type: 'add', 77 | game, 78 | time: game.createDate 79 | }) 80 | ) 81 | 82 | return events.sort((a, b) => b.time - a.time).slice(0, maxTimelineDisplayCount) 83 | }, [games, maxTimelineDisplayCount]) 84 | 85 | return ( 86 |
87 | 88 | 89 | 90 | {t`page.home.stats.total`} 91 | 92 | {games.length} 93 | 94 | 95 | 96 | {t`page.home.stats.local`} 97 | 98 | {games.filter((game) => game.local).length} 99 | 100 | 101 | 102 | {t`page.home.stats.cloud`} 103 | 104 | {games.filter((game) => !game.local).length} 105 | 106 | 107 | 108 | 109 | 110 | 111 | {t`page.home.stats.totalPlayTime`} 112 | 113 | {showMinutes(stats.totalPlayTime)} 114 | 115 | 116 | 117 | {t`page.home.stats.todayPlayTime`} 118 | 119 | {showMinutes(stats.todayPlayTime)} 120 | 121 | 122 | 123 | {t`page.home.stats.totalPlayCount`} 124 | 125 | {stats.totalPlayCount} 126 | 127 | 128 | 129 | 130 | 131 | 132 | {t`page.home.activity.recent`} 133 | 134 | {stats.recentPlayed.map((game) => ( 135 | 140 | {game.title} 141 |
142 | {game.title} 143 | {showTime(game.lastPlay / 1000)} 144 |
145 | 146 | ))} 147 |
148 |
149 | 150 | 151 | {t`page.home.activity.latest`} 152 | 153 | {stats.recentAdded.map((game) => ( 154 | 159 | {game.title} 160 |
161 | {game.title} 162 | {showTime(game.createDate / 1000)} 163 |
164 | 165 | ))} 166 |
167 |
168 | 169 | 170 | {t`page.home.activity.running`} 171 | 172 | {stats.runningGames.length > 0 ? ( 173 | stats.runningGames.map((game) => ( 174 | 179 | {game.title} 180 |
181 | {game.title} 182 |
183 | 184 | )) 185 | ) : ( 186 | {t`page.home.activity.running.empty`} 187 | )} 188 |
189 |
190 |
191 | 192 | 193 | {t`page.home.timeline.title`} 194 | 195 | {timeline.map((event) => ( 196 | 201 |
202 |
203 | {event.game.title} 208 |
209 | 210 | {event.game.title} 211 | 212 | 213 | {`${showTime(event.time / 1000)} • `} 214 | {event.type === 'play' ? t`page.home.timeline.played` : t`page.home.timeline.added`} 215 | {event.minutes && ` • ${showMinutes(event.minutes)}`} 216 | 217 |
218 |
219 |
220 | 221 | ))} 222 |
223 |
224 |
225 |
226 | ) 227 | } 228 | 229 | export default Home 230 | -------------------------------------------------------------------------------- /src/pages/Library/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import SortModal from '@/components/SortModal' 3 | import useStore from '@/store' 4 | import FilterModal from '@/components/FilterModal' 5 | import AddModal from '@/components/AddModal' 6 | import { t } from '@/utils/i18n' 7 | import GameList from '@/components/GameList' 8 | import type { ICommandBarItemProps } from '@fluentui/react' 9 | 10 | const Library: React.FC = () => { 11 | const [modalData, setModalData] = useState({ 12 | isOpenSortModal: false, 13 | isOpenFilterModal: false, 14 | isOpenAddModal: false 15 | }) 16 | const [games, setGames] = useState(useStore((state) => state.getAllGameData)(false)) 17 | 18 | const commandItems: ICommandBarItemProps[] = [ 19 | { 20 | key: 'add', 21 | text: t`page.library.command.add`, 22 | iconProps: { iconName: 'Add' }, 23 | onClick: () => setModalData((prev) => ({ ...prev, isOpenAddModal: true })) 24 | }, 25 | { 26 | key: 'sort', 27 | text: t`page.library.command.sort`, 28 | iconProps: { iconName: 'Sort' }, 29 | onClick: () => setModalData((prev) => ({ ...prev, isOpenSortModal: true })) 30 | }, 31 | { 32 | key: 'filter', 33 | text: t`page.library.command.filter`, 34 | iconProps: { iconName: 'Filter' }, 35 | onClick: () => setModalData((prev) => ({ ...prev, isOpenFilterModal: true })) 36 | } 37 | ] 38 | 39 | return ( 40 | 41 | setModalData((prev) => ({ ...prev, isOpenAddModal: false }))} 44 | setData={setGames} 45 | /> 46 | setModalData((prev) => ({ ...prev, isOpenSortModal: false }))} 49 | /> 50 | setModalData((prev) => ({ ...prev, isOpenFilterModal: false }))} 53 | /> 54 | 55 | ) 56 | } 57 | 58 | export default Library 59 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import Library from '@/pages/Library' 2 | import Settings from '@/pages/Settings' 3 | import Detail from '@/pages/Detail' 4 | import Home from '@/pages/Home' 5 | import Category from '@/pages/Category' 6 | import Edit from '@/pages/Edit' 7 | import About from '@/pages/About' 8 | 9 | export type RouteConfig = { 10 | path: string 11 | component: JSX.Element 12 | title: string 13 | } & ( 14 | | { 15 | icon: string 16 | } 17 | | { 18 | belong: string 19 | } 20 | ) 21 | 22 | const routes: RouteConfig[] = [ 23 | { 24 | path: '/', 25 | component: , 26 | title: 'page.home.title', 27 | icon: 'CompassNW' 28 | }, 29 | { 30 | path: '/library', 31 | component: , 32 | title: 'page.library.title', 33 | icon: 'WebAppBuilderFragment' 34 | }, 35 | { 36 | path: '/details/:id', 37 | component: , 38 | title: 'page.library.title', 39 | belong: '/library' 40 | }, 41 | { 42 | path: '/edit/:id', 43 | component: , 44 | title: 'page.library.title', 45 | belong: '/library' 46 | }, 47 | { 48 | path: '/category/:id?', 49 | component: , 50 | title: 'page.category.title', 51 | icon: 'Flag' 52 | }, 53 | { 54 | path: '/settings', 55 | component: , 56 | title: 'page.settings.title', 57 | icon: 'Settings' 58 | }, 59 | { 60 | path: '/about', 61 | component: , 62 | title: 'page.about.title', 63 | icon: 'Info' 64 | } 65 | ] 66 | 67 | export default routes 68 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { createJSONStorage, persist } from 'zustand/middleware' 3 | import { APP_STORE_KEY, IS_TAURI } from '@/constant' 4 | import type { GameData, GameWithLocalData, FetchMethods, LocalData, SortKeys, Group, Category } from '@/types' 5 | import tauriStorage from '@/utils/tauriStorage' 6 | import events from '@/utils/events' 7 | 8 | export interface RootState { 9 | _hasHydrated: boolean 10 | gameData: GameData[] 11 | localData: LocalData[] 12 | groups: Group[] 13 | categories: Category[] 14 | sync: { 15 | time: number 16 | deleteIds: string[] 17 | size: number 18 | visibility: string 19 | username: string 20 | bgmUsername: string 21 | vndbUsername: string 22 | avatar: string 23 | } 24 | cache: { 25 | [url: string]: string 26 | } 27 | settings: { 28 | syncMode: 'github' | 'server' 29 | githubToken: string 30 | githubRepo: string 31 | githubPath: string 32 | bgmToken: string 33 | vndbToken: string 34 | autoSyncMinutes: number 35 | theme: 'light' | 'dark' 36 | language: 'en_US' | 'zh_CN' | 'ja_JP' | 'zh_TW' 37 | fetchMethods: FetchMethods 38 | maxTimelineDisplayCount: number 39 | autoSetGameTitle: boolean 40 | autoCacheImage: boolean 41 | playLaunchVoice: boolean 42 | sortOnlyDisplayLocal: boolean 43 | sortPrimaryKey: SortKeys 44 | sortIsPrimaryDescending: boolean 45 | } 46 | } 47 | 48 | type RootStateMethods = { 49 | isRunningGame(id: string): boolean 50 | increasePlayTimeline(id: string, startTime: number, endTime: number): void 51 | addGroup(name: string): void 52 | deleteGroup(id: string): void 53 | addCategory(groupId: string, name: string): void 54 | updateCategory(id: string, ids: string[]): void 55 | deleteCategory(id: string): void 56 | addGameData(data: GameWithLocalData): void 57 | updateGameData(data: GameWithLocalData): void 58 | removeGameData(id: string, onlyLocal: boolean): void 59 | getGameData(id: string): GameWithLocalData | undefined 60 | importGameData(data: GameData[]): void 61 | getAllGameData(isPure: T): (true extends T ? GameData : GameWithLocalData)[] 62 | updateSettings(settings: Partial): [boolean, boolean, boolean] 63 | updateSync(sync: Partial): void 64 | addCache(url: string, file: string): void 65 | getCache(url: string): string | undefined 66 | removeCache(url: string): void 67 | } 68 | 69 | export const DEFAULT_STATE: RootState = { 70 | _hasHydrated: !IS_TAURI, 71 | gameData: [], 72 | localData: [], 73 | groups: [], 74 | categories: [], 75 | sync: { 76 | time: 0, 77 | deleteIds: [], 78 | size: 0, 79 | visibility: '', 80 | username: '', 81 | bgmUsername: '', 82 | vndbUsername: '', 83 | avatar: '' 84 | }, 85 | cache: {}, 86 | settings: { 87 | syncMode: 'github', 88 | githubToken: '', 89 | githubRepo: '', 90 | githubPath: 'gal-keeper-data/', 91 | bgmToken: '', 92 | vndbToken: '', 93 | autoSyncMinutes: 10, 94 | theme: 'light', 95 | language: navigator.language.includes('ja') 96 | ? 'ja_JP' 97 | : ['zh-TW', 'zh-HK'].includes(navigator.language) 98 | ? 'zh_TW' 99 | : navigator.language.includes('zh') 100 | ? 'zh_CN' 101 | : 'en_US', 102 | fetchMethods: IS_TAURI ? 'mixed' : 'vndb', 103 | maxTimelineDisplayCount: 50, 104 | autoSetGameTitle: true, 105 | autoCacheImage: true, 106 | playLaunchVoice: IS_TAURI, 107 | sortOnlyDisplayLocal: false, 108 | sortPrimaryKey: 'CreateDate', 109 | sortIsPrimaryDescending: true 110 | } 111 | } 112 | 113 | const useStore = create( 114 | persist( 115 | (set, get) => ({ 116 | ...DEFAULT_STATE, 117 | isRunningGame(id) { 118 | return ( 119 | Date.now() / 1000 - 120 | (get() 121 | .getGameData(id) 122 | ?.playTimelines.reduce((acc, cur) => Math.max(acc, cur[1]), 0) ?? 0) < 123 | 3 124 | ) 125 | }, 126 | increasePlayTimeline(id, startTime, endTime) { 127 | set((state) => ({ 128 | gameData: state.gameData.map((item) => 129 | item.id === id 130 | ? { 131 | ...item, 132 | lastPlay: endTime * 1000, 133 | playTimelines: ((index) => 134 | index === -1 135 | ? [...item.playTimelines, [startTime, endTime, 1]] 136 | : [ 137 | ...item.playTimelines.slice(0, index), 138 | [startTime, endTime, item.playTimelines[index][2] + 1], 139 | ...item.playTimelines.slice(index + 1) 140 | ])(item.playTimelines.findIndex((timeline) => timeline[0] === startTime)) 141 | } 142 | : item 143 | ) 144 | })) 145 | events.emit('updateGame', id) 146 | }, 147 | addGameData(data) { 148 | const { local, ...game } = data 149 | set((state) => ({ 150 | gameData: [...state.gameData, { ...game }], 151 | ...(local && IS_TAURI 152 | ? { 153 | localData: [...state.localData, local] 154 | } 155 | : {}) 156 | })) 157 | events.emit('updateGame', data.id) 158 | }, 159 | updateGameData(data) { 160 | const { local, ...game } = data 161 | set((state) => ({ 162 | gameData: state.gameData.map((item) => 163 | item.id === data.id ? { ...game, updateDate: Date.now() / 1000 } : item 164 | ), 165 | localData: 166 | local && IS_TAURI 167 | ? [ 168 | ...state.localData.filter((item) => item.id !== data.id), 169 | { ...(state.localData.find((item) => item.id === data.id) ?? {}), ...local } 170 | ] 171 | : state.localData 172 | })) 173 | events.emit('updateGame', data.id) 174 | }, 175 | removeGameData(id, onlyLocal) { 176 | set((state) => ({ 177 | gameData: onlyLocal 178 | ? state.gameData 179 | : state.gameData.filter((item) => { 180 | if (item.id !== id) return true 181 | if (item.cover) get().removeCache(item.cover) 182 | return false 183 | }), 184 | ...(IS_TAURI 185 | ? { 186 | localData: state.localData.filter((item) => item.id !== id) 187 | } 188 | : {}), 189 | sync: { 190 | ...state.sync, 191 | deleteIds: onlyLocal ? state.sync.deleteIds : [...state.sync.deleteIds, id] 192 | } 193 | })) 194 | 195 | events.emit('updateGame', id) 196 | }, 197 | addGroup(name) { 198 | const id = crypto.randomUUID() 199 | set((state) => ({ 200 | groups: [...state.groups, { id, name, categoryIds: [] }] 201 | })) 202 | }, 203 | deleteGroup(id) { 204 | set((state) => ({ 205 | groups: state.groups.filter((group) => group.id !== id) 206 | })) 207 | }, 208 | addCategory(groupId, name) { 209 | const id = crypto.randomUUID() 210 | set((state) => ({ 211 | categories: [...state.categories, { id, name, gameIds: [] }], 212 | groups: state.groups.map((group) => 213 | group.id === groupId ? { ...group, categoryIds: [...group.categoryIds, id] } : group 214 | ) 215 | })) 216 | }, 217 | updateCategory(id, ids) { 218 | set((state) => ({ 219 | categories: state.categories.map((category) => 220 | category.id === id ? { ...category, gameIds: ids } : category 221 | ) 222 | })) 223 | }, 224 | deleteCategory(id) { 225 | set((state) => ({ 226 | categories: state.categories.filter((category) => category.id !== id), 227 | groups: state.groups.map((group) => ({ 228 | ...group, 229 | categoryIds: group.categoryIds.filter((categoryId) => categoryId !== id) 230 | })) 231 | })) 232 | }, 233 | getGameData(id) { 234 | const gameData = get().gameData.find((item) => item.id === id) 235 | if (!gameData) return undefined 236 | 237 | return { 238 | ...gameData, 239 | local: get().localData.find((local) => local.id === id) 240 | } 241 | }, 242 | getAllGameData(isPure) { 243 | const { gameData, localData } = get() 244 | if (isPure) return gameData 245 | 246 | return gameData.map((item) => ({ 247 | ...item, 248 | local: localData.find((l) => l.id === item.id) 249 | })) 250 | }, 251 | importGameData(data) { 252 | set((state) => ({ 253 | gameData: [...state.gameData.filter((item) => !data.some((d) => d.id === item.id)), ...data], 254 | localData: state.localData.filter((item) => data.some((d) => d.id === item.id)) 255 | })) 256 | }, 257 | updateSettings(settings) { 258 | if (settings.language && settings.language !== get().settings.language) { 259 | setTimeout(() => { 260 | location.href = '/' 261 | }, 0) 262 | } 263 | 264 | const before = get().settings 265 | const changeStatus: [boolean, boolean, boolean] = [ 266 | `${before.githubPath}${before.githubToken}${before.githubRepo}` !== 267 | `${settings.githubPath}${settings.githubToken}${settings.githubRepo}`, 268 | before.vndbToken !== settings.vndbToken, 269 | before.bgmToken !== settings.bgmToken 270 | ] 271 | 272 | set((state) => ({ 273 | settings: { 274 | ...state.settings, 275 | ...settings 276 | } 277 | })) 278 | 279 | return changeStatus 280 | }, 281 | updateSync(sync) { 282 | set((state) => ({ 283 | sync: { 284 | ...state.sync, 285 | ...sync 286 | } 287 | })) 288 | }, 289 | addCache(url, file) { 290 | set((state) => ({ 291 | cache: { 292 | ...state.cache, 293 | [url]: file 294 | } 295 | })) 296 | }, 297 | getCache(url) { 298 | return get().cache[url] 299 | }, 300 | removeCache(url) { 301 | const { cache } = get() 302 | delete cache[url] 303 | set(() => ({ 304 | cache 305 | })) 306 | } 307 | }), 308 | { 309 | name: APP_STORE_KEY, 310 | storage: IS_TAURI ? createJSONStorage(() => tauriStorage) : createJSONStorage(() => localStorage), 311 | onRehydrateStorage() { 312 | return () => { 313 | useStore.setState({ _hasHydrated: true }) 314 | } 315 | } 316 | } 317 | ) 318 | ) 319 | 320 | export default useStore 321 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { GameData } from './schema' 2 | 3 | export * from './schema' 4 | 5 | export interface LocalData { 6 | id: string 7 | programFile: string 8 | savePath?: string 9 | guideFile?: string 10 | } 11 | 12 | export interface GameWithLocalData extends GameData { 13 | local?: LocalData 14 | } 15 | 16 | export interface Group { 17 | id: string 18 | name: string 19 | categoryIds: string[] 20 | } 21 | 22 | export interface Category { 23 | id: string 24 | name: string 25 | gameIds: string[] 26 | } 27 | 28 | export type FetchGameData = Omit 29 | 30 | export type SortKeys = 'Title' | 'CreateDate' | 'LastPlay' | 'Developer' | 'Rating' | 'ReleaseDate' 31 | 32 | export type FetchMethods = 'mixed' | 'vndb' | 'bgm' 33 | 34 | export type Timeline = [number, number, number] 35 | 36 | // biome-ignore lint: 37 | export const enum DefaultGroup { 38 | DEVELOPER = 'developer', 39 | RATING = 'rating', 40 | PLAY_STATE = 'play_state' 41 | } 42 | -------------------------------------------------------------------------------- /src/types/schema.ts: -------------------------------------------------------------------------------- 1 | import Tsu from 'tsukiko' 2 | 3 | export const gameDataSchema = Tsu.Object({ 4 | id: Tsu.String(), 5 | vndbId: Tsu.String().optional(), 6 | bgmId: Tsu.String().optional(), 7 | updateDate: Tsu.Number(), 8 | title: Tsu.String(), 9 | alias: Tsu.Array(Tsu.String()), 10 | cover: Tsu.String(), 11 | description: Tsu.String(), 12 | tags: Tsu.Array(Tsu.String()), 13 | playTimelines: Tsu.Array(Tsu.Tuple([Tsu.Number(), Tsu.Number(), Tsu.Number()])), 14 | expectedPlayHours: Tsu.Number(), 15 | lastPlay: Tsu.Number(), 16 | createDate: Tsu.Number(), 17 | releaseDate: Tsu.Number(), 18 | rating: Tsu.Number(), 19 | developer: Tsu.String(), 20 | images: Tsu.Array(Tsu.String()), 21 | links: Tsu.Array( 22 | Tsu.Object({ 23 | url: Tsu.String(), 24 | name: Tsu.String() 25 | }) 26 | ) 27 | }) 28 | 29 | export const gameDataListSchema = Tsu.Array(gameDataSchema) 30 | 31 | export const cloudDataSchema = Tsu.Object({ 32 | deleteIds: Tsu.Array(Tsu.String()), 33 | data: gameDataListSchema 34 | }) 35 | 36 | export type CloudData = Tsu.infer 37 | 38 | export type GameData = Tsu.infer 39 | -------------------------------------------------------------------------------- /src/utils/ErrorReporter.ts: -------------------------------------------------------------------------------- 1 | import { type LoggerData, Transport, LoggerLevel } from '@kotori-bot/logger' 2 | import events from './events' 3 | 4 | class ErrorReporter extends Transport { 5 | public constructor() { 6 | super({}) 7 | } 8 | 9 | public handle(data: LoggerData): void { 10 | if (data.level >= LoggerLevel.WARN) { 11 | events.emit('error', data) 12 | } 13 | } 14 | } 15 | 16 | export default ErrorReporter 17 | -------------------------------------------------------------------------------- /src/utils/events.ts: -------------------------------------------------------------------------------- 1 | import type { LoggerData } from '@kotori-bot/logger' 2 | import EventsEmiter from 'fluoro/dist/context/events' 3 | 4 | export interface EventsMapping { 5 | updateGame(id: string): void 6 | error(data: LoggerData): void 7 | } 8 | 9 | const events = new EventsEmiter() 10 | 11 | export default events 12 | -------------------------------------------------------------------------------- /src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import I18n from '@kotori-bot/i18n' 2 | 3 | import enUS from '@/locales/en-US' 4 | import jaJP from '@/locales/ja-JP' 5 | import zhCN from '@/locales/zh-CN' 6 | import zhTW from '@/locales/zh-TW' 7 | 8 | const i18n = new I18n() 9 | 10 | i18n.use(jaJP, 'ja_JP') 11 | i18n.use(enUS, 'en_US') 12 | i18n.use(zhTW, 'zh_TW') 13 | i18n.use(zhCN, 'zh_CN') 14 | 15 | i18n.set('ja_JP') 16 | 17 | export const t = i18n.t.bind(i18n) 18 | 19 | export const f = 20 | (raw: TemplateStringsArray) => 21 | (...params: string[]) => 22 | Array.from(params.entries()).reduce( 23 | (result, [index, value]) => result.replaceAll(`{${index}}`, value), 24 | i18n.locale(raw.join('')) 25 | ) 26 | 27 | export default i18n 28 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { IS_TAURI } from '@/constant' 2 | import { invoke, shell } from '@tauri-apps/api' 3 | import type { GameData, Timeline } from '@/types' 4 | import { f, t } from './i18n' 5 | import useStore from '@/store' 6 | 7 | export function generateUuid() { 8 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 9 | const r = (Math.random() * 16) | 0 10 | return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) 11 | }) 12 | } 13 | 14 | export async function openUrl(url: string) { 15 | if (IS_TAURI) { 16 | await shell.open(url) 17 | } else { 18 | window.open(url, '_blank') 19 | } 20 | } 21 | 22 | export async function cacheImage(data: GameData) { 23 | const { 24 | getCache, 25 | addCache, 26 | settings: { autoCacheImage } 27 | } = useStore.getState() 28 | if (IS_TAURI && autoCacheImage && data.cover && data.cover.startsWith('http') && !getCache(data.cover)) { 29 | const base64: string = await invoke('url_to_base64', { 30 | url: data.cover 31 | }) 32 | if (base64) addCache(data.cover, base64) 33 | } 34 | } 35 | 36 | export function base64Decode(base64: string) { 37 | // biome-ignore lint: 38 | return new TextDecoder().decode((Uint8Array.from as any)(atob(base64), (m: any) => m.codePointAt(0))) 39 | } 40 | 41 | export function base64Encode(str: string) { 42 | return btoa(Array.from(new TextEncoder().encode(str), (byte) => String.fromCodePoint(byte)).join('')) 43 | } 44 | 45 | export function calculateTotalPlayTime(timelines: Timeline[]) { 46 | return timelines.reduce((acc, cur) => acc + cur[2], 0) / 60 47 | } 48 | 49 | export function showMinutes(raw: number) { 50 | const hours = Math.floor(raw / 60) 51 | const minutes = Math.floor(raw % 60) 52 | return hours === 0 ? `${minutes}m` : `${hours}h${minutes}m` 53 | } 54 | 55 | export function showTime(raw: number) { 56 | const now = Date.now() / 1000 57 | if (now - raw < 60) return t`time.justnow` 58 | if (now - raw < 60 * 60) return f`time.minutesAgo`(Math.floor((now - raw) / 60).toString()) 59 | if (now - raw < 60 * 60 * 24) return f`time.hoursAgo`(Math.floor((now - raw) / (60 * 60)).toString()) 60 | if (now - raw < 60 * 60 * 24 * 2) return t`time.yesterday` 61 | if (now - raw < 60 * 60 * 24 * 7) return f`time.daysAgo`(Math.floor((now - raw) / (60 * 60 * 24)).toString()) 62 | if (now - raw < 60 * 60 * 24 * 7 * 2) return t`time.lastWeek` 63 | if (now - raw < 60 * 60 * 24 * 7 * 4) return f`time.weeksAgo`(Math.floor((now - raw) / (60 * 60 * 24 * 7)).toString()) 64 | if (now - raw < 60 * 60 * 24 * 30 * 2) return t`time.lastMonth` 65 | if (now - raw < 60 * 60 * 24 * 30 * 12) 66 | return f`time.monthsAgo`(Math.floor((now - raw) / (60 * 60 * 24 * 30)).toString()) 67 | if (now - raw < 60 * 60 * 24 * 365 * 2) return t`time.lastYear` 68 | return f`time.yearsAgo`(Math.floor((now - raw) / (60 * 60 * 24 * 365)).toString()) 69 | } 70 | 71 | export function getGameCover(data: GameData) { 72 | return data.cover ? useStore.getState().getCache(data.cover) || data.cover : '/assets/cover.png' 73 | } 74 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { IS_DEV } from '@/constant' 2 | import Logger, { ConsoleTransport, LoggerLevel } from '@kotori-bot/logger' 3 | import ErrorReporter from './ErrorReporter' 4 | 5 | const logger = new Logger({ 6 | level: IS_DEV ? LoggerLevel.DEBUG : LoggerLevel.INFO, 7 | transports: [ 8 | new ConsoleTransport({ template: '%time% %level% %labels%: %msg%' }), 9 | new ErrorReporter() 10 | ] 11 | }) 12 | 13 | export const dbLogger = logger.label('DATABASE') 14 | 15 | export const invokeLogger = logger.label('INVOKE') 16 | 17 | export const httpLogger = logger.label('HTTP') 18 | 19 | export default logger 20 | -------------------------------------------------------------------------------- /src/utils/tauriStorage.ts: -------------------------------------------------------------------------------- 1 | import type { StateStorage } from 'zustand/middleware' 2 | import { appDataDir } from '@tauri-apps/api/path' 3 | import { invoke } from '@tauri-apps/api' 4 | import { dbLogger } from './logger' 5 | 6 | const tauriStorage: StateStorage = { 7 | async getItem(key) { 8 | try { 9 | const data = await invoke('db_read_value', { 10 | directory: await appDataDir(), 11 | key 12 | }) 13 | if (!data) return null 14 | 15 | try { 16 | JSON.parse(data) 17 | return data 18 | } catch { 19 | dbLogger.fatal(`Failed to parse data for key ${key}:`, data) 20 | return null 21 | } 22 | } catch (error) { 23 | dbLogger.fatal(`Failed to read file for key ${key}:`, error) 24 | return null 25 | } 26 | }, 27 | async setItem(key, value) { 28 | try { 29 | await invoke('db_write_value', { 30 | directory: await appDataDir(), 31 | key, 32 | value 33 | }) 34 | } catch (error) { 35 | dbLogger.fatal(`Failed to write file for key ${key}:`, error) 36 | } 37 | }, 38 | async removeItem(key) { 39 | try { 40 | await invoke('db_remove_value', { directory: await appDataDir(), key }) 41 | dbLogger.debug(`Removed file for key ${key}`) 42 | } catch (error) { 43 | dbLogger.fatal(`Failed to remove file for key ${key}:`, error) 44 | } 45 | } 46 | } 47 | 48 | export default tauriStorage 49 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2021", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["src/*"] 25 | } 26 | }, 27 | "include": ["src"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'unocss' 2 | 3 | export default defineConfig({ 4 | // ...UnoCSS options 5 | }) -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { resolve } from 'node:path' 3 | import UnoCSS from 'unocss/vite' 4 | import react from '@vitejs/plugin-react' 5 | 6 | const host = process.env.TAURI_DEV_HOST 7 | 8 | export default defineConfig(async () => ({ 9 | plugins: [react(), UnoCSS()], 10 | 11 | clearScreen: false, 12 | server: { 13 | port: 327, 14 | strictPort: true, 15 | host: host || false, 16 | hmr: host 17 | ? { 18 | protocol: 'ws', 19 | host, 20 | port: 327 21 | } 22 | : undefined, 23 | watch: { 24 | ignored: ['**/src-tauri/**'] 25 | } 26 | }, 27 | resolve: { 28 | alias: { 29 | '@': resolve(__dirname, 'src') 30 | } 31 | } 32 | })) 33 | --------------------------------------------------------------------------------