├── .eslintignore
├── .gitignore
├── .prettierignore
├── .vscode
├── serverless.code-snippets
└── vue.code-snippets
├── LICENSE
├── README.md
├── api
├── auth
│ └── sign-in.ts
├── index.ts
├── users
│ └── password.ts
└── utils.ts
├── eslint.config.js
├── index.html
├── package.json
├── pnpm-lock.yaml
├── prettier.config.js
├── public
├── favicon.ico
└── images
│ └── spinner.svg
├── src
├── App.vue
├── assets
│ └── logo.png
├── components
│ ├── BookCard.vue
│ ├── BookListPaginator.vue
│ ├── BooksList.vue
│ ├── ExternalLink.vue
│ ├── GlobalFooter.vue
│ ├── GlobalHeader.vue
│ ├── GlobalSideNav.vue
│ ├── Lazyload.vue
│ ├── MBox.vue
│ ├── NProgress.vue
│ ├── NaiveuiProvider.vue
│ └── Placeholder.vue
├── config.ts
├── main.ts
├── router.ts
├── stores
│ ├── book.ts
│ ├── category.ts
│ ├── sidenav.ts
│ └── user.ts
├── styles
│ ├── elements.sass
│ ├── formats.sass
│ ├── index.sass
│ ├── states.sass
│ └── variables.sass
├── types
│ ├── Api.ts
│ ├── File.ts
│ └── index.ts
├── utils
│ ├── LRUMap.ts
│ ├── PicaCache.ts
│ ├── getErrMsg.ts
│ └── setTitle.ts
├── view
│ ├── 404.vue
│ ├── about.vue
│ ├── auth.vue
│ ├── book.vue
│ ├── categories.vue
│ ├── comics.vue
│ ├── favourite.vue
│ ├── index.vue
│ ├── profile.vue
│ ├── read.vue
│ └── search.vue
└── vue-app-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── vercel.json
└── vite.config.ts
/.eslintignore:
--------------------------------------------------------------------------------
1 | *.dev.*
2 | dist
3 | *.d.ts
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 |
78 | # Next.js build output
79 | .next
80 |
81 | # Nuxt.js build / generate output
82 | .nuxt
83 | dist
84 |
85 | # Gatsby files
86 | .cache/
87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
88 | # https://nextjs.org/blog/next-9-1#public-directory-support
89 | # public
90 |
91 | # vuepress build output
92 | .vuepress/dist
93 |
94 | # Serverless directories
95 | .serverless/
96 |
97 | # FuseBox cache
98 | .fusebox/
99 |
100 | # DynamoDB Local files
101 | .dynamodb/
102 |
103 | # TernJS port file
104 | .tern-port
105 |
106 | .vercel
107 |
108 | dev
109 | *.dev.*
110 | .vercel
111 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .github
2 | .vscode
3 | .eslintrc.yml
4 | dist
5 | tsconfig.json
6 | package.json
7 |
--------------------------------------------------------------------------------
/.vscode/serverless.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | // Place your workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
7 | // Placeholders with the same ids are connected.
8 | "Init": {
9 | "scope": "typescript",
10 | "prefix": "vercel-node",
11 | "description": "Init serverless module",
12 | "body": [
13 | "import { VercelRequest, VercelResponse } from '@vercel/node'",
14 | "import { HandleResponse } from 'serverless-kit'",
15 | "",
16 | "export default async (req: VercelRequest, res: VercelResponse) => {",
17 | " const http = new HandleResponse(req, res)",
18 | " $0",
19 | "}"
20 | ]
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/.vscode/vue.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | // Place your workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
7 | // Placeholders with the same ids are connected.
8 | "Init vue components": {
9 | "scope": "vue",
10 | "prefix": "vue",
11 | "body": [
12 | "",
13 | "$0",
14 | " ",
15 | "",
16 | "",
20 | "",
21 | ""
22 | ],
23 | "description": "Init vue components"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PicACG Web App
2 |
3 |
4 |
5 | Deploy your own
6 |
7 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FFreeNowOrg%2FPicaComicNow&demo-title=PicACG%20Web%20App&demo-url=https%3A%2F%2Fpica-comic.vercel.app)
8 |
9 | 想用的话点上面的按钮自己部署一个,一直点下一步就行了。别让我的被封了,我还要看本子呢,谢谢。
10 |
11 |
12 |
13 | ## Attention please
14 |
15 | This is a fan made website. We are NOT PicACG official. Please DO NOT share this website anywhere.
16 |
17 | 这是一个粉丝向网站,我们与 PicACG 官方没有任何关系。请勿在任何地方传播本网站。
18 |
19 | ---
20 |
21 | _For communication and learning only._
22 |
23 | **All data & pictures from query:** ©PICA & Illusts' authors
24 |
25 | > Copyright 2021 FreeNowOrg
26 | >
27 | > Licensed under the Apache License, Version 2.0 (the "License");
28 | > you may not use this file except in compliance with the License.
29 | > You may obtain a copy of the License at
30 | >
31 | > http://www.apache.org/licenses/LICENSE-2.0
32 | >
33 | > Unless required by applicable law or agreed to in writing, software
34 | > distributed under the License is distributed on an "AS IS" BASIS,
35 | > WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
36 | > See the License for the specific language governing permissions and
37 | > limitations under the License.
38 |
39 |
--------------------------------------------------------------------------------
/api/auth/sign-in.ts:
--------------------------------------------------------------------------------
1 | import { VercelRequest, VercelResponse } from '@vercel/node'
2 | import { PicaComicAPI } from '@l2studio/picacomic-api'
3 | import { HandleResponse } from 'serverless-kit'
4 |
5 | export default async (req: VercelRequest, res: VercelResponse) => {
6 | const http = new HandleResponse(req, res)
7 | const client = new PicaComicAPI({})
8 |
9 | if (req.method !== 'POST') {
10 | return http.send(405, 'Method Not Allowed')
11 | }
12 |
13 | const { email, password } = req.body || {}
14 | if (!email || !password) {
15 | return http.send(400, 'Missing params')
16 | }
17 |
18 | try {
19 | const token = await client.signIn({ email, password })
20 | // const time = new Date()
21 | // time.setTime(time.getTime() + 180 * 24 * 60 * 60 * 1000)
22 | // res.setHeader(
23 | // 'set-cookie',
24 | // `PICA_TOKEN=${token}; expires=${time.toUTCString()}; path=/; secure`
25 | // )
26 | res.setHeader('cache-control', 'no-cache')
27 | return http.send(200, 'ok', { token })
28 | } catch (err: any) {
29 | return http.send(err?.response?.statusCode || 500, err.message, err)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/api/index.ts:
--------------------------------------------------------------------------------
1 | import { PicaComicAPI } from '@l2studio/picacomic-api'
2 | import { VercelRequest, VercelResponse } from '@vercel/node'
3 | import { getTokenFromReq, replaceFileUrl } from './utils.js'
4 | import { HandleResponse } from 'serverless-kit'
5 |
6 | export default async (req: VercelRequest, res: VercelResponse) => {
7 | const http = new HandleResponse(req, res)
8 | const client = new PicaComicAPI({})
9 |
10 | const authorization = getTokenFromReq(req) || undefined
11 | const { __PATH } = req.query
12 | delete req.query.__PATH
13 |
14 | console.info(`[${req.method}] ${__PATH}`, req.query, req.body)
15 |
16 | try {
17 | const { data } = await client
18 | .fetch(__PATH as string, {
19 | headers: { authorization },
20 | method: req.method as 'GET' | 'POST' | 'PUT',
21 | searchParams: req.query as Record,
22 | json:
23 | typeof req.body === 'object' && Object.keys(req.body).length > 0
24 | ? req.body
25 | : undefined,
26 | })
27 | .json()
28 |
29 | return http.send(
30 | 200,
31 | 'ok',
32 | replaceFileUrl({ ...data, debug: { body: req.body, params: req.query } })
33 | )
34 | } catch (err: any) {
35 | return http.send(err?.response?.statusCode || 500, err.message, err)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/api/users/password.ts:
--------------------------------------------------------------------------------
1 | import { PicaComicAPI } from '@l2studio/picacomic-api'
2 | import { VercelRequest, VercelResponse } from '@vercel/node'
3 | import { getTokenFromReq } from '../utils.js'
4 | import { HandleResponse } from 'serverless-kit'
5 |
6 | export default async (req: VercelRequest, res: VercelResponse) => {
7 | const http = new HandleResponse(req, res)
8 | const client = new PicaComicAPI({})
9 |
10 | if (req.method !== 'PUT') {
11 | return http.send(405, 'Method Not Allowed')
12 | }
13 |
14 | const authorization = getTokenFromReq(req)
15 | if (!authorization) {
16 | return http.send(403, 'Please login')
17 | }
18 |
19 | try {
20 | const { message } = await client
21 | .fetch('users/password', {
22 | headers: {
23 | authorization,
24 | },
25 | method: 'PUT',
26 | body: req.body,
27 | })
28 | .json()
29 |
30 | res.setHeader('cache-control', 'no-cache')
31 | res.setHeader(
32 | 'set-cookie',
33 | `PICA_TOKEN=; expires=${new Date(0).toUTCString()}; path=/; secure`
34 | )
35 | res.setHeader('refresh', '0;URL=/auth')
36 | return http.send(200, message)
37 | } catch (err: any) {
38 | return http.send(err?.response?.statusCode || 500, err.message, err)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/api/utils.ts:
--------------------------------------------------------------------------------
1 | import { VercelRequest, VercelResponse } from '@vercel/node'
2 | import { HandleResponse } from 'serverless-kit'
3 |
4 | export default async (req: VercelRequest, res: VercelResponse) => {
5 | const http = new HandleResponse(req, res)
6 | http.send(403, 'Invalid endpoint')
7 | }
8 |
9 | export function getTokenFromReq(req: VercelRequest): string | null {
10 | return (
11 | (req.query.token as string) ||
12 | req.headers.authorization?.replace(/^Bearer\s+/, '') ||
13 | req.cookies?.['PICA_TOKEN'] ||
14 | null
15 | )
16 | }
17 |
18 | export function toUpperCamelCase(str: string) {
19 | const s = str.split('')
20 | const t = s.map((item, index) => {
21 | if (index === 0) {
22 | return item.toUpperCase()
23 | }
24 | if (item === '_' || item === '-') {
25 | s[index + 1] = s[index + 1].toUpperCase()
26 | return ''
27 | }
28 | return item
29 | })
30 | return t.join('')
31 | }
32 |
33 | export function replaceFileUrl(obj: Record) {
34 | for (const i in obj) {
35 | const key = i
36 | const val = obj[i]
37 |
38 | // String
39 | if (typeof val === 'string') {
40 | if (val.startsWith('https://')) {
41 | obj[key] = val
42 | .replace('storage1.picacomic.com', 's3.picacomic.com')
43 | .replace('storage-b.picacomic.com', 's3.picacomic.com')
44 | .replace('img.picacomic.com', 's3.picacomic.com')
45 | .replace('www.picacomic.com', 'pica-pica.wikawika.xyz')
46 | }
47 | }
48 | // Object
49 | else if (typeof val === 'object') {
50 | obj[key] = replaceFileUrl(val)
51 | if (val.fileServer && val.path) {
52 | obj[key].fileUrl = `${val.fileServer.replace(
53 | /\/$/,
54 | ''
55 | )}/static/${val.path.replace(/^\//, '')}`.replace(
56 | '/static/static',
57 | '/static'
58 | )
59 | }
60 | }
61 | }
62 |
63 | return obj
64 | }
65 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import eslint from '@eslint/js'
4 | import tseslint from 'typescript-eslint'
5 | import pluginVue from 'eslint-plugin-vue'
6 | import configPrettier from 'eslint-config-prettier'
7 | import globals from 'globals'
8 |
9 | export default tseslint.config(
10 | {
11 | ignores: ['*.d.ts', '**/dist', '*.dev.*'],
12 | },
13 | {
14 | extends: [
15 | eslint.configs.recommended,
16 | ...tseslint.configs.recommended,
17 | ...pluginVue.configs['flat/recommended'],
18 | ],
19 | files: ['**/*.{ts,vue}'],
20 | languageOptions: {
21 | ecmaVersion: 'latest',
22 | sourceType: 'module',
23 | parser: tseslint.parser,
24 | globals: globals.browser,
25 | },
26 | },
27 | configPrettier
28 | )
29 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite App
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vite-vue-ts-template",
3 | "version": "0.2.0",
4 | "main": "index.js",
5 | "repository": "git@github.com:FreeNowOrg/vite-vue-ts-template.git",
6 | "author": "FreeNowOrg ",
7 | "license": "Apache-2.0",
8 | "private": true,
9 | "type": "module",
10 | "scripts": {
11 | "start": "vite",
12 | "serve": "vercel dev",
13 | "build": "vite build"
14 | },
15 | "dependencies": {
16 | "axios": "^1.8.2",
17 | "fish-store": "^1.0.1",
18 | "js-cookie": "^3.0.5",
19 | "localforage": "^1.10.0",
20 | "naive-ui": "^2.41.0",
21 | "nprogress": "0.2.0",
22 | "pinia": "^3.0.1",
23 | "vue": "^3.5.13",
24 | "vue-router": "^4.5.0"
25 | },
26 | "devDependencies": {
27 | "@eslint/js": "^9.22.0",
28 | "@l2studio/picacomic-api": "0.1.13",
29 | "@prettier/plugin-pug": "^3.2.1",
30 | "@types/js-cookie": "^3.0.6",
31 | "@types/node": "^22.13.10",
32 | "@types/nprogress": "^0.2.3",
33 | "@vercel/node": "^5.1.12",
34 | "@vicons/fa": "^0.13.0",
35 | "@vicons/utils": "^0.1.4",
36 | "@vitejs/plugin-vue": "^5.2.1",
37 | "@vue/language-plugin-pug": "^2.2.8",
38 | "@vue/tsconfig": "^0.7.0",
39 | "eslint": "^9.22.0",
40 | "eslint-config-prettier": "^10.1.1",
41 | "eslint-plugin-vue": "^10.0.0",
42 | "globals": "^16.0.0",
43 | "prettier": "^3.5.3",
44 | "pug": "^3.0.3",
45 | "sass": "^1.85.1",
46 | "serverless-kit": "^0.4.0",
47 | "typescript": "^5.8.2",
48 | "typescript-eslint": "^8.26.0",
49 | "vercel": "^41.3.2",
50 | "vite": "^6.2.1",
51 | "vue-eslint-parser": "^10.1.1"
52 | },
53 | "packageManager": "pnpm@10.6.1"
54 | }
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | ///
3 |
4 | /**
5 | * @type {import('prettier').Options}
6 | */
7 | export default {
8 | trailingComma: 'es5',
9 | tabWidth: 2,
10 | semi: false,
11 | singleQuote: true,
12 | arrowParens: 'always',
13 | quoteProps: 'as-needed',
14 | endOfLine: 'auto',
15 |
16 | plugins: ['@prettier/plugin-pug'],
17 |
18 | vueIndentScriptAndStyle: false,
19 |
20 | pugSingleQuote: false,
21 | pugAttributeSeparator: 'as-needed',
22 | pugSortAttributes: 'asc',
23 | }
24 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FreeNowOrg/PicaComicNow/8e9dd8b02f2f3473dfd4acad175cc258af8e4253/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/spinner.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
295 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 | naiveui-provider#app-container
3 | main.flex-1
4 | article.responsive
5 | router-view
6 |
7 | n-progress
8 | global-header
9 | global-side-nav
10 | global-footer
11 |
12 |
13 |
46 |
47 |
61 |
--------------------------------------------------------------------------------
/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FreeNowOrg/PicaComicNow/8e9dd8b02f2f3473dfd4acad175cc258af8e4253/src/assets/logo.png
--------------------------------------------------------------------------------
/src/components/BookCard.vue:
--------------------------------------------------------------------------------
1 |
2 | li.book-card.card
3 | router-link.thumb-link(
4 | :to='{ name: "book", params: { bookid: data._id }, query: { backTo } }'
5 | )
6 | .thumb
7 | lazyload.img(:src='data.thumb.fileUrl', :key='data._id')
8 | .desc
9 | router-link(
10 | :to='{ name: "book", params: { bookid: data._id }, query: { backTo } }'
11 | )
12 | .title
13 | .pages [{{ data.epsCount > 1 ? data.epsCount + 'EP/' : '' }}{{ data.pagesCount }}P]
14 | .name {{ data.title }}
15 | .author
16 | router-link(:to='"/search/" + data.author') @{{ data.author }}
17 | .tags-list
18 | router-link.tag(
19 | v-for='item in data.categories',
20 | :data-tag='item',
21 | :to='{ name: "comics", params: { category: item } }'
22 | ) {{ item }}
23 | .stats
24 | .likes
25 | icon
26 | Heart
27 | | {{ data.likesCount }}
28 | .views
29 | icon
30 | Eye
31 | | {{ data.totalViews }}
32 | details
33 | pre {{ data }}
34 |
35 |
36 |
43 |
44 |
72 |
--------------------------------------------------------------------------------
/src/components/BookListPaginator.vue:
--------------------------------------------------------------------------------
1 |
2 | .book-list-paginator
3 | .sort-selector
4 | n-select(
5 | :options='PICA_LIST_SORT_OPTIONS',
6 | :value='sort',
7 | @update:value='emit("update:sort", $event)',
8 | size='small'
9 | )
10 | .page-selector
11 | n-pagination(
12 | :page='page',
13 | :page-count='totalPages',
14 | show-quick-jumper,
15 | :page-slot='7',
16 | @update:page='emit("update:page", $event)'
17 | )
18 |
19 |
20 |
57 |
58 |
67 |
--------------------------------------------------------------------------------
/src/components/BooksList.vue:
--------------------------------------------------------------------------------
1 |
2 | ul.books-list
3 | book-card(v-for='item in data', :data='item', :backTo='backTo')
4 |
5 |
6 |
13 |
14 |
23 |
--------------------------------------------------------------------------------
/src/components/ExternalLink.vue:
--------------------------------------------------------------------------------
1 |
2 | a.external-link(:href='href', target='_blank', rel='nofollow')
3 | slot
4 | icon.external-icon
5 | ExternalLinkAlt
6 |
7 |
8 |
15 |
16 |
25 |
--------------------------------------------------------------------------------
/src/components/GlobalFooter.vue:
--------------------------------------------------------------------------------
1 |
2 | footer.global-footer
3 | .top.flex.responsive
4 | section.flex-1
5 | h4 Discovery
6 | ul
7 | li
8 | router-link(to='/about') About us
9 | li
10 | router-link(to='/categories') Categories
11 | ul
12 | li
13 | router-link(to='/comics/妹妹系') 妹妹系
14 | li
15 | router-link(to='/search/獸耳') 兽耳娘,我现在就要看兽耳娘
16 |
17 | section.flex-1
18 | h4 Follow us
19 | ul
20 | li Free Now Organization
21 | ul
22 | li
23 | e-link(href='https://github.com/FreeNowOrg') @FreeNowOrg
24 |
25 | section.flex-1
26 | h4 Friend links
27 | p Come to GitHub issues to exchange friend links~
28 |
29 | .top.responsive
30 | section.flex-1
31 | h4 Attention please
32 | p This is a fan made website. We are NOT PicACG official. Please DO NOT share this website anywhere.
33 | p 这是一个粉丝向网站,我们与 PicACG 官方没有任何关系 。请勿 在任何地方传播本网站——珍惜眼前。
34 |
35 | .bottom.align-center.responsive
36 | .copyright
37 | | Copyright © {{ COPYRIGHT_STR }}
38 | |
39 | e-link(:href='GITHUB_URL') {{ PROJECT_NAME }}
40 | |
41 | em v{{ VERSION }}
42 | | |
43 | span For communication and learning only.
44 |
45 |
46 |
56 |
57 |
95 |
--------------------------------------------------------------------------------
/src/components/GlobalHeader.vue:
--------------------------------------------------------------------------------
1 |
2 | header.global-header.flex-center(
3 | :class='{ "not-at-top": notAtTop, "is-hide": isHide }'
4 | )
5 | .item
6 | a.plain.pointer.side-nav-toggler(
7 | @click='sidenav.toggle()',
8 | :class='{ "is-active": sidenav.isShow }'
9 | )
10 | icon
11 | bars
12 |
13 | .item.global-site-logo-container
14 | router-link.plain.global-site-logo(to='/', title='Home')
15 | span PicACG
16 |
17 | .flex-1.flex.nav-links(style='gap: 1rem')
18 | .item
19 | router-link(to='/categories') Categories
20 | .item
21 | router-link(to='/favourite') Favourite
22 | .item
23 | router-link(to='/about') About
24 |
25 | .item.search-area
26 | input.search-input(
27 | type='text',
28 | placeholder='Search...',
29 | v-model='searchInput',
30 | @keydown.enter='() => (router.push({ name: "search", params: { keyword: searchInput } }), (searchInput = ""))',
31 | :style='{ height: "2rem" }'
32 | )
33 |
34 | .item.user-area
35 | .user-dropdown(@click.stop='')
36 | a.pointer.plain.dropdown-btn(
37 | :class='{ "is-show": userDropdownShow }',
38 | @click='userDropdownShow = !userDropdownShow'
39 | )
40 | .avatar
41 | img(src='https://r2.epb.wiki/avatar.jpg')
42 | transition(
43 | name='fade',
44 | mode='out-in',
45 | enter-active-class='fadeInUp',
46 | leave-active-class='fadeOutDown'
47 | )
48 | .dropdown-content(v-show='userDropdownShow')
49 | ul
50 | //- notLogIn
51 | li(v-if='!user.profile')
52 | .nav-user-card
53 | .top
54 | .banner-bg
55 | img.avatar(src='https://r2.epb.wiki/avatar.jpg')
56 | .details
57 | a.user-name Anonymous
58 | .uid Please login
59 |
60 | //- isLogedIn
61 | li(v-if='user.profile')
62 | .nav-user-card
63 | .top
64 | .banner-bg
65 | router-link.plain.name(to='/profile')
66 | img.avatar(
67 | :src='user.profile.avatar.fileUrl',
68 | alt='',
69 | style='background-color: #ddd'
70 | )
71 | .details
72 | router-link.plain.user-name(to='/profile') {{ user.profile.name }}
73 | .uid {{ user.profile.email }}
74 | li(v-if='user.profile')
75 | router-link.plain(to='/favourite') My Favourites
76 |
77 | li(v-if='$route.path !== "/auth"')
78 | router-link.plain(to='/auth') {{ user.profile ? 'Logout' : 'Login' }}
79 |
80 |
81 |
135 |
136 |
299 |
--------------------------------------------------------------------------------
/src/components/GlobalSideNav.vue:
--------------------------------------------------------------------------------
1 |
2 | aside.global-site-nav(
3 | :class='{ "is-hide": !sidenav.isShow }',
4 | :data-is-show='sidenav.isShow'
5 | )
6 | .backdrop(@click='sidenav.toggle(false)')
7 | .inner
8 | .list
9 | .group
10 | .title Navigation
11 | ul
12 | li
13 | router-link(to='/')
14 | icon
15 | Home
16 | | Home
17 | li
18 | router-link(to='/categories')
19 | icon
20 | Folder
21 | | Categories Index
22 |
23 | .group
24 | .title User
25 | ul
26 | li(v-if='user.profile')
27 | router-link(to='/profile')
28 | icon
29 | icon-user
30 | | {{ user.profile.name }} (you)
31 | li(v-if='user.profile')
32 | router-link(to='/favourite')
33 | icon
34 | Bookmark
35 | | My Favourite
36 | li
37 | router-link(to='/auth')
38 | icon
39 | Fingerprint
40 | | Authorization
41 |
42 | .group
43 | .title {{ PROJECT_NAME }}
44 | ul
45 | li
46 | router-link(to='/about')
47 | icon
48 | Heart
49 | | About us
50 |
51 |
52 |
94 |
95 |
164 |
--------------------------------------------------------------------------------
/src/components/Lazyload.vue:
--------------------------------------------------------------------------------
1 |
2 | component.lazyload(
3 | :is='loaded ? "img" : "svg"',
4 | :width='width',
5 | :height='height',
6 | :src='src',
7 | :data-lazy-state='loaded ? "loaded" : error ? "failed" : "loading"',
8 | role='img',
9 | ref='imgRef'
10 | )
11 |
12 |
13 |
53 |
54 |
71 |
--------------------------------------------------------------------------------
/src/components/MBox.vue:
--------------------------------------------------------------------------------
1 |
2 | .mbox(:class='[type || "default"]')
3 | .mbox-title(v-if='header || $slots.header')
4 | slot(name='header')
5 | | {{ header }}
6 | .mbox-content
7 | slot
8 | p(v-if='content') {{ content }}
9 |
10 |
11 |
18 |
19 |
49 |
--------------------------------------------------------------------------------
/src/components/NProgress.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
44 |
45 |
60 |
--------------------------------------------------------------------------------
/src/components/NaiveuiProvider.vue:
--------------------------------------------------------------------------------
1 |
2 | NConfigProvider(
3 | :locale='zhCN',
4 | :theme-overrides='theme',
5 | preflight-style-disabled
6 | )
7 | NDialogProvider
8 | NMessageProvider
9 | slot
10 |
11 |
12 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/components/Placeholder.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
47 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | import { version } from '../package.json'
2 |
3 | export const ENV = process.env.NODE_ENV === 'development' ? 'dev' : 'prod'
4 | export const API_BASE = '/api'
5 | export const PROJECT_NAME = 'Pica Comic Now'
6 | export const VERSION = version
7 |
8 | // Copyright
9 | export const GITHUB_OWNER = 'FreeNowOrg'
10 | export const GITHUB_REPO = 'PicaComicNow'
11 | export const GITHUB_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}`
12 | const year = new Date().getFullYear()
13 | export const COPYRIGHT_YEAR = 2021
14 | export const COPYRIGHT_STR =
15 | year === COPYRIGHT_YEAR ? COPYRIGHT_YEAR : `${COPYRIGHT_YEAR} - ${year}`
16 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 |
3 | // Create App
4 | import App from './App.vue'
5 | const app = createApp(App)
6 |
7 | // Store
8 | import { createPinia } from 'pinia'
9 | const pinia = createPinia()
10 | app.use(pinia)
11 |
12 | // Router
13 | import { router } from './router'
14 | app.use(router)
15 |
16 | // Styles
17 | import './styles/index.sass'
18 |
19 | // Icon
20 | import { Icon } from '@vicons/utils'
21 | app.component('Icon', Icon)
22 |
23 | // External link
24 | import ExternalLink from './components/ExternalLink.vue'
25 | app.component('ELink', ExternalLink)
26 |
27 | // LazyLoad
28 | import Lazyload from './components/Lazyload.vue'
29 | app.component('Lazyload', Lazyload)
30 |
31 | // Placeholder
32 | import Placeholder from './components/Placeholder.vue'
33 | app.component('Placeholder', Placeholder)
34 |
35 | // Mount
36 | app.mount('#app')
37 |
--------------------------------------------------------------------------------
/src/router.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createRouter,
3 | createWebHistory,
4 | type NavigationGuard,
5 | } from 'vue-router'
6 | import { useUserStore } from './stores/user'
7 |
8 | export const router = createRouter({
9 | history: createWebHistory(),
10 | routes: [],
11 | scrollBehavior(_to, _from, savedPosition) {
12 | if (savedPosition) {
13 | return savedPosition
14 | } else {
15 | return {
16 | top: 0,
17 | behavior: 'smooth',
18 | }
19 | }
20 | },
21 | })
22 |
23 | // Home
24 | router.addRoute({
25 | path: '/',
26 | name: 'index',
27 | component: () => import('./view/index.vue'),
28 | })
29 |
30 | // Categories
31 | router.addRoute({
32 | path: '/categories',
33 | name: 'categories',
34 | component: () => import('./view/categories.vue'),
35 | })
36 |
37 | // Comics
38 | router.addRoute({
39 | path: '/comics',
40 | name: 'comics-index',
41 | component: () => import('./view/comics.vue'),
42 | })
43 | router.addRoute({
44 | path: '/comics/:category',
45 | name: 'comics',
46 | component: () => import('./view/comics.vue'),
47 | })
48 |
49 | // Book
50 | router.addRoute({
51 | path: '/book/:bookid',
52 | name: 'book',
53 | component: () => import('./view/book.vue'),
54 | })
55 |
56 | // Read
57 | router.addRoute({
58 | path: '/book/:bookid/:epsid',
59 | alias: ['/read/:bookid/:epsid'],
60 | name: 'read',
61 | component: () => import('./view/read.vue'),
62 | })
63 |
64 | // User
65 | router.addRoute({
66 | path: '/auth',
67 | name: 'auth',
68 | component: () => import('./view/auth.vue'),
69 | })
70 | router.addRoute({
71 | path: '/profile',
72 | name: 'profile',
73 | component: () => import('./view/profile.vue'),
74 | })
75 | router.addRoute({
76 | path: '/favourite',
77 | name: 'favourite',
78 | alias: ['/bookmark', '/bookmarks', '/favorite', '/favourites'],
79 | component: () => import('./view/favourite.vue'),
80 | })
81 |
82 | // Search
83 | router.addRoute({
84 | path: '/search/:keyword?',
85 | name: 'search',
86 | component: () => import('./view/search.vue'),
87 | })
88 |
89 | // About
90 | router.addRoute({
91 | path: '/about',
92 | name: 'about',
93 | component: () => import('./view/about.vue'),
94 | })
95 |
96 | // 404
97 | router.addRoute({
98 | path: '/:pathMatch(.*)*',
99 | name: 'not-found',
100 | component: () => import('./view/404.vue'),
101 | })
102 |
103 | const userAuthGuard: NavigationGuard = async ({ name, fullPath }) => {
104 | const user = useUserStore()
105 | if (!user.isLoggedIn && name !== 'auth') {
106 | await user.fetchProfile().catch(() => {
107 | console.warn('[App]', 'Verification information has expired')
108 | router.push({
109 | name: 'auth',
110 | query: { from: fullPath, tips: 1 },
111 | })
112 | })
113 | }
114 | }
115 | router.beforeEach(userAuthGuard)
116 | router.afterEach(userAuthGuard as any)
117 |
118 | router.afterEach(({ name }) => {
119 | document.body.setAttribute('data-route', name as string)
120 | // Fix route when modal opened
121 | document.body.classList.remove('lock-scroll')
122 | })
123 |
--------------------------------------------------------------------------------
/src/stores/book.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia'
2 | import axios from 'axios'
3 | import type {
4 | ApiResponseBookEps,
5 | ApiResponseBookMeta,
6 | ApiResponseBookPages,
7 | PicaBookMeta,
8 | PicaBookPage,
9 | PicaBookEp,
10 | } from '@/types'
11 | import { API_BASE } from '@/config'
12 | import { PicaCache } from '@/utils/PicaCache'
13 |
14 | export interface BookPagesStoreState {
15 | docs: PicaBookPage[]
16 | totalDocs: number
17 | pagination: number
18 | totalPagination: number
19 | }
20 |
21 | export const useBookStore = defineStore('book', () => {
22 | const noCacheMode =
23 | window?.location &&
24 | (location.search.includes('noCache') || location.hash.includes('noCache'))
25 |
26 | const bookMetaCache = new PicaCache('book/meta')
27 | async function fetchBookMeta(bookid: string) {
28 | return axios
29 | .get(`${API_BASE}/comics/${bookid}`)
30 | .then(({ data }) => {
31 | return data.body.comic
32 | })
33 | }
34 | async function getBookMeta(bookid: string, noCache: boolean = noCacheMode) {
35 | if (!noCache) {
36 | const cached = await bookMetaCache.get(bookid)
37 | if (cached) {
38 | return cached
39 | }
40 | }
41 | const data = await fetchBookMeta(bookid)
42 | await bookMetaCache.set(bookid, data)
43 | return data
44 | }
45 |
46 | const bookEpsCache = new PicaCache('book/eps')
47 | async function fetchBookEps(bookid: string) {
48 | const list: PicaBookEp[] = []
49 | const fetchOnePage = async (page: number): Promise => {
50 | return axios
51 | .get(`${API_BASE}/comics/${bookid}/eps`, {
52 | params: {
53 | page,
54 | limit: 100,
55 | },
56 | })
57 | .then(({ data }) => {
58 | const { eps } = data.body
59 | list.push(...eps.docs)
60 | if (eps.page < eps.pages) {
61 | return fetchOnePage(eps.page + 1)
62 | }
63 | })
64 | }
65 | await fetchOnePage(1)
66 | return list
67 | }
68 | async function getBookEps(bookid: string, noCache: boolean = noCacheMode) {
69 | if (!noCache) {
70 | const cached = await bookEpsCache.get(bookid)
71 | if (cached) {
72 | return cached
73 | }
74 | }
75 | const data = await fetchBookEps(bookid)
76 | await bookEpsCache.set(bookid, data)
77 | return data
78 | }
79 |
80 | async function toggleBookmark(bookid: string) {
81 | return axios
82 | .post(`${API_BASE}/comics/${bookid}/favourite`)
83 | .then(({ data }: any) => {
84 | if (data.body.action === 'favourite') {
85 | return true
86 | } else if (data.body.action === 'un_favourite') {
87 | return false
88 | } else {
89 | throw new Error('Invalid action')
90 | }
91 | })
92 | }
93 |
94 | const bookPagesCache = new PicaCache('book/pages')
95 | async function fetchBookPages(
96 | bookid: string,
97 | epsid: string,
98 | pagination: number
99 | ) {
100 | pagination = Math.max(1, pagination)
101 | return axios
102 | .get(
103 | `${API_BASE}/comics/${bookid}/order/${epsid}/pages`,
104 | {
105 | params: {
106 | page: pagination,
107 | limit: 30,
108 | },
109 | }
110 | )
111 | .then(({ data }) => {
112 | return {
113 | docs: data.body.pages.docs,
114 | totalDocs: data.body.pages.total,
115 | pagination: pagination,
116 | totalPagination: data.body.pages.pages,
117 | } as BookPagesStoreState
118 | })
119 | }
120 | async function getBookPages(
121 | bookid: string,
122 | epsid: string,
123 | pagination: number,
124 | noCache: boolean = noCacheMode
125 | ) {
126 | const cacheKey = `${bookid}/${epsid}/${pagination}`
127 | if (!noCache) {
128 | const cached = await bookPagesCache.get(cacheKey)
129 | if (cached) {
130 | return cached
131 | }
132 | }
133 | const data = await fetchBookPages(bookid, epsid, pagination)
134 | await bookPagesCache.set(cacheKey, data)
135 | return data
136 | }
137 |
138 | // CLEANUP LEGACY CACHES
139 | if (window?.localStorage.length) {
140 | const keys = Object.keys(window.localStorage).filter((key) =>
141 | key.startsWith('pica:book')
142 | )
143 | keys.forEach((key) => {
144 | window.localStorage.removeItem(key)
145 | })
146 | }
147 |
148 | return {
149 | getBookMeta,
150 | getBookEps,
151 | getBookPages,
152 | toggleBookmark,
153 | }
154 | })
155 |
--------------------------------------------------------------------------------
/src/stores/category.ts:
--------------------------------------------------------------------------------
1 | import { API_BASE } from '@/config'
2 | import {
3 | ApiResponseBookList,
4 | ApiResponseCategories,
5 | PicaCategory,
6 | PicaListSort,
7 | } from '@/types'
8 | import { LRUMap } from '@/utils/LRUMap'
9 | import axios from 'axios'
10 | import { defineStore } from 'pinia'
11 | import { ref } from 'vue'
12 |
13 | export const useCategoryStore = defineStore('category', () => {
14 | const categoriesIndex = ref([])
15 | async function fetchCategoriesIndex() {
16 | if (categoriesIndex.value.length) {
17 | return categoriesIndex.value
18 | }
19 | const { data } = await axios.get(
20 | `${API_BASE}/categories`
21 | )
22 | categoriesIndex.value = data.body.categories
23 | return data.body.categories
24 | }
25 |
26 | const booksInCategoryMemoryCache = new LRUMap<
27 | string,
28 | ApiResponseBookList['body']
29 | >()
30 | async function fetchBooksInCategory(
31 | category: string,
32 | pagination: number,
33 | sort = PicaListSort.DATE_DESC
34 | ): Promise {
35 | if (!Object.values(PicaListSort).includes(sort)) {
36 | sort = PicaListSort.DEFAULT
37 | }
38 | const key = `${category}-${sort}-${pagination}`
39 | if (booksInCategoryMemoryCache.has(key)) {
40 | return booksInCategoryMemoryCache.get(key)!.comics
41 | }
42 | const { data } = await axios.get(
43 | `${API_BASE}/comics`,
44 | {
45 | params: {
46 | c: category,
47 | page: pagination,
48 | s: sort,
49 | },
50 | }
51 | )
52 | booksInCategoryMemoryCache.set(key, data.body)
53 | return data.body.comics
54 | }
55 |
56 | return {
57 | categoriesIndex,
58 | fetchCategoriesIndex,
59 | fetchBooksInCategory,
60 | }
61 | })
62 |
--------------------------------------------------------------------------------
/src/stores/sidenav.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia'
2 | import { ref } from 'vue'
3 |
4 | export const useSidenavStore = defineStore('sidenav', () => {
5 | const isShow = ref(false)
6 | const toggle = (force?: boolean) => {
7 | isShow.value = typeof force === 'boolean' ? force : !isShow.value
8 | }
9 | return { isShow, toggle }
10 | })
11 |
--------------------------------------------------------------------------------
/src/stores/user.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { computed, ref } from 'vue'
3 | import { API_BASE } from '../config'
4 | import {
5 | ApiResponseBookList,
6 | PicaListSortType,
7 | type PicaUserProfile,
8 | } from '../types'
9 | import { defineStore } from 'pinia'
10 | import { router } from '@/router'
11 |
12 | enum StorageKey {
13 | TOKEN = 'pica:user/token',
14 | PROFILE = 'pica:user/profile',
15 | }
16 |
17 | export const useUserStore = defineStore('user', () => {
18 | const profile = ref(null)
19 | const isLoggedIn = computed(() => !!profile.value)
20 |
21 | // Inject inspectors
22 | axios.interceptors.request.use((config) => {
23 | const token = localStorage.getItem(StorageKey.TOKEN)
24 | if (token) {
25 | config.headers['authorization'] = token
26 | }
27 | return config
28 | })
29 | axios.interceptors.response.use(
30 | (response) => response,
31 | (error) => {
32 | if (error.response?.status === 401) {
33 | console.warn('Axios Error: 401 unauthorized. Clear user data!')
34 | logout()
35 | if (router.currentRoute.value.name !== 'auth') {
36 | router.push({
37 | name: 'auth',
38 | query: {
39 | from: router.currentRoute.value.fullPath,
40 | tips: '1',
41 | },
42 | })
43 | }
44 | }
45 | return Promise.reject(error)
46 | }
47 | )
48 |
49 | async function login(email: string, password: string) {
50 | console.log('Log in with credentials', {
51 | email,
52 | password,
53 | })
54 |
55 | const { data: d }: any = await axios.post(
56 | `${API_BASE}/auth/sign-in`,
57 | {
58 | email,
59 | password,
60 | },
61 | {
62 | headers: {
63 | 'cache-control': 'no-cache',
64 | },
65 | }
66 | )
67 |
68 | if (d?.body?.token) {
69 | localStorage.setItem(StorageKey.TOKEN, d.body.token)
70 | return fetchProfile()
71 | } else {
72 | throw new Error('Failed to log in, please check your credentials')
73 | }
74 | }
75 |
76 | /**
77 | * A.K.A. clear user data
78 | */
79 | async function logout() {
80 | profile.value = null
81 | localStorage.removeItem(StorageKey.PROFILE)
82 | localStorage.removeItem(StorageKey.TOKEN)
83 | }
84 |
85 | let _fetchProfilePromise: Promise | null = null
86 | async function fetchProfile(): Promise {
87 | if (!localStorage.getItem(StorageKey.TOKEN)) {
88 | throw new Error('Not logged in')
89 | }
90 | if (
91 | localStorage.getItem(StorageKey.TOKEN) &&
92 | localStorage.getItem(StorageKey.PROFILE)
93 | ) {
94 | try {
95 | profile.value = JSON.parse(localStorage.getItem(StorageKey.PROFILE)!)
96 | return profile.value!
97 | } catch (e) {
98 | console.error('Failed to parse user info from localStorage', e)
99 | }
100 | }
101 |
102 | if (!_fetchProfilePromise) {
103 | _fetchProfilePromise = axios
104 | .get(`${API_BASE}/users/profile`, {
105 | headers: {
106 | authorization: localStorage.getItem(StorageKey.TOKEN) || undefined,
107 | },
108 | })
109 | .catch((err) => {
110 | _fetchProfilePromise = null
111 | logout()
112 | return Promise.reject(err) as any
113 | })
114 | }
115 |
116 | const { data: d }: any = await _fetchProfilePromise
117 | const user = d?.body?.user || null
118 | if (!user) {
119 | _fetchProfilePromise = null
120 | logout()
121 | throw new Error('Failed to get user profile, please log in again')
122 | }
123 | console.info('Fetched user profile', user)
124 | profile.value = user
125 | localStorage.setItem(StorageKey.PROFILE, JSON.stringify(user))
126 | return user
127 | }
128 |
129 | async function fetchFavoriteBooks(payload: {
130 | sort: PicaListSortType
131 | page: number
132 | }) {
133 | return axios
134 | .get(`${API_BASE}/users/favourite`, {
135 | params: {
136 | s: payload.sort,
137 | page: payload.page,
138 | },
139 | })
140 | .then((res) => res.data.body)
141 | }
142 |
143 | return {
144 | profile,
145 | isLoggedIn,
146 | login,
147 | logout,
148 | fetchProfile,
149 | fetchFavoriteBooks,
150 | }
151 | })
152 |
--------------------------------------------------------------------------------
/src/styles/elements.sass:
--------------------------------------------------------------------------------
1 | // Header
2 | @mixin header-shared($font-size, $shadow-color)
3 | display: inline-block
4 | position: relative
5 | font-size: $font-size
6 | text-shadow: 1px 1px 0 var(--theme-text-shadow-color), -1px -1px 0 var(--theme-text-shadow-color), 1px -1px 0 var(--theme-text-shadow-color), -1px 1px 0 var(--theme-text-shadow-color)
7 | &::after
8 | content: ''
9 | display: block
10 | width: 100%
11 | height: 50%
12 | background-color: $shadow-color
13 | position: absolute
14 | left: 0
15 | bottom: -0.1em
16 | border-radius: 9999px
17 | z-index: -1
18 |
19 | h1
20 | font-size: 2.2rem
21 |
22 | h2
23 | @include header-shared(1.4rem, #ef76a3)
24 | left: 50%
25 | transform: translateX(-50%)
26 | padding: 0 1rem
27 |
28 | .align-center
29 | h2
30 | left: unset
31 | transform: unset
32 |
33 | // Links
34 | a
35 | --color: var(--theme-link-color)
36 | color: var(--color)
37 | text-decoration: none
38 | position: relative
39 | display: inline-block
40 |
41 | &.plain
42 | display: unset
43 |
44 | &:not(.plain)::after
45 | content: ''
46 | display: block
47 | position: absolute
48 | width: 100%
49 | height: 0.1em
50 | bottom: -0.1em
51 | left: 0
52 | background-color: var(--color)
53 | visibility: hidden
54 | transform: scaleX(0)
55 | transition: all 0.4s ease-in-out
56 |
57 | &:not(.plain):hover::after,
58 | &.router-link-active::after,
59 | &.tab-active::after,
60 | &.is-active::after
61 | visibility: visible
62 | transform: scaleX(1)
63 |
64 | &.button
65 | padding: 0.2rem 0.4rem
66 | background-color: var(--theme-tag-color)
67 | transition: all .4s ease
68 | cursor: pointer
69 |
70 | &:hover
71 | background-color: rgba(var(--theme-link-color--rgb), 1)
72 | color: var(--theme-accent-link-color)
73 |
74 | // Button
75 | button
76 | display: inline-block
77 | padding: 0.4rem 0.8rem
78 | font-size: 1rem
79 | border: none
80 | border-radius: 4px
81 | background-color: rgb(54, 151, 231)
82 | color: #fff
83 | cursor: pointer
84 |
85 | &:hover
86 | background-color: rgb(69, 162, 238)
87 |
88 | &:focus
89 | background-color: rgb(32, 125, 201)
90 | box-shadow: 0 0 0 2px #fff inset, 0 0 0 2px rgb(32, 125, 201)
91 |
92 | &:disabled
93 | background-color: #ccc
94 | cursor: not-allowed
95 |
96 | &:active
97 | box-shadow: 0 0 0 2px #fff inset, 0 0 0 2px #ccc
98 |
99 | // Card
100 | .card
101 | background-color: #fff
102 | border-radius: 0.5rem
103 | padding: 1rem
104 | transition: box-shadow .6s ease
105 | border: 1px solid #ddd
106 |
107 | &.gap
108 | margin-top: 1rem
109 | margin-bottom: 1.4rem
110 |
111 | // Tags
112 | .tags-list
113 | line-height: 1.6
114 | .tag
115 | line-height: 1em
116 | display: inline-block
117 | padding: 2px 4px
118 | margin-right: 0.4rem
119 | background-color: aliceblue
120 | &[data-tag="生肉"]
121 | color: #fff
122 | background-color: #dd8221
123 | &.router-link-active
124 | color: #fff
125 | background-color: var(--theme-link-color)
126 |
127 | input,
128 | textarea
129 | padding: 4px 0.75rem
130 | font-size: 1rem
131 | line-height: 1em
132 | border: none
133 | border-radius: 1em
134 | background-color: rgba(0, 0, 0, 0.05)
135 | outline: none
136 | &:hover
137 | box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25)
138 | &:focus
139 | box-shadow: 0 0 0 2px var(--theme-accent-color)
140 |
141 | // Responsive
142 | .responsive
143 | padding-left: 5%
144 | padding-right: 5%
145 |
146 | @media (max-width: 800px)
147 | .responsive
148 | padding-left: 1rem
149 | padding-right: 1rem
150 |
151 | // Loading
152 | .loading-cover
153 | position: relative
154 | &::before,&::after
155 | content: ""
156 | width: 100%
157 | height: 100%
158 | display: block
159 | position: absolute
160 | &::before
161 | background-image: url(/images/spinner.svg)
162 | background-size: 75px
163 | background-repeat: no-repeat
164 | background-position: center
165 | top: 50%
166 | left: 50%
167 | transform: translateX(-50%) translateY(-50%)
168 | z-index: 6
169 | &::after
170 | top: 0
171 | left: 0
172 | background-color: rgba(200,200,200,0.2)
173 | z-index: 5
174 |
175 | // Code
176 | pre
177 | overflow: auto
178 | background: rgba(172, 232, 255, 0.2)
179 | padding: 0.4rem
180 | border: 1px solid #cccccc
181 | border-radius: 6px
182 |
183 | code
184 | background-color: #efefef
185 | display: inline
186 | border-radius: 2px
187 | padding: .1rem .2rem
188 | color: #e02080
189 | word-break: break-word
190 |
191 | // Tabber
192 | .tabber
193 | .tabber-tabs
194 | margin-bottom: 1rem
195 | display: flex
196 | gap: 1rem
197 | a
198 | cursor: pointer
199 |
--------------------------------------------------------------------------------
/src/styles/formats.sass:
--------------------------------------------------------------------------------
1 | .align-center
2 | text-align: center
3 |
4 | .align-left
5 | text-align: left
6 |
7 | .align-right
8 | text-align: right
9 |
10 | .position-center
11 | text-align: left
12 | position: relative
13 | left: 50%
14 | transform: translateX(-50%)
15 |
16 | .flex-center
17 | display: flex
18 | align-items: center
19 |
20 | .pre,
21 | .poem
22 | white-space: pre-wrap
23 |
24 | .flex
25 | display: flex
26 |
27 | .flex-1
28 | flex: 1
29 |
30 | .gap-1
31 | gap: 1rem
32 |
33 | .flex-list
34 | .list-item
35 | display: flex
36 | gap: 0.5rem
37 |
38 | &:not(:first-of-type)
39 | margin-top: 4px
40 |
41 | &.header
42 | position: sticky
43 | top: 50px
44 | background-color: #f8f8f8
45 | font-weight: 600
46 | font-size: 1.24rem
47 | z-index: 10
48 |
49 | > div:not(:last-of-type)
50 | box-shadow: 2px 0 #dedede
51 |
52 | > div
53 | flex: 1
54 |
55 | .key
56 | font-weight: 600
57 | box-shadow: 2px 0 #dedede
58 |
59 | .pointer
60 | cursor: pointer
61 |
62 | .bread-crumb
63 | margin-bottom: 1.5rem
64 |
65 | // Info
66 | .mbox
67 | --border-color: rgba(0, 0, 0, 0.1)
68 | --bg-color: rgba(0, 0, 0, 0.04)
69 | background-color: #fff
70 | border: 1px solid var(--bg-color)
71 | border-left: 0.5rem solid var(--border-color)
72 | border-radius: 0.5rem
73 | overflow: hidden
74 | & > *
75 | padding: 1rem
76 | margin: 0
77 | & .title
78 | font-weight: 600
79 | font-size: 1.2rem
80 | padding: 0.4rem 1rem
81 | background-color: var(--bg-color)
82 | &.info
83 | --border-color: #30a0ff
84 | --bg-color: rgba(0, 140, 255, 0.1)
85 | &.success
86 | --border-color: #00a000
87 | --bg-color: rgba(0, 160, 0, 0.1)
88 | &.warning
89 | --border-color: #ffa500
90 | --bg-color: rgba(231, 139, 0, 0.1)
91 | &.error
92 | --border-color: #e00000
93 | --bg-color: rgba(233, 0, 0, 0.1)
94 |
--------------------------------------------------------------------------------
/src/styles/index.sass:
--------------------------------------------------------------------------------
1 | @import variables.sass
2 | @import elements.sass
3 | @import formats.sass
4 | @import states.sass
5 |
6 | html,
7 | body
8 | margin: 0
9 | padding: 0
10 | position: relative
11 |
12 | *
13 | box-sizing: border-box
14 |
15 | #app
16 | font-family: Avenir, Helvetica, Arial, sans-serif
17 | -webkit-font-smoothing: antialiased
18 | -moz-osx-font-smoothing: grayscale
19 | color: var(--theme-text-color)
20 |
--------------------------------------------------------------------------------
/src/styles/states.sass:
--------------------------------------------------------------------------------
1 | .lock-scroll
2 | overflow: hidden
3 |
--------------------------------------------------------------------------------
/src/styles/variables.sass:
--------------------------------------------------------------------------------
1 | :root
2 | font-size: 16px
3 | --theme-accent-color: #ef76a3
4 | --theme-accent-color--rgb: 239, 118, 163
5 | --theme-accent-color-darken: #cc5985
6 | --theme-accent-link-color: #fff
7 | --theme-secondary-color: #e02080
8 | --theme-secondary-color--rgb: 224, 32, 128
9 | --theme-text-color: #2c3e50
10 | --theme-link-color: #3f51b5
11 | --theme-link-color--rgb: 63, 81, 181
12 | --theme-background-color: #ffedf0
13 | --theme-text-shadow-color: #fff
14 | --theme-box-shadow-color: #eee
15 | --theme-box-shadow-color-hover: #ccc
16 | --theme-border-color: #888
17 | --theme-box-shadow: 0 0 8px var(--theme-box-shadow-color)
18 | --theme-box-shadow-hover: 0 0 14px var(--theme-box-shadow-color-hover)
19 | --theme-tag-color: rgb(214, 228, 255)
20 | --theme-danger-color: #f55
21 | --theme-bookmark-color: #ff69b4
22 |
--------------------------------------------------------------------------------
/src/types/Api.ts:
--------------------------------------------------------------------------------
1 | import { FileMedia, type FileThumb } from './File.js'
2 |
3 | export type ApiResponse = {
4 | code: number
5 | message: string
6 | body: T
7 | debug?: any
8 | }
9 |
10 | export interface PicaCategory {
11 | title: string
12 | thumb: FileMedia
13 | isWeb: boolean
14 | active: boolean
15 | link: string
16 | }
17 | export type ApiResponseCategories = ApiResponse<{
18 | categories: PicaCategory[]
19 | }>
20 |
21 | export interface PicaBookListItem {
22 | _id: string
23 | title: string
24 | author: string
25 | totalViews: number
26 | totalLikes: number
27 | pagesCount: number
28 | epsCount: number
29 | finished: boolean
30 | categories: string[]
31 | thumb: FileMedia
32 | id: string
33 | likesCount: number
34 | }
35 | export type ApiResponseBookList = ApiResponse<{
36 | comics: {
37 | docs: PicaBookListItem[]
38 | total: number
39 | limit: number
40 | page: number
41 | pages: number
42 | }
43 | }>
44 |
45 | export interface PicaUserProfile {
46 | _id: string
47 | birthday: string
48 | email: string
49 | gender: 'm' | 'f' | 'bot'
50 | name: string
51 | slogan: string
52 | title: string
53 | verified: boolean
54 | exp: number
55 | level: number
56 | characters: any[]
57 | created_at: string
58 | avatar: FileMedia
59 | isPunched: boolean
60 | }
61 | export type ApiResponseUserProfile = ApiResponse<{ user: PicaUserProfile }>
62 |
63 | export interface PicaBookMeta {
64 | _id: string
65 | _creator: PicaUserProfile
66 | title: string
67 | description: string
68 | thumb: FileMedia
69 | author: string
70 | chineseTeam: string
71 | categories: string[]
72 | tags: string[]
73 | pagesCount: number
74 | epsCount: number
75 | finished: boolean
76 | updated_at: string
77 | created_at: string
78 | allowDownload: boolean
79 | viewsCount: number
80 | likesCount: number
81 | isFavourite: boolean
82 | isLiked: boolean
83 | commentsCount: number
84 | }
85 | export type ApiResponseBookMeta = ApiResponse<{
86 | comic: PicaBookMeta
87 | }>
88 |
89 | export interface PicaBookEp {
90 | _id: string
91 | id: string
92 | order: number
93 | title: string
94 | updated_at: string
95 | }
96 | export type ApiResponseBookEps = ApiResponse<{
97 | eps: {
98 | docs: PicaBookEp[]
99 | total: number
100 | limit: number
101 | page: number
102 | pages: number
103 | }
104 | }>
105 |
106 | export interface PicaBookPage {
107 | _id: string
108 | id: string
109 | media: FileMedia
110 | }
111 | export type ApiResponseBookPages = ApiResponse<{
112 | ep: {
113 | _id: string
114 | title: string
115 | }
116 | pages: {
117 | docs: PicaBookPage[]
118 | limit: number
119 | page: number
120 | pages: number
121 | total: number
122 | }
123 | }>
124 |
125 | /**
126 | * 排序方式
127 | * ```
128 | * ua = user asing = 默认排序(用户指定)
129 | * dd = date desc = 新到旧
130 | * da = date asc = 旧到新
131 | * ld = like desc = 最多爱心(收藏)
132 | * vd = view desc = 最多指名(浏览)
133 | * ```
134 | */
135 | export type PicaListSortType = 'ua' | 'dd' | 'da' | 'ld' | 'vd'
136 | export enum PicaListSort {
137 | DEFAULT = 'ua',
138 | DATE_DESC = 'dd',
139 | DATE_ASC = 'da',
140 | LIKE_DESC = 'ld',
141 | VIEW_DESC = 'vd',
142 | }
143 | export const PICA_LIST_SORT_OPTIONS = [
144 | { label: '默认排序', value: PicaListSort.DEFAULT },
145 | { label: '新到旧', value: PicaListSort.DATE_DESC },
146 | { label: '旧到新', value: PicaListSort.DATE_ASC },
147 | { label: '最多收藏', value: PicaListSort.LIKE_DESC },
148 | { label: '最多浏览', value: PicaListSort.VIEW_DESC },
149 | ]
150 |
--------------------------------------------------------------------------------
/src/types/File.ts:
--------------------------------------------------------------------------------
1 | export interface FileThumb {
2 | originalName: string
3 | path: string
4 | fileServer: string
5 | }
6 |
7 | export interface FileMedia extends FileThumb {
8 | fileUrl: string
9 | }
10 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Api'
2 | export * from './File'
3 |
--------------------------------------------------------------------------------
/src/utils/LRUMap.ts:
--------------------------------------------------------------------------------
1 | export class LRUMap {
2 | private dataMap: Map
3 | private lruKeysQueue: K[]
4 | private limit: number
5 | constructor(limit = 100) {
6 | this.dataMap = new Map()
7 | this.lruKeysQueue = []
8 | this.limit = limit
9 | }
10 | get(key: K) {
11 | if (this.dataMap.has(key)) {
12 | this.lruKeysQueue = this.lruKeysQueue.filter((v) => v !== key)
13 | this.lruKeysQueue.unshift(key)
14 | return this.dataMap.get(key)
15 | }
16 | return undefined
17 | }
18 | set(key: K, value: V) {
19 | if (this.dataMap.has(key)) {
20 | this.lruKeysQueue = this.lruKeysQueue.filter((v) => v !== key)
21 | } else if (this.lruKeysQueue.length >= this.limit) {
22 | const last = this.lruKeysQueue.pop()!
23 | this.dataMap.delete(last)
24 | }
25 | this.lruKeysQueue.unshift(key)
26 | this.dataMap.set(key, value)
27 | }
28 | has(key: K) {
29 | return this.dataMap.has(key)
30 | }
31 | delete(key: K) {
32 | if (this.dataMap.has(key)) {
33 | this.lruKeysQueue = this.lruKeysQueue.filter((v) => v !== key)
34 | this.dataMap.delete(key)
35 | }
36 | }
37 | clear() {
38 | this.dataMap.clear()
39 | this.lruKeysQueue = []
40 | }
41 | get size() {
42 | return this.dataMap.size
43 | }
44 | get keys() {
45 | return this.dataMap.keys()
46 | }
47 | get values() {
48 | return this.dataMap.values()
49 | }
50 | get entries() {
51 | return this.dataMap.entries()
52 | }
53 | get [Symbol.iterator]() {
54 | return this.dataMap[Symbol.iterator]()
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/utils/PicaCache.ts:
--------------------------------------------------------------------------------
1 | import localforage from 'localforage'
2 | import { LRUMap } from './LRUMap'
3 |
4 | export class PicaCache {
5 | static DB_NAME = 'PicaComicNow'
6 | static CACHE_TIMEOUT = 1000 * 60 * 60 * 24 * 30 // 30 days
7 |
8 | readonly db: LocalForage
9 | readonly lru: LRUMap
10 |
11 | constructor(
12 | readonly storeName: string,
13 | public maxAge: number = PicaCache.CACHE_TIMEOUT
14 | ) {
15 | this.db = PicaCache.createDatabase(storeName)
16 | this.lru = new LRUMap(150)
17 | }
18 |
19 | static createDatabase(storeName: string) {
20 | return localforage.createInstance({
21 | name: PicaCache.DB_NAME,
22 | storeName,
23 | })
24 | }
25 |
26 | async get(key: string) {
27 | const memory = this.lru.get(key)
28 | if (memory) {
29 | return memory
30 | }
31 | const data = await this.loadFromDBWithExpiry(key)
32 | if (data) {
33 | this.lru.set(key, data)
34 | }
35 | return data
36 | }
37 |
38 | async set(key: string, value: T) {
39 | this.lru.set(key, value)
40 | return this.db.setItem(key, {
41 | time: Date.now(),
42 | value,
43 | })
44 | }
45 |
46 | async loadFromDBWithExpiry(key: string) {
47 | const data = await this.db.getItem<{ time: number; value: T }>(key)
48 | if (!data) {
49 | return null
50 | }
51 | if (Date.now() - data.time > this.maxAge) {
52 | return null
53 | }
54 | return data.value
55 | }
56 |
57 | /**
58 | * [DANGER] Use with caution!
59 | * Clear both memory and indexedDB cache
60 | */
61 | async clear() {
62 | this.lru.clear()
63 | await this.db.clear()
64 | return this
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/utils/getErrMsg.ts:
--------------------------------------------------------------------------------
1 | export function getErrMsg(err: any): string {
2 | return err?.response?.data?.message || err.message || 'HTTP Timeout'
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/setTitle.ts:
--------------------------------------------------------------------------------
1 | import { PROJECT_NAME } from '../config'
2 |
3 | export function setTitle(...title: any[]) {
4 | document.title = [...title, PROJECT_NAME]
5 | .filter(Boolean)
6 | .map(String)
7 | .join(' | ')
8 | return document.title
9 | }
10 |
--------------------------------------------------------------------------------
/src/view/404.vue:
--------------------------------------------------------------------------------
1 |
2 | #error-404
3 | h1 404 Not Found
4 | p This page has been lost
5 |
6 |
7 |
14 |
15 |
--------------------------------------------------------------------------------
/src/view/about.vue:
--------------------------------------------------------------------------------
1 |
2 | #about-container
3 | h1 About us
4 |
5 | h2 Lorem ipsum
6 | .card
7 | p Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
8 | p Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
9 | p Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
10 | p Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
11 |
12 |
13 |
21 |
22 |
--------------------------------------------------------------------------------
/src/view/auth.vue:
--------------------------------------------------------------------------------
1 |
2 | #auth-container
3 | h1 Authorization
4 |
5 | .mbox.info(v-if='$route.query.tips', style='margin-bottom: 1rem')
6 | .title Tips
7 | p You must log in to use this website
8 |
9 | section(v-if='user.profile')
10 | .card
11 | h2 Hello, {{ user.profile.name }}
12 | .align-center
13 | button(@click.prevent='handleSignOut') Sign out
14 |
15 | section(v-else)
16 | form.form.card.align-center(:class='{ "loading-cover": onAuthenticating }')
17 | h2(style='left: 0; transform: none') Login
18 | label
19 | strong Username/email
20 | input(v-model='email')
21 | label
22 | strong Password
23 | input(v-model='password', type='password')
24 | div
25 | button(@click.prevent='handleLogin') Login
26 | //- Error
27 | .mbox.error(v-if='errorMsg')
28 | .title {{ errorTitle }}
29 | p {{ errorMsg }}
30 |
31 |
32 |
95 |
96 |
135 |
--------------------------------------------------------------------------------
/src/view/book.vue:
--------------------------------------------------------------------------------
1 |
2 | #book-container
3 | section.book-info
4 | .bread-crumb(v-if='$route.query.backTo')
5 | router-link.button(:to='"" + $route.query.backTo') ← Back to {{ $route.query.backTo }}
6 | .loading.card.align-center(v-if='isLoadingMeta || !bookMeta')
7 | placeholder
8 | .card(v-if='bookMeta')
9 | .details.flex.gap-1
10 | .left
11 | e-link.no-icon.thumb(:href='bookMeta.thumb.fileUrl')
12 | lazyload.img(
13 | :src='bookMeta.thumb.fileUrl',
14 | :width='200',
15 | :height='266'
16 | )
17 | .right.flex(style='position: relative')
18 | .flex.title-area.flex-center
19 | h1.title.flex-1 {{ bookMeta.title }}
20 | a.bookmark.pointer(
21 | :class='bookMeta.isFavourite ? "is-favourite" : "not-favourite"',
22 | :title='bookMeta.isFavourite ? "Click to remove bookmark" : "Click to add bookmark"',
23 | @click='handleBookmark'
24 | )
25 | icon
26 | bookmark(v-if='bookMeta.isFavourite')
27 | bookmark-regular(v-else)
28 | .flex-column.flex-1.gap-1
29 | .finished
30 | icon
31 | CheckCircle(v-if='bookMeta.finished')
32 | PenNib(v-else)
33 | |
34 | | {{ bookMeta.finished ? 'Finished' : 'Writing' }}
35 | .pages
36 | strong Pages:
37 | | {{ bookMeta.pagesCount }} Pages, {{ bookMeta.epsCount }} Episodes
38 | .author
39 | strong Author:
40 | router-link(:to='"/search/" + bookMeta.author') @{{ bookMeta.author }}
41 | .chinese-team(v-if='bookMeta.chineseTeam')
42 | strong Chinese translator:
43 | router-link(:to='"/search/" + bookMeta.chineseTeam') {{ bookMeta.chineseTeam }}
44 | .tags-list
45 | strong Categories:
46 | router-link.tag(
47 | v-for='item in bookMeta.categories',
48 | :to='"/comics/" + item'
49 | ) {{ item }}
50 | .stats.flex
51 | .views.flex-1
52 | strong views:
53 | span {{ bookMeta.viewsCount }}
54 | .likes.flex-1
55 | strong Likes:
56 | span {{ bookMeta.likesCount }}
57 | .comments.flex-1
58 | strong Comments:
59 | span {{ bookMeta.commentsCount }}
60 |
61 | .tags-list
62 | strong Tags:
63 | router-link.tag(v-for='item in bookMeta.tags', :to='"/search/" + item') {{ item }}
64 |
65 | .description {{ bookMeta.description }}
66 |
67 | section.book-eps
68 | .card
69 | h2#eps Episodes
70 | p.loading.align-center(v-if='isLoadingEps || !bookEps.length')
71 | placeholder
72 | .eps-list(v-if='bookEps.length')
73 | router-link.ep-link.plain(
74 | v-for='item in bookEps',
75 | :to='{ name: "read", params: { bookid: bookid, epsid: item.order }, query: { backTo: $route.query.backTo } }'
76 | ) {{ item.title }}
77 |
78 | section.extra-actions
79 | .card
80 | h2 Extra Actions
81 | details
82 | summary Book Meta
83 | pre {{ bookMeta }}
84 | details
85 | summary Book Episodes
86 | pre {{ bookEps }}
87 | p(v-if='bookMeta')
88 | a.button.danger(@click='init(true)') Force Reload Book Info
89 |
90 |
91 |
184 |
185 |
245 |
--------------------------------------------------------------------------------
/src/view/categories.vue:
--------------------------------------------------------------------------------
1 |
2 | mixin thumb(item)
3 | .thumb
4 | lazyload.img(:src='item.thumb.fileUrl')
5 | .title
6 | | {{ item.title }}
7 | icon(v-if='item.isWeb', style='float: right')
8 | external-link-alt
9 |
10 | #categories-container
11 | h1 Categories Index
12 |
13 | .loading.align-center(v-if='loading')
14 | placeholder
15 |
16 | .mbox.error(v-if='error')
17 | .title Failed to get categories data
18 | p {{ error }}
19 |
20 | ul.categories-list
21 | li(v-for='item in list')
22 | .card(v-if='item.active !== false')
23 | e-link.no-icon(v-if='item.isWeb', :href='item.link')
24 | +thumb(item)
25 | router-link(v-else, :to='"/comics/" + item.title')
26 | +thumb(item)
27 |
28 |
29 |
71 |
72 |
122 |
--------------------------------------------------------------------------------
/src/view/comics.vue:
--------------------------------------------------------------------------------
1 |
2 | mixin pagenator
3 | book-list-paginator(v-model:page='page', v-model:sort='sort', :total-pages)
4 |
5 | #comics-container
6 | .bread-crumb
7 | router-link.button(to='/categories')
8 | icon
9 | arrow-left
10 | |
11 | | Categories Index
12 |
13 | h1(v-if='category') Comics in {{ category }}
14 | h1(v-else) Comics list
15 |
16 | .mbox.error(v-if='error')
17 | .title Failed to get comics data
18 | p {{ error }}
19 |
20 | .loading.align-center(v-if='loading && !comics.length')
21 | placeholder
22 |
23 | section(v-if='comics.length', :class='{ "loading-cover": loading }')
24 | +pagenator
25 | books-list(:data='comics', :backTo='"/comics/" + category')
26 | +pagenator
27 |
28 |
29 |
105 |
106 |
122 |
--------------------------------------------------------------------------------
/src/view/favourite.vue:
--------------------------------------------------------------------------------
1 |
2 | mixin pagenator
3 | book-list-paginator(
4 | v-model:page='page',
5 | v-model:sort='sort',
6 | :total-pages='totalPages'
7 | )
8 |
9 | #favourite-container
10 | .bread-crumb
11 | router-link.button(to='/profile')
12 | icon
13 | arrow-left
14 | |
15 | | Profile
16 |
17 | h1 My Favourites
18 |
19 | .mbox.error(v-if='error')
20 | .title Failed to get list
21 | p {{ error }}
22 |
23 | .loading.align-center(v-if='loading && !comics.length')
24 | placeholder
25 |
26 | section(v-if='comics.length', :class='{ "loading-cover": loading }')
27 | +pagenator
28 | books-list(:data='comics', :backTo='"/favourite"')
29 | +pagenator
30 |
31 |
32 |
99 |
100 |
116 |
--------------------------------------------------------------------------------
/src/view/index.vue:
--------------------------------------------------------------------------------
1 |
2 | #index-container
3 | h1 PicACG
4 |
5 | .mbox.info(style='margin-bottom: 1.5rem')
6 | .title Tips
7 | p This site is under construction
8 |
9 | .flex.gap-1
10 | .card.flex-1
11 | h2 Greeting
12 | p hello, world
13 | p {{ time }}
14 | .card.flex-1
15 | h2 Profile
16 | p
17 | router-link.button(to='/profile') profile
18 | |
19 | router-link.button(to='/favourite') favourite
20 |
21 |
22 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/view/profile.vue:
--------------------------------------------------------------------------------
1 |
2 | #profile-container
3 | section.user-profile(v-if='user.profile')
4 | //- h1 My Profile
5 | .card.metadata.align-center
6 | .avatar
7 | img(src='https://i.loli.net/2021/03/26/QPOtzh1XbF2eujd.png')
8 | h1.name {{ user.profile.name }}
9 | .extra
10 | span.title {{ user.profile.title }}
11 | |
12 | span.uid @{{ user.profile.email }}
13 | .card.slogan
14 | h2 Slogan
15 | .slogan-view.flex(
16 | v-if='!sloganEdit',
17 | :class='{ "loading-cover": sloganLoading }'
18 | )
19 | p.pre.flex-1 {{ user.profile.slogan || '-' }}
20 | .edit-btn
21 | a.pointer(@click='sloganEdit = true') edit
22 | .slogan-edit(v-else)
23 | .flex
24 | label.flex-1(for='sloganEdit')
25 | strong Update slogan
26 | .cancel-btn
27 | a.pointer(@click='sloganEdit = false') cancel
28 | .flex.gap-1
29 | .edit-area.flex-1
30 | textarea#sloganEdit(v-model='sloganInput')
31 | .btn-area
32 | button(:disabled='sloganLoading', @click='handleSloganEdit') Submit
33 | .card
34 | details
35 | pre {{ user.profile }}
36 |
37 | section.user-profile.no-profile(v-else)
38 | h1.name Please login
39 |
40 |
41 |
86 |
87 |
134 |
--------------------------------------------------------------------------------
/src/view/read.vue:
--------------------------------------------------------------------------------
1 |
2 | #read-container
3 | .bread-crumb
4 | router-link.button(
5 | :to='{ name: "book", params: { bookid }, query: { backTo: $route.query.backTo } }'
6 | )
7 | icon(style='margin-right: 0.5rem'): arrow-left
8 | | Back to {{ bookMeta ? `《${bookMeta.title}》` : 'book' }}
9 |
10 | .ep-pagination.flex.gap-1
11 | router-link.button.flex.flex-center(
12 | v-if='prevEp',
13 | :to='{ name: "read", params: { bookid, epsid: prevEp.order } }'
14 | )
15 | icon: chevron-left
16 | | {{ prevEp?.title || 'Previous' }}
17 | .ep-title.flex-1
18 | h1 {{ curEp?.title || 'Loading...' }}
19 | router-link.button.flex-center(
20 | v-if='nextEp',
21 | :to='{ name: "read", params: { bookid, epsid: nextEp.order } }'
22 | )
23 | | {{ nextEp?.title || 'Next' }}
24 | icon: chevron-right
25 |
26 | .pages-list
27 | .align-center(v-if='!pages.length')
28 | placeholder
29 | .page(v-else, v-for='(item, index) in pages', :id='"page-" + (index + 1)')
30 | .page-tag-container(:href='"#page-" + index')
31 | .page-tag {{ index + 1 }}
32 | Lazyload.page-img(:src='item.media.fileUrl', :key='item.id')
33 |
34 | p.align-center(v-if='pagesLeft > 0 && pages.length')
35 | a.pointer.button(@click='loadPages()') {{ isLoadingPages ? 'Loading...' : 'See more' }} ({{ pagesLeft }} pages left)
36 |
37 | .book-eps(v-if='bookEps.length && pages.length')
38 | .next-ep(v-if='nextEp', style='text-align: center')
39 | router-link.button(
40 | :to='{ name: "read", params: { bookid, epsid: nextEp.order } }'
41 | )
42 | | {{ nextEp.title }}
43 | icon: chevron-right
44 |
45 | h2 Episodes
46 | .eps-list
47 | router-link.ep-link(
48 | v-for='item in orderedEps',
49 | :to='{ name: "read", params: { bookid, epsid: item.order } }'
50 | ) {{ item.title }}
51 |
52 |
53 |
161 |
162 |
227 |
--------------------------------------------------------------------------------
/src/view/search.vue:
--------------------------------------------------------------------------------
1 |
2 | mixin pagenator
3 | book-list-paginator(
4 | v-model:page='page',
5 | v-model:sort='sort',
6 | :total-pages='totalPages'
7 | )
8 |
9 | #search-container
10 | .bread-crumb
11 | router-link.button(to='/categories')
12 | icon
13 | arrow-left
14 | |
15 | | Categories Index
16 |
17 | h1(v-if='keyword') Search『{{ keyword }}』comics (page {{ page }})
18 | h1(v-else) Advanced Search
19 |
20 | .mbox.error(v-if='error')
21 | .title Failed to get comics data
22 | p {{ error }}
23 |
24 | .loading.align-center(v-if='loading && !comics.length')
25 | placeholder
26 |
27 | section(v-if='comics.length', :class='{ "loading-cover": loading }')
28 | +pagenator
29 | books-list(:data='comics', :backTo='"/search/" + keyword')
30 | +pagenator
31 |
32 |
33 |
130 |
131 |
147 |
--------------------------------------------------------------------------------
/src/vue-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // "extends": "@vue/tsconfig/tsconfig.dom.json",
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "useDefineForClassFields": true,
6 | "module": "ESNext",
7 | "moduleResolution": "bundler",
8 | "strict": true,
9 | "jsx": "preserve",
10 | "sourceMap": true,
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "esModuleInterop": true,
14 | "lib": ["ESNext", "DOM"],
15 | "skipLibCheck": true,
16 | "paths": {
17 | "@/*": ["./src/*"]
18 | }
19 | },
20 | "include": [
21 | "auto-imports.d.ts",
22 | "components.d.ts",
23 | "env.d.ts",
24 | "src/**/*.ts",
25 | "src/**/*.d.ts",
26 | "src/**/*.tsx",
27 | "src/**/*.vue"
28 | ],
29 | "references": [
30 | ],
31 | "vueCompilerOptions": {
32 | "plugins": ["@vue/language-plugin-pug"]
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.json",
3 | "include": [
4 | "vite.config.*",
5 | "vitest.config.*",
6 | "cypress.config.*",
7 | "playwright.config.*",
8 | "node_modules/@prettier/plugin-pug/**/*.d.ts"
9 | ],
10 | "compilerOptions": {
11 | "composite": true,
12 | "types": ["node"]
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "headers": [],
3 | "rewrites": [
4 | {
5 | "source": "/api/:__PATH*",
6 | "destination": "/api/index"
7 | }
8 | ]
9 | }
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import vue from '@vitejs/plugin-vue'
3 | import { resolve } from 'node:path'
4 |
5 | const PROD = process.env.NODE_ENV === 'production'
6 |
7 | export default defineConfig({
8 | plugins: [vue()],
9 | resolve: {
10 | alias: {
11 | '@': resolve(import.meta.dirname, 'src'),
12 | },
13 | },
14 | esbuild: {
15 | drop: PROD ? ['console'] : [],
16 | },
17 | server: {
18 | host: true,
19 | },
20 | })
21 |
--------------------------------------------------------------------------------