├── .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 | "", 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 | [![Deploy with Vercel](https://vercel.com/button)](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 | 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 | 35 | 36 | 43 | 44 | 72 | -------------------------------------------------------------------------------- /src/components/BookListPaginator.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 57 | 58 | 67 | -------------------------------------------------------------------------------- /src/components/BooksList.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 13 | 14 | 23 | -------------------------------------------------------------------------------- /src/components/ExternalLink.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | 16 | 25 | -------------------------------------------------------------------------------- /src/components/GlobalFooter.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 56 | 57 | 95 | -------------------------------------------------------------------------------- /src/components/GlobalHeader.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 135 | 136 | 299 | -------------------------------------------------------------------------------- /src/components/GlobalSideNav.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 94 | 95 | 164 | -------------------------------------------------------------------------------- /src/components/Lazyload.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 53 | 54 | 71 | -------------------------------------------------------------------------------- /src/components/MBox.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | 19 | 49 | -------------------------------------------------------------------------------- /src/components/NProgress.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 44 | 45 | 60 | -------------------------------------------------------------------------------- /src/components/NaiveuiProvider.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/components/Placeholder.vue: -------------------------------------------------------------------------------- 1 | 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 | 6 | 7 | 14 | 15 | -------------------------------------------------------------------------------- /src/view/about.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 21 | 22 | -------------------------------------------------------------------------------- /src/view/auth.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 95 | 96 | 135 | -------------------------------------------------------------------------------- /src/view/book.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 184 | 185 | 245 | -------------------------------------------------------------------------------- /src/view/categories.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 71 | 72 | 122 | -------------------------------------------------------------------------------- /src/view/comics.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 105 | 106 | 122 | -------------------------------------------------------------------------------- /src/view/favourite.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 99 | 100 | 116 | -------------------------------------------------------------------------------- /src/view/index.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/view/profile.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 86 | 87 | 134 | -------------------------------------------------------------------------------- /src/view/read.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 161 | 162 | 227 | -------------------------------------------------------------------------------- /src/view/search.vue: -------------------------------------------------------------------------------- 1 | 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 | --------------------------------------------------------------------------------