Initial commit
5
.env.sample
Normal file
@@ -0,0 +1,5 @@
|
||||
VITE_ADSENSE_PUB_ID =
|
||||
VITE_GOOGLE_ANALYTICS_ID =
|
||||
VITE_GOOGLE_SEARCH_CONSOLE_VERIFICATION =
|
||||
VITE_PXIMG_BASEURL_I = /-/
|
||||
VITE_PXIMG_BASEURL_S = /~/
|
||||
111
.gitignore
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
.vercel
|
||||
|
||||
dev-test
|
||||
*.dev.*
|
||||
.vercel
|
||||
.vs
|
||||
12
.prettierrc.cjs
Normal file
@@ -0,0 +1,12 @@
|
||||
module.exports = {
|
||||
trailingComma: 'es5',
|
||||
tabWidth: 2,
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
arrowParens: 'always',
|
||||
quoteProps: 'as-needed',
|
||||
plugins: [require.resolve('@prettier/plugin-pug')],
|
||||
pugAttributeSeparator: 'as-needed',
|
||||
pugSortAttributes: 'asc',
|
||||
vueIndentScriptAndStyle: false,
|
||||
}
|
||||
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/locales"
|
||||
]
|
||||
}
|
||||
19
.vscode/vue.code-snippets
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Init vue components": {
|
||||
"scope": "vue",
|
||||
"prefix": "vue",
|
||||
"body": [
|
||||
"<template lang=\"pug\">",
|
||||
"$0",
|
||||
"</template>",
|
||||
"",
|
||||
"<script setup lang=\"ts\">",
|
||||
"import {} from 'vue'",
|
||||
"",
|
||||
"</script>",
|
||||
"",
|
||||
"<style scoped lang=\"sass\"></style>"
|
||||
],
|
||||
"description": "Init vue components"
|
||||
}
|
||||
}
|
||||
445
CHANGELOG.md
Normal file
@@ -0,0 +1,445 @@
|
||||
## [2.2.1](https://github.com/GratisNow/PixivNow/compare/2.2.0...2.2.1) (2021-07-11)
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.2.0](https://github.com/GratisNow/PixivNow/compare/2.1.1...2.2.0) (2021-07-11)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 2.2.0)([92a203d](https://github.com/GratisNow/PixivNow/commit/92a203d6affe0c0605c5b947551b3075b9c6bd82))
|
||||
|
||||
|
||||
### fix
|
||||
|
||||
* ranking(api/view)([c86d783](https://github.com/GratisNow/PixivNow/commit/c86d783e2581a172d54309ea5bf96d6ca83cb227))
|
||||
* typo([32ff15b](https://github.com/GratisNow/PixivNow/commit/32ff15b911f9b39eef2b26bd3b1954c000560502))
|
||||
* typo (#7)([155b04c](https://github.com/GratisNow/PixivNow/commit/155b04c642eb222bba8a1e8f4ef2d2eec93ed2ce)), closes [#7](https://bugs.jquery.com/ticket/7)
|
||||
|
||||
|
||||
### update
|
||||
|
||||
* update ranking([d5bbc2d](https://github.com/GratisNow/PixivNow/commit/d5bbc2dd8c55d33f6f9d6c597705ef81ac26a489))
|
||||
* view.about([12a72a3](https://github.com/GratisNow/PixivNow/commit/12a72a33f875fc320911eafd36c0b8ae8825f3c8))
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.1](https://github.com/GratisNow/PixivNow/compare/2.0.8...2.1.1) (2021-07-08)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump 2.1.0)([c4708f5](https://github.com/GratisNow/PixivNow/commit/c4708f589b53d3cd4fcd8d7871636e663e705f19))
|
||||
* bump version (bump version: 2.1.1)([5da008c](https://github.com/GratisNow/PixivNow/commit/5da008c03af479c9305abb2b9d5b5d1cfc6f77f6))
|
||||
|
||||
|
||||
### update
|
||||
|
||||
* +ranking, +userLogin (#3)([27fdc6f](https://github.com/GratisNow/PixivNow/commit/27fdc6fa84a3a66e147a63ced2c90ced95f47dae)), closes [#3](https://bugs.jquery.com/ticket/3)
|
||||
* +view.login, +restrict tag (#4)([affde4b](https://github.com/GratisNow/PixivNow/commit/affde4b76bcf6d83d10b2e7eb90943bfe662900e)), closes [#4](https://bugs.jquery.com/ticket/4)
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.8](https://github.com/GratisNow/PixivNow/compare/2.0.7...2.0.8) (2021-06-25)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 2.0.8)([a8c4cb8](https://github.com/GratisNow/PixivNow/commit/a8c4cb8f1a18b0ed02fd8fb7587da3b33cc67804))
|
||||
* rename org([1863ac1](https://github.com/GratisNow/PixivNow/commit/1863ac10e32a2128997786e3cf779919348fbedb))
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.7](https://github.com/GratisNow/PixivNow/compare/2.0.6...2.0.7) (2021-06-22)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* minor fix (bump version: 2.0.7)([b06cd96](https://github.com/GratisNow/PixivNow/commit/b06cd96ecffb198178687f8922b0453f00d03eb5))
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.6](https://github.com/GratisNow/PixivNow/compare/2.0.4...2.0.6) (2021-06-22)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* + redirects, + declare *.vue, - workflow (bump version: 2.0.6)([9f6dd20](https://github.com/GratisNow/PixivNow/commit/9f6dd20a59ea9e78a0ea7754a1fc7065af286e2c))
|
||||
* disable workflow([a42ec86](https://github.com/GratisNow/PixivNow/commit/a42ec86026b64c929886d1b826b45856469e1bd1))
|
||||
* enable workflow([45a5ef0](https://github.com/GratisNow/PixivNow/commit/45a5ef03a95afbff0015c731a16d66e3dda98cbe))
|
||||
* minor fix([0a1f31d](https://github.com/GratisNow/PixivNow/commit/0a1f31d6a5e55f89f458a828ff731a3fbd5b0809))
|
||||
* minor fix([34f2b6f](https://github.com/GratisNow/PixivNow/commit/34f2b6fec2690c1b89229de80e7903469001392b))
|
||||
* test auto deploy([7961719](https://github.com/GratisNow/PixivNow/commit/79617195c137076e3636209d0a0c5c30746586f5))
|
||||
* update github links (#2)([bf60468](https://github.com/GratisNow/PixivNow/commit/bf60468dfb085d17fcd42ed988ae244a6a6c2d2a)), closes [#2](https://bugs.jquery.com/ticket/2)
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.4](https://github.com/GratisNow/PixivNow/compare/2.0.3...2.0.4) (2021-06-21)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* minor fix (bump version: 2.0.4)([5c1074d](https://github.com/GratisNow/PixivNow/commit/5c1074d9caea2b05ba4fac6b172234d1269e995f))
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.3](https://github.com/GratisNow/PixivNow/compare/2.0.2...2.0.3) (2021-06-15)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 2.0.3)([1ed5303](https://github.com/GratisNow/PixivNow/commit/1ed53031383bf9b6c28e1cbeecd4ab9ea6c7ee99))
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.2](https://github.com/GratisNow/PixivNow/compare/2.0.1...2.0.2) (2021-06-15)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 2.0.2)([4a63cb0](https://github.com/GratisNow/PixivNow/commit/4a63cb03ae4607cf6de1d17e833db263356005d4))
|
||||
|
||||
|
||||
### feat
|
||||
|
||||
* + fontawesome, + image progress([7357714](https://github.com/GratisNow/PixivNow/commit/735771468c56f077d3f327d6207c7e760842e247))
|
||||
* + icons([25e591c](https://github.com/GratisNow/PixivNow/commit/25e591c603f424a2aa2d7df750cf45315a92d995))
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.1](https://github.com/GratisNow/PixivNow/compare/2.0.0...2.0.1) (2021-06-13)
|
||||
|
||||
|
||||
### feat
|
||||
|
||||
* + NProgress (bump version: 2.0.1)([04df6fd](https://github.com/GratisNow/PixivNow/commit/04df6fde97e13ba46e081bbd02417185b45c6899))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.17...2.0.0) (2021-06-13)
|
||||
|
||||
|
||||
### update
|
||||
|
||||
* release 2.0 (bump version: 2.0.0)([7e92fb7](https://github.com/GratisNow/PixivNow/commit/7e92fb7794ede97e7fe5968c7892358d7af8019d))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.17](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.16...2.0.0-alpha.17) (2021-06-13)
|
||||
|
||||
|
||||
### update
|
||||
|
||||
* header (bump version: 2.0.0-alpha.17)([69a0d59](https://github.com/GratisNow/PixivNow/commit/69a0d590c0efbb4e59452439f1222c2515485be7))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.16](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.15...2.0.0-alpha.16) (2021-06-13)
|
||||
|
||||
|
||||
### hotfix
|
||||
|
||||
* user.background is undefined (bump version: 2.0.0-alpha.16)([382baa5](https://github.com/GratisNow/PixivNow/commit/382baa505724d447d3e8f089840a940846d40517))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.15](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.14...2.0.0-alpha.15) (2021-06-13)
|
||||
|
||||
|
||||
### update
|
||||
|
||||
* users (bump version: 2.0.0-alpha.15)([13dbdbe](https://github.com/GratisNow/PixivNow/commit/13dbdbe8aec840d61ae79c6db4528efc8dc6dfb7))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.14](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.13...2.0.0-alpha.14) (2021-06-13)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 2.0.0-alpha.14)([608f79c](https://github.com/GratisNow/PixivNow/commit/608f79c82cc0341152a3a06587b812c676a3ba7c))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.13](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.12...2.0.0-alpha.13) (2021-06-13)
|
||||
|
||||
|
||||
### hotfix
|
||||
|
||||
* styles, api (bump version: 2.0.0-alpha.13)([03257fc](https://github.com/GratisNow/PixivNow/commit/03257fc4b0cc4f52b982992156a955edbfd0123e))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.12](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.11...2.0.0-alpha.12) (2021-06-13)
|
||||
|
||||
|
||||
### hotfix
|
||||
|
||||
* link style, imgProxy (bump version: 2.0.0-alpha.12)([5f286c2](https://github.com/GratisNow/PixivNow/commit/5f286c29ca10f623f6cd9372636a5eb7c6bd315c))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.11](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.10...2.0.0-alpha.11) (2021-06-13)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* minor fix (bump version: 2.0.0-alpha.11)([572a373](https://github.com/GratisNow/PixivNow/commit/572a37392c3c19d71a676d3bef0f16d40a4365d1))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.10](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.9...2.0.0-alpha.10) (2021-06-13)
|
||||
|
||||
|
||||
### fix
|
||||
|
||||
* artwork.index styles (bump version: 2.0.0-alpha.10)([62b6c05](https://github.com/GratisNow/PixivNow/commit/62b6c05fae34a014678827ac14cc1c9a2c380aa9))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.9](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.8...2.0.0-alpha.9) (2021-06-13)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 2.0.0-alpha.9)([3b1f57e](https://github.com/GratisNow/PixivNow/commit/3b1f57ead4c783d4dc32d6cf14b7444ffb2b14aa))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.8](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.7...2.0.0-alpha.8) (2021-06-13)
|
||||
|
||||
|
||||
### update
|
||||
|
||||
* update styles, + seaech pagenator (bump version: 2.0.0-alpha.8)([4773dfa](https://github.com/GratisNow/PixivNow/commit/4773dfac136831daab28201ed68e06bc7a00a755))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.7](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.6...2.0.0-alpha.7) (2021-06-12)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* update package.json([09f09b9](https://github.com/GratisNow/PixivNow/commit/09f09b9853e9d6dd67b41936bd765fc68a864355))
|
||||
|
||||
|
||||
### feat
|
||||
|
||||
* + search, update gallery, using sass & pug (bump version: 2.0.0-alpha.7)([1386064](https://github.com/GratisNow/PixivNow/commit/13860647a417dcc00990cdd4d04d5038c8d549f5))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.6](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.5...2.0.0-alpha.6) (2021-06-10)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* split modules (bump version: 2.0.0-alpha.6)([d8e6ae6](https://github.com/GratisNow/PixivNow/commit/d8e6ae61be49e0571cf70f9a025a693798463e2f))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.5](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.4...2.0.0-alpha.5) (2021-06-10)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* update styles (bump version: 2.0.0-alpha.5)([9db7b11](https://github.com/GratisNow/PixivNow/commit/9db7b118592d1235e246db0e4acec6eaad729664))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.4](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.3...2.0.0-alpha.4) (2021-06-10)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 2.0.0-alpha.4)([4a87c2e](https://github.com/GratisNow/PixivNow/commit/4a87c2e65834cf5c59b99cffc8460185ab3a2a27))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.3](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.2...2.0.0-alpha.3) (2021-06-09)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* minor fix (bump version: 2.0.0-alpha.3)([3e5aa50](https://github.com/GratisNow/PixivNow/commit/3e5aa501e7daaca3851427d7d8a150bc484908cf))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.2](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.1...2.0.0-alpha.2) (2021-06-09)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* minor fix (bump version: 2.0.0-alpha.2)([96bd745](https://github.com/GratisNow/PixivNow/commit/96bd745f37f5cc1a1b42cac4830199f2ca91728f))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.1](https://github.com/GratisNow/PixivNow/compare/2.0.0-alpha.0...2.0.0-alpha.1) (2021-06-09)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 2.0.0-alpha.1)([5dff17a](https://github.com/GratisNow/PixivNow/commit/5dff17ad9fc49587139c9b7e19904735462f87a1))
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.0.0-alpha.0](https://github.com/GratisNow/PixivNow/compare/1.1.4...2.0.0-alpha.0) (2021-06-09)
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.1.4](https://github.com/GratisNow/PixivNow/compare/1.1.3...1.1.4) (2021-06-09)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 1.1.4)([7337437](https://github.com/GratisNow/PixivNow/commit/73374374d5552e212eb0125048ce8ce047f75b7e))
|
||||
* update README([0782b81](https://github.com/GratisNow/PixivNow/commit/0782b81a02d5ceea1abd330832708864691caba4))
|
||||
|
||||
|
||||
### fix
|
||||
|
||||
* Access-Control-Allow-Origin([12b3163](https://github.com/GratisNow/PixivNow/commit/12b3163abb0eba54274eccc63bb15b92007286a4))
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.1.3](https://github.com/GratisNow/PixivNow/compare/1.1.2...1.1.3) (2021-06-06)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 1.1.3)([87d9878](https://github.com/GratisNow/PixivNow/commit/87d98783a96e4452e0266e3fd7f125800b7a9efc))
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.1.2](https://github.com/GratisNow/PixivNow/compare/1.1.1...1.1.2) (2021-06-06)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 1.1.2)([43b543f](https://github.com/GratisNow/PixivNow/commit/43b543f1f2d9ee2cda0524cec721204f2c6085c5))
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.1.1](https://github.com/GratisNow/PixivNow/compare/1.1.0...1.1.1) (2021-06-06)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 1.1.1)([3cdf2a4](https://github.com/GratisNow/PixivNow/commit/3cdf2a4bd4f59c719dafded838005012b992d979))
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.1.0](https://github.com/GratisNow/PixivNow/compare/1.0.6...1.1.0) (2021-06-06)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* rewrite (bump version: 1.1.0)([8d10a09](https://github.com/GratisNow/PixivNow/commit/8d10a09190acc7fb51a873b2822badbd35a09fb8))
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.0.6](https://github.com/GratisNow/PixivNow/compare/1.0.5...1.0.6) (2021-06-06)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* minor fix (bump version: 1.0.6)([52953fe](https://github.com/GratisNow/PixivNow/commit/52953fe96e1e321390a10eaed87ec20f44c40bf6))
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.0.5](https://github.com/GratisNow/PixivNow/compare/1.0.4...1.0.5) (2021-06-06)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 1.0.5)([c214151](https://github.com/GratisNow/PixivNow/commit/c214151cd369ee0549baea6f60d067873e253305))
|
||||
* minor fix([477b64b](https://github.com/GratisNow/PixivNow/commit/477b64bcf2a0f93d148b3e1d898ae34eba7e0b0d))
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.0.4](https://github.com/GratisNow/PixivNow/compare/1.0.3...1.0.4) (2021-06-05)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* update README (bump version: 1.0.4)([3257778](https://github.com/GratisNow/PixivNow/commit/3257778cf82cb0c8e6f66d5b207ef41a952b9b97))
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.0.3](https://github.com/GratisNow/PixivNow/compare/1.0.2...1.0.3) (2021-06-05)
|
||||
|
||||
|
||||
### update
|
||||
|
||||
* + ajax, + user (bump version: 1.0.3)([e04d2b5](https://github.com/GratisNow/PixivNow/commit/e04d2b5294f7ba1c7ab138aea80adbf72c34fdb3))
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.0.2](https://github.com/GratisNow/PixivNow/compare/1.0.1...1.0.2) (2021-06-05)
|
||||
|
||||
|
||||
### update
|
||||
|
||||
* + byUID, + byTagName (bump version: 1.0.2)([5c20b42](https://github.com/GratisNow/PixivNow/commit/5c20b4270b89d2b4c7ce896b5fec260536c8d88a))
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.0.1](https://github.com/GratisNow/PixivNow/compare/b81baf2bf5d89b0f7f6d0cc0119761a00a5f0c0b...1.0.1) (2021-06-05)
|
||||
|
||||
|
||||
### chore
|
||||
|
||||
* bump version (bump version: 1.0.1)([fcc7704](https://github.com/GratisNow/PixivNow/commit/fcc7704f11c555b6ceeb27edb30bb0155c5d017f))
|
||||
* commit([b81baf2](https://github.com/GratisNow/PixivNow/commit/b81baf2bf5d89b0f7f6d0cc0119761a00a5f0c0b))
|
||||
* imgProxy([be1d495](https://github.com/GratisNow/PixivNow/commit/be1d495b7b6f4c63c66f942d9ab5e5794e58f534))
|
||||
* minor fix([2bfc8ca](https://github.com/GratisNow/PixivNow/commit/2bfc8ca5133d46c8ff9cafffb8081c79eb93056d))
|
||||
* minor fix([9179bbf](https://github.com/GratisNow/PixivNow/commit/9179bbf7ff7032b2d87479198bd900eacb1bb682))
|
||||
* minor fix([68e3b4a](https://github.com/GratisNow/PixivNow/commit/68e3b4ace98f67c4ef640e84787f7dad6b447fcc))
|
||||
* minor fix([6bd5ef9](https://github.com/GratisNow/PixivNow/commit/6bd5ef9025144b3fe4aae4542276d621582553f5))
|
||||
* minor fix([04ba185](https://github.com/GratisNow/PixivNow/commit/04ba185c0151e8b49e6aac826ec1d8315cdddad0))
|
||||
* minor fix([a0e5bed](https://github.com/GratisNow/PixivNow/commit/a0e5bed8aba97fe98045063c942efbafb649c827))
|
||||
* minor fix([3b100cc](https://github.com/GratisNow/PixivNow/commit/3b100cc8d54cad0c20de2929af17baad396088b4))
|
||||
* test proxy([7144d94](https://github.com/GratisNow/PixivNow/commit/7144d941260cb993db7437fc96b8868670d61cc8))
|
||||
|
||||
|
||||
|
||||
|
||||
11
DEV_NOTES/followUser.http
Normal file
@@ -0,0 +1,11 @@
|
||||
POST https://www.pixiv.net/bookmark_add.php
|
||||
content-type: application/x-www-form-urlencoded
|
||||
|
||||
{
|
||||
"mode": "add",
|
||||
"type": "user",
|
||||
"user_id": "15552366",
|
||||
"tag": "",
|
||||
"restrict": "0",
|
||||
"format": "json"
|
||||
}
|
||||
43
DEV_NOTES/notification.http
Normal file
@@ -0,0 +1,43 @@
|
||||
GET https://www.pixiv.net/ajax/notification
|
||||
|
||||
[HTTP/1.1 200 OK]
|
||||
application/json
|
||||
{
|
||||
"error": false,
|
||||
"message": "",
|
||||
"body": {
|
||||
"items": [
|
||||
{
|
||||
"id": 698556583,
|
||||
"type": "bookmarked",
|
||||
"unread": true,
|
||||
"notifiedAt": "2021-07-17T12:22:25+09:00",
|
||||
"linkUrl": "/bookmark_detail.php?illust_id=86468782",
|
||||
"iconUrl": "https://i.pximg.net/c/128x128/img-master/img/2020/12/23/05/25/48/86468782_p0_square1200.jpg",
|
||||
"targetBlank": false,
|
||||
"isProfileIcon": false,
|
||||
"content": "2以上的用户把你的作品加入收藏了: <span>\"[草图] 原创角色 Kunika\"</span>"
|
||||
}
|
||||
],
|
||||
"remaining_unread_count": 0,
|
||||
"imageResponseCount": 0,
|
||||
"quotationCount": 0,
|
||||
"isNotAuthorized": false,
|
||||
"filter": {
|
||||
"reactions": [
|
||||
"bookmarked",
|
||||
"nice",
|
||||
"commented",
|
||||
"tagged",
|
||||
"content_response",
|
||||
"favorited",
|
||||
"group_content_reference",
|
||||
"group_like",
|
||||
"group_comment",
|
||||
"received_stacc_message",
|
||||
"series_watchlist_watched"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9874
DEV_NOTES/searchSuggestion.http
Normal file
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
66
README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
Pixiv Service Proxy
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FFreeNowOrg%2FPixivNow&demo-title=PixivNow)
|
||||
|
||||
免费部署一个一模一样的服务步骤很简单——点上面的按钮然后一直下一步即可。<br>
|
||||
各位爹不要再爬我的服务了好吗?100GB/h的额度都能被您爬完了,我自己还怎么看色图呢?<br>
|
||||
好自为之喵,不要怪我骂人喵。
|
||||
|
||||
</div>
|
||||
|
||||
## API
|
||||
|
||||
您可以使用以下方式传递用户 token 来鉴权:
|
||||
|
||||
- Header 以`Authorization`传递
|
||||
- Cookie 以键名`PHPSESSID`传递
|
||||
|
||||
请求路径 `/ajax/*` 的返回结果与 `https://pixiv.net/ajax/*` 的行为完全一致。
|
||||
|
||||
以下列举部分 PixivNow 的独特接口:
|
||||
|
||||
### `/api/illust/random`
|
||||
|
||||
返回随机图片,其实是 `/ajax/illust/discovery` 的语法糖,也支持直接返回图片。
|
||||
|
||||
- `max` `{number}` 返回图片的个数
|
||||
- `mode` `{'all' | 'safe' | 'r18'}` 其中 `r18` 只有在登录状态且参数设置允许时才会返回
|
||||
- `format` `{'image' | 'json'}` 返回的格式,如果 `Accept` 包含 `image` 则预设为 `image`
|
||||
|
||||
### `/api/ranking`
|
||||
|
||||
是 `/ranking.php` 的重定向。
|
||||
|
||||
### `/user`
|
||||
|
||||
通过传入的 token,以 json 格式返回源站 `<meta name="global-data">` 中的用户信息。
|
||||
|
||||
## 图片代理
|
||||
|
||||
本站的图片使用 CloudFlare Workers 进行代理,可以直接访问欣赏众多插画。
|
||||
|
||||
但是由于遭遇了大量不明流量,因此我们暂时开启了图片代理服务的防盗链。如果您有自行部署整站的需要,可以通过在Vercel环境变量`VITE_PXIMG_BASEURL_I`中传入反代url(首选),或修改 `vercel.json`中的对于图片的重定向配置,图片的请求路径与源站完全一致。
|
||||
|
||||
---
|
||||
|
||||
_For communication and learning only._
|
||||
|
||||
**All data & pictures from query:** ©Pixiv & Illusts' authors
|
||||
|
||||
> Copyright 2021 PixivNow
|
||||
>
|
||||
> Licensed under the Apache License, Version 2.0 (the "License");<br>
|
||||
> you may not use this file except in compliance with the License.<br>
|
||||
> You may obtain a copy of the License at
|
||||
>
|
||||
> http://www.apache.org/licenses/LICENSE-2.0
|
||||
>
|
||||
> Unless required by applicable law or agreed to in writing, software<br>
|
||||
> distributed under the License is distributed on an "AS IS" BASIS,<br>
|
||||
> WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.<br>
|
||||
> See the License for the specific language governing permissions and<br>
|
||||
> limitations under the License.
|
||||
43
api/http.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import escapeRegExp from 'lodash.escaperegexp'
|
||||
import { ajax } from './utils.js'
|
||||
|
||||
export default async function (req: VercelRequest, res: VercelResponse) {
|
||||
if (!isAccepted(req)) {
|
||||
return res.status(403).send('403')
|
||||
}
|
||||
|
||||
try {
|
||||
const { __PREFIX, __PATH } = req.query
|
||||
const { data } = await ajax({
|
||||
method: req.method ?? 'GET',
|
||||
url: `/${encodeURI(`${__PREFIX}${__PATH ? '/' + __PATH : ''}`)}`,
|
||||
params: req.query ?? {},
|
||||
data: req.body || undefined,
|
||||
headers: req.headers as Record<string, string>,
|
||||
})
|
||||
res.status(200).send(data)
|
||||
} catch (e: any) {
|
||||
res.status(e?.response?.status || 500).send(e?.response?.data || e)
|
||||
}
|
||||
}
|
||||
|
||||
function isAccepted(req: VercelRequest) {
|
||||
const { UA_BLACKLIST = '[]' } = process.env
|
||||
try {
|
||||
const list: string[] = JSON.parse(UA_BLACKLIST)
|
||||
const ua = req.headers['user-agent'] ?? ''
|
||||
return (
|
||||
!!ua &&
|
||||
Array.isArray(list) &&
|
||||
(list.length > 0
|
||||
? !new RegExp(
|
||||
`(${list.map((str) => escapeRegExp(str)).join('|')})`,
|
||||
'gi'
|
||||
).test(ua)
|
||||
: true)
|
||||
)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
82
api/image.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import axios from 'axios'
|
||||
import { USER_AGENT } from './utils.js'
|
||||
|
||||
export default async (req: VercelRequest, res: VercelResponse) => {
|
||||
const { __PREFIX, __PATH } = req.query
|
||||
if (!__PREFIX || !__PATH) {
|
||||
return res.status(400).send({ message: 'Missing param(s)' })
|
||||
}
|
||||
|
||||
let url = ''
|
||||
|
||||
switch (__PREFIX) {
|
||||
case '-': {
|
||||
url = `https://i.pximg.net/${__PATH}`
|
||||
break
|
||||
}
|
||||
case '~': {
|
||||
url = `https://s.pximg.net/${__PATH}`
|
||||
break
|
||||
}
|
||||
default:
|
||||
return res.status(400).send({ message: 'Invalid request' })
|
||||
}
|
||||
|
||||
const proxyHeaders = [
|
||||
'accept',
|
||||
'accept-encoding',
|
||||
'accept-language',
|
||||
'range',
|
||||
'if-range',
|
||||
'if-none-match',
|
||||
'if-modified-since',
|
||||
'cache-control',
|
||||
]
|
||||
|
||||
const headers = {} as Record<string, string>
|
||||
for (const h of proxyHeaders) {
|
||||
if (typeof req.headers[h] === 'string') {
|
||||
headers[h] = req.headers[h]
|
||||
}
|
||||
}
|
||||
Object.assign(headers, {
|
||||
referer: 'https://www.pixiv.net/',
|
||||
'user-agent': USER_AGENT,
|
||||
})
|
||||
|
||||
console.log('Proxy image:', url, headers)
|
||||
|
||||
return axios
|
||||
.get<ArrayBuffer>(url, {
|
||||
responseType: 'arraybuffer',
|
||||
headers,
|
||||
})
|
||||
.then(
|
||||
({ data, headers, status }) => {
|
||||
const exposeHeaders = [
|
||||
'content-type',
|
||||
'content-length',
|
||||
'cache-control',
|
||||
'content-disposition',
|
||||
'last-modified',
|
||||
'etag',
|
||||
'accept-ranges',
|
||||
'content-range',
|
||||
'vary',
|
||||
]
|
||||
for (const h of exposeHeaders) {
|
||||
if (typeof headers[h] === 'string') {
|
||||
res.setHeader(h, headers[h])
|
||||
}
|
||||
}
|
||||
res.status(status).send(Buffer.from(data))
|
||||
},
|
||||
(err) => {
|
||||
console.error('Image proxy error:', err)
|
||||
return res
|
||||
.status(err?.response?.status || 500)
|
||||
.send(err?.response?.data || err)
|
||||
}
|
||||
)
|
||||
}
|
||||
50
api/random.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import { formatInTimeZone } from 'date-fns-tz'
|
||||
import { PXIMG_BASEURL_I, ajax } from './utils.js'
|
||||
import { Artwork } from '../src/types/Artworks.js'
|
||||
|
||||
type ArtworkOrAd = Artwork | { isAdContainer: boolean }
|
||||
|
||||
export default async (req: VercelRequest, res: VercelResponse) => {
|
||||
const requestImage =
|
||||
(req.headers.accept?.includes('image') || req.query.format === 'image') &&
|
||||
req.query.format !== 'json'
|
||||
try {
|
||||
const data: { illusts?: ArtworkOrAd[] } = (
|
||||
await ajax({
|
||||
url: '/ajax/illust/discovery',
|
||||
params: {
|
||||
mode: req.query.mode ?? 'safe',
|
||||
max: requestImage ? '1' : req.query.max ?? '18',
|
||||
},
|
||||
headers: req.headers,
|
||||
})
|
||||
).data
|
||||
const illusts = (data.illusts ?? []).filter((value): value is Artwork =>
|
||||
Object.keys(value).includes('id')
|
||||
)
|
||||
illusts.forEach((value) => {
|
||||
const middle = `img/${formatInTimeZone(
|
||||
value.updateDate,
|
||||
'Asia/Tokyo',
|
||||
'yyyy/MM/dd/HH/mm/ss'
|
||||
)}/${value.id}`
|
||||
value.urls = {
|
||||
mini: `${PXIMG_BASEURL_I}c/48x48/img-master/${middle}_p0_square1200.jpg`,
|
||||
thumb: `${PXIMG_BASEURL_I}c/250x250_80_a2/img-master/${middle}_p0_square1200.jpg`,
|
||||
small: `${PXIMG_BASEURL_I}c/540x540_70/img-master/${middle}_p0_master1200.jpg`,
|
||||
regular: `${PXIMG_BASEURL_I}img-master/${middle}_p0_master1200.jpg`,
|
||||
original: `${PXIMG_BASEURL_I}img-original/${middle}_p0.jpg`,
|
||||
}
|
||||
})
|
||||
if (requestImage) {
|
||||
res.redirect(illusts[0].urls.regular)
|
||||
return
|
||||
} else {
|
||||
res.send(illusts)
|
||||
return
|
||||
}
|
||||
} catch (e: any) {
|
||||
res.status(e?.response?.status ?? 500).send(e?.response?.data ?? e)
|
||||
}
|
||||
}
|
||||
137
api/user.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import { CheerioAPI, load } from 'cheerio'
|
||||
import { ajax, replacePximgUrlsInObject } from './utils.js'
|
||||
|
||||
export default async (req: VercelRequest, res: VercelResponse) => {
|
||||
const token = req.cookies.PHPSESSID || req.query.token
|
||||
if (!token) {
|
||||
return res.status(403).send({ message: '未配置用户密钥' })
|
||||
}
|
||||
|
||||
ajax
|
||||
.get('/', { params: req.query, headers: req.headers })
|
||||
.then(async ({ data }) => {
|
||||
const $ = load(data)
|
||||
|
||||
let meta: { userData: any; token: string }
|
||||
const $legacyGlobalMeta = $('meta[name="global-data"]')
|
||||
const $nextDataScript = $('script#__NEXT_DATA__')
|
||||
|
||||
try {
|
||||
if ($legacyGlobalMeta.length > 0) {
|
||||
meta = resolveLegacyGlobalMeta($)
|
||||
} else if ($nextDataScript.length > 0) {
|
||||
meta = resolveNextData($)
|
||||
} else {
|
||||
throw new Error('未知的元数据类型', {
|
||||
cause: {
|
||||
error: new TypeError('No valid resolver found'),
|
||||
meta: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
return res.status(401).send({
|
||||
message: error.message,
|
||||
cause: error.cause,
|
||||
})
|
||||
}
|
||||
|
||||
res.setHeader('cache-control', 'no-cache')
|
||||
res.setHeader(
|
||||
'set-cookie',
|
||||
`CSRFTOKEN=${meta.token}; path=/; secure; sameSite=Lax`
|
||||
)
|
||||
res.send(replacePximgUrlsInObject(meta))
|
||||
})
|
||||
.catch((err) => {
|
||||
return res
|
||||
.status(err?.response?.status || 500)
|
||||
.send(err?.response?.data || err)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLegacyGlobalMeta($: CheerioAPI): {
|
||||
userData: any
|
||||
token: string
|
||||
} {
|
||||
const $meta = $('meta[name="global-data"]')
|
||||
if ($meta.length === 0 || !$meta.attr('content')) {
|
||||
throw new Error('无效的用户密钥', {
|
||||
cause: {
|
||||
error: new TypeError('No global-data meta found'),
|
||||
meta: $meta.prop('outerHTML'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let meta: any
|
||||
try {
|
||||
meta = JSON.parse($meta.attr('content') as string)
|
||||
} catch (error) {
|
||||
throw new Error('解析元数据时出错', {
|
||||
cause: {
|
||||
error,
|
||||
meta: $meta.attr('content'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!meta.userData) {
|
||||
throw new Error('无法获取登录状态', {
|
||||
cause: {
|
||||
error: new TypeError('userData is not defined'),
|
||||
meta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
userData: meta.userData,
|
||||
token: meta.token || '',
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNextData($: CheerioAPI): {
|
||||
userData: any
|
||||
token: string
|
||||
} {
|
||||
const $nextDataScript = $('script#__NEXT_DATA__')
|
||||
if ($nextDataScript.length === 0) {
|
||||
throw new Error('无法获取元数据', {
|
||||
cause: {
|
||||
error: new TypeError('No #__NEXT_DATA__ script found'),
|
||||
meta: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let nextData: any
|
||||
let perloadState: any
|
||||
try {
|
||||
nextData = JSON.parse($nextDataScript.text())
|
||||
perloadState = JSON.parse(
|
||||
nextData?.props?.pageProps?.serverSerializedPreloadedState
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error('解析元数据时出错', {
|
||||
cause: {
|
||||
error,
|
||||
meta: $nextDataScript.text(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const userData = perloadState?.userData?.self
|
||||
if (!userData) {
|
||||
throw new Error('意料外的元数据', {
|
||||
cause: {
|
||||
error: new TypeError('userData is not defined'),
|
||||
meta: nextData,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const token = perloadState?.api?.token || ''
|
||||
return { userData, token }
|
||||
}
|
||||
186
api/utils.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import axios from 'axios'
|
||||
import colors from 'picocolors'
|
||||
|
||||
// HTTP handler
|
||||
export default async function (req: VercelRequest, res: VercelResponse) {
|
||||
res.status(404).send({
|
||||
error: true,
|
||||
message: 'Not Found',
|
||||
body: null,
|
||||
})
|
||||
}
|
||||
|
||||
export const PROD = process.env.NODE_ENV === 'production'
|
||||
export const DEV = !PROD
|
||||
export const USER_AGENT =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0'
|
||||
export const PXIMG_BASEURL_I = (() => {
|
||||
const i = process.env.VITE_PXIMG_BASEURL_I
|
||||
return i ? i.replace(/\/$/, '') + '/' : 'https://i.pximg.net/'
|
||||
})()
|
||||
export const PXIMG_BASEURL_S = (() => {
|
||||
const s = process.env.VITE_PXIMG_BASEURL_S
|
||||
return s ? s.replace(/\/$/, '') + '/' : 'https://s.pximg.net/'
|
||||
})()
|
||||
|
||||
export class CookieUtils {
|
||||
static toJSON(raw: string) {
|
||||
return Object.fromEntries(new URLSearchParams(raw.replace(/;\s*/g, '&')))
|
||||
}
|
||||
static toString(obj: any) {
|
||||
return Object.keys(obj)
|
||||
.map((i) => `${i}=${obj[i]}`)
|
||||
.join(';')
|
||||
}
|
||||
}
|
||||
|
||||
export const ajax = axios.create({
|
||||
baseURL: 'https://www.pixiv.net/',
|
||||
params: {},
|
||||
headers: {
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
timeout: 9 * 1000,
|
||||
})
|
||||
ajax.interceptors.request.use((ctx) => {
|
||||
// 去除内部参数
|
||||
ctx.params = ctx.params || {}
|
||||
delete ctx.params.__PATH
|
||||
delete ctx.params.__PREFIX
|
||||
|
||||
const cookies = CookieUtils.toJSON(ctx.headers.cookie || '')
|
||||
const csrfToken = ctx.headers['x-csrf-token'] ?? cookies.CSRFTOKEN ?? ''
|
||||
// 强制覆写部分 headers
|
||||
ctx.headers = ctx.headers || {}
|
||||
ctx.headers.host = 'www.pixiv.net'
|
||||
ctx.headers.origin = 'https://www.pixiv.net'
|
||||
ctx.headers.referer = 'https://www.pixiv.net/'
|
||||
ctx.headers['user-agent'] = USER_AGENT
|
||||
ctx.headers['accept-language'] ??=
|
||||
'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6'
|
||||
csrfToken && (ctx.headers['x-csrf-token'] = csrfToken)
|
||||
|
||||
if (DEV) {
|
||||
console.info(
|
||||
colors.green(`[${ctx.method?.toUpperCase()}] <`),
|
||||
colors.cyan(ctx.url || '')
|
||||
)
|
||||
console.info({
|
||||
params: ctx.params,
|
||||
data: ctx.data,
|
||||
cookies,
|
||||
})
|
||||
}
|
||||
|
||||
return ctx
|
||||
})
|
||||
ajax.interceptors.response.use((ctx) => {
|
||||
typeof ctx.data === 'object' &&
|
||||
(ctx.data = replacePximgUrlsInObject(ctx.data?.body ?? ctx.data))
|
||||
if (DEV) {
|
||||
const out: string =
|
||||
typeof ctx.data === 'object'
|
||||
? JSON.stringify(ctx.data, null, 2)
|
||||
: ctx.data.toString().trim()
|
||||
console.info(
|
||||
colors.green('[SEND] >'),
|
||||
colors.cyan(ctx.request?.path?.replace('https://www.pixiv.net', '')),
|
||||
`\n${colors.yellow(typeof ctx.data)} ${
|
||||
out.length >= 200 ? out.slice(0, 200).trim() + '\n...' : out
|
||||
}`
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
})
|
||||
|
||||
export function replacePximgUrlsInString(str: string): string {
|
||||
if (!str.includes('pximg.net')) return str
|
||||
return str
|
||||
.replaceAll('https://i.pximg.net/', PXIMG_BASEURL_I)
|
||||
.replaceAll('https://s.pximg.net/', PXIMG_BASEURL_S)
|
||||
}
|
||||
|
||||
export function replacePximgUrlsInObject(
|
||||
obj: Record<string, any> | string
|
||||
): Record<string, any> | string {
|
||||
if (typeof obj === 'string') return replacePximgUrlsInString(obj)
|
||||
|
||||
return deepReplaceString(obj, replacePximgUrlsInString)
|
||||
}
|
||||
|
||||
function isObject(value: any): value is Record<string, any> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
export function deepReplaceString<T>(
|
||||
obj: T,
|
||||
replacer: (value: string) => string
|
||||
): T {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((value) =>
|
||||
deepReplaceString(value, replacer)
|
||||
) as unknown as T
|
||||
} else if (isObject(obj)) {
|
||||
if (
|
||||
['arraybuffer', 'blob', 'formdata'].includes(
|
||||
obj.constructor.name.toLowerCase()
|
||||
)
|
||||
) {
|
||||
return obj
|
||||
}
|
||||
const result: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = deepReplaceString(value, replacer)
|
||||
}
|
||||
return result as T
|
||||
} else if (typeof obj === 'string') {
|
||||
return replacer(obj) as unknown as T
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
export function safelyStringify(value: any, space?: number) {
|
||||
const visited = new WeakSet()
|
||||
|
||||
const replacer = (key: string, val: any) => {
|
||||
// 处理 BigInt
|
||||
if (typeof val === 'bigint') {
|
||||
return val.toString()
|
||||
}
|
||||
|
||||
// 处理 Set
|
||||
if (val instanceof Set) {
|
||||
return Array.from(val)
|
||||
}
|
||||
|
||||
// 处理 Map
|
||||
if (val instanceof Map) {
|
||||
return Array.from(val.entries())
|
||||
}
|
||||
|
||||
// 处理 function
|
||||
if (typeof val === 'function') {
|
||||
return val.toString()
|
||||
}
|
||||
|
||||
// 处理自循环引用
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
if (visited.has(val)) {
|
||||
return '<circular>'
|
||||
}
|
||||
visited.add(val)
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
return JSON.stringify(value, replacer, space)
|
||||
}
|
||||
|
||||
JSON.safelyStringify = safelyStringify
|
||||
declare global {
|
||||
interface JSON {
|
||||
safelyStringify: typeof safelyStringify
|
||||
}
|
||||
}
|
||||
45
index.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PixivNow</title>
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<!-- <script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=%VITE_GOOGLE_ANALYTICS_ID%"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || []
|
||||
function gtag() {
|
||||
dataLayer.push(arguments)
|
||||
}
|
||||
gtag('js', new Date())
|
||||
gtag('config', '%VITE_GOOGLE_ANALYTICS_ID%')
|
||||
</script> -->
|
||||
<!-- Umami Analytics -->
|
||||
<script defer src="https://cloud.umami.is/script.js" data-website-id="842d980c-5e11-4834-a2a8-5daaa285ce66"></script>
|
||||
<!-- Google Search Console -->
|
||||
<meta
|
||||
name="google-site-verification"
|
||||
content="%VITE_GOOGLE_SEARCH_CONSOLE_VERIFICATION%"
|
||||
/>
|
||||
<!-- Google AdSense -->
|
||||
<script
|
||||
async
|
||||
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=%VITE_ADSENSE_PUB_ID%"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- jQuery -->
|
||||
<!-- <script src="https://unpkg.com/jquery@3.7.1/dist/jquery.js"></script> -->
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
This site requires JavaScript enabled. Please check your browser settings.
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
68
package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "pixiv-now",
|
||||
"version": "3.5.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/FreeNowOrg/PixivNow.git",
|
||||
"contributors": [
|
||||
"Dragon-Fish <824399619@qq.com>",
|
||||
"AlPha5130 <34984380+AlPha5130@users.noreply.github.com>"
|
||||
],
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"serve": "vercel dev",
|
||||
"build": "vite build",
|
||||
"preview": "vercel deploy",
|
||||
"changelog": "conventional-changelog -p jquery -i CHANGELOG.md -s -r 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.11.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"fflate": "^0.8.2",
|
||||
"gif.js": "^0.2.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"modern-mp4": "^0.2.0",
|
||||
"naive-ui": "^2.42.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.20",
|
||||
"vue-gtag": "^3.5.2",
|
||||
"vue-i18n": "^11.1.11",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-waterfall-plugin-next": "^2.6.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dragon-fish/sensitive-words-filter": "^2.0.1",
|
||||
"@iconify-json/fa-solid": "^1.2.2",
|
||||
"@prettier/plugin-pug": "^3.4.2",
|
||||
"@tabler/icons-vue": "^3.34.1",
|
||||
"@types/gif.js": "^0.2.5",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/lodash.escaperegexp": "^4.1.9",
|
||||
"@types/node": "^22.17.2",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@vercel/node": "^5.3.13",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/language-plugin-pug": "^3.0.6",
|
||||
"@vueuse/core": "^13.7.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"conventional-changelog-cli": "^5.0.0",
|
||||
"cookie": "^1.0.2",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"prettier": "^3.6.2",
|
||||
"pug": "^3.0.3",
|
||||
"sass": "^1.90.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.2",
|
||||
"unplugin-auto-import": "^20.0.0",
|
||||
"unplugin-icons": "^22.2.0",
|
||||
"unplugin-vue-components": "^29.0.0",
|
||||
"vercel": "^46.0.2",
|
||||
"vite": "^7.1.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.18.1"
|
||||
}
|
||||
5185
pnpm-lock.yaml
generated
Normal file
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
295
public/images/spinner.svg
Normal file
@@ -0,0 +1,295 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" height="45px" width="45px" class="ms-spinner">
|
||||
<g class="circle-group" transform="translate(22.5,22.5)">
|
||||
<circle r="20" fill="none" stroke="#000" stroke-width="5"/>
|
||||
<circle r="20" fill="none" stroke="#000" stroke-width="5"/>
|
||||
<circle r="20" fill="none" stroke="#000" stroke-width="5"/>
|
||||
<circle r="20" fill="none" stroke="#000" stroke-width="5"/>
|
||||
<circle r="20" fill="none" stroke="#000" stroke-width="5"/>
|
||||
</g>
|
||||
<style>
|
||||
/* Copyright Microsoft, Imitated by: 机智的小鱼君 */
|
||||
@keyframes opacity-keyframe {
|
||||
|
||||
0%,
|
||||
74.72706% {
|
||||
opacity: 1;
|
||||
animation-timing-function: step-start
|
||||
}
|
||||
|
||||
74.75029%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
animation-timing-function: step-start
|
||||
}
|
||||
}
|
||||
|
||||
.ms-spinner circle {
|
||||
stroke: #1483da;
|
||||
stroke-dasharray: 126;
|
||||
stroke-dashoffset: 125.999;
|
||||
stroke-linecap: round;
|
||||
opacity: 0;
|
||||
transform: rotate(225deg);
|
||||
animation-iteration-count: infinite;
|
||||
animation-duration: 4500ms;
|
||||
}
|
||||
|
||||
@keyframes e1r {
|
||||
0% {
|
||||
animation-timing-function: cubic-bezier(.02, .33, .38, .77);
|
||||
transform: rotate(90deg)
|
||||
}
|
||||
|
||||
10.05807% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(210deg)
|
||||
}
|
||||
|
||||
27.87456% {
|
||||
animation-timing-function: cubic-bezier(.57, .17, .95, .75);
|
||||
transform: rotate(293deg)
|
||||
}
|
||||
|
||||
37.56098% {
|
||||
animation-timing-function: cubic-bezier(0, .19, .07, .72);
|
||||
transform: rotate(405deg)
|
||||
}
|
||||
|
||||
46.8525% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(557deg)
|
||||
}
|
||||
|
||||
64.64576% {
|
||||
animation-timing-function: cubic-bezier(0, 0, .95, .37);
|
||||
transform: rotate(639deg)
|
||||
}
|
||||
|
||||
74.72706%,
|
||||
100% {
|
||||
transform: rotate(785deg);
|
||||
animation-timing-function: cubic-bezier(.13, .21, .1, .7)
|
||||
}
|
||||
}
|
||||
|
||||
.ms-spinner circle:nth-child(1) {
|
||||
animation-name: e1r, opacity-keyframe;
|
||||
animation-delay: 0ms
|
||||
}
|
||||
|
||||
@keyframes e2r {
|
||||
0% {
|
||||
animation-timing-function: cubic-bezier(.02, .33, .38, .77);
|
||||
transform: rotate(84deg)
|
||||
}
|
||||
|
||||
10.05807% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(204deg)
|
||||
}
|
||||
|
||||
27.87456% {
|
||||
animation-timing-function: cubic-bezier(.57, .17, .95, .75);
|
||||
transform: rotate(287deg)
|
||||
}
|
||||
|
||||
37.56098% {
|
||||
animation-timing-function: cubic-bezier(0, .19, .07, .72);
|
||||
transform: rotate(399deg)
|
||||
}
|
||||
|
||||
46.8525% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(551deg)
|
||||
}
|
||||
|
||||
64.64576% {
|
||||
animation-timing-function: cubic-bezier(0, 0, .95, .37);
|
||||
transform: rotate(633deg)
|
||||
}
|
||||
|
||||
74.72706%,
|
||||
100% {
|
||||
transform: rotate(779deg);
|
||||
animation-timing-function: cubic-bezier(.13, .21, .1, .7)
|
||||
}
|
||||
}
|
||||
|
||||
.ms-spinner circle:nth-child(2) {
|
||||
animation-name: e2r, opacity-keyframe;
|
||||
animation-delay: 180ms
|
||||
}
|
||||
|
||||
@keyframes e3r {
|
||||
0% {
|
||||
animation-timing-function: cubic-bezier(.02, .33, .38, .77);
|
||||
transform: rotate(78deg)
|
||||
}
|
||||
|
||||
10.05807% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(198deg)
|
||||
}
|
||||
|
||||
27.87456% {
|
||||
animation-timing-function: cubic-bezier(.57, .17, .95, .75);
|
||||
transform: rotate(281deg)
|
||||
}
|
||||
|
||||
37.56098% {
|
||||
animation-timing-function: cubic-bezier(0, .19, .07, .72);
|
||||
transform: rotate(393deg)
|
||||
}
|
||||
|
||||
46.8525% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(545deg)
|
||||
}
|
||||
|
||||
64.64576% {
|
||||
animation-timing-function: cubic-bezier(0, 0, .95, .37);
|
||||
transform: rotate(627deg)
|
||||
}
|
||||
|
||||
74.72706%,
|
||||
100% {
|
||||
transform: rotate(773deg);
|
||||
animation-timing-function: cubic-bezier(.13, .21, .1, .7)
|
||||
}
|
||||
}
|
||||
|
||||
.ms-spinner circle:nth-child(3) {
|
||||
animation-name: e3r, opacity-keyframe;
|
||||
animation-delay: 360ms
|
||||
}
|
||||
|
||||
@keyframes e4r {
|
||||
0% {
|
||||
animation-timing-function: cubic-bezier(.02, .33, .38, .77);
|
||||
transform: rotate(72deg)
|
||||
}
|
||||
|
||||
10.05807% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(192deg)
|
||||
}
|
||||
|
||||
27.87456% {
|
||||
animation-timing-function: cubic-bezier(.57, .17, .95, .75);
|
||||
transform: rotate(275deg)
|
||||
}
|
||||
|
||||
37.56098% {
|
||||
animation-timing-function: cubic-bezier(0, .19, .07, .72);
|
||||
transform: rotate(387deg)
|
||||
}
|
||||
|
||||
46.8525% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(539deg)
|
||||
}
|
||||
|
||||
64.64576% {
|
||||
animation-timing-function: cubic-bezier(0, 0, .95, .37);
|
||||
transform: rotate(621deg)
|
||||
}
|
||||
|
||||
74.72706%,
|
||||
100% {
|
||||
transform: rotate(767deg);
|
||||
animation-timing-function: cubic-bezier(.13, .21, .1, .7)
|
||||
}
|
||||
}
|
||||
|
||||
.ms-spinner circle:nth-child(4) {
|
||||
animation-name: e4r, opacity-keyframe;
|
||||
animation-delay: 540ms
|
||||
}
|
||||
|
||||
@keyframes e5r {
|
||||
0% {
|
||||
animation-timing-function: cubic-bezier(.02, .33, .38, .77);
|
||||
transform: rotate(66deg)
|
||||
}
|
||||
|
||||
10.05807% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(186deg)
|
||||
}
|
||||
|
||||
27.87456% {
|
||||
animation-timing-function: cubic-bezier(.57, .17, .95, .75);
|
||||
transform: rotate(269deg)
|
||||
}
|
||||
|
||||
37.56098% {
|
||||
animation-timing-function: cubic-bezier(0, .19, .07, .72);
|
||||
transform: rotate(381deg)
|
||||
}
|
||||
|
||||
46.8525% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(533deg)
|
||||
}
|
||||
|
||||
64.64576% {
|
||||
animation-timing-function: cubic-bezier(0, 0, .95, .37);
|
||||
transform: rotate(615deg)
|
||||
}
|
||||
|
||||
74.72706%,
|
||||
100% {
|
||||
transform: rotate(761deg);
|
||||
animation-timing-function: cubic-bezier(.13, .21, .1, .7)
|
||||
}
|
||||
}
|
||||
|
||||
.ms-spinner circle:nth-child(5) {
|
||||
animation-name: e5r, opacity-keyframe;
|
||||
animation-delay: 720ms
|
||||
}
|
||||
|
||||
@keyframes e6r {
|
||||
0% {
|
||||
animation-timing-function: cubic-bezier(.02, .33, .38, .77);
|
||||
transform: rotate(60deg)
|
||||
}
|
||||
|
||||
10.05807% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(180deg)
|
||||
}
|
||||
|
||||
27.87456% {
|
||||
animation-timing-function: cubic-bezier(.57, .17, .95, .75);
|
||||
transform: rotate(263deg)
|
||||
}
|
||||
|
||||
37.56098% {
|
||||
animation-timing-function: cubic-bezier(0, .19, .07, .72);
|
||||
transform: rotate(375deg)
|
||||
}
|
||||
|
||||
46.8525% {
|
||||
animation-timing-function: cubic-bezier(0, 0, 1, 1);
|
||||
transform: rotate(527deg)
|
||||
}
|
||||
|
||||
64.64576% {
|
||||
animation-timing-function: cubic-bezier(0, 0, .95, .37);
|
||||
transform: rotate(609deg)
|
||||
}
|
||||
|
||||
74.72706%,
|
||||
100% {
|
||||
transform: rotate(755deg);
|
||||
animation-timing-function: cubic-bezier(.13, .21, .1, .7)
|
||||
}
|
||||
}
|
||||
|
||||
.ms-spinner circle:nth-child(6) {
|
||||
animation-name: e6r, opacity-keyframe;
|
||||
animation-delay: 900ms
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
10
public/robots.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Disallow: /ajax/
|
||||
Disallow: /user/
|
||||
Disallow: /api/
|
||||
Disallow: *.php
|
||||
|
||||
# Google的爬虫太顶了,扛不住
|
||||
User-agent: googlebot-image
|
||||
Disallow: /
|
||||
64
renovate.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"labels": ["dependencies"],
|
||||
"baseBranches": ["dev"],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "framework",
|
||||
"matchPackageNames": [
|
||||
"vue",
|
||||
"vue-i18n",
|
||||
"vue-router",
|
||||
"axios",
|
||||
"pinia",
|
||||
"@vercel/node"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "ui",
|
||||
"matchPackageNames": [
|
||||
"naive-ui",
|
||||
"vue-flex-waterfall",
|
||||
"vue-gtag",
|
||||
"nprogress",
|
||||
"@iconify{/,}**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "build",
|
||||
"matchPackageNames": [
|
||||
"typescript",
|
||||
"vite",
|
||||
"pug",
|
||||
"sass",
|
||||
"tslib",
|
||||
"vercel",
|
||||
"@vue/language-plugin-pug",
|
||||
"@vitejs{/,}**",
|
||||
"unplugin{/,}**",
|
||||
"@vueuse{/,}**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "types",
|
||||
"matchPackageNames": ["@types{/,}**"]
|
||||
},
|
||||
{
|
||||
"groupName": "utils",
|
||||
"matchPackageNames": [
|
||||
"picocolors",
|
||||
"js-cookie",
|
||||
"cookie",
|
||||
"cheerio",
|
||||
"conventional-changelog-cli",
|
||||
"lodash{/,}**",
|
||||
"date{/,}**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
24
script/getStampList.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @desc 获取绘文字的映射表
|
||||
* 这个是扔到浏览器控制台里用的
|
||||
* 点开评论框旁边的笑脸然后运行
|
||||
* @example (normal) => https://pixiv.js.org/~/common/images/emoji/101.png
|
||||
*/
|
||||
!(() => {
|
||||
const btn = document.querySelectorAll('.emoji-mart-category-list button')
|
||||
const list = {}
|
||||
for (let i of btn) {
|
||||
const label = i.attributes['aria-label'].value.replace(/[\(\)]/g, '')
|
||||
const url = i
|
||||
.querySelector('span')
|
||||
.style.backgroundImage.replace(
|
||||
/url\("https:\/\/s\.pximg\.net(.+)"\)/g,
|
||||
'/~$1'
|
||||
)
|
||||
list[label] = url
|
||||
}
|
||||
console.log(list)
|
||||
})()
|
||||
|
||||
// 表情贴图的地址
|
||||
// `/common/images/stamp/generated-stamps/${stampId}_s.jpg`
|
||||
63
src/App.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template lang="pug">
|
||||
NaiveuiProvider#app-full-container
|
||||
SiteNoticeBanner
|
||||
SiteHeader
|
||||
|
||||
main
|
||||
article
|
||||
RouterView
|
||||
|
||||
SideNav
|
||||
SiteFooter
|
||||
NProgress
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import NaiveuiProvider from './components/NaiveuiProvider.vue'
|
||||
import NProgress from './components/NProgress.vue'
|
||||
import { existsSessionId, initUser } from '@/components/userData'
|
||||
import { useUserStore } from '@/composables/states'
|
||||
|
||||
const SideNav = defineAsyncComponent(
|
||||
() => import('./components/SideNav/SideNav.vue')
|
||||
)
|
||||
const SiteFooter = defineAsyncComponent(
|
||||
() => import('./components/SiteFooter.vue')
|
||||
)
|
||||
const SiteHeader = defineAsyncComponent(
|
||||
() => import('./components/SiteHeader.vue')
|
||||
)
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
onMounted(async () => {
|
||||
if (!existsSessionId()) {
|
||||
console.log('No session id found. Maybe you are not logged in?')
|
||||
userStore.logout()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const userData = await initUser()
|
||||
userStore.login(userData)
|
||||
} catch (err) {
|
||||
console.error('User init failed:', err)
|
||||
userStore.logout()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
#app-full-container
|
||||
min-height: 100vh
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
main
|
||||
// padding-top: 50px
|
||||
position: relative
|
||||
flex: 1
|
||||
article
|
||||
background-color: rgba(0, 0, 0, 0.02)
|
||||
padding-bottom: 3rem
|
||||
z-index: 1
|
||||
</style>
|
||||
BIN
src/assets/LogoH.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/assets/LogoV.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
1
src/assets/pixiv-rabbit.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="36" viewBox="0 0 48 36"><path fill="#EEE" fill-rule="evenodd" d="M32 35a2 2 0 110-4 2 2 0 010 4zm-3-21.945c0-2.423.313-5.31 1.8-5.31 1.06 0 2.2 1.661 2.2 5.31 0 3.664-1.042 7.622-1.67 9.68A14.958 14.958 0 0029 21.673v-8.618zm-10 8.618c-.816.291-1.594.648-2.329 1.062-.629-2.057-1.67-6.013-1.67-9.68 0-3.649 1.14-5.31 2.2-5.31 1.486 0 1.799 2.887 1.799 5.31v8.618zM16 35a2 2 0 110-4 2 2 0 010 4zm20-21.945c0-5.846-2.55-8.305-5.2-8.305-2.653 0-4.8 1.93-4.8 8.305v7.891c-.656-.089-1.32-.15-2-.15-.68 0-1.344.061-2 .15v-7.891c0-6.375-2.147-8.305-4.8-8.305-2.65 0-5.2 2.46-5.2 8.305 0 4.73 1.52 9.756 2.1 11.498-2.534 2.262-4.1 5.29-4.1 8.354C10 41.004 16.268 43 24 43s14-1.996 14-10.093c0-3.065-1.566-6.092-4.1-8.354.58-1.743 2.1-6.767 2.1-11.498z"/></svg>
|
||||
|
After Width: | Height: | Size: 811 B |
31
src/assets/placeholder.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" class="svgspinner" width="400" height="300">
|
||||
<g class="spingroup" transform="matrix(1,0,0,1,200,150)">
|
||||
<circle class="spincircle" r="36" stroke-width="5" stroke="#088488" fill="none" stroke-linecap="round"/>
|
||||
</g>
|
||||
<style>
|
||||
.svgspinner .spincircle {
|
||||
animation: loading-round 1.2s infinite linear, loading-dash 2s infinite linear alternate;
|
||||
stroke-dasharray: 236;
|
||||
}
|
||||
|
||||
@keyframes loading-round {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(720deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-dash {
|
||||
0% {
|
||||
stroke-dashoffset: 236;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 750 B |
347
src/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,347 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const IllustType: typeof import('./types/Artworks')['IllustType']
|
||||
const UgoiraPlayer: typeof import('./utils/UgoiraPlayer')['UgoiraPlayer']
|
||||
const UserPrivacyLevel: typeof import('./types/Users')['UserPrivacyLevel']
|
||||
const UserXRestrict: typeof import('./types/Users')['UserXRestrict']
|
||||
const ZipDownloader: typeof import('./utils/ZipDownloader')['ZipDownloader']
|
||||
const addBookmark: typeof import('./utils/artworkActions')['addBookmark']
|
||||
const addUserFollow: typeof import('./utils/userActions')['addUserFollow']
|
||||
const ajax: typeof import('./utils/ajax')['ajax']
|
||||
const ajaxPostWithFormData: typeof import('./utils/ajax')['ajaxPostWithFormData']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
const axios: typeof import('axios')['default']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const computedAsync: typeof import('@vueuse/core')['computedAsync']
|
||||
const computedEager: typeof import('@vueuse/core')['computedEager']
|
||||
const computedInject: typeof import('@vueuse/core')['computedInject']
|
||||
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
|
||||
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
|
||||
const controlledRef: typeof import('@vueuse/core')['controlledRef']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createEventHook: typeof import('@vueuse/core')['createEventHook']
|
||||
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createOptimizedUgoiraPlayer: typeof import('./src/utils/UgoiraPlayerExample')['createOptimizedUgoiraPlayer']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createRef: typeof import('@vueuse/core')['createRef']
|
||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
|
||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
||||
const defaultArtwork: typeof import('./utils/index')['defaultArtwork']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const demonstrateOptimizedPlayer: typeof import('./src/utils/UgoiraPlayerExample')['demonstrateOptimizedPlayer']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const exampleSessionId: typeof import('./components/userData')['exampleSessionId']
|
||||
const existsSessionId: typeof import('./components/userData')['existsSessionId']
|
||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
|
||||
const h: typeof import('vue')['h']
|
||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
||||
const initUser: typeof import('./components/userData')['initUser']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const injectLocal: typeof import('@vueuse/core')['injectLocal']
|
||||
const isArtwork: typeof import('./utils/artworkActions')['isArtwork']
|
||||
const isDefined: typeof import('@vueuse/core')['isDefined']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const isShallow: typeof import('vue')['isShallow']
|
||||
const login: typeof import('./components/userData')['login']
|
||||
const logout: typeof import('./components/userData')['logout']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
|
||||
const onLongPress: typeof import('@vueuse/core')['onLongPress']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||
const reactify: typeof import('@vueuse/core')['reactify']
|
||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
|
||||
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
|
||||
const reactivePick: typeof import('@vueuse/core')['reactivePick']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
|
||||
const refDebounced: typeof import('@vueuse/core')['refDebounced']
|
||||
const refDefault: typeof import('@vueuse/core')['refDefault']
|
||||
const refThrottled: typeof import('@vueuse/core')['refThrottled']
|
||||
const refWithControl: typeof import('@vueuse/core')['refWithControl']
|
||||
const removeBookmark: typeof import('./utils/artworkActions')['removeBookmark']
|
||||
const removeUserFollow: typeof import('./utils/userActions')['removeUserFollow']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
const setTitle: typeof import('./utils/setTitle')['setTitle']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const sortArtList: typeof import('./utils/artworkActions')['sortArtList']
|
||||
const syncRef: typeof import('@vueuse/core')['syncRef']
|
||||
const syncRefs: typeof import('@vueuse/core')['syncRefs']
|
||||
const templateRef: typeof import('@vueuse/core')['templateRef']
|
||||
const throttledRef: typeof import('@vueuse/core')['throttledRef']
|
||||
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toReactive: typeof import('@vueuse/core')['toReactive']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
|
||||
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
|
||||
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
|
||||
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const unrefElement: typeof import('@vueuse/core')['unrefElement']
|
||||
const until: typeof import('@vueuse/core')['until']
|
||||
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
||||
const useAnimate: typeof import('@vueuse/core')['useAnimate']
|
||||
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
|
||||
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
|
||||
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
|
||||
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
|
||||
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
|
||||
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
|
||||
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
|
||||
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
|
||||
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
|
||||
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
|
||||
const useArraySome: typeof import('@vueuse/core')['useArraySome']
|
||||
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
|
||||
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
|
||||
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useBase64: typeof import('@vueuse/core')['useBase64']
|
||||
const useBattery: typeof import('@vueuse/core')['useBattery']
|
||||
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
|
||||
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
|
||||
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
|
||||
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
|
||||
const useCached: typeof import('@vueuse/core')['useCached']
|
||||
const useClipboard: typeof import('@vueuse/core')['useClipboard']
|
||||
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
|
||||
const useCloned: typeof import('@vueuse/core')['useCloned']
|
||||
const useColorMode: typeof import('@vueuse/core')['useColorMode']
|
||||
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
|
||||
const useCountdown: typeof import('@vueuse/core')['useCountdown']
|
||||
const useCounter: typeof import('@vueuse/core')['useCounter']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVar: typeof import('@vueuse/core')['useCssVar']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
|
||||
const useCycleList: typeof import('@vueuse/core')['useCycleList']
|
||||
const useDark: typeof import('@vueuse/core')['useDark']
|
||||
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
|
||||
const useDebounce: typeof import('@vueuse/core')['useDebounce']
|
||||
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
|
||||
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
|
||||
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
|
||||
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
|
||||
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
|
||||
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
|
||||
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
|
||||
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
|
||||
const useDraggable: typeof import('@vueuse/core')['useDraggable']
|
||||
const useDropZone: typeof import('@vueuse/core')['useDropZone']
|
||||
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
|
||||
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
|
||||
const useElementHover: typeof import('@vueuse/core')['useElementHover']
|
||||
const useElementSize: typeof import('@vueuse/core')['useElementSize']
|
||||
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
|
||||
const useEventBus: typeof import('@vueuse/core')['useEventBus']
|
||||
const useEventListener: typeof import('@vueuse/core')['useEventListener']
|
||||
const useEventSource: typeof import('@vueuse/core')['useEventSource']
|
||||
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
|
||||
const useFavicon: typeof import('@vueuse/core')['useFavicon']
|
||||
const useFetch: typeof import('@vueuse/core')['useFetch']
|
||||
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
|
||||
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
|
||||
const useFocus: typeof import('@vueuse/core')['useFocus']
|
||||
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
|
||||
const useFps: typeof import('@vueuse/core')['useFps']
|
||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useIdle: typeof import('@vueuse/core')['useIdle']
|
||||
const useImage: typeof import('@vueuse/core')['useImage']
|
||||
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
|
||||
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
|
||||
const useInterval: typeof import('@vueuse/core')['useInterval']
|
||||
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
|
||||
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
|
||||
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
|
||||
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
|
||||
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
|
||||
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
|
||||
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
|
||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
||||
const useMouse: typeof import('@vueuse/core')['useMouse']
|
||||
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
||||
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
|
||||
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
||||
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
||||
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
||||
const useNow: typeof import('@vueuse/core')['useNow']
|
||||
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
|
||||
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
|
||||
const useOnline: typeof import('@vueuse/core')['useOnline']
|
||||
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
|
||||
const useParallax: typeof import('@vueuse/core')['useParallax']
|
||||
const useParentElement: typeof import('@vueuse/core')['useParentElement']
|
||||
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
|
||||
const usePermission: typeof import('@vueuse/core')['usePermission']
|
||||
const usePointer: typeof import('@vueuse/core')['usePointer']
|
||||
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
|
||||
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
|
||||
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
|
||||
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
|
||||
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
|
||||
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
|
||||
const usePrevious: typeof import('@vueuse/core')['usePrevious']
|
||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
||||
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
||||
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
|
||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
||||
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
||||
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
|
||||
const useScroll: typeof import('@vueuse/core')['useScroll']
|
||||
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
|
||||
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
|
||||
const useShare: typeof import('@vueuse/core')['useShare']
|
||||
const useSideNavStore: typeof import('./composables/states')['useSideNavStore']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useSorted: typeof import('@vueuse/core')['useSorted']
|
||||
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
|
||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
||||
const useSupported: typeof import('@vueuse/core')['useSupported']
|
||||
const useSwipe: typeof import('@vueuse/core')['useSwipe']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
|
||||
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
|
||||
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
|
||||
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
|
||||
const useThrottle: typeof import('@vueuse/core')['useThrottle']
|
||||
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
|
||||
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
|
||||
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
|
||||
const useTimeAgoIntl: typeof import('@vueuse/core')['useTimeAgoIntl']
|
||||
const useTimeout: typeof import('@vueuse/core')['useTimeout']
|
||||
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
|
||||
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
|
||||
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
|
||||
const useTitle: typeof import('@vueuse/core')['useTitle']
|
||||
const useToNumber: typeof import('@vueuse/core')['useToNumber']
|
||||
const useToString: typeof import('@vueuse/core')['useToString']
|
||||
const useToggle: typeof import('@vueuse/core')['useToggle']
|
||||
const useTransition: typeof import('@vueuse/core')['useTransition']
|
||||
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
|
||||
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
|
||||
const useUserStore: typeof import('./composables/states')['useUserStore']
|
||||
const useVModel: typeof import('@vueuse/core')['useVModel']
|
||||
const useVModels: typeof import('@vueuse/core')['useVModels']
|
||||
const useVibrate: typeof import('@vueuse/core')['useVibrate']
|
||||
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
|
||||
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
|
||||
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
|
||||
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
|
||||
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
|
||||
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
|
||||
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
|
||||
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
|
||||
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
|
||||
const validateSessionId: typeof import('./components/userData')['validateSessionId']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchArray: typeof import('@vueuse/core')['watchArray']
|
||||
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
|
||||
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
|
||||
const watchDeep: typeof import('@vueuse/core')['watchDeep']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
|
||||
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
|
||||
const watchOnce: typeof import('@vueuse/core')['watchOnce']
|
||||
const watchPausable: typeof import('@vueuse/core')['watchPausable']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
|
||||
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
|
||||
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
|
||||
const whenever: typeof import('@vueuse/core')['whenever']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
// @ts-ignore
|
||||
export type { UgoiraPlayer, UgoiraPlayerOptions, UgoiraFrame, UgoiraMeta } from './utils/UgoiraPlayer'
|
||||
import('./utils/UgoiraPlayer')
|
||||
// @ts-ignore
|
||||
export type { ZipDownloader, FetchLike, ZipDownloaderOptions, ZipEntry, ZipEntryWithData, ZipOverview, DataRange } from './utils/ZipDownloader'
|
||||
import('./utils/ZipDownloader')
|
||||
// @ts-ignore
|
||||
export type { IllustType, ArtworkUrls, ArtworkPageUrls, ArtworkTag, ArtworkGallery, ArtworkInfo, ArtworkInfoOrAd, ArtworkRank, Artwork, IllustType } from './types/Artworks'
|
||||
import('./types/Artworks')
|
||||
// @ts-ignore
|
||||
export type { Comments } from './types/Comment'
|
||||
import('./types/Comment')
|
||||
// @ts-ignore
|
||||
export type { UserXRestrict, UserPrivacyLevel, User, PixivUser, UserListItem, UserXRestrict, UserPrivacyLevel } from './types/Users'
|
||||
import('./types/Users')
|
||||
}
|
||||
46
src/components.d.ts
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ArtTag: typeof import('./components/ArtTag.vue')['default']
|
||||
ArtworkCard: typeof import('./components/ArtworksList/ArtworkCard.vue')['default']
|
||||
ArtworkLargeCard: typeof import('./components/ArtworksList/ArtworkLargeCard.vue')['default']
|
||||
ArtworkLargeList: typeof import('./components/ArtworksList/ArtworkLargeList.vue')['default']
|
||||
ArtworkList: typeof import('./components/ArtworksList/ArtworkList.vue')['default']
|
||||
ArtworksByUser: typeof import('./components/ArtworksList/ArtworksByUser.vue')['default']
|
||||
AuthorCard: typeof import('./components/AuthorCard.vue')['default']
|
||||
Card: typeof import('./components/Card.vue')['default']
|
||||
Comment: typeof import('./components/Comment/Comment.vue')['default']
|
||||
CommentsArea: typeof import('./components/Comment/CommentsArea.vue')['default']
|
||||
CommentSubmit: typeof import('./components/Comment/CommentSubmit.vue')['default']
|
||||
ErrorPage: typeof import('./components/ErrorPage.vue')['default']
|
||||
ExternalLink: typeof import('./components/ExternalLink.vue')['default']
|
||||
FollowUserCard: typeof import('./components/FollowUserCard.vue')['default']
|
||||
Gallery: typeof import('./components/Gallery.vue')['default']
|
||||
LazyLoad: typeof import('./components/LazyLoad.vue')['default']
|
||||
ListLink: typeof import('./components/SideNav/ListLink.vue')['default']
|
||||
NaiveuiProvider: typeof import('./components/NaiveuiProvider.vue')['default']
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NLi: typeof import('naive-ui')['NLi']
|
||||
NProgress: typeof import('./components/NProgress.vue')['default']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NUl: typeof import('naive-ui')['NUl']
|
||||
Placeholder: typeof import('./components/Placeholder.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SearchBox: typeof import('./components/SearchBox.vue')['default']
|
||||
ShowMore: typeof import('./components/ShowMore.vue')['default']
|
||||
SideNav: typeof import('./components/SideNav/SideNav.vue')['default']
|
||||
SiteFooter: typeof import('./components/SiteFooter.vue')['default']
|
||||
SiteHeader: typeof import('./components/SiteHeader.vue')['default']
|
||||
SiteNoticeBanner: typeof import('./components/SiteNoticeBanner.vue')['default']
|
||||
UgoiraViewer: typeof import('./components/UgoiraViewer.vue')['default']
|
||||
}
|
||||
}
|
||||
18
src/components/ArtTag.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template lang="pug">
|
||||
NTag.artwork-tag(
|
||||
@click='$router.push({ name: "search", params: { keyword: tag, p: 1 } })'
|
||||
type='info'
|
||||
) {{ '#' }}{{ tag }}
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { NTag } from 'naive-ui'
|
||||
|
||||
defineProps<{ tag: string }>()
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
.artwork-tag
|
||||
margin: 2px
|
||||
cursor: pointer
|
||||
</style>
|
||||
268
src/components/ArtworksList/ArtworkCard.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<template lang="pug">
|
||||
.artwork-card-container
|
||||
.artwork-card.placeholder(v-if='loading')
|
||||
.artwork-image
|
||||
NSkeleton(block height='180px' width='180px')
|
||||
.artwork-info
|
||||
.title: a: NSkeleton(height='1.4em' text width='8em')
|
||||
.author: a
|
||||
NSkeleton.avatar(circle height='1.5em' text width='1.5em')
|
||||
NSkeleton(text width='4em')
|
||||
.artwork-card(v-else-if='item')
|
||||
.artwork-image
|
||||
.side-tags
|
||||
.restrict.x-restrict(title='R-18' v-if='item.xRestrict')
|
||||
IFasEye(data-icon)
|
||||
.restrict.ai-restrict(
|
||||
:title='`AI生成(${item.aiType})`'
|
||||
v-if='item.aiType === 2'
|
||||
)
|
||||
IFasRobot(data-icon)
|
||||
.page-count(
|
||||
:title='"共 " + item.pageCount + " 张"'
|
||||
v-if='item.pageCount > 1'
|
||||
)
|
||||
IFasImages(data-icon)
|
||||
| {{ item.pageCount }}
|
||||
.bookmark(
|
||||
:class='{ bookmarked: item.bookmarkData, disabled: loadingBookmark }'
|
||||
@click='handleBookmark'
|
||||
)
|
||||
IFasHeart(data-icon)
|
||||
RouterLink(:to='"/artworks/" + item.id')
|
||||
LazyLoad.img(
|
||||
:alt='item.alt',
|
||||
:src='item.url',
|
||||
:title='item.alt'
|
||||
lazyload
|
||||
)
|
||||
.hover-title {{ item.title }}
|
||||
.type-ugoira(v-if='item.illustType === IllustType.UGOIRA'): IPlayCircle
|
||||
.artwork-info
|
||||
.title
|
||||
RouterLink(:to='"/artworks/" + item.id') {{ item.title }}
|
||||
.author(:title='item.userName')
|
||||
RouterLink(:to='"/users/" + item.userId')
|
||||
img.avatar(:src='item.profileImageUrl' lazyload)
|
||||
| {{ item.userName }}
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LazyLoad from '../LazyLoad.vue'
|
||||
import { addBookmark, removeBookmark } from '@/utils/artworkActions'
|
||||
import { NSkeleton } from 'naive-ui'
|
||||
import { IllustType } from '@/types'
|
||||
import IFasEye from '~icons/fa-solid/eye'
|
||||
import IFasHeart from '~icons/fa-solid/heart'
|
||||
import IFasImages from '~icons/fa-solid/images'
|
||||
import IFasRobot from '~icons/fa-solid/robot'
|
||||
import IPlayCircle from '~icons/fa-solid/play-circle'
|
||||
|
||||
import type { ArtworkInfo } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
item?: ArtworkInfo
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const loadingBookmark = ref(false)
|
||||
async function handleBookmark() {
|
||||
if (loadingBookmark.value) return
|
||||
loadingBookmark.value = true
|
||||
const item = props.item!
|
||||
try {
|
||||
if (item.bookmarkData) {
|
||||
await removeBookmark(item.bookmarkData.id).then(() => {
|
||||
item.bookmarkData = null
|
||||
})
|
||||
} else {
|
||||
await addBookmark(item.id).then((data) => {
|
||||
if (data.last_bookmark_id) {
|
||||
item.bookmarkData = { id: data.last_bookmark_id, private: false }
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('handleBookmark', e)
|
||||
} finally {
|
||||
loadingBookmark.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
.artwork-image
|
||||
position: relative
|
||||
overflow: hidden
|
||||
border-radius: 8px
|
||||
width: 100%
|
||||
height: 0
|
||||
padding-top: 100%
|
||||
animation: imgProgress 0.6s ease infinite alternate
|
||||
|
||||
a
|
||||
position: absolute
|
||||
left: 0
|
||||
top: 0
|
||||
display: block
|
||||
|
||||
&::before
|
||||
content: ''
|
||||
display: block
|
||||
position: absolute
|
||||
// background-color: rgba(0, 0, 0, 0.05)
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
z-index: 1
|
||||
pointer-events: none
|
||||
transition: all 0.4s ease-in-out
|
||||
|
||||
.img
|
||||
position: relative
|
||||
left: 0
|
||||
top: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
transition: all 0.25s ease-in-out
|
||||
|
||||
.bookmark
|
||||
cursor: pointer
|
||||
&.disabled
|
||||
opacity: 0.7
|
||||
|
||||
.hover-title
|
||||
z-index: 10
|
||||
color: #fff
|
||||
position: absolute
|
||||
left: 50%
|
||||
top: 50%
|
||||
transform: translateX(-50%) translateY(-50%)
|
||||
text-shadow: 0 0 4px #000
|
||||
font-weight: 600
|
||||
pointer-events: none
|
||||
opacity: 0
|
||||
transition: all 0.25s ease-in-out
|
||||
|
||||
.type-ugoira
|
||||
position: absolute
|
||||
pointer-events: none
|
||||
top: 50%
|
||||
left: 50%
|
||||
font-size: 2.5rem
|
||||
color: #fff
|
||||
opacity: 0.75
|
||||
transform: translate(-50%, -50%)
|
||||
transition: all 0.25s ease-in-out
|
||||
|
||||
&:hover a,
|
||||
& a.router-link-active
|
||||
&::before
|
||||
background-color: rgba(0, 0, 0, 0.2)
|
||||
img
|
||||
transform: scale(1.2)
|
||||
.hover-title
|
||||
opacity: 1
|
||||
.type-ugoira
|
||||
opacity: 0
|
||||
transform: translate(-50%, -50%) scale(1.5)
|
||||
|
||||
.router-link-active
|
||||
cursor: default
|
||||
box-shadow: 0 0 0 2px #aaa
|
||||
|
||||
& + .cover
|
||||
background-color: rgba(100, 100, 100, 0.6) !important
|
||||
|
||||
.side-tags > *
|
||||
position: absolute
|
||||
z-index: 10
|
||||
|
||||
[data-icon]
|
||||
font-size: 1em
|
||||
|
||||
.page-count
|
||||
top: .4rem
|
||||
right: .4rem
|
||||
color: #fff
|
||||
background-color: rgba(0, 0, 0, 0.6)
|
||||
padding: .1rem .2rem
|
||||
border-radius: 4px
|
||||
font-size: 0.8rem
|
||||
|
||||
[data-icon]
|
||||
margin-right: .2rem
|
||||
|
||||
.restrict
|
||||
color: #fff
|
||||
font-size: 0.8rem
|
||||
width: 1.5rem
|
||||
height: 1.5rem
|
||||
border-radius: 50%
|
||||
display: flex
|
||||
align-items: center
|
||||
[data-icon]
|
||||
margin: 0 auto
|
||||
.x-restrict
|
||||
top: .4rem
|
||||
left: .4rem
|
||||
background-color: rgb(255, 0, 0, 0.8)
|
||||
.ai-restrict
|
||||
bottom: .4rem
|
||||
left: .4rem
|
||||
background-color: rgba(204, 102, 0, 0.8)
|
||||
|
||||
.bookmark
|
||||
bottom: 0.4rem
|
||||
right: 0.4rem
|
||||
font-size: 1.2rem
|
||||
color: #fff
|
||||
|
||||
&.bookmarked
|
||||
color: var(--theme-bookmark-color)
|
||||
|
||||
.artwork-info
|
||||
.title,
|
||||
.author
|
||||
white-space: nowrap
|
||||
text-overflow: ellipsis
|
||||
overflow: hidden
|
||||
width: 100%
|
||||
padding-bottom: 2px
|
||||
|
||||
a
|
||||
align-items: center
|
||||
|
||||
&.router-link-active
|
||||
color: var(--theme-text-color)
|
||||
font-weight: 700
|
||||
font-style: normal
|
||||
cursor: default
|
||||
|
||||
&::after
|
||||
visibility: hidden
|
||||
|
||||
.title
|
||||
margin: 0.4rem 0
|
||||
|
||||
a
|
||||
display: inline
|
||||
font-weight: 600
|
||||
|
||||
.author
|
||||
.avatar
|
||||
display: inline-block
|
||||
width: 1.5rem
|
||||
height: 1.5rem
|
||||
border: 2px solid #fff
|
||||
border-radius: 50%
|
||||
box-shadow: 0 0 4px #ccc
|
||||
margin-right: .4rem
|
||||
|
||||
a
|
||||
font-size: 0.8rem
|
||||
font-style: italic
|
||||
display: inline-flex
|
||||
</style>
|
||||
204
src/components/ArtworksList/ArtworkLargeCard.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template lang="pug">
|
||||
.artwork-large-card
|
||||
.top
|
||||
RouterLink.plain(:to='"/artworks/" + illust.id')
|
||||
.thumb
|
||||
LazyLoad.image(
|
||||
:alt='illust.title',
|
||||
:src='illust.url.replace("p0_master", "p0_square")'
|
||||
)
|
||||
.restrict.x-restrict(title='R-18' v-if='illust.xRestrict === 2')
|
||||
IFasEye(data-icon)
|
||||
.restrict.ai-restrict(title='AI生成' v-if='illust.aiType === 2')
|
||||
IFasRobot(data-icon)
|
||||
.page-count(
|
||||
:title='"共 " + illust.pageCount + " 张"'
|
||||
v-if='+illust.pageCount > 1'
|
||||
)
|
||||
IFasImages(data-icon)
|
||||
| {{ illust.pageCount }}
|
||||
.ranking(
|
||||
:class='{ gold: rank === 1, silver: rank === 2, bronze: rank === 3 }'
|
||||
v-if='rank !== 0'
|
||||
) {{ rank }}
|
||||
.type-ugoira(v-if='illust.illustType === IllustType.UGOIRA'): IPlayCircle
|
||||
.bottom
|
||||
h3.title.plain(:title='illust.title')
|
||||
RouterLink(:to='"/artworks/" + illust.id') {{ illust.title }}
|
||||
.author(:title='illust.userName')
|
||||
RouterLink(:to='"/users/" + illust.userId')
|
||||
img.avatar(:src='illust.profileImageUrl' lazyload)
|
||||
| {{ illust.userName }}
|
||||
.tags
|
||||
ArtTag(:key='_', :tag='item' v-for='(item, _) in illust.tags')
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type ArtworkInfo, IllustType } from '@/types'
|
||||
import LazyLoad from '../LazyLoad.vue'
|
||||
import ArtTag from '../ArtTag.vue'
|
||||
import IFasEye from '~icons/fa-solid/eye'
|
||||
import IFasImages from '~icons/fa-solid/images'
|
||||
import IFasRobot from '~icons/fa-solid/robot'
|
||||
import IPlayCircle from '~icons/fa-solid/play-circle'
|
||||
|
||||
defineProps<{
|
||||
illust: ArtworkInfo
|
||||
rank: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
h3
|
||||
margin-bottom: .4rem
|
||||
|
||||
.artwork-large-card
|
||||
display: block
|
||||
border: 1px solid #eee
|
||||
background-color: var(--theme-background-color)
|
||||
border-radius: 0.5rem
|
||||
transition: all .24s ease-in-out
|
||||
margin: 0.5rem auto
|
||||
--parent-width: calc(100vw - 2rem)
|
||||
--counts: 1
|
||||
width: calc((var(--parent-width) - calc(var(--counts) - 1) * 2rem) / var(--counts))
|
||||
@media (max-width: 380px)
|
||||
width: 100%
|
||||
@media (min-width: 380px)
|
||||
--counts: 2
|
||||
@media (min-width: 640px)
|
||||
--counts: 3
|
||||
@media (min-width: 750px)
|
||||
--counts: 4
|
||||
@media (min-width: 1200px)
|
||||
--counts: 5
|
||||
@media (min-width: 1600px)
|
||||
--counts: 6
|
||||
|
||||
.top
|
||||
position: relative
|
||||
a
|
||||
display: block
|
||||
.thumb
|
||||
border-radius: 0.5rem 0.5rem 0 0
|
||||
overflow: hidden
|
||||
position: relative
|
||||
width: 100%
|
||||
height: 0
|
||||
padding-top: 100%
|
||||
animation: imgProgress 0.6s ease infinite alternate
|
||||
.image
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
.page-count
|
||||
position: absolute
|
||||
top: .4rem
|
||||
right: .4rem
|
||||
color: #fff
|
||||
background-color: rgba(0, 0, 0, 0.6)
|
||||
padding: .2rem .6rem
|
||||
border-radius: 0.2rem
|
||||
[data-icon]
|
||||
margin-right: .2rem
|
||||
|
||||
.restrict
|
||||
position: absolute
|
||||
color: #fff
|
||||
width: 2rem
|
||||
height: 2rem
|
||||
border-radius: 50%
|
||||
display: flex
|
||||
align-items: center
|
||||
[data-icon]
|
||||
margin: 0 auto
|
||||
.x-restrict
|
||||
top: .4rem
|
||||
left: .4rem
|
||||
background-color: rgb(255, 0, 0, 0.8)
|
||||
.ai-restrict
|
||||
bottom: .4rem
|
||||
left: .4rem
|
||||
background-color: rgba(204, 102, 0, 0.8)
|
||||
|
||||
.ranking
|
||||
position: absolute
|
||||
top: -0.9rem
|
||||
left: -0.89rem
|
||||
font-size: 1.2rem
|
||||
color: #252525
|
||||
background-color: #fff
|
||||
border-radius: 50%
|
||||
width: 1.8rem
|
||||
height: 1.8rem
|
||||
text-align: center
|
||||
line-height: 1.6
|
||||
--ring-color: rgba(var(--theme-accent-color--rgb), 0.4)
|
||||
box-shadow: 0 0 0 1px var(--ring-color) inset, 0 0 0 2px #fff
|
||||
&.gold
|
||||
--ring-color: gold
|
||||
&.silver
|
||||
--ring-color: darkgray
|
||||
&.bronze
|
||||
--ring-color: #b87333
|
||||
|
||||
.type-ugoira
|
||||
pointer-events: none
|
||||
position: absolute
|
||||
width: 100%
|
||||
height: 100%
|
||||
left: 0
|
||||
top: 0
|
||||
svg
|
||||
position: absolute
|
||||
bottom: 50%
|
||||
right: 50%
|
||||
color: #fff
|
||||
width: 35%
|
||||
height: 35%
|
||||
transform: translate(50%, 50%)
|
||||
opacity: 0.75
|
||||
|
||||
.bottom
|
||||
padding: 0.5rem
|
||||
.title a
|
||||
display: inline
|
||||
.author a
|
||||
display: inline-flex
|
||||
|
||||
.title,
|
||||
.author
|
||||
white-space: nowrap
|
||||
text-overflow: ellipsis
|
||||
overflow: hidden
|
||||
width: 100%
|
||||
padding-bottom: 2px
|
||||
|
||||
a
|
||||
align-items: center
|
||||
&.RouterLink-active
|
||||
color: var(--theme-text-color)
|
||||
font-weight: 600
|
||||
font-style: normal
|
||||
cursor: default
|
||||
&::after
|
||||
visibility: hidden
|
||||
|
||||
.avatar
|
||||
display: inline-block
|
||||
width: 2rem
|
||||
height: 2rem
|
||||
box-sizing: border-box
|
||||
border: 2px solid #fff
|
||||
border-radius: 50%
|
||||
box-shadow: 0 0 4px #ccc
|
||||
margin-right: .4rem
|
||||
|
||||
.author
|
||||
margin: .4rem 0
|
||||
.tags
|
||||
overflow: hidden
|
||||
</style>
|
||||
86
src/components/ArtworksList/ArtworkLargeList.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template lang="pug">
|
||||
Waterfall.artwork-large-list(
|
||||
:breakpoints='{ 9999: { rowPerView: 6 }, 1600: { rowPerView: 5 }, 1200: { rowPerView: 4 }, 750: { rowPerView: 3 }, 640: { rowPerView: 2 }, 380: { rowPerView: 1 } }',
|
||||
:list='artworks'
|
||||
ref='waterfallRef'
|
||||
)
|
||||
template(#default='{ item, index }')
|
||||
ArtworkLargeCard(:illust='item[0]', :key='index', :rank='item[1]')
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ArtworkLargeCard from './ArtworkLargeCard.vue'
|
||||
import type { ArtworkInfo, ArtworkRank } from '@/types'
|
||||
import { Waterfall } from 'vue-waterfall-plugin-next'
|
||||
import 'vue-waterfall-plugin-next/dist/style.css'
|
||||
|
||||
const props = defineProps<{
|
||||
rankList?: ArtworkRank[]
|
||||
artworkList?: ArtworkInfo[]
|
||||
}>()
|
||||
const artworks = computed(() => {
|
||||
if (props.rankList) {
|
||||
return convertRankToInfo(props.rankList)
|
||||
} else if (props.artworkList) {
|
||||
return props.artworkList.map((item): [ArtworkInfo, number] => {
|
||||
return [item, 0]
|
||||
})
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
function convertRankToInfo(rankInfo: ArtworkRank[]): [ArtworkInfo, number][] {
|
||||
return rankInfo.map((item): [ArtworkInfo, number] => {
|
||||
return [
|
||||
// @ts-ignore
|
||||
{
|
||||
id: `${item.illust_id}`,
|
||||
title: item.title,
|
||||
description: '',
|
||||
createDate: item.date,
|
||||
updateDate: item.date,
|
||||
illustType: 0,
|
||||
restrict: 0,
|
||||
xRestrict: item.illust_content_type.sexual,
|
||||
sl: 2,
|
||||
userId: `${item.user_id}`,
|
||||
userName: item.user_name,
|
||||
alt: item.title,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
pageCount: +item.illust_page_count,
|
||||
isBookmarkable: true,
|
||||
bookmarkData: null,
|
||||
titleCaptionTranslation: {
|
||||
workTitle: null,
|
||||
workCaption: null,
|
||||
},
|
||||
isUnlisted: false,
|
||||
url: item.url,
|
||||
tags: item.tags,
|
||||
profileImageUrl: item.profile_img,
|
||||
type: 'illust',
|
||||
},
|
||||
item.rank,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const waterfallRef = ref<any>()
|
||||
|
||||
function resize() {
|
||||
waterfallRef.value?.renderer()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
const event = new Event('resize')
|
||||
window.dispatchEvent(event)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
.artwork-large-list
|
||||
align-items: center
|
||||
</style>
|
||||
66
src/components/ArtworksList/ArtworkList.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template lang="pug">
|
||||
Component.artworks-list(
|
||||
:class='{ inline }',
|
||||
:is='inline ? NScrollbar : "ul"'
|
||||
trigger='none'
|
||||
x-scrollable
|
||||
)
|
||||
li(v-for='_ in skeletonNumber' v-if='loading')
|
||||
ArtworkCard(loading)
|
||||
li(:key='item.id' v-else v-for='item in artworks')
|
||||
ArtworkCard(:item='item')
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ArtworkCard from './ArtworkCard.vue'
|
||||
import { isArtwork } from '@/utils'
|
||||
import type { ArtworkInfo, ArtworkInfoOrAd } from '@/types'
|
||||
import { NScrollbar } from 'naive-ui'
|
||||
|
||||
const props = defineProps<{
|
||||
list: ArtworkInfoOrAd[]
|
||||
loading?: boolean | number
|
||||
inline?: boolean
|
||||
}>()
|
||||
|
||||
const skeletonNumber = computed(() =>
|
||||
typeof props.loading === 'number' ? props.loading : 8
|
||||
)
|
||||
|
||||
const artworks = computed(() => {
|
||||
return props.list.filter((item): item is ArtworkInfo => isArtwork(item))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
.artworks-list
|
||||
margin-top: 1rem
|
||||
list-style: none
|
||||
padding-left: 0
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 1.5rem
|
||||
justify-content: center
|
||||
|
||||
&.inline
|
||||
overflow-y: auto
|
||||
white-space: nowrap
|
||||
display: block
|
||||
|
||||
li:not(:first-of-type)
|
||||
margin-left: 0.75rem
|
||||
|
||||
li
|
||||
width: 180px
|
||||
max-width: calc(45vw - 1.5rem)
|
||||
display: inline-block
|
||||
|
||||
.tiny
|
||||
gap: 0.75rem
|
||||
|
||||
li
|
||||
width: 100px
|
||||
|
||||
.info
|
||||
display: none
|
||||
</style>
|
||||
107
src/components/ArtworksList/ArtworksByUser.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template lang="pug">
|
||||
.artworks-by-user(ref='containerRef')
|
||||
NFlex(align='center' justify='center')
|
||||
NPagination(
|
||||
:item-count='artworkIds.length',
|
||||
:page-size='pageSize'
|
||||
v-model:page='curPage'
|
||||
)
|
||||
ArtworkList(
|
||||
:list='curArtworks',
|
||||
:loading='!curArtworks.length ? pageSize : false'
|
||||
)
|
||||
NFlex(align='center' justify='center')
|
||||
NPagination(
|
||||
:item-count='artworkIds.length',
|
||||
:page-size='pageSize'
|
||||
v-model:page='curPage'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type ArtworkInfo } from '@/types'
|
||||
import { NPagination } from 'naive-ui'
|
||||
import {} from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
userId: string
|
||||
workCategory?: 'illust' | 'manga'
|
||||
}>(),
|
||||
{
|
||||
workCategory: 'illust',
|
||||
}
|
||||
)
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const artworkIds = ref<string[]>([])
|
||||
const pageSize = 24
|
||||
const curPage = ref(1)
|
||||
const cachedPages = ref<Record<number, ArtworkInfo[]>>({})
|
||||
const curArtworks = computed(() => {
|
||||
return (cachedPages.value[curPage.value] || []).sort(
|
||||
(a, b) => Number(b.id) - Number(a.id)
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
firstInit()
|
||||
})
|
||||
watch(curPage, (page) => {
|
||||
backToTop()
|
||||
fetchArtworksByPage(page)
|
||||
})
|
||||
|
||||
function backToTop() {
|
||||
const container = containerRef.value!
|
||||
const top = container.getBoundingClientRect().top + window.scrollY - 120
|
||||
window.scrollTo({
|
||||
top,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
async function firstInit() {
|
||||
artworkIds.value = []
|
||||
curPage.value = 1
|
||||
cachedPages.value = {}
|
||||
artworkIds.value = (await fetchAllArtworkIds()).sort(
|
||||
(a, b) => Number(b) - Number(a)
|
||||
)
|
||||
await fetchArtworksByPage(1)
|
||||
}
|
||||
|
||||
async function fetchAllArtworkIds() {
|
||||
const { data } = await ajax.get<{
|
||||
illusts: Record<string, null>
|
||||
manga: Record<string, null>
|
||||
}>(`/ajax/user/${props.userId}/profile/all`)
|
||||
const works =
|
||||
props.workCategory === 'illust'
|
||||
? Object.keys(data.illusts)
|
||||
: Object.keys(data.manga)
|
||||
return works
|
||||
}
|
||||
|
||||
function getArtworkIdsByPage(page: number) {
|
||||
return artworkIds.value.slice((page - 1) * pageSize, page * pageSize)
|
||||
}
|
||||
|
||||
async function fetchArtworksByPage(page: number) {
|
||||
if (cachedPages.value[page]) return cachedPages.value[page]
|
||||
const ids = getArtworkIdsByPage(page)
|
||||
const { data } = await ajax.get<{
|
||||
works: Record<string, ArtworkInfo>
|
||||
}>(`/ajax/user/${props.userId}/profile/illusts`, {
|
||||
params: {
|
||||
ids,
|
||||
work_category: props.workCategory,
|
||||
is_first_page: 0,
|
||||
},
|
||||
})
|
||||
cachedPages.value[page] = Object.values(data.works)
|
||||
return data
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass"></style>
|
||||
89
src/components/AuthorCard.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template lang="pug">
|
||||
.author-card
|
||||
.author-inner(v-if='user')
|
||||
.flex-center
|
||||
.left
|
||||
RouterLink(:to='"/users/" + user.userId')
|
||||
img(:src='user.imageBig' alt='')
|
||||
.right
|
||||
.flex
|
||||
h4.plain
|
||||
RouterLink(:to='"/users/" + user.userId') {{ user.name }}
|
||||
NButton(
|
||||
:loading='loadingUserFollow',
|
||||
:type='user.isFollowed ? "success" : undefined'
|
||||
@click='handleUserFollow'
|
||||
round
|
||||
secondary
|
||||
size='small'
|
||||
v-if='user.userId !== userStore.userId'
|
||||
)
|
||||
template(#icon)
|
||||
IFasCheck(v-if='user.isFollowed')
|
||||
IFasPlus(v-else)
|
||||
| {{ user.isFollowed ? '已关注' : '关注' }}
|
||||
NEllipsis.description.pre(:line-clamp='3', :tooltip='false') {{ user.comment }}
|
||||
ArtworkList.tiny(:list='user.illusts' inline)
|
||||
|
||||
.author-placeholder(v-else)
|
||||
.flex-center
|
||||
.left: a: NSkeleton(circle height='80px' text width='80px')
|
||||
.right
|
||||
h4.plain: NSkeleton(height='1.6em' text width='12em')
|
||||
NSkeleton(block height='3em' width='100%')
|
||||
ArtworkList.tiny(:list='[]' inline loading)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ArtworkList from './ArtworksList/ArtworkList.vue'
|
||||
import type { User } from '@/types'
|
||||
import { addUserFollow, removeUserFollow } from '@/utils'
|
||||
import { NButton, NEllipsis, NSkeleton } from 'naive-ui'
|
||||
import IFasCheck from '~icons/fa-solid/check'
|
||||
import IFasPlus from '~icons/fa-solid/plus'
|
||||
import { useUserStore } from '@/composables/states'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const props = defineProps<{
|
||||
user?: User
|
||||
}>()
|
||||
|
||||
const loadingUserFollow = ref(false)
|
||||
function handleUserFollow() {
|
||||
if (!props.user || loadingUserFollow.value) return
|
||||
const user = props.user
|
||||
|
||||
loadingUserFollow.value = true
|
||||
const isFollowed = user.isFollowed
|
||||
const handler = isFollowed ? removeUserFollow : addUserFollow
|
||||
handler(user.userId)
|
||||
.then(() => {
|
||||
user.isFollowed = !isFollowed
|
||||
})
|
||||
.finally(() => {
|
||||
loadingUserFollow.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
.left
|
||||
margin-right: 1rem
|
||||
|
||||
img
|
||||
border-radius: 50%
|
||||
width: 80px
|
||||
height: 80px
|
||||
|
||||
.right
|
||||
flex: 1
|
||||
|
||||
h4
|
||||
margin: 0.2rem 0
|
||||
flex: 1
|
||||
font-weight: 700
|
||||
|
||||
:deep(.artworks-list .author)
|
||||
display: none
|
||||
</style>
|
||||
18
src/components/Card.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template lang="pug">
|
||||
.card
|
||||
h2(:id='title' v-if='title') {{ title }}
|
||||
.inner
|
||||
slot/
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ title: string | undefined }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
.inner
|
||||
background-color: var(--theme-background-color)
|
||||
border: 1px solid #efefef
|
||||
border-radius: 0.5rem
|
||||
padding: 1rem
|
||||
</style>
|
||||
80
src/components/Comment/Comment.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template lang="pug">
|
||||
li.comment-block
|
||||
.left
|
||||
RouterLink.plain(:to='"/users/" + comment.userId')
|
||||
img.avatar(
|
||||
:src='comment.img',
|
||||
:title='comment.userName + " (" + comment.userId + ")"'
|
||||
)
|
||||
.right
|
||||
h4.user.plain
|
||||
span.comment-author
|
||||
| {{ comment.userName }}
|
||||
.tag(v-if='store.userId === comment.userId') 您
|
||||
span.comment-reply(v-if='comment.replyToUserId')  ▶ {{ comment.replyToUserName }}
|
||||
.content(v-html='replaceStamps(comment.comment)' v-if='!comment.stampId')
|
||||
.content(v-if='comment.stampId')
|
||||
img.big-stamp(
|
||||
:src='`/~/common/images/stamp/generated-stamps/${comment.stampId}_s.jpg`'
|
||||
alt='表情包'
|
||||
lazyload
|
||||
)
|
||||
.comment-date {{ comment.commentDate }}
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import stampList from './stampList.json'
|
||||
import type { Comments } from '@/types'
|
||||
import { useUserStore } from '@/composables/states'
|
||||
|
||||
defineProps<{ comment: Comments }>()
|
||||
const store = useUserStore()
|
||||
|
||||
function replaceStamps(str: string): string {
|
||||
for (const [stampName, stampUrl] of Object.entries(stampList)) {
|
||||
str = str.replaceAll(
|
||||
`(${stampName})`,
|
||||
`<img class="stamp" src="${stampUrl}" alt="表情包" lazyload>`
|
||||
)
|
||||
}
|
||||
return str
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
.comment-block
|
||||
display: flex
|
||||
gap: .6rem
|
||||
|
||||
+ .comment-block
|
||||
margin-top: 1rem
|
||||
|
||||
.left
|
||||
flex: none
|
||||
|
||||
.avatar
|
||||
width: 40px
|
||||
height: 40px
|
||||
background-size: 40px
|
||||
border-radius: 50%
|
||||
|
||||
.right
|
||||
.user
|
||||
margin: 0 0 .3em
|
||||
|
||||
.content
|
||||
white-space: pre-wrap
|
||||
margin-bottom: .3em
|
||||
|
||||
.big-stamp
|
||||
width: 3em
|
||||
|
||||
.stamp
|
||||
height: 1.4rem
|
||||
width: auto
|
||||
|
||||
.comment-date
|
||||
font-size: .75em
|
||||
color: #aaa
|
||||
</style>
|
||||
82
src/components/Comment/CommentSubmit.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template lang="pug">
|
||||
.comment-submit(:data-illust_id='id')
|
||||
em 发表评论
|
||||
.flex.logged-in(v-if='store.isLoggedIn')
|
||||
.left
|
||||
.avatar
|
||||
img(:src='store.userProfileImg')
|
||||
.right
|
||||
textarea(:disabled='loading' v-model='comment')
|
||||
.submit.align-right
|
||||
button(:disabled='loading' @click='async () => await submit()') 发送
|
||||
.flex.not-logged-in(v-if='!store.isLoggedIn')
|
||||
p
|
||||
| 您需要
|
||||
RouterLink(:to='"/login?back=" + $route.path') 设置 Pixiv 令牌
|
||||
| 以发表评论。
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Cookies from 'js-cookie'
|
||||
import { useUserStore } from '@/composables/states'
|
||||
|
||||
const store = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const comment = ref('')
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'push-comment',
|
||||
value: {
|
||||
img: string
|
||||
commentDate: string
|
||||
[key: string]: any
|
||||
}
|
||||
): void
|
||||
}>()
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (loading.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
const { data } = await axios.post(
|
||||
`/ajax/illusts/comments/post`,
|
||||
{
|
||||
type: 'comment',
|
||||
illust_id: props.id,
|
||||
author_user_id: store.userId,
|
||||
comment,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': Cookies.get('csrf_token'),
|
||||
},
|
||||
}
|
||||
)
|
||||
comment.value = ''
|
||||
emit('push-comment', {
|
||||
img: store.userProfileImg,
|
||||
commentDate: new Date().toLocaleString(),
|
||||
...data,
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Comment submit error', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
||||
.right
|
||||
flex: 1
|
||||
|
||||
textarea
|
||||
width: 100%
|
||||
|
||||
.not-logged-in
|
||||
color: #888
|
||||
</style>
|
||||
92
src/components/Comment/CommentsArea.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template lang="pug">
|
||||
.comments-area(ref='commentsArea')
|
||||
//- CommentSubmit(:id="id" @push-comment="pushComment")
|
||||
em.stats
|
||||
| 共{{ count || comments.length || 0 }}条评论
|
||||
p(v-if='!comments.length && !loading') 还没有人发表评论呢~
|
||||
ul.comments-list(v-if='comments.length')
|
||||
comment(:comment='item' v-for='item in comments')
|
||||
.show-more.align-center
|
||||
NButton(
|
||||
:loading='loading'
|
||||
@click='async () => await init(id)'
|
||||
round
|
||||
secondary
|
||||
size='small'
|
||||
v-if='comments.length && hasNext'
|
||||
)
|
||||
template(#icon)
|
||||
IFasPlus
|
||||
| {{ loading ? '正在加载' : '查看更多' }}
|
||||
.align-center(v-if='!comments.length && loading')
|
||||
placeholder
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Comment from './Comment.vue'
|
||||
import { ajax } from '@/utils/ajax'
|
||||
import type { Comments } from '@/types'
|
||||
import { NButton } from 'naive-ui'
|
||||
import IFasPlus from '~icons/fa-solid/plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const comments = ref<Comments[]>([])
|
||||
const hasNext = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
count: number
|
||||
}>()
|
||||
|
||||
async function init(id: string | number): Promise<void> {
|
||||
if (loading.value) return
|
||||
if (!props.count) {
|
||||
hasNext.value = false
|
||||
comments.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const { data } = await ajax.get(`/ajax/illusts/comments/roots`, {
|
||||
params: new URLSearchParams({
|
||||
illust_id: `${id}`,
|
||||
limit: comments.value.length ? '30' : '3',
|
||||
offset: `${comments.value.length}`,
|
||||
}),
|
||||
})
|
||||
hasNext.value = data.hasNext
|
||||
comments.value = comments.value.concat(data.comments)
|
||||
} catch (err) {
|
||||
console.warn('Comments fetch error', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pushComment(data: Comments) {
|
||||
console.log(data)
|
||||
comments.value.unshift(data)
|
||||
}
|
||||
|
||||
const commentsArea = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const ob = useIntersectionObserver(
|
||||
commentsArea,
|
||||
async ([{ isIntersecting }]) => {
|
||||
if (isIntersecting) {
|
||||
await nextTick()
|
||||
init(props.id)
|
||||
ob.stop()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
||||
.comments-list
|
||||
list-style: none
|
||||
padding-left: 0
|
||||
</style>
|
||||
40
src/components/Comment/stampList.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"normal": "/~/common/images/emoji/101.png",
|
||||
"surprise": "/~/common/images/emoji/102.png",
|
||||
"serious": "/~/common/images/emoji/103.png",
|
||||
"heaven": "/~/common/images/emoji/104.png",
|
||||
"happy": "/~/common/images/emoji/105.png",
|
||||
"excited": "/~/common/images/emoji/106.png",
|
||||
"sing": "/~/common/images/emoji/107.png",
|
||||
"cry": "/~/common/images/emoji/108.png",
|
||||
"normal2": "/~/common/images/emoji/201.png",
|
||||
"shame2": "/~/common/images/emoji/202.png",
|
||||
"love2": "/~/common/images/emoji/203.png",
|
||||
"interesting2": "/~/common/images/emoji/204.png",
|
||||
"blush2": "/~/common/images/emoji/205.png",
|
||||
"fire2": "/~/common/images/emoji/206.png",
|
||||
"angry2": "/~/common/images/emoji/207.png",
|
||||
"shine2": "/~/common/images/emoji/208.png",
|
||||
"panic2": "/~/common/images/emoji/209.png",
|
||||
"normal3": "/~/common/images/emoji/301.png",
|
||||
"satisfaction3": "/~/common/images/emoji/302.png",
|
||||
"surprise3": "/~/common/images/emoji/303.png",
|
||||
"smile3": "/~/common/images/emoji/304.png",
|
||||
"shock3": "/~/common/images/emoji/305.png",
|
||||
"gaze3": "/~/common/images/emoji/306.png",
|
||||
"wink3": "/~/common/images/emoji/307.png",
|
||||
"happy3": "/~/common/images/emoji/308.png",
|
||||
"excited3": "/~/common/images/emoji/309.png",
|
||||
"love3": "/~/common/images/emoji/310.png",
|
||||
"normal4": "/~/common/images/emoji/401.png",
|
||||
"surprise4": "/~/common/images/emoji/402.png",
|
||||
"serious4": "/~/common/images/emoji/403.png",
|
||||
"love4": "/~/common/images/emoji/404.png",
|
||||
"shine4": "/~/common/images/emoji/405.png",
|
||||
"sweat4": "/~/common/images/emoji/406.png",
|
||||
"shame4": "/~/common/images/emoji/407.png",
|
||||
"sleep4": "/~/common/images/emoji/408.png",
|
||||
"heart": "/~/common/images/emoji/501.png",
|
||||
"teardrop": "/~/common/images/emoji/502.png",
|
||||
"star": "/~/common/images/emoji/503.png"
|
||||
}
|
||||
127
src/components/ErrorPage.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template lang="pug">
|
||||
section.error-page
|
||||
NResult(
|
||||
:description='description',
|
||||
:status='status || "warning"',
|
||||
:title='title'
|
||||
)
|
||||
template(#footer)
|
||||
.random(@click='randomMsg') {{ msg }}
|
||||
.extra: slot
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { setTitle } from '@/utils/setTitle'
|
||||
import { NResult } from 'naive-ui'
|
||||
import { effect } from 'vue'
|
||||
|
||||
const msgList = [
|
||||
// 正经向提示
|
||||
'频繁遇到此问题?请通过关于里的联系方式联系我们!',
|
||||
'您可以尝试刷新页面。',
|
||||
// 日常玩梗
|
||||
'这像装在游戏机盒子里的作业本一样没有人喜欢!',
|
||||
'↓↑↓↑↓↑↓↑↓↑↓↑↓↑↓↑↓↑↓↑↓↑↓↑↓↑↓↑',
|
||||
'▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇▆▅▄▃▂▁',
|
||||
'卧槽 ²³³³³³³ 6666666 厉害了23333 ²³³³³³³³³³³ 2333 6666 ²³³ 666 太流弊了!!',
|
||||
'生命、宇宙以及任何事情的终极答案——42',
|
||||
'单击此处添加副标题',
|
||||
// 杰哥不要梗
|
||||
'阿伟你又在点炒饭哦,休息一下吧,念个书好不好?',
|
||||
'死了啦,都你害的啦!',
|
||||
// 音乐梗
|
||||
'変わったああああああああああああああ', // 你比蔷薇更美丽
|
||||
'だめだね、だめよだめなのよ——', // 像笨蛋一样
|
||||
'壊れた 僕なんてさ、息を止めて', // unravel
|
||||
'Groupons nous et demain. L’Internationale. Sera le genre humain.', // 国际歌
|
||||
// FF14 骚话
|
||||
'这像闪耀登场释放天辉的白魔法师一样没有人喜欢!',
|
||||
'这像冰4火4一个慢动作的黑魔法师一样没有人喜欢!',
|
||||
'这像耗尽了了以太超流的学者一样没有人喜欢!',
|
||||
'这像忘记了每分钟背刺的忍者一样没有人喜欢!',
|
||||
'这像进本后跳999接受LB需求退本一气呵成的龙骑一样没有人喜欢!',
|
||||
'这像把拉拉菲尔族当做食材的人一样没有人喜欢!',
|
||||
'这像死而不僵状态下的暗黑骑士一样没有人喜欢!',
|
||||
'这像诗人触发不了诗心一样没有人喜欢!',
|
||||
// 程序员梗
|
||||
'锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷',
|
||||
'烫烫烫烫烫烫烫烫烫烫烫烫烫烫烫',
|
||||
'程序员 酒吧 炒饭 炸了',
|
||||
'谁点的炒饭?请取餐。',
|
||||
// Destiny2 梗
|
||||
'噶迪恩荡。',
|
||||
'你的光能消散了。',
|
||||
'神谕正在准备吟唱他们的叠句。',
|
||||
// Cyberpunk2077 梗
|
||||
'梆梆哔铛~梆梆哔铛梆!',
|
||||
// 音游梗
|
||||
'您有一个小姐。',
|
||||
'这像劲爆纵连一样没有人喜欢!',
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
status?:
|
||||
| 'warning'
|
||||
| '500'
|
||||
| 'error'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| '404'
|
||||
| '403'
|
||||
| '418'
|
||||
}>()
|
||||
const msg = ref('')
|
||||
function randomMsg(): void {
|
||||
const newValue = msgList[Math.floor(Math.random() * msgList.length)]
|
||||
if (newValue !== msg.value) {
|
||||
msg.value = newValue
|
||||
} else {
|
||||
randomMsg()
|
||||
}
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
setTitle(props.title, 'Error')
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
randomMsg()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
||||
.error-page
|
||||
padding: 10vh 0
|
||||
height: 100%
|
||||
text-align: center
|
||||
display: flex
|
||||
align-items: center
|
||||
flex-wrap: wrap
|
||||
|
||||
> div
|
||||
width: 100%
|
||||
|
||||
.title
|
||||
font-size: 5rem
|
||||
font-weight: bold
|
||||
margin-bottom: 0.4em
|
||||
|
||||
> span
|
||||
box-shadow: 0 -0.5em 0 rgb(54, 151, 231) inset
|
||||
text-shadow: 2px 2px var(--theme-text-shadow-color)
|
||||
padding: 0 0.4em
|
||||
|
||||
.description
|
||||
font-size: 1.5rem
|
||||
|
||||
.random
|
||||
color: #aaa
|
||||
user-select: none
|
||||
margin-top: 1rem
|
||||
|
||||
.extra
|
||||
margin-top: 1em
|
||||
</style>
|
||||
18
src/components/ExternalLink.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template lang="pug">
|
||||
a(:href='href' rel='nofollow' target='_blank')
|
||||
slot
|
||||
IFasExternalLinkAlt.external-icon
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import IFasExternalLinkAlt from '~icons/fa-solid/external-link-alt'
|
||||
defineProps<{ href: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
||||
.external-icon
|
||||
margin-left: 0.4em
|
||||
font-size: 0.7em
|
||||
vertical-align: 0
|
||||
</style>
|
||||
101
src/components/FollowUserCard.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template lang="pug">
|
||||
.follow-user-card
|
||||
.follow-user-inner(v-if='user')
|
||||
.flex-1.flex.gap-1
|
||||
.left
|
||||
RouterLink(:to='"/users/" + user.userId')
|
||||
img(:src='user.profileImageUrl' alt='')
|
||||
.right
|
||||
.username: h4.plain
|
||||
RouterLink(:to='"/users/" + user.userId') {{ user.userName }}
|
||||
.comment: NEllipsis.description.pre(:line-clamp='3', :tooltip='false') {{ user.userComment }}
|
||||
.action: NButton(
|
||||
:loading='loadingUserFollow',
|
||||
:type='user.following ? "success" : undefined'
|
||||
@click='handleUserFollow'
|
||||
round
|
||||
secondary
|
||||
size='small'
|
||||
v-if='user.userId !== userStore.userId'
|
||||
)
|
||||
template(#icon)
|
||||
IFasCheck(v-if='user.following')
|
||||
IFasPlus(v-else)
|
||||
| {{ user.following ? '已关注' : '关注' }}
|
||||
.user-artworks
|
||||
ArtworkList.tiny(:list='user.illusts' inline)
|
||||
|
||||
.follow-user-inner.placeholder(v-else)
|
||||
.flex-1.flex.gap-1
|
||||
.left: a: NSkeleton(circle height='80px' text width='80px')
|
||||
.right
|
||||
h4.plain: NSkeleton(height='1.6em' text width='12em')
|
||||
NSkeleton(block height='6em' width='100%')
|
||||
.user-artworks: ArtworkList.tiny(:list='[]', :loading='4' inline)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ArtworkList from './ArtworksList/ArtworkList.vue'
|
||||
import type { User, UserListItem } from '@/types'
|
||||
import { addUserFollow, removeUserFollow } from '@/utils'
|
||||
import { NButton, NEllipsis, NSkeleton } from 'naive-ui'
|
||||
import IFasCheck from '~icons/fa-solid/check'
|
||||
import IFasPlus from '~icons/fa-solid/plus'
|
||||
import { useUserStore } from '@/composables/states'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const props = defineProps<{
|
||||
user?: UserListItem
|
||||
}>()
|
||||
|
||||
const loadingUserFollow = ref(false)
|
||||
function handleUserFollow() {
|
||||
if (!props.user || loadingUserFollow.value) return
|
||||
const user = props.user
|
||||
|
||||
loadingUserFollow.value = true
|
||||
const isFollowing = user.following
|
||||
const handler = isFollowing ? removeUserFollow : addUserFollow
|
||||
handler(user.userId)
|
||||
.then(() => {
|
||||
user.following = !isFollowing
|
||||
})
|
||||
.finally(() => {
|
||||
loadingUserFollow.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
.follow-user-card
|
||||
overflow: hidden
|
||||
|
||||
.follow-user-inner
|
||||
display: flex
|
||||
gap: 1rem
|
||||
@media (max-width: 860px)
|
||||
flex-direction: column
|
||||
gap: 0.25rem
|
||||
|
||||
.left
|
||||
margin-right: 1rem
|
||||
img
|
||||
border-radius: 50%
|
||||
width: 80px
|
||||
height: 80px
|
||||
|
||||
.right
|
||||
flex: 1
|
||||
> div:not(:first-of-type)
|
||||
margin-top: 1rem
|
||||
|
||||
h4
|
||||
margin: 0.2rem 0
|
||||
padding-left: 0
|
||||
flex: 1
|
||||
font-weight: 700
|
||||
|
||||
:deep(.artworks-list .author)
|
||||
display: none
|
||||
</style>
|
||||
102
src/components/Gallery.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template lang="pug">
|
||||
.gallery
|
||||
.center-img(:class='showAll ? "show-all" : "show-single"')
|
||||
div(:data-pic-index='index' v-for='(item, index) in pages')
|
||||
a.image-container(
|
||||
:href='item.urls.original'
|
||||
target='_blank'
|
||||
title='点击下载原图'
|
||||
v-if='picShow === index'
|
||||
)
|
||||
LazyLoad.img(
|
||||
:height='item.height',
|
||||
:src='item.urls.regular',
|
||||
:width='item.width'
|
||||
lazyload
|
||||
)
|
||||
//- .tips.align-center (这是预览图,点击下载原图)
|
||||
ul.pagenator(v-if='pages.length > 1')
|
||||
li(v-for='(item, index) in pages')
|
||||
a.pointer(
|
||||
:class='{ "is-active": picShow === index }',
|
||||
:title='`第${index + 1}张,共${pages.length}张`'
|
||||
@click='picShow = index'
|
||||
)
|
||||
LazyLoad.pic(
|
||||
:height='80',
|
||||
:src='item.urls.thumb_mini',
|
||||
:width='80'
|
||||
lazyload
|
||||
)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LazyLoad from './LazyLoad.vue'
|
||||
import type { ArtworkGallery } from '@/types'
|
||||
|
||||
defineProps<{ pages: ArtworkGallery[] }>()
|
||||
const showAll = ref(false)
|
||||
const picShow = ref(0)
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
.gallery
|
||||
.center-img
|
||||
width: 100%
|
||||
overflow: auto
|
||||
margin: 0.4rem auto
|
||||
padding: 0.2rem
|
||||
display: flex
|
||||
flex-wrap: nowrap
|
||||
gap: 1rem
|
||||
|
||||
li
|
||||
display: inline-block
|
||||
// margin: 0.2rem 0
|
||||
// gap: 1rem
|
||||
|
||||
.flex-center
|
||||
gap: 1rem
|
||||
|
||||
.left-btn,
|
||||
.right-btn
|
||||
flex: 1
|
||||
|
||||
.left-btn
|
||||
text-align: right
|
||||
|
||||
.tips
|
||||
font-size: small
|
||||
font-style: italic
|
||||
|
||||
[role="img"]
|
||||
border-radius: 4px
|
||||
box-shadow: var(--theme-box-shadow)
|
||||
transition: box-shadow 0.24s ease-in-out
|
||||
|
||||
&:hover
|
||||
box-shadow: var(--theme-box-shadow-hover)
|
||||
|
||||
.center-img
|
||||
display: block
|
||||
text-align: center
|
||||
|
||||
[role="img"]
|
||||
max-width: 100%
|
||||
max-height: 60vh
|
||||
width: auto
|
||||
height: auto
|
||||
|
||||
.pagenator
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0.2rem
|
||||
white-space: nowrap
|
||||
overflow-y: auto
|
||||
text-align: center
|
||||
|
||||
li
|
||||
margin: 0.5rem
|
||||
display: inline-block
|
||||
</style>
|
||||
56
src/components/LazyLoad.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template lang="pug">
|
||||
Component(
|
||||
:class='{ lazyload: true, isLoading: !loaded && !error, isLoaded: loaded, isError: error }',
|
||||
:height='height',
|
||||
:is='loaded ? "img" : "svg"',
|
||||
:key='src',
|
||||
:src='src',
|
||||
:width='width'
|
||||
ref='imgRef'
|
||||
role='img'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
width?: number
|
||||
height?: number
|
||||
}>()
|
||||
|
||||
const loaded = ref(false)
|
||||
const error = ref(false)
|
||||
const imgRef = ref<HTMLImageElement | null>(null)
|
||||
|
||||
const ob = useIntersectionObserver(imgRef, async ([{ isIntersecting }]) => {
|
||||
if (isIntersecting) {
|
||||
await nextTick()
|
||||
loadImage()
|
||||
ob.stop()
|
||||
}
|
||||
})
|
||||
|
||||
function loadImage() {
|
||||
loaded.value = false
|
||||
error.value = false
|
||||
|
||||
const img = new Image(props.width, props.height)
|
||||
img.src = props.src
|
||||
img.onload = () => {
|
||||
loaded.value = true
|
||||
error.value = false
|
||||
imgRef.value = img
|
||||
}
|
||||
img.onerror = () => {
|
||||
loaded.value = false
|
||||
error.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
.isLoading
|
||||
animation: imgProgress 0.6s ease infinite alternate
|
||||
.isError
|
||||
background-color: #e8e8e8
|
||||
</style>
|
||||
32
src/components/NProgress.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template lang="pug"></template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import nprogress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
// 介入路由事件
|
||||
router.beforeEach(() => void nprogress.start())
|
||||
|
||||
router.afterEach(() => void nprogress.done())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
#nprogress
|
||||
.bar
|
||||
background-color: var(--theme-secondary-color)
|
||||
top: 50px
|
||||
.peg
|
||||
display: none
|
||||
|
||||
.spinner
|
||||
top: 60px
|
||||
|
||||
.spinner-icon
|
||||
border-top-color: var(--theme-secondary-color)
|
||||
border-left-color: var(--theme-secondary-color)
|
||||
</style>
|
||||
33
src/components/NaiveuiProvider.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template lang="pug">
|
||||
NConfigProvider(
|
||||
:locale='zhCN',
|
||||
:theme-overrides='theme'
|
||||
preflight-style-disabled
|
||||
)
|
||||
NDialogProvider
|
||||
NMessageProvider
|
||||
slot
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
NMessageProvider,
|
||||
zhCN,
|
||||
} from 'naive-ui'
|
||||
|
||||
const theme = ref({
|
||||
common: {
|
||||
primaryColor: 'rgb(53, 151, 231)',
|
||||
primaryColorHover: 'rgb(63, 161, 241)',
|
||||
primaryColorPressed: 'rgb(43, 141, 221)',
|
||||
infoColor: '#7a9dff',
|
||||
infoColorHover: '#b1c6ff',
|
||||
infoColorPressed: '#557ef1',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass"></style>
|
||||
44
src/components/Placeholder.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
version="1.1"
|
||||
class="svgspinner"
|
||||
width="400"
|
||||
height="300"
|
||||
>
|
||||
<g class="spingroup" transform="matrix(1,0,0,1,200,150)">
|
||||
<circle
|
||||
class="spincircle"
|
||||
r="36"
|
||||
stroke-width="5"
|
||||
stroke="#3697e7"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
||||
.svgspinner
|
||||
max-width: 100%
|
||||
|
||||
.svgspinner .spincircle
|
||||
animation: loading-round 1.2s infinite linear, loading-dash 2s infinite linear alternate
|
||||
stroke-dasharray: 236
|
||||
|
||||
@keyframes loading-round
|
||||
0%
|
||||
transform: rotate(0deg)
|
||||
|
||||
100%
|
||||
transform: rotate(720deg)
|
||||
|
||||
@keyframes loading-dash
|
||||
0%
|
||||
stroke-dashoffset: 236
|
||||
|
||||
100%
|
||||
stroke-dashoffset: 0
|
||||
</style>
|
||||
78
src/components/SearchBox.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template lang="pug">
|
||||
.search-box
|
||||
input(
|
||||
@keyup.enter='makeSearch'
|
||||
placeholder='输入关键词搜索/输入 id:数字 查看作品'
|
||||
v-model='keyword'
|
||||
)
|
||||
IFasSearch.icon(data-icon)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import IFasSearch from '~icons/fa-solid/search'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const keyword = ref((route.params.keyword as string) || '')
|
||||
|
||||
function makeSearch(): void {
|
||||
if (!keyword.value) {
|
||||
return
|
||||
}
|
||||
if (/^id:(\d+)$/.test(keyword.value)) {
|
||||
router.push(`/artworks/${/^id:(\d+)$/.exec(keyword.value)?.[1]}`)
|
||||
return
|
||||
}
|
||||
router.push(`/search/${encodeURIComponent(keyword.value)}/1`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
// Search Box
|
||||
.search-box
|
||||
display: flex
|
||||
position: relative
|
||||
align-items: center
|
||||
font-size: 0.8rem
|
||||
|
||||
.icon, [data-icon]
|
||||
position: absolute
|
||||
left: 0.6em
|
||||
pointer-events: none
|
||||
color: var(--theme-border-color)
|
||||
transition: all 0.24s ease-in-out
|
||||
|
||||
input
|
||||
color: var(--theme-border-color)
|
||||
font-size: inherit
|
||||
box-sizing: border-box
|
||||
border: none
|
||||
// border: 2px solid #fff
|
||||
border-radius: 2em
|
||||
outline: none
|
||||
padding: 0.2rem 0.6em
|
||||
padding-left: 2em
|
||||
height: 2rem
|
||||
background-color: rgba(255, 255, 255, 0.7)
|
||||
width: 100%
|
||||
transition: all 0.12s ease-in-out
|
||||
|
||||
&:focus
|
||||
color: var(--theme-text-color)
|
||||
background-color: rgba(255, 255, 255, 0.94)
|
||||
// width: calc(25vw + 10em)
|
||||
|
||||
&:focus + .icon, &:focus + [data-icon]
|
||||
color: var(--theme-text-color)
|
||||
|
||||
&.big
|
||||
font-size: 1.4rem
|
||||
|
||||
input
|
||||
width: 100%
|
||||
height: 3rem
|
||||
border-width: 4px
|
||||
|
||||
.global-navbar .search-box input
|
||||
background-color: none
|
||||
</style>
|
||||
42
src/components/ShowMore.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template lang="pug">
|
||||
.show-more(ref='elRef')
|
||||
a(@click='method')
|
||||
| {{ text }}
|
||||
|
|
||||
IFasPlus(v-if='!loading')
|
||||
IFasSpinner.spin(v-else)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import IFasPlus from '~icons/fa-solid/plus'
|
||||
import IFasSpinner from '~icons/fa-solid/spinner'
|
||||
|
||||
const elRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
method: () => any | Promise<any>
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
useIntersectionObserver(elRef, async ([{ isIntersecting }]) => {
|
||||
if (isIntersecting) {
|
||||
await nextTick()
|
||||
props.method()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
||||
.show-more
|
||||
text-align: center
|
||||
|
||||
a
|
||||
display: inline-block
|
||||
margin: 1rem auto
|
||||
background-color: var(--theme-tag-color)
|
||||
padding: 0.4rem 8rem
|
||||
border-radius: 4px
|
||||
cursor: pointer
|
||||
</style>
|
||||
44
src/components/SideNav/ListLink.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template lang="pug">
|
||||
mixin content
|
||||
slot
|
||||
IFasListUl.svg--ListLink
|
||||
| {{ text }}
|
||||
|
||||
li
|
||||
RouterLink.plain(:to='link' v-if='link')
|
||||
+content
|
||||
a.plain(:href='externalLink' target='_blank' v-else-if='externalLink')
|
||||
+content
|
||||
a.plain.not-allowed(v-else)
|
||||
+content
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import IFasListUl from '~icons/fa-solid/list-ul'
|
||||
defineProps<{
|
||||
text: string
|
||||
externalLink?: string
|
||||
link?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
li
|
||||
a
|
||||
padding: 0.8rem 1.6rem
|
||||
display: block
|
||||
color: #888
|
||||
|
||||
&:hover
|
||||
background-color: rgba(0, 0, 0, 0.05)
|
||||
|
||||
&.not-allowed
|
||||
cursor: not-allowed
|
||||
text-decoration: line-through
|
||||
|
||||
.svg--ListLink
|
||||
width: 2em
|
||||
|
||||
:slotted(svg)
|
||||
width: 2em
|
||||
</style>
|
||||
177
src/components/SideNav/SideNav.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template lang="pug">
|
||||
aside.global-side-nav(:class='{ hidden: !sideNavStore.isOpened }')
|
||||
.backdrop(@click='closeSideNav')
|
||||
.inner
|
||||
.group
|
||||
.search-area
|
||||
SearchBox
|
||||
|
||||
.list
|
||||
.group
|
||||
.title 导航
|
||||
ul
|
||||
ListLink(link='/' text='首页')
|
||||
IFasHome.link-icon
|
||||
ListLink(link='/discovery' text='探索发现')
|
||||
IFasImage.link-icon
|
||||
ListLink(link='/ranking' text='排行榜')
|
||||
IFasCrown.link-icon
|
||||
|
||||
.group
|
||||
.title 用户
|
||||
ul
|
||||
ListLink(
|
||||
:text='userStore.isLoggedIn ? "查看令牌" : "设置令牌"'
|
||||
link='/login'
|
||||
)
|
||||
IFasFingerprint.link-icon
|
||||
ListLink(
|
||||
:link='userStore.isLoggedIn ? `/users/${userStore.userId}` : `/login?back=${$route.fullPath}`'
|
||||
text='我的页面'
|
||||
)
|
||||
IFasUser.link-icon
|
||||
ListLink(
|
||||
:link='userStore.isLoggedIn ? `/users/${userStore.userId}/following` : `/login?back=${$route.fullPath}`'
|
||||
text='我的关注'
|
||||
)
|
||||
IFasUser.link-icon
|
||||
ListLink(link='/following/latest' text='关注用户的作品')
|
||||
IFasUser.link-icon
|
||||
|
||||
.group
|
||||
.title PixivNow
|
||||
ul
|
||||
ListLink(externalLink='https://www.pixiv.net/' text='Pixiv.net')
|
||||
IFasExternalLinkAlt.link-icon
|
||||
ListLink(link='/about' text='关于我们')
|
||||
IFasHeart.link-icon
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ListLink from './ListLink.vue'
|
||||
import SearchBox from '../SearchBox.vue'
|
||||
import IFasCrown from '~icons/fa-solid/crown'
|
||||
import IFasExternalLinkAlt from '~icons/fa-solid/external-link-alt'
|
||||
import IFasFingerprint from '~icons/fa-solid/fingerprint'
|
||||
import IFasHeart from '~icons/fa-solid/heart'
|
||||
import IFasHome from '~icons/fa-solid/home'
|
||||
import IFasImage from '~icons/fa-solid/image'
|
||||
import IFasUser from '~icons/fa-solid/user'
|
||||
import { useSideNavStore, useUserStore } from '@/composables/states'
|
||||
|
||||
const sideNavStore = useSideNavStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
router.afterEach(() => sideNavStore.close())
|
||||
|
||||
sideNavStore.$subscribe((_mutation, state): void => {
|
||||
if (state.openState) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = 'visible'
|
||||
}
|
||||
})
|
||||
|
||||
function closeSideNav() {
|
||||
sideNavStore.close()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeSideNav()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
svg.link-icon
|
||||
width: 2em
|
||||
|
||||
.global-side-nav
|
||||
z-index: 90
|
||||
|
||||
.backdrop
|
||||
position: fixed
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100vw
|
||||
height: 100vh
|
||||
background-color: rgba(0, 0, 0, 0.1)
|
||||
z-index: 90
|
||||
|
||||
.inner
|
||||
position: fixed
|
||||
top: 0
|
||||
left: 0
|
||||
width: 240px
|
||||
max-width: 80vw
|
||||
padding-top: 50px
|
||||
height: 100vh
|
||||
background-color: #fff
|
||||
z-index: 95
|
||||
transition: all 0.5s
|
||||
|
||||
.side-nav-toggle
|
||||
font-size: 1.2rem
|
||||
text-align: center
|
||||
margin: auto 0.5rem
|
||||
color: var(--theme-border-color)
|
||||
cursor: pointer
|
||||
width: 2.4rem
|
||||
height: 2.4rem
|
||||
border-radius: 50%
|
||||
display: flex
|
||||
align-items: center
|
||||
background-color: rgba(0,0,0,0.05)
|
||||
|
||||
[data-icon]
|
||||
margin: 0 auto
|
||||
|
||||
.list
|
||||
max-height: calc(100vh - 56px)
|
||||
overflow-x: auto
|
||||
|
||||
.group
|
||||
margin: 0.5rem 0
|
||||
|
||||
.title
|
||||
user-select: none
|
||||
padding: 0 1.6rem
|
||||
margin: 1.6rem 0 0.4rem 0
|
||||
font-weight: 600
|
||||
font-size: 0.8rem
|
||||
color: #aaa
|
||||
|
||||
ul
|
||||
margin: 0
|
||||
list-style: none
|
||||
padding-left: 0
|
||||
|
||||
// Top banner
|
||||
.banner
|
||||
height: 50px
|
||||
padding: 0.4rem
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
.siteLogo
|
||||
height: 2.2rem
|
||||
|
||||
// Hidden state
|
||||
.hidden
|
||||
.inner
|
||||
left: -300px
|
||||
.backdrop
|
||||
display: none
|
||||
|
||||
.search-area
|
||||
display: block
|
||||
padding: 0 1.6rem
|
||||
.search-box
|
||||
box-shadow: 0 0 8px #ddd
|
||||
border-radius: 2em
|
||||
|
||||
@media screen and (min-width: 450px)
|
||||
.search-area
|
||||
display: none !important
|
||||
</style>
|
||||
101
src/components/SiteFooter.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template lang="pug">
|
||||
footer.global-footer
|
||||
.top.flex.container
|
||||
section.flex-1
|
||||
h4 探索更多
|
||||
ul
|
||||
li
|
||||
ExternalLink(href='/api/random?format=image') 随机图片
|
||||
li
|
||||
RouterLink(to='/ranking') 今日排行
|
||||
li
|
||||
RouterLink(to='/about') 关于本站
|
||||
section.flex-1
|
||||
h4 关注我们
|
||||
ul
|
||||
li
|
||||
| 网站作者:
|
||||
| pixivperoe
|
||||
li
|
||||
| 原项目团队:
|
||||
RouterLink.plain(to='/users/32338232') Dragon Fish
|
||||
| 、
|
||||
RouterLink.plain(to='/users/15552366') MysticNebula70
|
||||
//- section.flex-1
|
||||
//- h4 社交媒体
|
||||
//- p Placeholder
|
||||
section.flex-1
|
||||
h4 友情链接
|
||||
div 快来 GitHub issues 交换友链吧~
|
||||
//- ul
|
||||
//- li 链接
|
||||
|
||||
.bottom.align-center
|
||||
p.copyright
|
||||
| Copyright © {{ yearStr }}
|
||||
|
|
||||
a(:href='GITHUB_URL' target='_blank') {{ PROJECT_NAME }}
|
||||
|
|
||||
em v{{ version }}
|
||||
.dev-only(style='font-style: italic')
|
||||
| This is test site →
|
||||
a(:href='"https://pixiv.js.org" + $route.path' target='_blank') Go to Prod.
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ExternalLink from './ExternalLink.vue'
|
||||
import { GITHUB_URL, PROJECT_NAME, GITHUB_OWNER, version } from '@/config'
|
||||
|
||||
const yearStr = ref(`2021 - ${new Date().getFullYear()}`)
|
||||
</script>
|
||||
<style scoped lang="sass">
|
||||
|
||||
.global-footer
|
||||
background-color: var(--theme-accent-color)
|
||||
font-size: 1rem
|
||||
color: var(--theme-accent-link-color)
|
||||
|
||||
.top
|
||||
padding-top: 2rem
|
||||
padding-bottom: 2rem
|
||||
gap: 1.5rem
|
||||
|
||||
.bottom
|
||||
padding-top: 0.5rem
|
||||
padding-bottom: 0.5rem
|
||||
|
||||
a
|
||||
--color: #eee
|
||||
font-weight: 600
|
||||
|
||||
&::after
|
||||
visibility: visible
|
||||
transform: scaleX(1)
|
||||
width: 40%
|
||||
height: 1px
|
||||
|
||||
&:hover::after
|
||||
width: 100%
|
||||
|
||||
.bottom
|
||||
background-color: var(--theme-accent-color-darken)
|
||||
|
||||
h4
|
||||
position: relative
|
||||
margin: 1rem 0 0.5rem 0
|
||||
padding-bottom: 0.2rem
|
||||
border-bottom: 2px solid
|
||||
font-size: 1.1rem
|
||||
|
||||
ul
|
||||
padding-left: 1rem
|
||||
margin: 0.2rem 0
|
||||
|
||||
a
|
||||
display: inline
|
||||
font-weight: 400
|
||||
|
||||
@media screen and (max-width: 600px)
|
||||
.top
|
||||
flex-direction: column
|
||||
</style>
|
||||
275
src/components/SiteHeader.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template lang="pug">
|
||||
header.global-navbar(:class='{ "not-at-top": notAtTop, hidden }')
|
||||
.flex
|
||||
a.side-nav-toggle.plain(@click='toggleSideNav')
|
||||
IFasBars(data-icon)
|
||||
|
||||
.logo-area
|
||||
RouterLink.plain(to='/')
|
||||
img.site-logo(:src='LogoH')
|
||||
|
||||
.flex.search-area(v-if='$route.name !== "search"')
|
||||
.search-full.align-right.flex-1
|
||||
SearchBox
|
||||
.search-icon.align-right.flex-1
|
||||
a.pointer.plain(@click='openSideNav')
|
||||
IFasSearch
|
||||
| 搜索
|
||||
.flex.search-area(v-else)
|
||||
|
||||
#global-nav__user-area.user-area
|
||||
.user-link
|
||||
a.dropdown-btn.plain.pointer(
|
||||
:class='{ "show-user": showUserDropdown }'
|
||||
@click.stop='showUserDropdown = !showUserDropdown'
|
||||
)
|
||||
img.avatar(
|
||||
:src='userStore.isLoggedIn ? userStore.userProfileImg : "/~/common/images/no_profile.png"',
|
||||
:title='userStore.isLoggedIn ? userStore.userId + " (" + userStore.userPixivId + ")" : "未登入"'
|
||||
)
|
||||
|
||||
Transition(
|
||||
enter-active-class='fade-in-up'
|
||||
leave-active-class='fade-out-down'
|
||||
mode='out-in'
|
||||
name='fade'
|
||||
)
|
||||
.dropdown-content(v-show='showUserDropdown')
|
||||
ul
|
||||
//- notLogIn
|
||||
li(v-if='!userStore.isLoggedIn')
|
||||
.nav-user-card
|
||||
.top
|
||||
.banner-bg
|
||||
img.avatar(:src='"/~/common/images/no_profile.png"')
|
||||
.details
|
||||
a.user-name 游客
|
||||
.uid 绑定令牌,同步您的 Pixiv 信息!
|
||||
|
||||
//- isLogedIn
|
||||
li(v-if='userStore.isLoggedIn')
|
||||
.nav-user-card
|
||||
.top
|
||||
.banner-bg
|
||||
RouterLink.plain.name(:to='"/users/" + userStore.userId')
|
||||
img.avatar(:src='userStore.userProfileImgBig')
|
||||
.details
|
||||
RouterLink.plain.user-name(
|
||||
:to='"/users/" + userStore.userId'
|
||||
) {{ userStore.userName }}
|
||||
.uid @{{ userStore.userPixivId }}
|
||||
|
||||
li(v-if='userStore.isLoggedIn')
|
||||
RouterLink.plain(
|
||||
:to='{ name: "users", params: { id: userStore.userId }, query: { tab: "public_bookmarks" } }'
|
||||
) 公开收藏
|
||||
li(v-if='userStore.isLoggedIn')
|
||||
RouterLink.plain(
|
||||
:to='{ name: "users", params: { id: userStore.userId }, query: { tab: "hidden_bookmarks" } }'
|
||||
) 私密收藏
|
||||
li(v-if='userStore.isLoggedIn')
|
||||
RouterLink.plain(
|
||||
:to='{ name: "following", params: { id: userStore.userId } }'
|
||||
) 我的关注
|
||||
li(v-if='$route.path !== "/login"')
|
||||
RouterLink.plain(:to='"/login?back=" + $route.path') {{ userStore.isLoggedIn ? '查看令牌' : '用户登入' }}
|
||||
li(v-if='userStore.isLoggedIn')
|
||||
a.plain(@click='logoutUser') 用户登出
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import SearchBox from './SearchBox.vue'
|
||||
import IFasBars from '~icons/fa-solid/bars'
|
||||
import IFasSearch from '~icons/fa-solid/search'
|
||||
import { logout } from './userData'
|
||||
import LogoH from '@/assets/LogoH.png'
|
||||
import { useSideNavStore, useUserStore } from '@/composables/states'
|
||||
|
||||
const hidden = ref(false)
|
||||
const notAtTop = ref(false)
|
||||
const showUserDropdown = ref(false)
|
||||
const sideNavStore = useSideNavStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
function toggleSideNav() {
|
||||
sideNavStore.toggle()
|
||||
}
|
||||
|
||||
function openSideNav() {
|
||||
sideNavStore.open()
|
||||
}
|
||||
|
||||
function logoutUser() {
|
||||
logout()
|
||||
userStore.logout()
|
||||
}
|
||||
|
||||
watch(hidden, (value) => {
|
||||
if (value) {
|
||||
document.body.classList.add('global-navbar_hidden')
|
||||
} else {
|
||||
document.body.classList.remove('global-navbar_hidden')
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
router.afterEach(() => {
|
||||
showUserDropdown.value = false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
const newTop = document.documentElement.scrollTop
|
||||
if (newTop > 50) {
|
||||
notAtTop.value = true
|
||||
} else {
|
||||
notAtTop.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Outside close user dropdown
|
||||
document.addEventListener('click', () => {
|
||||
showUserDropdown.value = false
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
.global-navbar
|
||||
background-color: var(--theme-accent-color)
|
||||
padding: 0.4rem 1rem
|
||||
color: var(--theme-background-color)
|
||||
display: flex
|
||||
align-items: center
|
||||
position: sticky
|
||||
height: 50px
|
||||
width: 100%
|
||||
box-sizing: border-box
|
||||
white-space: nowrap
|
||||
top: 0
|
||||
z-index: 100
|
||||
transition: all .8s ease
|
||||
|
||||
.flex
|
||||
display: flex
|
||||
width: 100%
|
||||
gap: 1rem
|
||||
align-items: center
|
||||
|
||||
&.not-at-top
|
||||
box-shadow: 0 0px 8px var(--theme-box-shadow-color)
|
||||
|
||||
.side-nav-toggle
|
||||
font-size: 1.2rem
|
||||
text-align: center
|
||||
color: var(--theme-accent-link-color)
|
||||
cursor: pointer
|
||||
width: 2.4rem
|
||||
height: 2.4rem
|
||||
border-radius: 50%
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
&:hover
|
||||
background-color: rgba(255,255,255,0.2)
|
||||
|
||||
[data-icon]
|
||||
margin: 0 auto
|
||||
|
||||
.logo-area
|
||||
.site-logo
|
||||
height: 2.2rem
|
||||
width: auto
|
||||
|
||||
.search-area
|
||||
flex: 1
|
||||
|
||||
.user-area
|
||||
.avatar
|
||||
height: 2rem
|
||||
width: 2rem
|
||||
border-radius: 50%
|
||||
|
||||
.user-link
|
||||
position: relative
|
||||
|
||||
.dropdown-btn
|
||||
list-style: none
|
||||
display: flex
|
||||
align-items: center
|
||||
.avatar
|
||||
box-shadow: 0 0 0 2px #fff
|
||||
transition: box-shadow 0.24s ease
|
||||
&.show-user
|
||||
.avatar
|
||||
box-shadow: 0 0 0 2px var(--theme-secondary-color)
|
||||
|
||||
.dropdown-content
|
||||
position: absolute
|
||||
top: 1.4rem
|
||||
right: 0
|
||||
padding: 0
|
||||
padding-top: 0.4rem
|
||||
width: 200px
|
||||
|
||||
ul
|
||||
list-style: none
|
||||
padding: 4px
|
||||
background-color: #fff
|
||||
box-shadow: 0 0 4px #aaa
|
||||
border-radius: 4px
|
||||
|
||||
li > *
|
||||
padding: 0.5rem
|
||||
|
||||
li a
|
||||
display: block
|
||||
cursor: pointer
|
||||
|
||||
&:hover
|
||||
background-color: var(--theme-tag-color)
|
||||
|
||||
.nav-user-card
|
||||
border-bottom: 1px solid
|
||||
position: relative
|
||||
|
||||
.top
|
||||
position: relative
|
||||
|
||||
.banner-bg
|
||||
position: absolute
|
||||
top: calc(-0.4rem - 6px)
|
||||
left: -12px
|
||||
height: 56px
|
||||
width: calc(100% + 24px)
|
||||
background-color: rgba(var(--theme-accent-color--rgb), 0.1)
|
||||
z-index: 0
|
||||
|
||||
a
|
||||
display: inline !important
|
||||
|
||||
.avatar
|
||||
width: 68px
|
||||
height: 68px
|
||||
|
||||
.details
|
||||
.user-name
|
||||
font-size: 1rem
|
||||
|
||||
.uid
|
||||
font-size: 0.8rem
|
||||
color: #aaa
|
||||
|
||||
.search-icon
|
||||
display: none
|
||||
a
|
||||
color: var(--theme-accent-link-color)
|
||||
|
||||
@media (max-width: 450px)
|
||||
.global-navbar
|
||||
.search-full
|
||||
display: none
|
||||
.search-icon
|
||||
display: block
|
||||
</style>
|
||||
49
src/components/SiteNoticeBanner.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template lang="pug">
|
||||
Transition(name='fade')
|
||||
#sitenotice-banner(v-if='isShow')
|
||||
NAlert(
|
||||
@close='handleClose'
|
||||
closable
|
||||
style='font-size: 1.5rem'
|
||||
title='全站公告'
|
||||
type='warning'
|
||||
)
|
||||
NUl
|
||||
NLi: RouterLink(to='/notifications/2024-04-26') 关于 PixivNow 由橘络搭建请问传播)
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {} from 'vue'
|
||||
|
||||
const alreadyShown = ref(false)
|
||||
const forceShow = computed(() => route.name === 'about-us')
|
||||
const isShow = computed(() => {
|
||||
if (route.path === '/notifications/2024-04-26') {
|
||||
return false
|
||||
}
|
||||
if (forceShow.value) return true
|
||||
return !alreadyShown.value
|
||||
})
|
||||
const key = `pixivnow:sitenotice/2024-04-26`
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(() => {
|
||||
alreadyShown.value = !!localStorage.getItem(key)
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
localStorage.setItem(key, '1')
|
||||
alreadyShown.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
.fade-enter-active,
|
||||
.fade-leave-active
|
||||
transition: all 0.5s ease-in-out
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to
|
||||
opacity: 0
|
||||
height: 0
|
||||
</style>
|
||||
319
src/components/UgoiraViewer.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<template lang="pug">
|
||||
#ugoira-viewer
|
||||
canvas.media(
|
||||
:height='illust?.height',
|
||||
:width='illust.width'
|
||||
ref='canvasRef'
|
||||
v-if='firstLoaded'
|
||||
)
|
||||
LazyLoad.media(
|
||||
:height='illust.height',
|
||||
:src='illust.urls.regular',
|
||||
:style='{ cursor: isLoading ? "wait" : "pointer" }',
|
||||
:width='illust.width'
|
||||
@click='handleInit(false)'
|
||||
loading='lazy'
|
||||
v-else
|
||||
)
|
||||
NProgress(
|
||||
:height='6',
|
||||
:percentage='+downloadProgress.toFixed(2)',
|
||||
:processing='isLoading',
|
||||
:style='{ left: 0, right: 0, position: "absolute", ...(isLoading ? { top: "calc(100% + 4px)", opacity: "1", transitionDuration: "0.25s" } : { top: "calc(100% - 4px)", opacity: "0", transitionDelay: "3s", transitionDuration: "0.5s" }) }'
|
||||
show-value
|
||||
status='default'
|
||||
transition='all ease-in-out'
|
||||
type='line'
|
||||
)
|
||||
NFloatButton(
|
||||
:bottom='20',
|
||||
:menu-trigger='"hover"',
|
||||
:right='20',
|
||||
:style='{ cursor: isLoading ? "wait" : "pointer", opacity: 0.75 }'
|
||||
shape='circle'
|
||||
)
|
||||
//- button
|
||||
template(v-if='!firstLoaded')
|
||||
NSpin(size='small' v-if='isLoading')
|
||||
NIcon(v-else): IPlay
|
||||
template(v-else)
|
||||
NIcon: IDownload
|
||||
//- menu
|
||||
template(#menu v-if='!firstLoaded')
|
||||
NFloatButton(@click='handleInit(true)' title='加载原画' v-if='!isLoading')
|
||||
IconPhotoSpark
|
||||
NFloatButton(@click='handleInit(false)' title='加载普通画质')
|
||||
NSpin(size='small' v-if='isLoading')
|
||||
IconPhotoScan(v-else)
|
||||
template(#menu v-if='firstLoaded')
|
||||
NFloatButton(@click='handleJumpToCover' title='查看封面'): IconPhotoDown
|
||||
NFloatButton(@click='handleDownloadGif' title='下载GIF')
|
||||
NSpin(size='small' v-if='isLoadingGif || isLoading')
|
||||
template(v-else): IconGif
|
||||
NFloatButton(@click='handleDownloadMp4' title='下载MP4')
|
||||
NSpin(size='small' v-if='isLoadingMp4 || isLoading')
|
||||
template(v-else): IconMovie
|
||||
NFloatButton(@click='handleInit(true)' title='加载原画' v-if='!isHQLoaded')
|
||||
NSpin(size='small' v-if='isLoading')
|
||||
template(v-else): IconPhotoSpark
|
||||
|
||||
.badge {{ firstLoaded ? (isHQLoaded ? 'HQ' : 'NQ') : 'Cover' }}
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Artwork } from '@/types'
|
||||
import { NSpin, NIcon, NFloatButton, useMessage, NProgress } from 'naive-ui'
|
||||
import { UgoiraPlayer } from '@/utils/UgoiraPlayer'
|
||||
import LazyLoad from './LazyLoad.vue'
|
||||
import IPlay from '~icons/fa-solid/play'
|
||||
import IDownload from '~icons/fa-solid/download'
|
||||
import {
|
||||
IconGif,
|
||||
IconMovie,
|
||||
IconPhotoScan,
|
||||
IconPhotoSpark,
|
||||
IconPhotoDown,
|
||||
} from '@tabler/icons-vue'
|
||||
|
||||
const props = defineProps<{
|
||||
illust: Artwork
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'on:player': [UgoiraPlayer]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const firstLoaded = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const downloadProgress = ref(0)
|
||||
const player = computed(() => {
|
||||
const p = new UgoiraPlayer(props.illust, {
|
||||
onDownloadProgress: (progress, frameIndex, totalFrames) => {
|
||||
downloadProgress.value = progress
|
||||
console.log(
|
||||
`下载进度: ${progress.toFixed(1)}% (${frameIndex + 1}/${totalFrames})`
|
||||
)
|
||||
},
|
||||
onDownloadComplete: () => {
|
||||
console.log('所有帧下载完成')
|
||||
isLoading.value = false
|
||||
},
|
||||
onDownloadError: (error) => {
|
||||
console.error('下载失败:', error)
|
||||
|
||||
// 下载失败后还原状态,让用户可以重新尝试
|
||||
firstLoaded.value = false
|
||||
isHQLoaded.value = false
|
||||
downloadProgress.value = 0
|
||||
isLoading.value = false
|
||||
|
||||
// 清理播放器状态
|
||||
p.destroy()
|
||||
|
||||
message.warning('Ugoira 下载失败,请重试')
|
||||
},
|
||||
})
|
||||
emit('on:player', p)
|
||||
return p
|
||||
})
|
||||
const isPlaying = ref(true)
|
||||
|
||||
const isHQLoaded = ref(false)
|
||||
async function handleInit(originalQuality?: boolean) {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
downloadProgress.value = 0
|
||||
|
||||
try {
|
||||
player.value.destroy()
|
||||
await player.value.fetchMeta()
|
||||
|
||||
// 立即设置 canvas 和标记为已加载,这样下载过程中就可以开始渲染
|
||||
firstLoaded.value = true
|
||||
if (originalQuality) isHQLoaded.value = true
|
||||
await nextTick()
|
||||
player.value.setupCanvas(canvasRef.value!)
|
||||
|
||||
// 开始下载帧,下载过程中会自动渲染到 canvas
|
||||
await player.value.fetchFrames(originalQuality)
|
||||
|
||||
// 下载完成后开始正常播放
|
||||
player.value.play()
|
||||
} catch (error) {
|
||||
console.error('Ugoira 初始化失败:', error)
|
||||
|
||||
// 初始化失败后还原状态,让用户可以重新尝试
|
||||
firstLoaded.value = false
|
||||
isHQLoaded.value = false
|
||||
downloadProgress.value = 0
|
||||
isLoading.value = false
|
||||
|
||||
// 清理播放器状态
|
||||
player.value.destroy()
|
||||
|
||||
message.error('Ugoira 初始化失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
function handlePause() {
|
||||
if (isPlaying.value) {
|
||||
player.value.pause()
|
||||
} else {
|
||||
player.value.play()
|
||||
}
|
||||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
function handleJumpToCover() {
|
||||
const a = document.createElement('a')
|
||||
a.href = props.illust.urls.original
|
||||
a.target = '_blank'
|
||||
a.click()
|
||||
}
|
||||
|
||||
const isLoadingGif = ref(false)
|
||||
const gifBlob = ref<Blob>()
|
||||
async function handleDownloadGif() {
|
||||
if (!player.value.canExport) return
|
||||
const filename = `${props.illust.illustId}.ugoira.gif`
|
||||
|
||||
if (gifBlob.value) {
|
||||
downloadBlob(gifBlob.value, filename)
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoadingGif.value) return
|
||||
|
||||
// 检查是否可以导出
|
||||
if (!player.value.canExport) {
|
||||
console.warn('下载未完成,无法导出 GIF')
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingGif.value = true
|
||||
|
||||
try {
|
||||
const blob = await player.value.renderGif()
|
||||
gifBlob.value = blob
|
||||
downloadBlob(blob, filename)
|
||||
} finally {
|
||||
isLoadingGif.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isLoadingMp4 = ref(false)
|
||||
const mp4Blob = ref<Blob>()
|
||||
async function handleDownloadMp4() {
|
||||
if (!player.value.canExport) return
|
||||
const filename = `${props.illust.illustId}.ugoira.mp4`
|
||||
|
||||
if (mp4Blob.value) {
|
||||
downloadBlob(mp4Blob.value, filename)
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoadingMp4.value) return
|
||||
|
||||
// 检查是否可以导出
|
||||
if (!player.value.canExport) {
|
||||
console.warn('下载未完成,无法导出 MP4')
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingMp4.value = true
|
||||
|
||||
try {
|
||||
const blob = await player.value.renderMp4()
|
||||
mp4Blob.value = blob
|
||||
downloadBlob(blob, filename)
|
||||
} finally {
|
||||
isLoadingMp4.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
player.value.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
#ugoira-viewer
|
||||
display: inline-block
|
||||
position: relative
|
||||
transform: translate(0)
|
||||
line-height: 0
|
||||
|
||||
.media
|
||||
border-radius: 4px
|
||||
box-shadow: var(--theme-box-shadow)
|
||||
transition: box-shadow 0.24s ease-in-out
|
||||
max-width: 100%
|
||||
max-height: 60vh
|
||||
width: auto
|
||||
height: auto
|
||||
&:hover
|
||||
box-shadow: var(--theme-box-shadow-hover)
|
||||
|
||||
.controller
|
||||
position: absolute
|
||||
bottom: 1rem
|
||||
right: 1rem
|
||||
|
||||
.badge
|
||||
color: #999
|
||||
font-size: 0.6rem
|
||||
background: rgba(150, 150, 150, 0.25)
|
||||
padding: 0.1rem 0.25rem
|
||||
border-radius: 0.2rem
|
||||
position: absolute
|
||||
right: 0.25rem
|
||||
top: 0.25rem
|
||||
line-height: 1
|
||||
user-select: none
|
||||
pointer-events: none
|
||||
z-index: 1
|
||||
|
||||
.download-progress
|
||||
position: absolute
|
||||
bottom: 0.5rem
|
||||
left: 0.5rem
|
||||
right: 0.5rem
|
||||
z-index: 2
|
||||
background: rgba(0, 0, 0, 0.7)
|
||||
border-radius: 0.25rem
|
||||
padding: 0.5rem
|
||||
backdrop-filter: blur(4px)
|
||||
|
||||
.progress-bar
|
||||
width: 100%
|
||||
height: 4px
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
border-radius: 2px
|
||||
overflow: hidden
|
||||
margin-bottom: 0.25rem
|
||||
|
||||
.progress-fill
|
||||
height: 100%
|
||||
background: linear-gradient(90deg, #4CAF50, #8BC34A)
|
||||
border-radius: 2px
|
||||
transition: width 0.3s ease
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.5)
|
||||
|
||||
.progress-text
|
||||
color: white
|
||||
font-size: 0.75rem
|
||||
text-align: center
|
||||
font-weight: 500
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5)
|
||||
</style>
|
||||
81
src/components/userData.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { PixivUser } from '@/types'
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
export function existsSessionId(): boolean {
|
||||
const sessionId = Cookies.get('PHPSESSID')
|
||||
if (sessionId) {
|
||||
return true
|
||||
} else {
|
||||
Cookies.remove('CSRFTOKEN')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function initUser(): Promise<PixivUser> {
|
||||
try {
|
||||
const { data } = await axios.get<{ userData: PixivUser; token: string }>(
|
||||
`/api/user`,
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
}
|
||||
)
|
||||
if (data.token) {
|
||||
console.log('session ID认证成功', data)
|
||||
Cookies.set('CSRFTOKEN', data.token, { secure: true, sameSite: 'Strict' })
|
||||
const res = data.userData
|
||||
return res
|
||||
} else {
|
||||
Cookies.remove('CSRFTOKEN')
|
||||
return Promise.reject('无效的session ID')
|
||||
}
|
||||
} catch (err) {
|
||||
Cookies.remove('CSRFTOKEN')
|
||||
return Promise.reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
export function login(token: string): Promise<PixivUser> {
|
||||
if (!validateSessionId(token)) {
|
||||
console.error('访问令牌格式错误')
|
||||
return Promise.reject('访问令牌格式错误')
|
||||
}
|
||||
Cookies.set('PHPSESSID', token, {
|
||||
expires: 180,
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'Strict',
|
||||
})
|
||||
return initUser()
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
const token = Cookies.get('PHPSESSID')
|
||||
if (token && confirm(`您要移除您的令牌吗?\n${token}`)) {
|
||||
Cookies.remove('PHPSESSID')
|
||||
Cookies.remove('CSRFTOKEN')
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSessionId(token: string): boolean {
|
||||
return /^\d{2,10}_[0-9A-Za-z]{32}$/.test(token)
|
||||
}
|
||||
|
||||
export function exampleSessionId(): string {
|
||||
const uid = new Uint32Array(1)
|
||||
window.crypto.getRandomValues(uid)
|
||||
const secret = (() => {
|
||||
const strSet =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const final = []
|
||||
const indexes = new Uint8Array(32)
|
||||
window.crypto.getRandomValues(indexes)
|
||||
for (const i of indexes) {
|
||||
const charIndex = Math.floor((i * strSet.length) / 256)
|
||||
final.push(strSet[charIndex])
|
||||
}
|
||||
return final.join('')
|
||||
})()
|
||||
return `${uid[0]}_${secret}`
|
||||
}
|
||||
44
src/composables/states.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { PixivUser } from '@/types'
|
||||
|
||||
export const useSideNavStore = defineStore('sidenav', () => {
|
||||
const openState = ref(false)
|
||||
const isOpened = computed(() => openState.value)
|
||||
function toggle() {
|
||||
openState.value = !openState.value
|
||||
}
|
||||
function open() {
|
||||
openState.value = true
|
||||
}
|
||||
function close() {
|
||||
openState.value = false
|
||||
}
|
||||
return { openState, isOpened, toggle, open, close }
|
||||
})
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref<PixivUser | null>(null)
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
const userId = computed(() => user.value?.id)
|
||||
const userName = computed(() => user.value?.name)
|
||||
const userPixivId = computed(() => user.value?.pixivId)
|
||||
const userProfileImg = computed(() => user.value?.profileImg)
|
||||
const userProfileImgBig = computed(() => user.value?.profileImgBig)
|
||||
function login(data: PixivUser) {
|
||||
user.value = data
|
||||
}
|
||||
function logout() {
|
||||
user.value = null
|
||||
}
|
||||
return {
|
||||
user,
|
||||
isLoggedIn,
|
||||
userId,
|
||||
userName,
|
||||
userPixivId,
|
||||
userProfileImg,
|
||||
userProfileImgBig,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
23
src/config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Env
|
||||
import { version } from '../package.json'
|
||||
export { version }
|
||||
|
||||
export const SITE_ENV =
|
||||
import.meta.env.MODE === 'development' ||
|
||||
version.includes('-') ||
|
||||
location.hostname === 'pixiv-next.vercel.app'
|
||||
? 'development'
|
||||
: 'production'
|
||||
|
||||
// Copyright links
|
||||
// Do not modify please
|
||||
export const GITHUB_OWNER = 'FreeNowOrg'
|
||||
export const GITHUB_REPO = 'PixivNow'
|
||||
export const GITHUB_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}`
|
||||
|
||||
// Site name
|
||||
export const PROJECT_NAME = 'PixivNow'
|
||||
export const PROJECT_TAGLINE = 'Enjoy Pixiv Now (pixiv.js.org)'
|
||||
|
||||
// Image proxy cache seconds
|
||||
export const IMAGE_CACHE_SECONDS = 12 * 60 * 60 * 1000
|
||||
7
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// declare module '*.vue' {
|
||||
// import { ComponentOptions } from 'vue'
|
||||
// const componentOptions: ComponentOptions
|
||||
// export default componentOptions
|
||||
// }
|
||||
3
src/locales/zh-Hans.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ArtworkCard.pageCount": "共{0}张"
|
||||
}
|
||||
14
src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue'
|
||||
import { SITE_ENV } from '@/config'
|
||||
import { registerPlugins } from '@/plugins'
|
||||
import App from './App.vue'
|
||||
import '@/styles/index.sass'
|
||||
|
||||
// Create App
|
||||
const app = createApp(App)
|
||||
|
||||
registerPlugins(app)
|
||||
|
||||
// Mount
|
||||
app.mount('#app')
|
||||
document.body?.setAttribute('data-env', SITE_ENV)
|
||||
30
src/plugins/i18n.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createI18n, I18n } from 'vue-i18n'
|
||||
|
||||
export const SUPPORTED_LOCALES = ['zh-Hans']
|
||||
|
||||
export function setupI18n(options = { locale: 'zh-Hans' }) {
|
||||
const i18n = createI18n({ ...options, legacy: false })
|
||||
setI18nLanguage(i18n, options.locale)
|
||||
return i18n
|
||||
}
|
||||
|
||||
export function setI18nLanguage(
|
||||
i18n: I18n<any, any, any, string, false>,
|
||||
locale: string
|
||||
) {
|
||||
i18n.global.locale.value = locale
|
||||
document.querySelector('html')?.setAttribute('lang', locale)
|
||||
}
|
||||
|
||||
export async function loadLocaleMessages(
|
||||
i18n: I18n<any, any, any, string, false>,
|
||||
locale: string
|
||||
) {
|
||||
const messages = await import(
|
||||
/* webpackChunkName: "locale-[request]" */ `@/locales/${locale}.json`
|
||||
)
|
||||
i18n.global.setLocaleMessage(locale, messages.default)
|
||||
setI18nLanguage(i18n, locale)
|
||||
|
||||
return nextTick()
|
||||
}
|
||||
26
src/plugins/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { router } from './router'
|
||||
import { loadLocaleMessages, setupI18n } from './i18n'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createGtag } from 'vue-gtag'
|
||||
import type { App } from 'vue'
|
||||
|
||||
export async function registerPlugins(app: App<Element>) {
|
||||
const i18n = setupI18n()
|
||||
const initialLocale = 'zh-Hans'
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
app.use(createPinia())
|
||||
|
||||
if (import.meta.env.VITE_GOOGLE_ANALYTICS_ID) {
|
||||
app.use(
|
||||
createGtag({
|
||||
tagId: import.meta.env.VITE_GOOGLE_ANALYTICS_ID,
|
||||
pageTracker: {
|
||||
router,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
await loadLocaleMessages(i18n, initialLocale)
|
||||
}
|
||||
116
src/plugins/router.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
||||
import { createDiscreteApi } from 'naive-ui'
|
||||
const { message } = createDiscreteApi(['message'])
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('@/view/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/artworks/:id',
|
||||
alias: ['/illust/:id', '/i/:id'],
|
||||
name: 'artworks',
|
||||
component: () => import('@/view/artworks.vue'),
|
||||
},
|
||||
{
|
||||
path: '/following/latest',
|
||||
alias: ['/bookmark_new_illust'],
|
||||
name: 'following-latest',
|
||||
component: () => import('@/view/following-latest.vue'),
|
||||
},
|
||||
{
|
||||
path: '/users/:id',
|
||||
name: 'users',
|
||||
alias: ['/u/:id'],
|
||||
component: () => import('@/view/users.vue'),
|
||||
},
|
||||
{
|
||||
path: '/users/:id/following',
|
||||
name: 'following',
|
||||
component: () => import('@/view/following.vue'),
|
||||
},
|
||||
{
|
||||
path: '/search/:keyword',
|
||||
name: 'search-index-redirect',
|
||||
redirect: (to) => `/search/${to.params.keyword}/1`,
|
||||
},
|
||||
{
|
||||
path: '/search/:keyword/:p',
|
||||
name: 'search',
|
||||
component: () => import('@/view/search.vue'),
|
||||
},
|
||||
{
|
||||
path: '/discovery',
|
||||
name: 'discovery',
|
||||
component: () => import('@/view/discovery.vue'),
|
||||
},
|
||||
{
|
||||
path: '/ranking',
|
||||
name: 'ranking',
|
||||
component: () => import('@/view/ranking.vue'),
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'user-login',
|
||||
component: () => import('@/view/login.vue'),
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about-us',
|
||||
component: () => import('@/view/about.vue'),
|
||||
},
|
||||
{
|
||||
path: '/notifications/2024-04-26',
|
||||
name: 'notification-2024-04-26',
|
||||
component: () => import('@/view/notifications/2024-04-26.vue'),
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/view/404.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
routes.push({
|
||||
path: '/_debug',
|
||||
name: 'debug',
|
||||
children: [
|
||||
{
|
||||
path: 'zip',
|
||||
name: 'debug-zip',
|
||||
component: () => import('@/view/_debug/zip.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return {
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
router.afterEach(({ name }) => {
|
||||
document.body.setAttribute('data-route', name as string)
|
||||
// Fix route when modal opened
|
||||
document.body.style.overflow = 'visible'
|
||||
})
|
||||
|
||||
router.onError((error, to, from) => {
|
||||
console.log(error, to, from)
|
||||
message.error(error)
|
||||
})
|
||||
|
||||
export default router
|
||||
39
src/styles/animate.sass
Normal file
@@ -0,0 +1,39 @@
|
||||
@use "sass:color"
|
||||
|
||||
.fade-in-up
|
||||
animation: fadeInUp 0.24s ease
|
||||
|
||||
.fade-out-down
|
||||
animation: fadeOutDown 0.4s ease
|
||||
|
||||
svg.spin
|
||||
animation: spin 2s linear infinite
|
||||
|
||||
@keyframes fadeInUp
|
||||
0%
|
||||
opacity: 0
|
||||
transform: translate3d(0, 1rem, 0)
|
||||
|
||||
to
|
||||
opacity: 1
|
||||
transform: translateZ(0)
|
||||
|
||||
@keyframes fadeOutDown
|
||||
0%
|
||||
opacity: 1
|
||||
|
||||
to
|
||||
opacity: 0
|
||||
transform: translate3d(0, 1rem, 0)
|
||||
|
||||
@keyframes imgProgress
|
||||
from
|
||||
background-color: color.adjust(#e8e8e8, $lightness: 4%)
|
||||
to
|
||||
background-color: #e8e8e8
|
||||
|
||||
@keyframes spin
|
||||
from
|
||||
transform: rotate(0deg)
|
||||
to
|
||||
transform: rotate(360deg)
|
||||
141
src/styles/elements.sass
Normal file
@@ -0,0 +1,141 @@
|
||||
// Headers
|
||||
@mixin header-shared($font-size, $shadow-color)
|
||||
font-size: $font-size
|
||||
text-shadow: 1px 1px 0 var(--theme-text-shadow-color), -1px -1px 0 var(--theme-text-shadow-color)
|
||||
box-shadow: 0 0.1em 0 $shadow-color
|
||||
|
||||
article
|
||||
h1
|
||||
font-size: 1.8rem
|
||||
|
||||
h2, h3, h4, h5, h6
|
||||
font-weight: 700
|
||||
padding: 0.2rem
|
||||
color: var(--theme-text-color)
|
||||
position: relative
|
||||
&::before
|
||||
content: ""
|
||||
display: block
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 0
|
||||
transform: translateY(-50%)
|
||||
border-radius: 1em
|
||||
&.plain::before
|
||||
display: none
|
||||
// 用于辅助目录进行定位,让锚点不会被顶部导航遮住
|
||||
[id]
|
||||
padding-top: 60px
|
||||
margin-top: -60px
|
||||
|
||||
h2:not(.plain)
|
||||
font-size: 1.5em
|
||||
margin: 1rem 0
|
||||
padding-left: 1rem
|
||||
&::before
|
||||
width: 0.25em
|
||||
height: 70%
|
||||
background-color: var(--theme-accent-color)
|
||||
|
||||
h3:not(.plain)
|
||||
font-size: 1.3rem
|
||||
margin: 0.5em 0
|
||||
padding-left: 0.8rem
|
||||
&::before
|
||||
width: 0.2em
|
||||
height: 60%
|
||||
background-color: rgba(var(--theme-accent-color--rgb), 0.75)
|
||||
h4:not(.plain)
|
||||
font-size: 1.2rem
|
||||
margin: 0.5em 0
|
||||
padding-left: 0.6rem
|
||||
&::before
|
||||
width: 0.15em
|
||||
height: 50%
|
||||
background-color: rgba(var(--theme-accent-color--rgb), 0.6)
|
||||
h5:not(.plain)
|
||||
font-size: 1.125rem
|
||||
margin: 0.5em 0
|
||||
padding-left: 0.4rem
|
||||
&::before
|
||||
width: 0.1em
|
||||
height: 40%
|
||||
background-color: rgba(var(--theme-accent-color--rgb), 0.5)
|
||||
h6:not(.plain)
|
||||
font-size: 1.125rem
|
||||
margin: 0.5em 0
|
||||
|
||||
// Links
|
||||
a
|
||||
--color: var(--theme-link-color)
|
||||
color: var(--color)
|
||||
text-decoration: none
|
||||
position: relative
|
||||
display: inline-block
|
||||
|
||||
&.plain
|
||||
display: unset
|
||||
|
||||
&:not(.plain)::after
|
||||
content: ''
|
||||
display: block
|
||||
position: absolute
|
||||
width: 100%
|
||||
height: 0.1em
|
||||
bottom: -0.1em
|
||||
left: 0
|
||||
background-color: var(--color)
|
||||
visibility: hidden
|
||||
transform: scaleX(0)
|
||||
transition: all 0.4s ease-in-out
|
||||
|
||||
&:not(.plain):hover::after,
|
||||
&.router-link-active::after,
|
||||
&.tab-active::after,
|
||||
&.is-active::after
|
||||
visibility: visible
|
||||
transform: scaleX(1)
|
||||
|
||||
&.button
|
||||
padding: 0.2rem 0.4rem
|
||||
background-color: var(--theme-tag-color)
|
||||
transition: all .4s ease
|
||||
cursor: pointer
|
||||
|
||||
&:hover
|
||||
background-color: rgba(var(--theme-link-color--rgb), 1)
|
||||
color: var(--theme-accent-link-color)
|
||||
|
||||
// Code
|
||||
pre
|
||||
overflow: auto
|
||||
background: #efefef
|
||||
padding: 4px
|
||||
|
||||
code
|
||||
background-color: #efefef
|
||||
display: inline
|
||||
border-radius: 2px
|
||||
padding: .1rem .2rem
|
||||
color: #e02080
|
||||
word-break: break-word
|
||||
|
||||
// Responsive
|
||||
.responsive,
|
||||
.body-inner
|
||||
@media (min-width: 1280px)
|
||||
margin-left: auto
|
||||
margin-right: auto
|
||||
width: 1200px
|
||||
@media (max-width: 1280px)
|
||||
margin-left: 1rem
|
||||
margin-right: 1rem
|
||||
|
||||
svg.svg--inline
|
||||
display: inline-block
|
||||
vertical-align: -0.125em
|
||||
overflow: visible
|
||||
|
||||
.n-modal
|
||||
width: 600px
|
||||
max-width: 86vw
|
||||
70
src/styles/formats.sass
Normal file
@@ -0,0 +1,70 @@
|
||||
.align-center
|
||||
text-align: center
|
||||
|
||||
.align-left
|
||||
text-align: left
|
||||
|
||||
.align-right
|
||||
text-align: right
|
||||
|
||||
.position-center
|
||||
text-align: left
|
||||
position: relative
|
||||
left: 50%
|
||||
transform: translateX(-50%)
|
||||
|
||||
.flex-center
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
.pre,
|
||||
.poem
|
||||
white-space: pre-wrap
|
||||
|
||||
.flex
|
||||
display: flex
|
||||
|
||||
.flex-1
|
||||
flex: 1
|
||||
|
||||
.flex-list
|
||||
.list-item
|
||||
display: flex
|
||||
gap: 0.5rem
|
||||
|
||||
&:not(:first-of-type)
|
||||
margin-top: 4px
|
||||
|
||||
> div
|
||||
flex: 1
|
||||
|
||||
.key
|
||||
font-weight: 600
|
||||
box-shadow: 2px 0 #dedede
|
||||
|
||||
.pointer
|
||||
cursor: pointer
|
||||
|
||||
// Loading
|
||||
.loading-cover
|
||||
position: relative
|
||||
&::before,&::after
|
||||
content: ""
|
||||
width: 100%
|
||||
height: 100%
|
||||
display: block
|
||||
position: absolute
|
||||
&::before
|
||||
background-image: url(/images/spinner.svg)
|
||||
background-size: 75px
|
||||
background-repeat: no-repeat
|
||||
background-position: center
|
||||
top: 50%
|
||||
left: 50%
|
||||
transform: translateX(-50%) translateY(-50%)
|
||||
z-index: 6
|
||||
&::after
|
||||
top: 0
|
||||
left: 0
|
||||
background-color: rgba(255,255,255,0.25)
|
||||
z-index: 5
|
||||
49
src/styles/index.sass
Normal file
@@ -0,0 +1,49 @@
|
||||
@use 'animate'
|
||||
@use 'elements'
|
||||
@use 'formats'
|
||||
@use 'variables'
|
||||
|
||||
html,
|
||||
body
|
||||
margin: 0
|
||||
padding: 0
|
||||
position: relative
|
||||
|
||||
*
|
||||
box-sizing: border-box
|
||||
|
||||
#app
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif
|
||||
-webkit-font-smoothing: antialiased
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
color: var(--theme-text-color)
|
||||
display: flex
|
||||
flex-direction: column
|
||||
min-height: 100vh
|
||||
|
||||
// Env specific
|
||||
[data-env="production"]
|
||||
.dev-only, .dev-test
|
||||
display: none !important
|
||||
[data-env="development"]
|
||||
.prod-only
|
||||
display: none !important
|
||||
|
||||
.container
|
||||
margin-left: 10%
|
||||
margin-right: 10%
|
||||
|
||||
.narrow-only
|
||||
display: none
|
||||
|
||||
.isAdContainer
|
||||
display: none !important
|
||||
|
||||
@media screen and (max-width: 800px)
|
||||
.container
|
||||
margin-left: 2rem
|
||||
margin-right: 2rem
|
||||
.narrow-only
|
||||
display: inherit
|
||||
.wide-only
|
||||
display: none
|
||||
25
src/styles/variables.sass
Normal file
@@ -0,0 +1,25 @@
|
||||
@use 'sass:color'
|
||||
|
||||
$accent-color: rgb(73, 147, 255)
|
||||
|
||||
:root
|
||||
font-size: 16px
|
||||
--theme-accent-color: #{$accent-color}
|
||||
--theme-accent-color--rgb: 73, 147, 255
|
||||
--theme-accent-color-darken: #{color.scale($accent-color, $lightness: -12%)}
|
||||
--theme-accent-link-color: rgb(255, 255, 255)
|
||||
--theme-secondary-color: rgb(224, 32, 128)
|
||||
--theme-secondary-color--rgb: 224, 32, 128
|
||||
--theme-text-color: rgb(44, 62, 80)
|
||||
--theme-link-color: rgb(63, 81, 181)
|
||||
--theme-link-color--rgb: 63, 81, 181
|
||||
--theme-background-color: rgb(255, 255, 255)
|
||||
--theme-text-shadow-color: rgb(255, 255, 255)
|
||||
--theme-box-shadow-color: rgb(204, 204, 204)
|
||||
--theme-box-shadow-color-hover: rgb(170, 170, 170)
|
||||
--theme-border-color: rgb(136, 136, 136)
|
||||
--theme-box-shadow: 0 0 4px var(--theme-box-shadow-color)
|
||||
--theme-box-shadow-hover: 0 0 8px var(--theme-box-shadow-color-hover)
|
||||
--theme-tag-color: rgb(214, 228, 255)
|
||||
--theme-danger-color: rgb(255, 85, 85)
|
||||
--theme-bookmark-color: rgb(255, 105, 180)
|
||||
210
src/types/Artworks.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
export interface ArtworkUrls {
|
||||
mini: string
|
||||
thumb: string
|
||||
small: string
|
||||
regular: string
|
||||
original: string
|
||||
}
|
||||
|
||||
export interface ArtworkPageUrls {
|
||||
original: string
|
||||
small: string
|
||||
regular: string
|
||||
thumb_mini: string
|
||||
}
|
||||
|
||||
export interface ArtworkTag {
|
||||
tag: string
|
||||
locked: boolean
|
||||
deletable: boolean
|
||||
userId: `${number}`
|
||||
translation?: {
|
||||
en?: string
|
||||
}
|
||||
userName: string
|
||||
}
|
||||
|
||||
export interface ArtworkGallery {
|
||||
urls: ArtworkPageUrls
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface ArtworkCommon {
|
||||
id: `${number}`
|
||||
title: string
|
||||
description: string
|
||||
createDate: string
|
||||
updateDate: string
|
||||
illustType: IllustType
|
||||
restrict: 0
|
||||
xRestrict: 0 | 1 | 2
|
||||
sl: number
|
||||
userId: `${number}`
|
||||
userName: string
|
||||
alt: string
|
||||
width: number
|
||||
height: number
|
||||
pageCount: number
|
||||
isBookmarkable: boolean
|
||||
bookmarkData: {
|
||||
id: `${number}`
|
||||
private: boolean
|
||||
} | null
|
||||
titleCaptionTranslation: {
|
||||
workTitle: string | null
|
||||
workCaption: string | null
|
||||
}
|
||||
isUnlisted: boolean
|
||||
aiType: number
|
||||
}
|
||||
|
||||
export interface ArtworkInfo extends ArtworkCommon {
|
||||
url: string
|
||||
tags: string[]
|
||||
profileImageUrl: string
|
||||
type: 'illust' | 'novel'
|
||||
}
|
||||
|
||||
export type ArtworkInfoOrAd =
|
||||
| ArtworkInfo
|
||||
| {
|
||||
isAdContainer: true
|
||||
}
|
||||
|
||||
export enum IllustType {
|
||||
ILLUST = 0,
|
||||
MANGA = 1,
|
||||
UGOIRA = 2,
|
||||
}
|
||||
|
||||
export interface ArtworkRank {
|
||||
title: string
|
||||
date: string
|
||||
tags: string[]
|
||||
url: string
|
||||
illust_type: IllustType
|
||||
illust_book_style: '0'
|
||||
illust_page_count: `${number}`
|
||||
user_name: string
|
||||
profile_img: string
|
||||
illust_content_type: {
|
||||
sexual: 0 | 1 | 2
|
||||
lo: boolean
|
||||
grotesque: boolean
|
||||
violent: boolean
|
||||
homosexual: boolean
|
||||
drug: boolean
|
||||
thoughts: boolean
|
||||
antisocial: boolean
|
||||
religional: boolean
|
||||
original: boolean
|
||||
furry: boolean
|
||||
bl: boolean
|
||||
yuri: boolean
|
||||
}
|
||||
illust_series:
|
||||
| {
|
||||
illustSeriesId: `${number}`
|
||||
illustSeriesUserId: `${number}`
|
||||
illustSeriesTitle: string
|
||||
illustSeriesCaption: string
|
||||
illustSeriesContentCount: `${number}`
|
||||
illustSeriesCreateDatetime: string
|
||||
illustSeriesContentIllustId: `${number}`
|
||||
illustSeriesContentOrder: `${number}`
|
||||
pageUrl: string
|
||||
}
|
||||
| false
|
||||
illust_id: number
|
||||
width: number
|
||||
height: number
|
||||
user_id: number
|
||||
rank: number
|
||||
yes_rank: number
|
||||
rating_count: number
|
||||
view_count: number
|
||||
illust_upload_timestamp: number
|
||||
attr: string
|
||||
}
|
||||
|
||||
export interface Artwork extends ArtworkCommon {
|
||||
illustId: `${number}`
|
||||
illustTitle: string
|
||||
illustComment: string
|
||||
urls: ArtworkUrls
|
||||
tags: {
|
||||
authorId: `${number}`
|
||||
isLocked: boolean
|
||||
tags: ArtworkTag[]
|
||||
writable: boolean
|
||||
}
|
||||
storableTags: string[]
|
||||
userAccount: string
|
||||
userIllusts: Record<`${number}`, ArtworkInfo | null>
|
||||
likeData: boolean
|
||||
bookmarkCount: number
|
||||
likeCount: number
|
||||
commentCount: number
|
||||
responseCount: number
|
||||
viewCount: number
|
||||
isHowto: boolean
|
||||
isOriginal: boolean
|
||||
imageResponseOutData: any[]
|
||||
imageResponseData: any[]
|
||||
imageResponseCount: number
|
||||
pollData: any
|
||||
seriesNavData: any
|
||||
descriptionBoothId: any
|
||||
descriptionYoutubeId: any
|
||||
comicPromotion: any
|
||||
fanboxPromotion: any
|
||||
contestBanners: any[]
|
||||
contestData: any
|
||||
profileImageUrl: string
|
||||
zoneConfig?: any
|
||||
extraData?: {
|
||||
meta: {
|
||||
title: string
|
||||
description: string
|
||||
canonical: string
|
||||
alternateLanguages: {
|
||||
ja: string
|
||||
en: string
|
||||
}
|
||||
descriptionHeader: string
|
||||
ogp: {
|
||||
description: string
|
||||
image: string
|
||||
title: string
|
||||
type: string
|
||||
}
|
||||
twitter: {
|
||||
description: string
|
||||
image: string
|
||||
title: string
|
||||
card: string
|
||||
}
|
||||
}
|
||||
}
|
||||
noLoginData?: {
|
||||
breadcrumbs: {
|
||||
successor: any[]
|
||||
current: {
|
||||
zh?: string
|
||||
}
|
||||
}
|
||||
zengoIdWorks: ArtworkInfo[]
|
||||
zengoWorkData: {
|
||||
nextWork: {
|
||||
id: `${number}`
|
||||
title: string
|
||||
}
|
||||
prevWork: {
|
||||
id: `${number}`
|
||||
title: string
|
||||
}
|
||||
}
|
||||
}
|
||||
pages: ArtworkGallery[]
|
||||
}
|
||||
18
src/types/Comment.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface Comments {
|
||||
userId: `${number}`
|
||||
userName: string
|
||||
isDeletedUser: boolean
|
||||
img: string
|
||||
id: `${number}`
|
||||
comment: string
|
||||
stampId: number | null
|
||||
stampLink: null
|
||||
commentDate: string
|
||||
commentRootId: string | null
|
||||
commentParentId: string | null
|
||||
commentUserId: `${number}`
|
||||
replyToUserId: string | null
|
||||
replyToUserName: string | null
|
||||
editable: boolean
|
||||
hasReplies: boolean
|
||||
}
|
||||
119
src/types/Users.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Artwork, ArtworkInfo } from './Artworks'
|
||||
|
||||
export enum UserXRestrict {
|
||||
SAFE,
|
||||
R18,
|
||||
R18G,
|
||||
}
|
||||
export enum UserPrivacyLevel {
|
||||
PUBLIC_FOR_ALL,
|
||||
PUBLIC_FOR_FRIENDS,
|
||||
PRIVATE,
|
||||
}
|
||||
|
||||
export interface User {
|
||||
userId: `${number}`
|
||||
name: string
|
||||
image: string
|
||||
imageBig: string
|
||||
premium: boolean
|
||||
isFollowed: boolean
|
||||
isMypixiv: boolean
|
||||
isBlocking: boolean
|
||||
background: {
|
||||
url: string | null
|
||||
color: string | null
|
||||
repeat: string | null
|
||||
isPrivate: boolean
|
||||
} | null
|
||||
sketchLiveId: {} | null
|
||||
partial: number
|
||||
acceptRequest: boolean
|
||||
sketchLives: any[]
|
||||
following: number
|
||||
followedBack: boolean
|
||||
comment: string
|
||||
commentHtml: string
|
||||
webpage: string | null
|
||||
social: {
|
||||
twitter?: {
|
||||
url: string
|
||||
}
|
||||
facebook?: {
|
||||
url: string
|
||||
}
|
||||
instagram?: {
|
||||
url: string
|
||||
}
|
||||
[key: string]: any
|
||||
}
|
||||
region: {
|
||||
name: string
|
||||
privacyLevel: UserPrivacyLevel
|
||||
} | null
|
||||
birthDay: {
|
||||
name: string
|
||||
privacyLevel: UserPrivacyLevel
|
||||
} | null
|
||||
gender: {
|
||||
name: string
|
||||
privacyLevel: UserPrivacyLevel
|
||||
} | null
|
||||
job: {
|
||||
name: string
|
||||
privacyLevel: UserPrivacyLevel
|
||||
} | null
|
||||
workspace: {
|
||||
userWorkspacePc?: string
|
||||
userWorkspaceMonitor?: string
|
||||
userWorkspaceTool?: string
|
||||
userWorkspaceScanner?: string
|
||||
userWorkspaceTablet?: string
|
||||
userWorkspaceMouse?: string
|
||||
userWorkspacePrinter?: string
|
||||
userWorkspaceDesktop?: string
|
||||
userWorkspaceMusic?: string
|
||||
userWorkspaceDesk?: string
|
||||
userWorkspaceChair?: string
|
||||
userWorkspaceComment?: string
|
||||
wsUrl?: string
|
||||
wsBigUrl?: string
|
||||
}
|
||||
official: boolean
|
||||
group: null
|
||||
illusts: ArtworkInfo[]
|
||||
manga: ArtworkInfo[]
|
||||
novels: ArtworkInfo[]
|
||||
}
|
||||
|
||||
export interface PixivUser {
|
||||
id: `${number}`
|
||||
pixivId: string
|
||||
name: string
|
||||
profileImg: string
|
||||
profileImgBig: string
|
||||
premium: boolean
|
||||
xRestrict: UserXRestrict
|
||||
adult: boolean
|
||||
illustCreator: boolean
|
||||
novelCreator: boolean
|
||||
hideAiWorks: boolean
|
||||
readingStatusEnabled: boolean
|
||||
illustMaskRules: any[]
|
||||
location: string
|
||||
isSensitiveViewable: boolean
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
userId: `${number}`
|
||||
userName: string
|
||||
profileImageUrl: string
|
||||
userComment: string
|
||||
following: boolean
|
||||
followed: boolean
|
||||
isBlocking: boolean
|
||||
isMypixiv: boolean
|
||||
illusts: ArtworkInfo[]
|
||||
novels: any[]
|
||||
acceptRequest: boolean
|
||||
}
|
||||
3
src/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './Artworks'
|
||||
export * from './Comment'
|
||||
export * from './Users'
|
||||
610
src/utils/UgoiraPlayer.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
import { Artwork } from '@/types'
|
||||
import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url'
|
||||
import { ZipDownloader, ZipDownloaderOptions } from './ZipDownloader'
|
||||
|
||||
/**
|
||||
* Public options
|
||||
*/
|
||||
export interface UgoiraPlayerOptions {
|
||||
onDownloadProgress?: (
|
||||
progress: number,
|
||||
frameIndex: number,
|
||||
totalFrames: number
|
||||
) => void
|
||||
onDownloadComplete?: () => void
|
||||
onDownloadError?: (error: Error) => void
|
||||
zipDownloaderOptions?: ZipDownloaderOptions
|
||||
requestTimeoutMs?: number
|
||||
preferImageBitmap?: boolean
|
||||
playbackRate?: number
|
||||
progressiveRender?: boolean
|
||||
}
|
||||
|
||||
export interface UgoiraFrame {
|
||||
file: string
|
||||
delay: number
|
||||
}
|
||||
export interface UgoiraMeta {
|
||||
frames: UgoiraFrame[]
|
||||
mime_type: string
|
||||
originalSrc: string
|
||||
src: string
|
||||
}
|
||||
|
||||
/** Internal structures */
|
||||
interface CachedVisual {
|
||||
/** kept for backward-compat paths (gif/mp4) */
|
||||
img?: HTMLImageElement
|
||||
/** preferred for runtime drawing */
|
||||
bitmap?: ImageBitmap
|
||||
/** object URL for cleanup */
|
||||
url: string
|
||||
/** raw bytes for re-encode */
|
||||
buf: Uint8Array
|
||||
}
|
||||
|
||||
/** Player state */
|
||||
const enum PlayerState {
|
||||
Idle,
|
||||
Downloading,
|
||||
Ready,
|
||||
Playing,
|
||||
Paused,
|
||||
Destroyed,
|
||||
}
|
||||
|
||||
/**
|
||||
* UgoiraPlayer
|
||||
* @author dragon-fish
|
||||
* @license MIT
|
||||
*/
|
||||
export class UgoiraPlayer {
|
||||
// ====== private fields ======
|
||||
private _canvas?: HTMLCanvasElement
|
||||
private _illust!: Artwork
|
||||
private _meta?: UgoiraMeta
|
||||
|
||||
private state: PlayerState = PlayerState.Idle
|
||||
private isPlaying = false
|
||||
|
||||
private curFrame = 0
|
||||
private lastFrameTime = 0
|
||||
private nextFrameDue = 0
|
||||
|
||||
private cached: Map<string, CachedVisual> = new Map()
|
||||
private objectURLs: Set<string> = new Set()
|
||||
private files: Record<string, Uint8Array> = {}
|
||||
|
||||
private zipDownloader?: ZipDownloader
|
||||
private aborter?: AbortController
|
||||
|
||||
private downloadProgress = 0
|
||||
private isDownloading = false
|
||||
private isDownloadComplete = false
|
||||
private downloadStartTime = 0
|
||||
private frameDownloadTimes: number[] = []
|
||||
private frameReady: boolean[] = []
|
||||
|
||||
private lastRenderedFrameIndex = -1
|
||||
private renderTimer: number | undefined
|
||||
|
||||
// New: runtime settings
|
||||
private _playbackRate = 1
|
||||
private _preferImageBitmap = true
|
||||
private _progressiveRender = true
|
||||
|
||||
constructor(
|
||||
illust: Artwork,
|
||||
public options: UgoiraPlayerOptions = {}
|
||||
) {
|
||||
this._preferImageBitmap = options.preferImageBitmap ?? true
|
||||
this._playbackRate = options.playbackRate ?? 1
|
||||
this._progressiveRender = options.progressiveRender ?? true
|
||||
this.reset(illust)
|
||||
}
|
||||
|
||||
// ====== lifecycle ======
|
||||
reset(illust: Artwork) {
|
||||
this.destroy()
|
||||
this._canvas = undefined
|
||||
this._illust = illust
|
||||
|
||||
this.downloadProgress = 0
|
||||
this.isDownloading = false
|
||||
this.isDownloadComplete = false
|
||||
this.downloadStartTime = 0
|
||||
this.frameDownloadTimes = []
|
||||
this.frameReady = []
|
||||
this.lastRenderedFrameIndex = -1
|
||||
this.curFrame = 0
|
||||
this.lastFrameTime = 0
|
||||
this.nextFrameDue = 0
|
||||
|
||||
if (this.renderTimer) {
|
||||
clearTimeout(this.renderTimer)
|
||||
this.renderTimer = undefined
|
||||
}
|
||||
|
||||
this.state = PlayerState.Idle
|
||||
}
|
||||
|
||||
setupCanvas(canvas: HTMLCanvasElement) {
|
||||
this._canvas = canvas
|
||||
this._canvas.width = this.initWidth
|
||||
this._canvas.height = this.initHeight
|
||||
}
|
||||
|
||||
// ====== getters (public API preserved) ======
|
||||
get isReady() {
|
||||
return !!this._meta && Object.keys(this.files).length > 0
|
||||
}
|
||||
get canExport() {
|
||||
return this.isDownloadComplete && this.isReady
|
||||
}
|
||||
get downloadProgressPercent() {
|
||||
return this.downloadProgress
|
||||
}
|
||||
get downloadStats() {
|
||||
if (!this.isDownloadComplete && this.frameDownloadTimes.length === 0) {
|
||||
return null
|
||||
}
|
||||
const totalTime = performance.now() - this.downloadStartTime
|
||||
const avgFrameTime =
|
||||
this.frameDownloadTimes.length > 0
|
||||
? this.frameDownloadTimes.reduce((a, b) => a + b, 0) /
|
||||
this.frameDownloadTimes.length
|
||||
: 0
|
||||
|
||||
return {
|
||||
totalDownloadTime: totalTime,
|
||||
averageFrameTime: avgFrameTime,
|
||||
totalFrames: this.frameDownloadTimes.length,
|
||||
isComplete: this.isDownloadComplete,
|
||||
progress: this.downloadProgress,
|
||||
}
|
||||
}
|
||||
get isUgoira() {
|
||||
return this._illust.illustType === 2
|
||||
}
|
||||
get canvas() {
|
||||
return this._canvas
|
||||
}
|
||||
get illust() {
|
||||
return this._illust
|
||||
}
|
||||
get meta() {
|
||||
return this._meta
|
||||
}
|
||||
get totalFrames() {
|
||||
return this._meta?.frames.length ?? 0
|
||||
}
|
||||
get now() {
|
||||
return performance.now()
|
||||
}
|
||||
get initWidth() {
|
||||
return this._illust.width
|
||||
}
|
||||
get initHeight() {
|
||||
return this._illust.height
|
||||
}
|
||||
get mimeType() {
|
||||
return this._meta?.mime_type ?? ''
|
||||
}
|
||||
/** New: playbackRate getter/setter */
|
||||
get playbackRate() {
|
||||
return this._playbackRate
|
||||
}
|
||||
set playbackRate(v: number) {
|
||||
this._playbackRate = Math.max(0.1, v || 1)
|
||||
}
|
||||
|
||||
// ====== network / assets ======
|
||||
async fetchMeta() {
|
||||
this._meta = await fetch(
|
||||
new URL(`/ajax/illust/${this._illust.id}/ugoira_meta`, location.href)
|
||||
.href,
|
||||
{
|
||||
cache: 'default',
|
||||
}
|
||||
).then((res) => res.json())
|
||||
return this
|
||||
}
|
||||
|
||||
async fetchFrames(originalQuality = false) {
|
||||
if (!this._meta) {
|
||||
await this.fetchMeta()
|
||||
}
|
||||
if (!this._meta) {
|
||||
throw new Error('Failed to fetch meta')
|
||||
}
|
||||
return this.streamingFetchAndDrawFrames(originalQuality)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized streaming download
|
||||
*/
|
||||
private async streamingFetchAndDrawFrames(originalQuality = false) {
|
||||
if (this.isDownloading) {
|
||||
throw new Error('Download already in progress')
|
||||
}
|
||||
|
||||
this.isDownloading = true
|
||||
this.state = PlayerState.Downloading
|
||||
this.downloadStartTime = performance.now()
|
||||
this.downloadProgress = 0
|
||||
this.frameDownloadTimes = []
|
||||
|
||||
// Abort any previous network work
|
||||
this.aborter?.abort()
|
||||
this.aborter = new AbortController()
|
||||
|
||||
try {
|
||||
const zipUrl = new URL(
|
||||
this._meta![originalQuality ? 'originalSrc' : 'src'],
|
||||
location.href
|
||||
).href
|
||||
|
||||
if (!this.zipDownloader) {
|
||||
this.zipDownloader = new ZipDownloader('')
|
||||
}
|
||||
this.zipDownloader.setUrl(zipUrl).setOptions({
|
||||
chunkSize: 256 * 1024,
|
||||
maxConcurrentRequests: 3,
|
||||
tryDecompress: true,
|
||||
timeoutMs: this.options.requestTimeoutMs ?? 10000,
|
||||
retries: 2,
|
||||
...this.options.zipDownloaderOptions,
|
||||
})
|
||||
|
||||
const { frames } = this._meta!
|
||||
const totalFrames = frames.length
|
||||
let processedFrames = 0
|
||||
|
||||
this.frameReady = new Array(totalFrames).fill(false)
|
||||
this.lastRenderedFrameIndex = -1
|
||||
|
||||
const result = await this.zipDownloader.streamingDownload({
|
||||
signal: this.aborter.signal,
|
||||
onFileComplete: (entryWithData, info) => {
|
||||
if (this.state === PlayerState.Destroyed) return
|
||||
|
||||
const frameIndex = frames.findIndex(
|
||||
(f) => f.file === entryWithData.fileName
|
||||
)
|
||||
if (frameIndex === -1) {
|
||||
console.warn(
|
||||
`[UgoiraPlayer] Unknown frame: ${entryWithData.fileName}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const frame = frames[frameIndex]
|
||||
|
||||
// Store bytes & prepare visual cache lazily
|
||||
this.files[frame.file] = entryWithData.data
|
||||
this.frameDownloadTimes[frameIndex] = info.downloadTime
|
||||
processedFrames++
|
||||
|
||||
// update progress
|
||||
this.downloadProgress = (processedFrames / totalFrames) * 100
|
||||
this.options.onDownloadProgress?.(
|
||||
this.downloadProgress,
|
||||
frameIndex,
|
||||
totalFrames
|
||||
)
|
||||
|
||||
// flag ready and optionally schedule render
|
||||
this.frameReady[frameIndex] = true
|
||||
if (this._progressiveRender) this.scheduleNextFrame()
|
||||
},
|
||||
})
|
||||
|
||||
console.info('[UgoiraPlayer] download complete', result)
|
||||
|
||||
// completed
|
||||
this.isDownloadComplete = true
|
||||
this.isDownloading = false
|
||||
this.state = PlayerState.Ready
|
||||
|
||||
this.options.onDownloadComplete?.()
|
||||
|
||||
return this
|
||||
} catch (error) {
|
||||
this.isDownloading = false
|
||||
this.state = PlayerState.Idle
|
||||
this.options.onDownloadError?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequential scheduler: render frames 0..N in order as soon as each is ready.
|
||||
* If a gap is encountered, pause until the missing frame arrives.
|
||||
*/
|
||||
private scheduleNextFrame() {
|
||||
if (!this._canvas || !this._meta) return
|
||||
if (this.renderTimer) return
|
||||
|
||||
const { frames } = this._meta
|
||||
const nextIndex = this.lastRenderedFrameIndex + 1
|
||||
if (!this.frameReady[nextIndex]) return
|
||||
|
||||
const renderSequential = async () => {
|
||||
while (true) {
|
||||
const idx = this.lastRenderedFrameIndex + 1
|
||||
if (idx >= frames.length) {
|
||||
this.renderTimer = undefined
|
||||
return
|
||||
}
|
||||
if (!this.frameReady[idx]) {
|
||||
this.renderTimer = undefined
|
||||
return
|
||||
}
|
||||
const frame = frames[idx]
|
||||
await this.renderFrameToCanvas(idx, frame)
|
||||
this.lastRenderedFrameIndex = idx
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.renderTimer = window.setTimeout(
|
||||
() => {
|
||||
this.renderTimer = undefined
|
||||
resolve()
|
||||
},
|
||||
Math.max(0, frame.delay / this._playbackRate)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
renderSequential().catch((e) => {
|
||||
console.error('[UgoiraPlayer] sequential render error:', e)
|
||||
this.renderTimer = undefined
|
||||
})
|
||||
}
|
||||
|
||||
/** Render a single frame to the canvas */
|
||||
private async renderFrameToCanvas(frameIndex: number, frame: UgoiraFrame) {
|
||||
if (!this._canvas) return
|
||||
const ctx = this._canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
const visual = await this.getVisual(frame.file)
|
||||
const source = visual.bitmap ?? visual.img!
|
||||
// drawImage supports both HTMLImageElement and ImageBitmap
|
||||
ctx.drawImage(source as any, 0, 0, this.initWidth, this.initHeight)
|
||||
// console.debug(`[UgoiraPlayer] rendered frame ${frameIndex+1}`)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[UgoiraPlayer] frame ${frameIndex + 1} render failed:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ====== caching primitives ======
|
||||
private async getVisual(fileName: string): Promise<CachedVisual> {
|
||||
const hit = this.cached.get(fileName)
|
||||
if (hit) {
|
||||
// Ensure image element fully loaded if present
|
||||
if (hit.img && !(hit.img.complete && hit.img.naturalWidth > 0)) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
hit.img!.onload = () => resolve()
|
||||
hit.img!.onerror = () => reject(new Error('image load error'))
|
||||
})
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
const buf = this.files[fileName]
|
||||
if (!buf) throw new Error(`File ${fileName} not found`)
|
||||
|
||||
const blob = new Blob([new Uint8Array(buf)], { type: this.mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
this.objectURLs.add(url)
|
||||
|
||||
const visual: CachedVisual = { url, buf, img: undefined, bitmap: undefined }
|
||||
|
||||
// Prefer ImageBitmap for runtime rendering; keep HTMLImageElement for encoders
|
||||
if (this._preferImageBitmap && 'createImageBitmap' in window) {
|
||||
try {
|
||||
visual.bitmap = await createImageBitmap(blob)
|
||||
} catch {
|
||||
// Fallback to HTMLImageElement
|
||||
}
|
||||
}
|
||||
|
||||
if (!visual.bitmap) {
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve()
|
||||
img.onerror = () => reject(new Error('image load error'))
|
||||
})
|
||||
visual.img = img
|
||||
}
|
||||
|
||||
this.cached.set(fileName, visual)
|
||||
return visual
|
||||
}
|
||||
|
||||
/** Back-compat helper returning HTMLImageElement (may synthesize from cache) */
|
||||
private getImage(fileName: string): HTMLImageElement {
|
||||
const cached = this.cached.get(fileName)
|
||||
if (cached?.img) return cached.img
|
||||
|
||||
const buf = this.files[fileName]
|
||||
if (!buf) throw new Error(`File ${fileName} not found`)
|
||||
|
||||
const blob = new Blob([new Uint8Array(buf)], { type: this.mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
this.objectURLs.add(url)
|
||||
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
|
||||
this.cached.set(fileName, { url, buf, img, bitmap: undefined })
|
||||
return img
|
||||
}
|
||||
|
||||
/** Back-compat async image getter */
|
||||
private async getImageAsync(fileName: string): Promise<HTMLImageElement> {
|
||||
const v = await this.getVisual(fileName)
|
||||
if (v.img) return v.img
|
||||
// need to synthesize <img> from existing blob URL for encoders
|
||||
const img = new Image()
|
||||
img.src = v.url
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve()
|
||||
img.onerror = () => reject(new Error('image load error'))
|
||||
})
|
||||
v.img = img
|
||||
return img
|
||||
}
|
||||
|
||||
getRealFrameSize() {
|
||||
if (!this.isReady) {
|
||||
throw new Error('Ugoira assets not ready, please fetch first')
|
||||
}
|
||||
const firstFrame = this.getImage(this.meta!.frames[0].file)
|
||||
return { width: firstFrame.width, height: firstFrame.height }
|
||||
}
|
||||
|
||||
// ====== classic playback loop (preserved) ======
|
||||
private drawFrame() {
|
||||
if (!this.canvas || !this._meta || !this.isPlaying) return
|
||||
|
||||
const ctx = this.canvas.getContext('2d')!
|
||||
const frame = this._meta.frames[this.curFrame]
|
||||
const delay = Math.max(0, frame.delay / this._playbackRate)
|
||||
|
||||
const now = this.now
|
||||
if (this.nextFrameDue === 0) this.nextFrameDue = now + delay
|
||||
|
||||
if (now >= this.nextFrameDue) {
|
||||
this.lastFrameTime = now
|
||||
this.curFrame = (this.curFrame + 1) % this.totalFrames
|
||||
this.nextFrameDue = now + delay
|
||||
}
|
||||
|
||||
// Render current frame
|
||||
const img = this.getImage(frame.file)
|
||||
ctx.drawImage(img, 0, 0, this.initWidth, this.initHeight)
|
||||
|
||||
requestAnimationFrame(() => this.drawFrame())
|
||||
}
|
||||
|
||||
// ====== controls ======
|
||||
play() {
|
||||
this.isPlaying = true
|
||||
this.lastFrameTime = this.now
|
||||
this.nextFrameDue = 0
|
||||
this.state = PlayerState.Playing
|
||||
this.drawFrame()
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.isPlaying = false
|
||||
if (this.state !== PlayerState.Destroyed) this.state = PlayerState.Paused
|
||||
}
|
||||
|
||||
/** Cancel any in-flight downloads */
|
||||
cancelDownload() {
|
||||
this.aborter?.abort()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.pause()
|
||||
|
||||
if (this.renderTimer) {
|
||||
clearTimeout(this.renderTimer)
|
||||
this.renderTimer = undefined
|
||||
}
|
||||
|
||||
this.cancelDownload()
|
||||
|
||||
// Revoke URLs & clear caches
|
||||
this.objectURLs.forEach((url) => URL.revokeObjectURL(url))
|
||||
this.objectURLs.clear()
|
||||
|
||||
this.cached.clear()
|
||||
this.files = {}
|
||||
|
||||
this._meta = undefined
|
||||
this.zipDownloader = undefined
|
||||
this.isDownloading = false
|
||||
this.isDownloadComplete = false
|
||||
this.downloadProgress = 0
|
||||
this.downloadStartTime = 0
|
||||
this.frameDownloadTimes = []
|
||||
this.frameReady = []
|
||||
this.lastRenderedFrameIndex = -1
|
||||
|
||||
this.state = PlayerState.Destroyed
|
||||
}
|
||||
|
||||
// ====== encoders ======
|
||||
private async genGifEncoder() {
|
||||
const { width, height } = this.getRealFrameSize()
|
||||
const GifJs = (await import('gif.js')).default
|
||||
return new GifJs({
|
||||
debug: import.meta.env.DEV,
|
||||
workers: 5,
|
||||
workerScript: gifWorkerUrl,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
async renderGif(): Promise<Blob> {
|
||||
if (!this.canExport) {
|
||||
throw new Error(
|
||||
'Cannot export: download not complete or assets not ready'
|
||||
)
|
||||
}
|
||||
|
||||
const encoder = await this.genGifEncoder()
|
||||
const frames = this._meta!.frames
|
||||
|
||||
// Prepare HTMLImageElements for gif.js
|
||||
const imageList = await Promise.all(
|
||||
frames.map((f) => this.getImageAsync(f.file))
|
||||
)
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
try {
|
||||
imageList.forEach((img, idx) => {
|
||||
encoder.addFrame(img, { delay: Math.max(0, frames[idx].delay) })
|
||||
})
|
||||
encoder.on('finished', (blob: Blob) => {
|
||||
// Best-effort worker cleanup (gif.js specific)
|
||||
// @ts-ignore
|
||||
encoder.freeWorkers?.forEach?.((w: Worker) => w?.terminate?.())
|
||||
resolve(blob)
|
||||
})
|
||||
encoder.on('abort', () => reject(new Error('GIF encoding aborted')))
|
||||
encoder.render()
|
||||
} catch (e) {
|
||||
reject(e as Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async renderMp4() {
|
||||
if (!this.canExport) {
|
||||
throw new Error(
|
||||
'Cannot export: download not complete or assets not ready'
|
||||
)
|
||||
}
|
||||
|
||||
const { width, height } = this.getRealFrameSize()
|
||||
const frames = this._meta!.frames.map((i) => ({
|
||||
data: this.getImage(i.file).src!,
|
||||
duration: Math.max(0, i.delay),
|
||||
}))
|
||||
|
||||
const { encode } = await import('modern-mp4')
|
||||
const buf = await encode({ frames, width, height, audio: false })
|
||||
return new Blob([buf], { type: 'video/mp4' })
|
||||
}
|
||||
}
|
||||
1002
src/utils/ZipDownloader.ts
Normal file
41
src/utils/ajax.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
import nprogress from 'nprogress'
|
||||
|
||||
export const ajax = axios.create({
|
||||
timeout: 15 * 1000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
ajax.interceptors.request.use((config) => {
|
||||
nprogress.start()
|
||||
return config
|
||||
})
|
||||
ajax.interceptors.response.use(
|
||||
(res) => {
|
||||
nprogress.done()
|
||||
return res
|
||||
},
|
||||
(err) => {
|
||||
nprogress.done()
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export const ajaxPostWithFormData = (
|
||||
url: string,
|
||||
data:
|
||||
| string
|
||||
| string[][]
|
||||
| Record<string, string>
|
||||
| URLSearchParams
|
||||
| undefined,
|
||||
config?: AxiosRequestConfig
|
||||
) =>
|
||||
ajax.post(url, new URLSearchParams(data).toString(), {
|
||||
...config,
|
||||
headers: {
|
||||
...config?.headers,
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
},
|
||||
})
|
||||
35
src/utils/artworkActions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ajax, ajaxPostWithFormData } from '@/utils/ajax'
|
||||
import { ArtworkInfo, ArtworkInfoOrAd } from '@/types'
|
||||
|
||||
export function sortArtList<T extends { id: number | `${number}` }>(
|
||||
obj: Record<string, T>
|
||||
): T[] {
|
||||
return Object.values(obj).sort((a, b) => +b.id - +a.id)
|
||||
}
|
||||
|
||||
export function isArtwork(item: ArtworkInfoOrAd): item is ArtworkInfo {
|
||||
return Object.keys(item).includes('id')
|
||||
}
|
||||
|
||||
export async function addBookmark(
|
||||
illust_id: number | `${number}`
|
||||
): Promise<any> {
|
||||
return (
|
||||
await ajax.post('/ajax/illusts/bookmarks/add', {
|
||||
illust_id,
|
||||
restrict: 0,
|
||||
comment: '',
|
||||
tags: [],
|
||||
})
|
||||
).data
|
||||
}
|
||||
|
||||
export async function removeBookmark(
|
||||
bookmark_id: number | `${number}`
|
||||
): Promise<any> {
|
||||
return (
|
||||
await ajaxPostWithFormData('/ajax/illusts/bookmarks/delete', {
|
||||
bookmark_id: '' + bookmark_id,
|
||||
})
|
||||
).data
|
||||
}
|
||||
34
src/utils/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ArtworkInfo } from '@/types'
|
||||
|
||||
export * from './artworkActions'
|
||||
export * from './userActions'
|
||||
|
||||
export const defaultArtwork: ArtworkInfo = {
|
||||
id: '0',
|
||||
title: '',
|
||||
description: '',
|
||||
createDate: '',
|
||||
updateDate: '',
|
||||
illustType: 0,
|
||||
restrict: 0,
|
||||
xRestrict: 0,
|
||||
sl: 0,
|
||||
userId: '0',
|
||||
userName: '',
|
||||
alt: '',
|
||||
width: 0,
|
||||
height: 0,
|
||||
pageCount: 0,
|
||||
isBookmarkable: false,
|
||||
bookmarkData: null,
|
||||
titleCaptionTranslation: {
|
||||
workTitle: null,
|
||||
workCaption: null,
|
||||
},
|
||||
isUnlisted: false,
|
||||
url: '',
|
||||
tags: [],
|
||||
profileImageUrl: '',
|
||||
type: 'illust',
|
||||
aiType: 1,
|
||||
}
|
||||
8
src/utils/setTitle.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PROJECT_NAME, PROJECT_TAGLINE } from '@/config'
|
||||
|
||||
export function setTitle(...args: (string | number | null | undefined)[]) {
|
||||
return (document.title = [
|
||||
...args.filter((i) => i !== null && typeof i !== 'undefined'),
|
||||
`${PROJECT_NAME} - ${PROJECT_TAGLINE}`,
|
||||
].join(' | '))
|
||||
}
|
||||
28
src/utils/userActions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ajaxPostWithFormData } from '@/utils/ajax'
|
||||
|
||||
export async function addUserFollow(
|
||||
user_id: number | `${number}`
|
||||
): Promise<any> {
|
||||
return (
|
||||
await ajaxPostWithFormData(`/bookmark_add.php`, {
|
||||
mode: 'add',
|
||||
type: 'user',
|
||||
user_id: '' + user_id,
|
||||
tag: '',
|
||||
restrict: '0',
|
||||
format: 'json',
|
||||
})
|
||||
).data
|
||||
}
|
||||
|
||||
export async function removeUserFollow(
|
||||
user_id: number | `${number}`
|
||||
): Promise<any> {
|
||||
return (
|
||||
await ajaxPostWithFormData(`/rpc_group_setting.php`, {
|
||||
mode: 'del',
|
||||
type: 'bookuser',
|
||||
id: '' + user_id,
|
||||
})
|
||||
).data
|
||||
}
|
||||
14
src/view/404.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template lang="pug">
|
||||
#error-view
|
||||
ErrorPage(
|
||||
description='啊咧?啊咧咧——?!页面跑丢了!!!'
|
||||
status='404'
|
||||
title='404 Not Found'
|
||||
)
|
||||
RouterLink(to='/'): NButton(type='primary') Take me home
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ErrorPage from '@/components/ErrorPage.vue'
|
||||
import { NButton } from 'naive-ui'
|
||||
</script>
|
||||
203
src/view/_debug/zip.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template lang="pug">
|
||||
section.responsive
|
||||
NH1 ZipDownloader
|
||||
|
||||
NFlex(direction='column')
|
||||
NInputGroup
|
||||
NInput(v-model:value='urlInput')
|
||||
NButton(@click='getCentralDirectory' type='primary') Fetch Information
|
||||
NInputGroup
|
||||
NInputNumber(
|
||||
:max='10 * 1024 * 1024',
|
||||
:min='1024',
|
||||
:step='1024'
|
||||
v-model:value='chunkSize'
|
||||
)
|
||||
template(#prefix) Chunk Size:
|
||||
template(#suffix) B
|
||||
|
||||
NUl
|
||||
NLi
|
||||
strong Current URL:
|
||||
span {{ currentUrl }}
|
||||
NLi
|
||||
strong ZIP File Size:
|
||||
span {{ formatFileSize(data?.contentLength || 0) }}
|
||||
NLi
|
||||
strong Central Directory Size:
|
||||
span {{ formatFileSize(data?.centralDirectorySize || 0) }}
|
||||
NLi
|
||||
strong Download Chunk Size:
|
||||
span {{ formatFileSize(chunkSize) }}
|
||||
|
||||
NDataTable(:columns='columns', :data='entries || []', :scroll-x='1200')
|
||||
|
||||
details
|
||||
pre {{ data }}
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NH1,
|
||||
NInputGroup,
|
||||
NP,
|
||||
NDataTable,
|
||||
NInputNumber,
|
||||
NIcon,
|
||||
NButton,
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
ZipDownloader,
|
||||
type ZipOverview,
|
||||
type ZipEntry,
|
||||
} from '@/utils/ZipDownloader'
|
||||
import { IconDownload } from '@tabler/icons-vue'
|
||||
import { type TableColumn } from 'naive-ui/es/data-table/src/interface'
|
||||
|
||||
const downloader = new ZipDownloader('')
|
||||
|
||||
const data = ref<ZipOverview>()
|
||||
const entries = computed(() => data.value?.entries)
|
||||
|
||||
const columns = ref<TableColumn<ZipEntry>[]>([
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 60,
|
||||
render: (row) => {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
circle: true,
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => downloadByIndex(row.index),
|
||||
},
|
||||
{
|
||||
icon: () => h(NIcon, null, { default: () => h(IconDownload) }),
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '#',
|
||||
key: 'index',
|
||||
render: (row) => h('div', { style: { whiteSpace: 'nowrap' } }, row.index),
|
||||
},
|
||||
{
|
||||
title: 'File Name',
|
||||
key: 'fileName',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: 'MIME Type',
|
||||
key: 'mimeType',
|
||||
width: 120,
|
||||
render: (row) => row.mimeType || 'unknown',
|
||||
},
|
||||
{
|
||||
title: 'Compressed Size',
|
||||
key: 'compressedSize',
|
||||
width: 120,
|
||||
render: (row) => formatFileSize(row.compressedSize),
|
||||
},
|
||||
{
|
||||
title: 'Uncompressed Size',
|
||||
key: 'uncompressedSize',
|
||||
width: 120,
|
||||
render: (row) => formatFileSize(row.uncompressedSize),
|
||||
},
|
||||
{
|
||||
title: 'CRC32',
|
||||
key: 'crc32',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'Compression Method',
|
||||
key: 'compressionMethod',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: 'General Purpose Bit Flag',
|
||||
key: 'generalPurposeBitFlag',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: 'Local Header Offset',
|
||||
key: 'localHeaderOffset',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: 'Central Header Offset',
|
||||
key: 'centralHeaderOffset',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: 'Requires Zip64',
|
||||
key: 'requiresZip64',
|
||||
width: 100,
|
||||
},
|
||||
])
|
||||
|
||||
const urlInput = ref(
|
||||
'https://i.pixiv.re/img-zip-ugoira/img/2024/10/16/12/50/03/123379890_ugoira600x600.zip'
|
||||
)
|
||||
const currentUrl = ref('')
|
||||
const chunkSize = ref(512 * 1024)
|
||||
|
||||
watch(
|
||||
chunkSize,
|
||||
(newVal) => {
|
||||
downloader.setOptions({ chunkSize: newVal })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function setUrl(url: string) {
|
||||
currentUrl.value = url
|
||||
downloader.setUrl(url)
|
||||
if (currentUrl.value !== url) {
|
||||
data.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getCentralDirectory() {
|
||||
setUrl(urlInput.value)
|
||||
downloader.getCentralDirectory().then((overview) => {
|
||||
data.value = overview
|
||||
})
|
||||
}
|
||||
|
||||
function downloadByIndex(index: number) {
|
||||
setUrl(urlInput.value)
|
||||
downloader.downloadByIndex(index).then((result) => {
|
||||
const blob = new Blob([result.bytes as Uint8Array<ArrayBuffer>], {
|
||||
type: result.mimeType,
|
||||
})
|
||||
console.log('下载结果:', result, blob)
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
window.open(url, '_blank')
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
|
||||
const formatFileSize = (size: number) => {
|
||||
size = parseFloat(size as any)
|
||||
if (isNaN(size) || size < 0) {
|
||||
return '0.00 B'
|
||||
}
|
||||
let unit = 'B'
|
||||
while (size > 1024) {
|
||||
size /= 1024
|
||||
if (unit === 'B') unit = 'KB'
|
||||
else if (unit === 'KB') unit = 'MB'
|
||||
else if (unit === 'MB') unit = 'GB'
|
||||
else if (unit === 'GB') unit = 'TB'
|
||||
else break
|
||||
}
|
||||
return `${size.toFixed(2)} ${unit}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass"></style>
|
||||
83
src/view/about.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template lang="pug">
|
||||
mixin repoLink
|
||||
ExternalLink(:href='GITHUB_URL' target='_blank') {{ GITHUB_OWNER }}/{{ GITHUB_REPO }}
|
||||
|
||||
#about-view.body-inner
|
||||
h1#top 关于我们
|
||||
section.intro
|
||||
Card(title='简介')
|
||||
p PixivNow - Now, everyone can enjoy Pixiv!
|
||||
p 现在,每个人都能享受 Pixiv!
|
||||
p 也许能给你带来不一样体验的奇妙网站。让你更专注于欣赏插画本身,而不会被<i>神秘</i>因素干扰。
|
||||
|
||||
Card(title='使用方法')
|
||||
h3 访客
|
||||
p 正常用,有手就行(
|
||||
h3 开发者
|
||||
p 懒得写 API 文档……
|
||||
p 绝大多数地方用的都是 Pixiv Web 版的 ajax API。具体你可以看看源码,如果你真的很好奇,可以用 issues 问问我,我随缘回答。
|
||||
|
||||
Card(title='开销')
|
||||
p 我们曾经是没有任何经济开销的,直到2023年10月。
|
||||
p 原作者的服务被爬爆了,账号也被 Vercel 暂时封禁。他不得不动用钞能力临时维持住了服务。Vercel 的资费是 $20/月,这相当于原作者每个月得少吃三顿肯德鸡疯狂星期四!这实在是太残忍了!
|
||||
p 之后也许会在站内放一些谷歌自动广告之类的。纵然杯水车薪,不过能回点血是一点吧。
|
||||
p 我们正在积极寻找更便宜的解决方案,不过目前来说进度不太乐观就是了。<s>我是不是应该在这里放个收款码,说不定会有富哥包养我</s>
|
||||
|
||||
Card(title='访问令牌')
|
||||
h3 这是什么
|
||||
p “访问令牌”指的是您在 Pixiv 源站登录账号后键名为<code>PHPSESSID</code>的 cookie。
|
||||
h3 隐私政策
|
||||
p 我们不会收集或转让您的个人信息或 cookie。
|
||||
h3 有什么用
|
||||
p 如果您选择提供您的访问令牌,就能使用一些高级功能。包括但不限于:
|
||||
ul
|
||||
li 能看到更感兴趣的相关推荐
|
||||
li 能够访问自己的收藏夹(暂时无法编辑)
|
||||
li 能够访问 NSFW 内容(设定为允许时)
|
||||
li 能够使用高级搜索(订阅过 Pixiv 会员时)
|
||||
p 部分高级功能的效果取决于您在 Pixiv 源站的设定,您可以在 <ExternalLink href="https://www.pixiv.net/setting_profile.php" target="_blank">这里</ExternalLink> 查看。
|
||||
|
||||
Card(title='鸣谢')
|
||||
p: em 以下排名不分先后
|
||||
h3 组织
|
||||
ul
|
||||
li <strong>GitHub</strong> 提供了源码托管和版本管控服务
|
||||
li <s>Vercel</s> 提供了页面托管和 serverless 计算服务(但是现在白嫖的额度用完了)
|
||||
li <strong>JS.ORG</strong> 提供了域名服务
|
||||
h3 Pixiv.cat
|
||||
p
|
||||
| 我们使用
|
||||
a(href='https://pixiv.cat/' target='_blank') Pixiv.cat
|
||||
| 提供的图片服务。
|
||||
h3 个人
|
||||
p
|
||||
| 感谢为
|
||||
|
|
||||
+repoLink
|
||||
|
|
||||
| 贡献内容的全部编辑者!
|
||||
|
||||
Card(title='免责声明')
|
||||
h3 色图相关
|
||||
p 我们本身不提供 NSFW 资源(例如 R-18 插画),任意访问此类资源的行为均是由用户在源站的参数设置中设定的。对于可能出现的 NSFW 资源我们均有做出明显的警告标记,我们不鼓励访问或传播此类资源。
|
||||
h3 用户言论
|
||||
p “用户言论”指的是 Pixiv 源站用户通过自我介绍、插画简介、评论区等功能发布的言论。这部分属于发表者自身的行为,我们无法有效控制。我们不会发布也不鼓励传播不当言论。如果您在浏览过程中发现了不当内容,我们非常鼓励您前往源站进行举报。
|
||||
h3 版权声明
|
||||
p 请求得到的全部数据以及媒体资源,版权归 Pixiv 或其原作者所有。
|
||||
p PixivNow 程序通过 Apache-2.0 协议授权。
|
||||
p 仅供交流与学习。
|
||||
|
||||
Card(title='加入我们')
|
||||
p
|
||||
| 我们是开源项目,欢迎给我们点星星或者提交 PR 以及 issue:
|
||||
+repoLink
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PROJECT_NAME, GITHUB_OWNER, GITHUB_REPO, GITHUB_URL } from '@/config'
|
||||
import Card from '@/components/Card.vue'
|
||||
import ExternalLink from '@/components/ExternalLink.vue'
|
||||
import { setTitle } from '@/utils/setTitle'
|
||||
|
||||
onMounted(() => setTitle('About'))
|
||||
</script>
|
||||
462
src/view/artworks.vue
Normal file
@@ -0,0 +1,462 @@
|
||||
<template lang="pug">
|
||||
#artwork-view
|
||||
//- Loading
|
||||
section.placeholder(v-if='loading')
|
||||
.gallery
|
||||
NSkeleton(
|
||||
:sharp='false'
|
||||
block
|
||||
height='50vh'
|
||||
style='margin: 0 auto; width: 500px; max-width: 80vw'
|
||||
)
|
||||
.body-inner
|
||||
.artwork-info
|
||||
h1.loading(style='padding: 0.5rem 0'): NSkeleton(
|
||||
height='2rem'
|
||||
style='margin-top: 1em'
|
||||
width='20rem'
|
||||
)
|
||||
Card(title='')
|
||||
p.description: NSkeleton(:repeat='4' text)
|
||||
p.stats: span(v-for='_ in 4')
|
||||
NSkeleton(circle height='1em' text width='1em')
|
||||
NSkeleton(style='margin-left: 0.5em' text width='4em')
|
||||
p.create-date: NSkeleton(text width='12em')
|
||||
p.canonical-link: NSkeleton(height='1.5rem' width='8rem')
|
||||
h2: NSkeleton(height='2rem' width='8rem')
|
||||
Card(title='')
|
||||
AuthorCard
|
||||
h2: NSkeleton(height='2rem' width='8rem')
|
||||
NSkeleton(:sharp='false' height='8rem' width='100%')
|
||||
|
||||
//- Done
|
||||
section.illust-container(v-if='!error && illust')
|
||||
#top-area
|
||||
.align-center(:style='{ marginBottom: "1rem" }' v-if='isUgoira')
|
||||
UgoiraViewer(:illust='illust')
|
||||
Gallery(:pages='pages' v-else)
|
||||
|
||||
.body-inner
|
||||
#meta-area
|
||||
h1(:class='illust.xRestrict ? "danger" : ""') {{ illust.illustTitle }}
|
||||
Card(title='')
|
||||
.artwork-info
|
||||
p.description.pre(v-html='illust.description')
|
||||
p.description.no-desc(
|
||||
:style='{ color: "#aaa" }'
|
||||
v-if='!illust.description'
|
||||
) (作者未填写简介)
|
||||
|
||||
p.stats
|
||||
span.like-count(title='点赞')
|
||||
IFasThumbsUp(data-icon)
|
||||
| {{ illust.likeCount }}
|
||||
|
||||
//- 收藏
|
||||
span.bookmark-count(
|
||||
:class='{ bookmarked: illust.bookmarkData }',
|
||||
:title='!store.isLoggedIn ? "收藏" : illust.bookmarkData ? "取消收藏" : "添加收藏"'
|
||||
@click='illust?.bookmarkData ? handleRemoveBookmark() : handleAddBookmark()'
|
||||
)
|
||||
IFasHeart(data-icon)
|
||||
| {{ illust.bookmarkCount }}
|
||||
|
||||
span.view-count(title='浏览')
|
||||
IFasEye(data-icon)
|
||||
| {{ illust.viewCount }}
|
||||
span.count
|
||||
IFasImages(data-icon)
|
||||
| {{ pages.length }}张
|
||||
|
||||
p.create-date {{ new Date(illust.createDate).toLocaleString() }}
|
||||
|
||||
.artwork-tags
|
||||
span.original-tag(v-if='illust.isOriginal')
|
||||
IFasLaughWink(data-icon)
|
||||
| 原创
|
||||
span.restrict-tag.x-restrict(
|
||||
title='R-18'
|
||||
v-if='illust?.xRestrict'
|
||||
) R-18
|
||||
span.restrict-tag.ai-restrict(
|
||||
:title='`AI生成 (${illust.aiType})`'
|
||||
v-if='illust?.aiType === 2'
|
||||
) AI生成
|
||||
ArtTag(
|
||||
:key='_',
|
||||
:tag='item.tag'
|
||||
v-for='(item, _) in illust.tags.tags'
|
||||
)
|
||||
|
||||
.canonical-link
|
||||
NButton(
|
||||
:href='illust?.extraData?.meta?.canonical || "#"'
|
||||
icon-placement='right'
|
||||
rel='noopener noreferrer'
|
||||
size='small'
|
||||
tag='a'
|
||||
target='_blank'
|
||||
)
|
||||
template(#icon)
|
||||
IFasArrowRight
|
||||
| 前往 Pixiv 查看
|
||||
|
||||
aside.author-area(ref='authorRef')
|
||||
Card(title='作者')
|
||||
AuthorCard(:user='user')
|
||||
|
||||
Card.comments(title='评论')
|
||||
CommentsArea(
|
||||
:count='illust.commentCount',
|
||||
:id='illust.id || illust.illustId'
|
||||
)
|
||||
|
||||
//- 相关推荐
|
||||
.recommend-works.body-inner(ref='recommendRef')
|
||||
h2 相关推荐
|
||||
ArtworkList(:list='recommend', :loading='!recommend.length')
|
||||
ShowMore(
|
||||
:loading='recommendLoading',
|
||||
:method='handleMoreRecommend',
|
||||
:text='recommendLoading ? "加载中" : "加载更多"'
|
||||
v-if='recommend.length && recommendNextIds.length'
|
||||
)
|
||||
|
||||
//- Error
|
||||
section.error(v-if='error')
|
||||
ErrorPage(:description='error' title='出大问题')
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ArtTag from '@/components/ArtTag.vue'
|
||||
import ArtworkList from '@/components/ArtworksList/ArtworkList.vue'
|
||||
import AuthorCard from '@/components/AuthorCard.vue'
|
||||
import Card from '@/components/Card.vue'
|
||||
import CommentsArea from '@/components/Comment/CommentsArea.vue'
|
||||
import ErrorPage from '@/components/ErrorPage.vue'
|
||||
import Gallery from '@/components/Gallery.vue'
|
||||
import ShowMore from '@/components/ShowMore.vue'
|
||||
import IFasArrowRight from '~icons/fa-solid/arrow-right'
|
||||
import IFasEye from '~icons/fa-solid/eye'
|
||||
import IFasHeart from '~icons/fa-solid/heart'
|
||||
import IFasImages from '~icons/fa-solid/images'
|
||||
import IFasLaughWink from '~icons/fa-solid/laugh-wink'
|
||||
import IFasThumbsUp from '~icons/fa-solid/thumbs-up'
|
||||
|
||||
import { getCache, setCache } from './siteCache'
|
||||
import { ajax } from '@/utils/ajax'
|
||||
|
||||
// Types
|
||||
import type { Artwork, ArtworkInfo, ArtworkGallery, User } from '@/types'
|
||||
|
||||
import { useUserStore } from '@/composables/states'
|
||||
import {
|
||||
addBookmark,
|
||||
removeBookmark,
|
||||
sortArtList,
|
||||
} from '@/utils/artworkActions'
|
||||
import { NButton, NSkeleton } from 'naive-ui'
|
||||
import { effect } from 'vue'
|
||||
import { setTitle } from '@/utils/setTitle'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const illust = ref<Artwork>()
|
||||
const pages = ref<ArtworkGallery[]>([])
|
||||
const user = ref<User>()
|
||||
const recommend = ref<ArtworkInfo[]>([])
|
||||
const recommendNextIds = ref<string[]>([])
|
||||
const recommendLoading = ref(false)
|
||||
const bookmarkLoading = ref(false)
|
||||
const route = useRoute()
|
||||
const store = useUserStore()
|
||||
|
||||
const recommendRef = ref<HTMLDivElement | null>(null)
|
||||
const authorRef = ref<HTMLElement>()
|
||||
|
||||
const isUgoira = computed(() => illust.value?.illustType === 2)
|
||||
const UgoiraViewer = defineAsyncComponent({
|
||||
loader: () => import('@/components/UgoiraViewer.vue'),
|
||||
loadingComponent: () =>
|
||||
h('svg', {
|
||||
width: illust.value?.width,
|
||||
height: illust.value?.height,
|
||||
style: {
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '60vh',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#e8e8e8',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
function addObserver(elementRef: Ref, cb: () => any) {
|
||||
const unWatch = watch(loading, async (val) => {
|
||||
console.log(loading.value)
|
||||
if (val) return
|
||||
await nextTick()
|
||||
if (illust.value?.illustId) {
|
||||
unWatch()
|
||||
const ob = useIntersectionObserver(
|
||||
elementRef.value,
|
||||
([{ isIntersecting }]) => {
|
||||
if (isIntersecting) {
|
||||
cb()
|
||||
ob.stop()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function init(id: string): Promise<void> {
|
||||
loading.value = true
|
||||
|
||||
// Reset states
|
||||
illust.value = undefined
|
||||
pages.value = []
|
||||
user.value = undefined
|
||||
recommend.value = []
|
||||
recommendNextIds.value = []
|
||||
|
||||
addObserver(recommendRef, () => handleRecommendInit(illust.value!.illustId))
|
||||
addObserver(authorRef, () => handleUserInit(illust.value!.userId))
|
||||
|
||||
const dataCache = getCache(`illust.${id}`)
|
||||
const pageCache = getCache(`illust.${id}.page`)
|
||||
if (dataCache && pageCache) {
|
||||
illust.value = dataCache
|
||||
pages.value = pageCache
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const [{ data: illustData }, { data: illustPage }] = await Promise.all([
|
||||
ajax.get<Artwork>(`/ajax/illust/${id}?full=1`),
|
||||
ajax.get<ArtworkGallery[]>(`/ajax/illust/${id}/pages`),
|
||||
])
|
||||
setCache(`illust.${id}`, illustData)
|
||||
setCache(`illust.${id}.page`, illustPage)
|
||||
illust.value = illustData
|
||||
pages.value = illustPage
|
||||
} catch (err) {
|
||||
console.warn('illust fetch error', `#${id}`, err)
|
||||
if (err instanceof Error) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = '未知错误'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUserInit(userId: string): Promise<void> {
|
||||
const value = getCache(`user.${userId}`)
|
||||
if (value) {
|
||||
user.value = value
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const [{ data: userData }, { data: profileData }] = await Promise.all([
|
||||
axios.get<User>(`/ajax/user/${userId}?full=1`),
|
||||
axios.get<{ illusts: Record<string, ArtworkInfo> }>(
|
||||
`/ajax/user/${userId}/profile/top`
|
||||
),
|
||||
])
|
||||
const { illusts } = profileData
|
||||
const userValue = {
|
||||
...userData,
|
||||
illusts: sortArtList(illusts),
|
||||
}
|
||||
user.value = userValue
|
||||
setCache(`user.${userId}`, userValue)
|
||||
} catch (err) {
|
||||
console.warn('User fetch error', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRecommendInit(id: string): Promise<void> {
|
||||
if (recommendLoading.value) return
|
||||
try {
|
||||
recommendLoading.value = true
|
||||
console.log('init recommend')
|
||||
const { data } = await ajax.get<{
|
||||
illusts: ArtworkInfo[]
|
||||
nextIds: string[]
|
||||
}>(`/ajax/illust/${id}/recommend/init?limit=18`)
|
||||
recommend.value = data.illusts
|
||||
recommendNextIds.value = data.nextIds
|
||||
} catch (err) {
|
||||
console.error('recommend fetch error', err)
|
||||
} finally {
|
||||
recommendLoading.value = false
|
||||
}
|
||||
}
|
||||
async function handleMoreRecommend(): Promise<void> {
|
||||
if (recommendLoading.value) return
|
||||
if (!recommendNextIds.value.length) {
|
||||
console.log('no more recommend')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
recommendLoading.value = true
|
||||
console.log('get more recommend')
|
||||
const requestIds = recommendNextIds.value.splice(0, 18)
|
||||
const searchParams = new URLSearchParams()
|
||||
for (const id of requestIds) {
|
||||
searchParams.append('illust_ids', id)
|
||||
}
|
||||
const { data } = await ajax.get<{
|
||||
illusts: ArtworkInfo[]
|
||||
nextIds: string[]
|
||||
}>('/ajax/illust/recommend/illusts', { params: searchParams })
|
||||
recommend.value = recommend.value.concat(data.illusts)
|
||||
recommendNextIds.value = recommendNextIds.value.concat(data.nextIds)
|
||||
} catch (err) {
|
||||
console.error('recommend fetch error', err)
|
||||
} finally {
|
||||
recommendLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddBookmark(): Promise<void> {
|
||||
if (!illust.value) return
|
||||
if (!store.isLoggedIn) {
|
||||
console.log('需要登录才可以添加收藏')
|
||||
return
|
||||
}
|
||||
if (!illust.value.isBookmarkable) {
|
||||
console.log('无法添加收藏')
|
||||
return
|
||||
}
|
||||
if (illust.value.bookmarkData) {
|
||||
console.log('已经收藏过啦')
|
||||
return
|
||||
}
|
||||
if (bookmarkLoading.value) return
|
||||
try {
|
||||
bookmarkLoading.value = true
|
||||
const data = await addBookmark(illust.value.illustId)
|
||||
if (data.last_bookmark_id) {
|
||||
illust.value.bookmarkData = {
|
||||
id: data.last_bookmark_id,
|
||||
private: false,
|
||||
}
|
||||
illust.value.bookmarkCount++
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('bookmark add error:', err)
|
||||
} finally {
|
||||
bookmarkLoading.value = false
|
||||
}
|
||||
}
|
||||
async function handleRemoveBookmark(): Promise<void> {
|
||||
if (!illust.value) return
|
||||
if (bookmarkLoading.value || !illust.value.bookmarkData) return
|
||||
try {
|
||||
bookmarkLoading.value = true
|
||||
await removeBookmark(illust.value.bookmarkData.id)
|
||||
illust.value.bookmarkData = null
|
||||
illust.value.bookmarkCount--
|
||||
} catch (err) {
|
||||
console.error('bookmark remove failed:', err)
|
||||
} finally {
|
||||
bookmarkLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteUpdate(async (to) => {
|
||||
if (to.name !== 'artworks') {
|
||||
return
|
||||
}
|
||||
init(to.params.id as string)
|
||||
})
|
||||
|
||||
effect(() => setTitle(illust.value?.illustTitle, 'Artworks'))
|
||||
|
||||
onMounted(() => {
|
||||
init(route.params.id as string)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
section
|
||||
padding-top: 1rem
|
||||
|
||||
.gallery
|
||||
margin: 0 auto
|
||||
|
||||
.artwork-tags
|
||||
margin: 1rem 0
|
||||
> span
|
||||
font-weight: 700
|
||||
margin-right: 1rem
|
||||
|
||||
h1
|
||||
--bg-color: var(--theme-accent-color)
|
||||
box-shadow: 0 2px 0 var(--bg-color)
|
||||
margin: 0
|
||||
margin-bottom: 1rem
|
||||
&.danger
|
||||
--bg-color: var(--theme-danger-color)
|
||||
&.loading
|
||||
--bg-color: rgba(0, 0, 0, .08)
|
||||
opacity: 0.85
|
||||
|
||||
.original-tag
|
||||
color: #e02080
|
||||
.x-restrict
|
||||
color: #c00
|
||||
.ai-restrict
|
||||
color: #c70
|
||||
|
||||
.stats
|
||||
> span, > a
|
||||
margin-right: 0.5rem
|
||||
color: #aaa
|
||||
|
||||
[data-icon]
|
||||
margin-right: 4px
|
||||
|
||||
.bookmark-count
|
||||
cursor: pointer
|
||||
|
||||
&.bookmarked
|
||||
color: var(--theme-bookmark-color)
|
||||
font-weight: 700
|
||||
|
||||
.create-date
|
||||
color: #aaa
|
||||
font-size: 0.85rem
|
||||
|
||||
.breadcrumb
|
||||
margin-top: 1rem
|
||||
|
||||
.user-illusts
|
||||
ul
|
||||
margin-left: -1rem
|
||||
margin-right: -1rem
|
||||
background-color: var(--theme-background-color)
|
||||
|
||||
.load-more
|
||||
a.plain
|
||||
color: var(--theme-text-color)
|
||||
cursor: pointer
|
||||
|
||||
.top .inner
|
||||
border-radius: 8px
|
||||
width: 100%
|
||||
padding: 28% 0
|
||||
background-color: var(--theme-box-shadow-color)
|
||||
text-align: center
|
||||
|
||||
.bottom .author
|
||||
font-size: 0.8rem
|
||||
</style>
|
||||
212
src/view/discovery.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template lang="pug">
|
||||
#discovery-view
|
||||
//- Error
|
||||
section(v-if='error')
|
||||
.body-inner
|
||||
h1 探索发现加载失败
|
||||
ErrorPage(:description='error' title='出大问题')
|
||||
|
||||
//- Loading
|
||||
section(v-if='loadingDiscovery && !discoveryList.length')
|
||||
.body-inner
|
||||
h1 探索发现加载中……
|
||||
.loading
|
||||
Placeholder
|
||||
|
||||
//- Result
|
||||
section(v-if='!error')
|
||||
.body-inner
|
||||
h1 探索发现
|
||||
.align-center
|
||||
NButton(
|
||||
:loading='loadingDiscovery'
|
||||
@click='refreshDiscovery'
|
||||
round
|
||||
secondary
|
||||
size='large'
|
||||
style='margin-bottom: 2rem'
|
||||
)
|
||||
template(#default) {{ loadingDiscovery ? '加载中' : '换一批' }}
|
||||
template(#icon): NIcon: IFasRandom
|
||||
|
||||
NSpin(:show='loadingDiscovery && discoveryList.length')
|
||||
ArtworkLargeList(:artwork-list='discoveryList' v-if='discoveryList.length')
|
||||
|
||||
//- 无限滚动加载更多
|
||||
ShowMore(
|
||||
:loading='loadingMore',
|
||||
:method='loadMoreDiscovery',
|
||||
:text='loadingMore ? "加载中..." : "加载更多"'
|
||||
v-if='hasMore && discoveryList.length && !error'
|
||||
)
|
||||
|
||||
.no-more(v-if='!loadingDiscovery && !loadingMore && !discoveryList.length && !error')
|
||||
NCard(style='padding: 15vh 0'): NEmpty(description='暂无内容,请稍后再试')
|
||||
|
||||
.no-more(v-if='!hasMore && discoveryList.length && !loadingMore')
|
||||
NCard(style='padding: 2rem 0'): NEmpty(description='没有更多内容了')
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ArtworkLargeList from '@/components/ArtworksList/ArtworkLargeList.vue'
|
||||
import ErrorPage from '@/components/ErrorPage.vue'
|
||||
import Placeholder from '@/components/Placeholder.vue'
|
||||
import ShowMore from '@/components/ShowMore.vue'
|
||||
import { NButton, NIcon, NCard, NEmpty, NSpin } from 'naive-ui'
|
||||
import IFasRandom from '~icons/fa-solid/random'
|
||||
|
||||
import { getCache, setCache } from './siteCache'
|
||||
import { isArtwork } from '@/utils'
|
||||
import { ajax } from '@/utils/ajax'
|
||||
import type { ArtworkInfo, ArtworkInfoOrAd } from '@/types'
|
||||
import { setTitle } from '@/utils/setTitle'
|
||||
import { effect } from 'vue'
|
||||
|
||||
const discoveryList = ref<ArtworkInfo[]>([])
|
||||
const loadingDiscovery = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const error = ref('')
|
||||
const currentOffset = ref(0)
|
||||
|
||||
// 刷新探索发现内容(替换当前内容)
|
||||
async function refreshDiscovery(): Promise<void> {
|
||||
currentOffset.value = 0
|
||||
hasMore.value = true
|
||||
discoveryList.value = []
|
||||
await setDiscoveryNoCache()
|
||||
}
|
||||
|
||||
// 加载更多内容(追加到现有内容)
|
||||
async function loadMoreDiscovery(): Promise<void> {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const response = await ajax.get(
|
||||
'/ajax/illust/discovery',
|
||||
{ params: new URLSearchParams({
|
||||
mode: 'all',
|
||||
max: '8',
|
||||
offset: currentOffset.value.toString()
|
||||
}) }
|
||||
)
|
||||
|
||||
console.info('loadMoreDiscovery response:', response)
|
||||
|
||||
// 检查 API 是否返回错误
|
||||
if (response.data?.error) {
|
||||
throw new Error(response.data.message || 'API 请求失败')
|
||||
}
|
||||
|
||||
// 处理 Pixiv API 的标准响应格式
|
||||
const data = response.data?.body || response.data
|
||||
console.info('loadMoreDiscovery data:', data)
|
||||
|
||||
// 检查数据结构
|
||||
if (!data || !data.illusts || !Array.isArray(data.illusts)) {
|
||||
console.warn('API 返回的数据格式:', data)
|
||||
hasMore.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const illusts = data.illusts.filter((item): item is ArtworkInfo =>
|
||||
isArtwork(item)
|
||||
)
|
||||
|
||||
// 如果返回的数据少于请求的数量,说明没有更多了
|
||||
if (illusts.length < 8) {
|
||||
hasMore.value = false
|
||||
}
|
||||
|
||||
// 追加新数据到现有列表
|
||||
discoveryList.value.push(...illusts)
|
||||
currentOffset.value += illusts.length
|
||||
|
||||
// 更新缓存
|
||||
setCache('discovery.discoveryList', discoveryList.value)
|
||||
} catch (err) {
|
||||
console.error('加载更多探索发现失败', err)
|
||||
hasMore.value = false
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setDiscoveryNoCache(): Promise<void> {
|
||||
if (loadingDiscovery.value) return
|
||||
try {
|
||||
loadingDiscovery.value = true
|
||||
const response = await ajax.get(
|
||||
'/ajax/illust/discovery',
|
||||
{ params: new URLSearchParams({ mode: 'all', max: '8' }) }
|
||||
)
|
||||
console.info('setDiscoveryNoCache response:', response)
|
||||
|
||||
// 检查 API 是否返回错误
|
||||
if (response.data?.error) {
|
||||
throw new Error(response.data.message || 'API 请求失败')
|
||||
}
|
||||
|
||||
// 处理 Pixiv API 的标准响应格式
|
||||
const data = response.data?.body || response.data
|
||||
console.info('setDiscoveryNoCache data:', data)
|
||||
|
||||
// 检查数据结构
|
||||
if (!data || !data.illusts || !Array.isArray(data.illusts)) {
|
||||
console.warn('API 返回的数据格式:', data)
|
||||
// 如果没有数据,设置为空数组而不是抛出错误
|
||||
discoveryList.value = []
|
||||
hasMore.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const illusts = data.illusts.filter((item): item is ArtworkInfo =>
|
||||
isArtwork(item)
|
||||
)
|
||||
discoveryList.value = illusts
|
||||
currentOffset.value = illusts.length
|
||||
|
||||
// 如果返回的数据少于请求的数量,说明没有更多了
|
||||
if (illusts.length < 8) {
|
||||
hasMore.value = false
|
||||
}
|
||||
|
||||
setCache('discovery.discoveryList', illusts)
|
||||
} catch (err) {
|
||||
console.error('获取探索发现失败', err)
|
||||
// 设置为空数组,避免页面崩溃
|
||||
discoveryList.value = []
|
||||
hasMore.value = false
|
||||
} finally {
|
||||
loadingDiscovery.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setDiscoveryFromCache(): Promise<void> {
|
||||
const cache = getCache('discovery.discoveryList')
|
||||
if (cache) {
|
||||
discoveryList.value = cache
|
||||
currentOffset.value = cache.length
|
||||
loadingDiscovery.value = false
|
||||
} else {
|
||||
await setDiscoveryNoCache()
|
||||
}
|
||||
}
|
||||
|
||||
effect(() => setTitle('探索发现'))
|
||||
|
||||
onMounted(async () => {
|
||||
setDiscoveryFromCache()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.loading
|
||||
text-align: center
|
||||
|
||||
.no-more
|
||||
text-align: center
|
||||
padding: 1rem
|
||||
opacity: 0.75
|
||||
</style>
|
||||
58
src/view/following-latest.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template lang="pug">
|
||||
#following-latest-view.body-inner
|
||||
h1 已关注用户的作品
|
||||
ArtworkList(:list='illusts', :loading='isLoading && !illusts.length')
|
||||
ShowMore(
|
||||
:loading='isLoading',
|
||||
:method='fetchList',
|
||||
:text='isLoading ? "加载中" : "加载更多"'
|
||||
v-if='hasNextPage && illusts.length'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type ArtworkInfo } from '@/types'
|
||||
|
||||
onMounted(() => {
|
||||
setTitle('New Artworks from Following Users')
|
||||
fetchList()
|
||||
})
|
||||
|
||||
const illusts = ref<ArtworkInfo[]>([])
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const nextPage = ref(1)
|
||||
const hasNextPage = ref(true)
|
||||
const isLoading = ref(false)
|
||||
|
||||
async function fetchList() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
return router.push({
|
||||
name: 'user-login',
|
||||
query: { back: route.fullPath },
|
||||
})
|
||||
}
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const { data } = await ajax.get<{
|
||||
page: {
|
||||
isLastPage: boolean
|
||||
}
|
||||
thumbnails: {
|
||||
illust: ArtworkInfo[]
|
||||
}
|
||||
}>(`/ajax/follow_latest/illust`, {
|
||||
params: { p: nextPage.value, mode: 'all' },
|
||||
})
|
||||
illusts.value.push(...data.thumbnails.illust)
|
||||
nextPage.value++
|
||||
hasNextPage.value = !data.page.isLastPage
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
168
src/view/following.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template lang="pug">
|
||||
#about-view.body-inner
|
||||
h1
|
||||
.flex.gap-1
|
||||
NButton(
|
||||
@click='$router.push({ name: "users", params: { id: targetUserId } })'
|
||||
circle
|
||||
secondary
|
||||
)
|
||||
template(#icon)
|
||||
IChevronLeft
|
||||
.first-heading {{ title }}
|
||||
|
||||
NTabs(
|
||||
:bar-width='32'
|
||||
justify-content='space-evenly'
|
||||
type='line'
|
||||
v-model:value='tab'
|
||||
)
|
||||
NTabPane(display-directive='show:lazy' name='public' tab='公开关注')
|
||||
.user-list
|
||||
Card(
|
||||
title=''
|
||||
v-for='_ in 8'
|
||||
v-if='publicList.length === 0 && isLoadingPublic'
|
||||
)
|
||||
FollowUserCard
|
||||
Card(:key='user.userId' title='' v-for='user in publicList')
|
||||
FollowUserCard(:user='user')
|
||||
ShowMore(
|
||||
:loading='isLoadingPublic',
|
||||
:method='() => fetchList(false)',
|
||||
:text='isLoadingPublic ? "加载中..." : "加载更多"'
|
||||
v-if='hasMorePublic'
|
||||
)
|
||||
NTabPane(
|
||||
:disabled='!isSelfPage'
|
||||
display-directive='show:lazy'
|
||||
name='hidden'
|
||||
tab='私密关注'
|
||||
)
|
||||
.user-list
|
||||
Card(
|
||||
title=''
|
||||
v-for='_ in 8'
|
||||
v-if='hiddenList.length === 0 && isLoadingHidden'
|
||||
)
|
||||
FollowUserCard
|
||||
Card(:key='user.userId' title='' v-for='user in hiddenList')
|
||||
FollowUserCard(:user='user')
|
||||
ShowMore(
|
||||
:loading='isLoadingHidden',
|
||||
:method='() => fetchList(true)',
|
||||
:text='isLoadingHidden ? "加载中..." : "加载更多"'
|
||||
v-if='hasMoreHidden'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { UserListItem } from '@/types'
|
||||
import IChevronLeft from '~icons/fa-solid/chevron-left'
|
||||
|
||||
onMounted(() => {
|
||||
setTitle('Following')
|
||||
resetAll('' + route.params.id)
|
||||
fetchList(false)
|
||||
})
|
||||
onBeforeRouteUpdate((to, from) => {
|
||||
if (to.name === from.name && to.params.id !== from.params.id) {
|
||||
resetAll('' + to.params.id)
|
||||
fetchList(false)
|
||||
}
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const targetUserId = ref(route.params.id)
|
||||
|
||||
const tab = ref<'public' | 'hidden'>('public')
|
||||
|
||||
const publicList = ref<UserListItem[]>([])
|
||||
const isLoadingPublic = ref(false)
|
||||
const totalPublic = ref(0)
|
||||
const hasMorePublic = computed(
|
||||
() => totalPublic.value > publicList.value.length
|
||||
)
|
||||
|
||||
const hiddenList = ref<UserListItem[]>([])
|
||||
const isLoadingHidden = ref(false)
|
||||
const totalHidden = ref(0)
|
||||
const hasMoreHidden = computed(
|
||||
() => totalHidden.value > hiddenList.value.length
|
||||
)
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isSelfPage = computed(() => userStore.userId === targetUserId.value)
|
||||
const title = ref('Following')
|
||||
|
||||
function resetAll(userId: string) {
|
||||
targetUserId.value = userId
|
||||
tab.value = 'public'
|
||||
publicList.value = []
|
||||
hiddenList.value = []
|
||||
totalPublic.value = 0
|
||||
totalHidden.value = 0
|
||||
isLoadingPublic.value = false
|
||||
isLoadingHidden.value = false
|
||||
}
|
||||
async function fetchList(hidden?: boolean) {
|
||||
const list = hidden ? hiddenList : publicList
|
||||
const isLoading = hidden ? isLoadingHidden : isLoadingPublic
|
||||
const total = hidden ? totalHidden : totalPublic
|
||||
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const { data } = await ajax.get<{
|
||||
total: number
|
||||
users: UserListItem[]
|
||||
extraData: {
|
||||
meta: {
|
||||
ogp: {
|
||||
title: string
|
||||
image: string
|
||||
description: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}>(`/ajax/user/${targetUserId.value}/following`, {
|
||||
params: {
|
||||
offset: list.value.length,
|
||||
limit: 24,
|
||||
rest: hidden ? 'hide' : 'show',
|
||||
},
|
||||
})
|
||||
list.value.push(...data.users)
|
||||
total.value = data.total
|
||||
title.value = data.extraData.meta.ogp.title || 'Following'
|
||||
setTitle(title.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(tab, (newTab) => {
|
||||
const isPublicEmpty = !publicList.value.length
|
||||
const isHiddenEmpty = !hiddenList.value.length
|
||||
|
||||
if (newTab === 'public' && isPublicEmpty) {
|
||||
fetchList(false)
|
||||
}
|
||||
if (newTab === 'hidden' && isHiddenEmpty) {
|
||||
fetchList(true)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
#about-view
|
||||
padding-top: 2rem
|
||||
|
||||
h1
|
||||
margin-top: 0
|
||||
|
||||
.user-list
|
||||
.card:not(:first-of-type)
|
||||
margin-top: 1rem
|
||||
</style>
|
||||
228
src/view/index.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template lang="pug">
|
||||
#home-view
|
||||
.top-slider.align-center(
|
||||
:style='{ "background-image": `url(${randomBg.urls?.regular || randomBg.url || ""})` }'
|
||||
)
|
||||
section.search-area.flex-1
|
||||
SearchBox.big.search
|
||||
|
||||
.site-logo
|
||||
img(:src='LogoH')
|
||||
.description Now, everyone can enjoy Pixiv
|
||||
|
||||
.bg-info
|
||||
a.pointer(@click='async () => await setRandomBgNoCache()' title='换一个~')
|
||||
IFasRandom
|
||||
a.pointer(
|
||||
@click='isShowBgInfo = true'
|
||||
style='margin-left: 0.5em'
|
||||
title='关于背景'
|
||||
v-if='randomBg.id'
|
||||
)
|
||||
IFasInfoCircle
|
||||
|
||||
NModal(
|
||||
:title='`背景图片:${randomBg.alt}`'
|
||||
closable
|
||||
preset='card'
|
||||
v-model:show='isShowBgInfo'
|
||||
)
|
||||
.bg-info-modal
|
||||
.align-center
|
||||
RouterLink.thumb(:to='"/artworks/" + randomBg.id')
|
||||
img(:src='randomBg.urls?.regular || randomBg.url' lazyload)
|
||||
.desc
|
||||
.author
|
||||
RouterLink(:to='"/users/" + randomBg.userId') {{ randomBg.userName }}
|
||||
| 的作品 (ID: {{ randomBg.id }})
|
||||
NSpace(justify='center' size='small' style='margin-top: 1rem')
|
||||
NTag(
|
||||
:key='tag'
|
||||
@click='$router.push({ name: "search", params: { keyword: tag, p: 1 } })'
|
||||
style='cursor: pointer'
|
||||
v-for='tag in randomBg.tags'
|
||||
) {{ tag }}
|
||||
|
||||
.body-inner
|
||||
section.discover
|
||||
NH2 探索发现
|
||||
.align-center
|
||||
NButton(
|
||||
:loading='loadingDiscovery'
|
||||
@click='discoveryList.length ? (async () => await setDiscoveryNoCache())() : void 0'
|
||||
round
|
||||
secondary
|
||||
size='small'
|
||||
)
|
||||
template(#default) {{ loadingDiscovery ? '加载中' : '换一批' }}
|
||||
template(#icon): NIcon: IFasRandom
|
||||
ArtworkList(:list='discoveryList', :loading='loadingDiscovery')
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ArtworkList from '@/components/ArtworksList/ArtworkList.vue'
|
||||
import SearchBox from '@/components/SearchBox.vue'
|
||||
import { NH2, NButton, NIcon, NModal } from 'naive-ui'
|
||||
import IFasInfoCircle from '~icons/fa-solid/info-circle'
|
||||
import IFasRandom from '~icons/fa-solid/random'
|
||||
|
||||
import { formatInTimeZone } from 'date-fns-tz'
|
||||
import { getCache, setCache } from './siteCache'
|
||||
import { defaultArtwork, isArtwork } from '@/utils'
|
||||
import { ajax } from '@/utils/ajax'
|
||||
|
||||
import LogoH from '@/assets/LogoH.png'
|
||||
import type { Artwork, ArtworkInfo, ArtworkInfoOrAd } from '@/types'
|
||||
import { setTitle } from '@/utils/setTitle'
|
||||
|
||||
const isShowBgInfo = ref(false)
|
||||
const discoveryList = ref<ArtworkInfo[]>([])
|
||||
const randomBg = ref<Artwork>({ ...defaultArtwork, urls: {} } as any)
|
||||
|
||||
async function setRandomBgNoCache(): Promise<void> {
|
||||
try {
|
||||
const { data } = await ajax.get<Artwork[]>('/api/random', {
|
||||
params: {
|
||||
max: '1',
|
||||
},
|
||||
})
|
||||
const info = data[0]
|
||||
randomBg.value = info
|
||||
setCache('home.randomBg', info)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function setRandomBgFromCache(): Promise<void> {
|
||||
const cache = getCache('home.randomBg')
|
||||
if (cache) {
|
||||
randomBg.value = cache
|
||||
} else {
|
||||
await setRandomBgNoCache()
|
||||
}
|
||||
}
|
||||
|
||||
const loadingDiscovery = ref(false)
|
||||
async function setDiscoveryNoCache(): Promise<void> {
|
||||
if (loadingDiscovery.value) return
|
||||
try {
|
||||
loadingDiscovery.value = true
|
||||
// discoveryList.value = []
|
||||
const { data } = await ajax.get<{ illusts: ArtworkInfoOrAd[] }>(
|
||||
'/ajax/illust/discovery',
|
||||
{ params: new URLSearchParams({ mode: 'all', max: '8' }) }
|
||||
)
|
||||
console.info('setDiscoveryNoCache', data)
|
||||
const illusts = data.illusts.filter((item): item is ArtworkInfo =>
|
||||
isArtwork(item)
|
||||
)
|
||||
discoveryList.value = illusts
|
||||
setCache('home.discoveryList', illusts)
|
||||
} catch (err) {
|
||||
console.error('获取探索发现失败', err)
|
||||
} finally {
|
||||
loadingDiscovery.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setDiscoveryFromCache(): Promise<void> {
|
||||
const cache = getCache('home.discoveryList')
|
||||
if (cache) {
|
||||
discoveryList.value = cache
|
||||
loadingDiscovery.value = false
|
||||
} else {
|
||||
await setDiscoveryNoCache()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
setTitle()
|
||||
setRandomBgFromCache()
|
||||
setDiscoveryFromCache()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
[data-route="home"]
|
||||
.top-slider
|
||||
min-height: calc(100vh)
|
||||
margin-top: -50px
|
||||
padding: 30px 10%
|
||||
background-position: center
|
||||
background-repeat: no-repeat
|
||||
background-size: cover
|
||||
background-attachment: fixed
|
||||
position: relative
|
||||
color: #fff
|
||||
text-shadow: 0 0 2px #222
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
&::before
|
||||
content: ''
|
||||
display: block
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
background-color: rgba(0, 0, 0, 0.2)
|
||||
pointer-events: none
|
||||
z-index: 0
|
||||
|
||||
> *
|
||||
position: relative
|
||||
z-index: 1
|
||||
|
||||
.bg-info
|
||||
position: absolute
|
||||
right: 1.5rem
|
||||
bottom: 1rem
|
||||
font-size: 1.25rem
|
||||
|
||||
a
|
||||
--color: #fff
|
||||
|
||||
.site-logo
|
||||
img
|
||||
height: 4rem
|
||||
width: auto
|
||||
|
||||
.description
|
||||
font-size: 1.2rem
|
||||
|
||||
.search-area
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
> *
|
||||
width: 100%
|
||||
|
||||
.global-navbar
|
||||
background: none
|
||||
.search-area
|
||||
opacity: 0
|
||||
transition: opacity 0.4s ease
|
||||
pointer-events: none
|
||||
|
||||
&.not-at-top
|
||||
background-color: var(--theme-accent-color)
|
||||
.search-area
|
||||
opacity: 1
|
||||
pointer-events: all
|
||||
|
||||
.bg-info-modal
|
||||
.thumb
|
||||
> *
|
||||
width: auto
|
||||
height: auto
|
||||
max-width: 100%
|
||||
max-height: 60vh
|
||||
border-radius: 8px
|
||||
.desc
|
||||
margin-top: 1rem
|
||||
font-size: 0.75rem
|
||||
font-style: italic
|
||||
</style>
|
||||
158
src/view/login.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template lang="pug">
|
||||
NForm#login-form.not-logged-in(v-if='!userStore.isLoggedIn')
|
||||
RouterLink.button(
|
||||
:to='$route.query.back.toString()'
|
||||
v-if='$route.query.back'
|
||||
)
|
||||
IFasAngleLeft
|
||||
| 取消
|
||||
h1.title 设置 Pixiv 令牌
|
||||
NFormItem(
|
||||
:feedback='sessionIdInput && !validateSessionId(sessionIdInput) ? "哎呀,这个格式看上去不太对……" : error ? error : "这个格式看上去没问题,点击保存试试"',
|
||||
:validation-status='(sessionIdInput && !validateSessionId(sessionIdInput)) || error ? "error" : "success"'
|
||||
label='PHPSESSID'
|
||||
required
|
||||
)
|
||||
NInput(
|
||||
:class='validateSessionId(sessionIdInput) ? "valid" : "invalid"'
|
||||
v-model:value='sessionIdInput'
|
||||
)
|
||||
#submit
|
||||
NButton(
|
||||
:disabled='!!error || loading || !validateSessionId(sessionIdInput)'
|
||||
@click='async () => await submit()'
|
||||
block
|
||||
type='primary'
|
||||
) {{ loading ? '登录中……' : '保存令牌' }}
|
||||
.tips
|
||||
h2 如何获取 Pixiv 令牌?
|
||||
p 访问 <a href="https://www.pixiv.net" target="_blank">www.pixiv.net</a> 源站并登录,打开浏览器控制台(f12),点击“存储(storage)”一栏,在 cookie 列表里找到“键(key)”为<code>PHPSESSID</code>的一栏,将它的“值(value)”复制后填写到这里。
|
||||
p
|
||||
| 它应该形如:
|
||||
code(@click='exampleSessionId' title='此处的令牌为随机生成,仅供演示使用') {{ example }}
|
||||
| 。
|
||||
h2 PixivNow 会窃取我的个人信息吗?
|
||||
p 我们<strong>不会</strong>存储或转让您的个人信息以及 cookie。
|
||||
p 不过我们建议妥善保存您的 cookie。您在此处保存的信息若被他人获取有被盗号的风险。
|
||||
|
||||
#login-form.logged-in(v-if='userStore.isLoggedIn')
|
||||
RouterLink.button(
|
||||
:to='$route.query.back.toString()'
|
||||
v-if='$route.query.back'
|
||||
)
|
||||
IFasAngleLeft
|
||||
| 返回
|
||||
h1 查看 Pixiv 令牌
|
||||
NInput.token(:value='Cookies.get("PHPSESSID")' readonly)
|
||||
#submit
|
||||
NButton(@click='remove' type='error') 移除令牌
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Cookies from 'js-cookie'
|
||||
import {
|
||||
exampleSessionId,
|
||||
validateSessionId,
|
||||
login,
|
||||
logout,
|
||||
} from '@/components/userData'
|
||||
import { useUserStore } from '@/composables/states'
|
||||
import IFasAngleLeft from '~icons/fa-solid/angle-left'
|
||||
import { NButton, NForm, NFormItem, NInput } from 'naive-ui'
|
||||
|
||||
const example = ref(exampleSessionId())
|
||||
const sessionIdInput = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
function goBack(): void {
|
||||
const back = route.query.back
|
||||
if (back) {
|
||||
router.push(back as string)
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!validateSessionId(sessionIdInput.value)) {
|
||||
error.value = '哎呀,这个格式看上去不太对……'
|
||||
console.warn(error.value)
|
||||
return
|
||||
}
|
||||
try {
|
||||
loading.value = true
|
||||
const userData = await login(sessionIdInput.value)
|
||||
userStore.login(userData)
|
||||
error.value = ''
|
||||
goBack()
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = '哎呀,出错了,请重试!'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
logout()
|
||||
userStore.logout()
|
||||
goBack()
|
||||
}
|
||||
|
||||
watch(sessionIdInput, () => (error.value = ''))
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
||||
#login-form
|
||||
width: 400px
|
||||
margin: 0 auto
|
||||
padding: 1rem
|
||||
box-sizing: border-box
|
||||
box-shadow: var(--theme-box-shadow)
|
||||
border-radius: 4px
|
||||
padding: 1rem
|
||||
transition: box-shadow .24s ease-in-out
|
||||
|
||||
&:hover
|
||||
box-shadow: var(--theme-box-shadow-hover)
|
||||
|
||||
@media screen and (max-width: 500px)
|
||||
#login-form
|
||||
width: 100%
|
||||
|
||||
input
|
||||
width: 100%
|
||||
display: block
|
||||
padding: 4px 8px
|
||||
font-size: 1.2rem
|
||||
|
||||
#submit
|
||||
text-align: center
|
||||
margin: 1rem auto
|
||||
|
||||
.btn
|
||||
width: 50%
|
||||
|
||||
code
|
||||
user-select: none
|
||||
|
||||
.status
|
||||
margin-top: 0.2rem
|
||||
text-align: center
|
||||
padding: 4px
|
||||
color: #fff
|
||||
|
||||
&.valid
|
||||
background-color: green
|
||||
|
||||
&.invalid
|
||||
background-color: #a00
|
||||
</style>
|
||||
39
src/view/notifications/2024-04-26.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template lang="pug">
|
||||
#notification-view.body-inner
|
||||
h1.align-center 关于网站新建立的通知(2024年4月26日)
|
||||
Card
|
||||
p 各位,早上好中午好晚上好:
|
||||
p 欢迎来到我们新建立的 PixivNow 网站!本站由 pixivperoe 创建和维护,致力于为大家提供更好的 Pixiv 浏览体验。
|
||||
p 请注意:本网站内容仅供个人学习和研究使用,<strong>严禁传播</strong>。我们尊重原创作者的版权,请大家合理使用本站服务。
|
||||
p 如果您在使用过程中遇到任何问题,欢迎通过下方联系方式与我们取得联系。
|
||||
p 感谢您对我们网站的支持和理解!
|
||||
|
||||
div(style='text-align: right')
|
||||
strong pixivperoe
|
||||
br
|
||||
time 2024年4月26日
|
||||
|
||||
Card(title='赞助我们')
|
||||
.align-center
|
||||
iframe(
|
||||
frameborder='0'
|
||||
height='200'
|
||||
scrolling='no'
|
||||
src='https://afdian.com/leaflet?slug=peroe'
|
||||
width='640'
|
||||
)
|
||||
|
||||
Card(title='联系我们')
|
||||
ul
|
||||
li QQ群:858701548
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {} from 'vue'
|
||||
|
||||
const fromTime = new Date()
|
||||
const toTime = new Date('2024-09-30T23:59:59Z')
|
||||
const duration = toTime.getTime() - fromTime.getTime()
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass"></style>
|
||||
99
src/view/ranking.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template lang="pug">
|
||||
#ranking-view
|
||||
//- Error
|
||||
section(v-if='error')
|
||||
.body-inner
|
||||
h1 排行榜加载失败
|
||||
ErrorPage(:description='error' title='出大问题')
|
||||
|
||||
//- Loading
|
||||
section(v-if='loading')
|
||||
.body-inner
|
||||
h1 排行榜加载中……
|
||||
.loading
|
||||
Placeholder
|
||||
|
||||
//- Result
|
||||
section(v-if='list')
|
||||
.body-inner
|
||||
h1 {{ list.date.toLocaleDateString('zh', { dateStyle: 'long' }) }}排行榜
|
||||
ArtworkLargeList(:rank-list='list.contents')
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ArtworkLargeList from '@/components/ArtworksList/ArtworkLargeList.vue'
|
||||
import ErrorPage from '@/components/ErrorPage.vue'
|
||||
import Placeholder from '@/components/Placeholder.vue'
|
||||
|
||||
import type { ArtworkRank } from '@/types'
|
||||
import { getCache, setCache } from './siteCache'
|
||||
import { ajax } from '@/utils/ajax'
|
||||
import { effect } from 'vue'
|
||||
import { setTitle } from '@/utils/setTitle'
|
||||
|
||||
const error = ref('')
|
||||
const loading = ref(true)
|
||||
const list = ref<{
|
||||
date: Date
|
||||
contents: ArtworkRank[]
|
||||
} | null>(null)
|
||||
const route = useRoute()
|
||||
|
||||
async function init(): Promise<void> {
|
||||
loading.value = true
|
||||
list.value = getCache('ranking.rankingList')
|
||||
if (list.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { p, mode, date } = route.query
|
||||
const searchParams = new URLSearchParams()
|
||||
if (p && typeof p === 'string') searchParams.append('p', p)
|
||||
if (mode && typeof mode === 'string') searchParams.append('mode', mode)
|
||||
if (date && typeof date === 'string') searchParams.append('date', date)
|
||||
searchParams.append('format', 'json')
|
||||
const { data } = await ajax.get<{
|
||||
date: string
|
||||
contents: ArtworkRank[]
|
||||
}>('/ranking.php', { params: searchParams })
|
||||
// Date
|
||||
const rankingDate = data.date
|
||||
const listValue = {
|
||||
date: new Date(
|
||||
+rankingDate.substring(0, 4),
|
||||
+rankingDate.substring(4, 6) - 1,
|
||||
+rankingDate.substring(6, 8)
|
||||
),
|
||||
contents: data.contents,
|
||||
}
|
||||
list.value = listValue
|
||||
setCache('ranking.rankingList', listValue)
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = '哎呀,出错了!'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
effect(() =>
|
||||
setTitle(
|
||||
list.value?.date?.toLocaleDateString('zh', { dateStyle: 'long' }),
|
||||
'Ranking'
|
||||
)
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
||||
.loading
|
||||
text-align: center
|
||||
</style>
|
||||
131
src/view/search.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template lang="pug">
|
||||
#search-view
|
||||
.body-inner
|
||||
SearchBox.big
|
||||
|
||||
//- Error
|
||||
section(v-if='error && !loading')
|
||||
ErrorPage(:description='error' title='出大问题')
|
||||
|
||||
//- Result
|
||||
section(v-if='!error')
|
||||
|
||||
//- Loading
|
||||
.loading-area(v-if='loading && !resultList.length')
|
||||
ArtworkList(:list='[]', :loading='16')
|
||||
|
||||
.no-more(v-if='!loading && !resultList.length')
|
||||
NCard(style='padding: 15vh 0'): NEmpty(description='没有了,一滴都没有了……')
|
||||
|
||||
NSpin.result-area(:show='loading' v-if='resultList.length')
|
||||
.pagenator
|
||||
NPagination(v-model:page='page' :item-count='total' :page-size='resultList.length')
|
||||
ArtworkLargeList(:artwork-list='resultList')
|
||||
.pagenator
|
||||
NPagination(v-model:page='page' :item-count='total' :page-size='resultList.length')
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ArtworkLargeList from '@/components/ArtworksList/ArtworkLargeList.vue'
|
||||
import ArtworkList from '@/components/ArtworksList/ArtworkList.vue'
|
||||
import ErrorPage from '@/components/ErrorPage.vue'
|
||||
import SearchBox from '@/components/SearchBox.vue'
|
||||
import IFasAngleLeft from '~icons/fa-solid/angle-left'
|
||||
import IFasAngleRight from '~icons/fa-solid/angle-right'
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
|
||||
import { ajax } from '@/utils/ajax'
|
||||
import type { ArtworkInfo } from '@/types'
|
||||
import { effect } from 'vue'
|
||||
import { setTitle } from '@/utils/setTitle'
|
||||
|
||||
const error = ref('')
|
||||
const loading = ref(true)
|
||||
const searchKeyword = ref('')
|
||||
const resultList = ref<ArtworkInfo[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
async function makeSearch({
|
||||
keyword,
|
||||
p,
|
||||
mode,
|
||||
}: {
|
||||
keyword: string
|
||||
p?: `${number}`
|
||||
mode?: string
|
||||
}): Promise<void> {
|
||||
searchKeyword.value = keyword
|
||||
page.value = parseInt(p || '1')
|
||||
error.value = ''
|
||||
if (!searchKeyword.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
const { data } = await ajax.get<{ illustManga: { data: ArtworkInfo[] } }>(
|
||||
`/ajax/search/artworks/${encodeURIComponent(keyword)}`,
|
||||
{ params: new URLSearchParams({ p: p ?? '1', mode: mode ?? 'text' }) }
|
||||
)
|
||||
resultList.value = data.illustManga?.data ?? []
|
||||
total.value = data.illustManga?.total || 0
|
||||
console.info(data.illustManga?.data)
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = '哎呀,出错了!'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(page, (value) => {
|
||||
page.value = value < 1 ? 1 : value
|
||||
router.push(
|
||||
`/search/${searchKeyword.value}/${page.value}${
|
||||
route.query.mode ? `?mode=${route.query.mode}` : ''
|
||||
}`
|
||||
)
|
||||
})
|
||||
|
||||
onBeforeRouteUpdate(async (to) => {
|
||||
const params = to.params as {
|
||||
keyword: string
|
||||
p?: `${number}`
|
||||
mode?: string
|
||||
}
|
||||
makeSearch(params)
|
||||
})
|
||||
|
||||
effect(() =>
|
||||
setTitle(`${route.params.keyword} (第${route.params.p}页)`, 'Search')
|
||||
)
|
||||
onMounted(async () => {
|
||||
const params = route.params as {
|
||||
keyword: string
|
||||
p?: `${number}`
|
||||
mode?: string
|
||||
}
|
||||
await makeSearch(params)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.pagenator
|
||||
display: flex
|
||||
justify-content: center
|
||||
margin: 1rem auto
|
||||
|
||||
.no-more
|
||||
text-align: center
|
||||
padding: 1rem
|
||||
opacity: 0.75
|
||||
|
||||
.search-box
|
||||
margin: 1rem auto
|
||||
margin-top: 2rem
|
||||
box-shadow: 0 0 8px #ddd
|
||||
border-radius: 2em
|
||||
</style>
|
||||
10
src/view/siteCache.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const _siteCacheData = new Map<string | number, any>()
|
||||
export function setCache(key: string | number, val: any) {
|
||||
console.log('setCache', key, val)
|
||||
_siteCacheData.set(key, val)
|
||||
}
|
||||
export function getCache(key: string | number) {
|
||||
const val = _siteCacheData.get(key)
|
||||
console.log('getCache', key, val)
|
||||
return val
|
||||
}
|
||||