├── .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 | [](https://vndb.org/c18258)
8 |
9 |
10 |
11 |
12 |
13 | # Nanno | GalKeeper
14 |
15 | [English](README.md) | 日本語
16 |
17 | **TauriとReactで構築された、プログラマー向けの軽量で高速なビジュアルノベル管理、統計、クラウド同期ツール**
18 |
19 | [](https://github.com/BIYUEHU/gal-keeper/actions/workflows/build.yml)
20 | 
21 | 
22 | [](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 | 
59 | 
60 | 
61 | 
62 | 
63 | 
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | [](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 | [](https://github.com/BIYUEHU/gal-keeper/actions/workflows/build.yml)
20 | 
21 | 
22 | [](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 | 
67 | 
68 | 
69 | 
70 | 
71 | 
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 | openUrl('https://github.com/BIYUEHU/gal-keeper/releases/')}
100 | >
101 | {t`app.update.button`}
102 |
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 |
31 |
32 | {alert.title !== t`alert.title` && }
33 |
34 |
35 |
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 |
33 |
34 |
35 |
36 |
37 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------