Initial commit

This commit is contained in:
juluo
2025-10-08 14:48:45 +08:00
committed by GitHub
commit 8b93d6f42f
108 changed files with 27618 additions and 0 deletions

63
src/App.vue Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
src/assets/LogoV.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View 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

View 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
View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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') &emsp;&emsp;{{ 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>

View 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>

View 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>

View 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"
}

View 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. LInternationale. 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,42 @@
<template lang="pug">
.show-more(ref='elRef')
a(@click='method')
| {{ text }}
| &nbsp;
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>

View 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>

View 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>

View 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 &copy; {{ yearStr }}
|
a(:href='GITHUB_URL' target='_blank') {{ PROJECT_NAME }}
| &nbsp;
em v{{ version }}
.dev-only(style='font-style: italic')
| This is test site &nbsp;
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>

View 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
| &nbsp;搜索
.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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
{
"ArtworkCard.pageCount": "共{0}张"
}

14
src/main.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
export * from './Artworks'
export * from './Comment'
export * from './Users'

610
src/utils/UgoiraPlayer.ts Normal file
View 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

File diff suppressed because it is too large Load Diff

41
src/utils/ajax.ts Normal file
View 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',
},
})

View 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
View 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
View 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
View 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
View 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
View 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:&nbsp;
span {{ currentUrl }}
NLi
strong ZIP File Size:&nbsp;
span {{ formatFileSize(data?.contentLength || 0) }}
NLi
strong Central Directory Size:&nbsp;
span {{ formatFileSize(data?.centralDirectorySize || 0) }}
NLi
strong Download Chunk Size:&nbsp;
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
View 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
| &nbsp;
| 贡献内容的全部编辑者
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
View 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
View 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>

View 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
View 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
View 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
View 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
| &nbsp;取消
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
| &nbsp;返回
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>

View 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
View 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
View 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
View 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
}

518
src/view/users.vue Normal file
View File

@@ -0,0 +1,518 @@
<template lang="pug">
#user-view
//- Loading
section.loading(v-if='loadingUser')
.bg-area.no-background
.bg-container(style='background-color: #efefef')
.user-info
.info-area
.avatar-area
NSkeleton(circle height='6rem' width='6rem')
.username-header.flex
h1.username: NSkeleton(text width='8em')
.desc(v-for='_ in 5')
NSkeleton(circle height='1em' text width='1em')
NSkeleton(
:width='`${Math.max(4, 12 * Math.random())}em`'
style='margin-left: 0.5em'
text
)
#user-artworks.body-inner
ArtworkList(:list='[]', :loading='20')
//- Error
section.error(v-else-if='error')
ErrorPage(:description='error' title='出大问题')
//- :)
section.user(v-else-if='user')
.user-info
.bg-area(:class='{ "no-background": !user?.background }')
.bg-container(
:style='{ backgroundImage: user?.background?.url ? `url("${user.background.url}")` : undefined }'
)
span(v-if='!user?.background') 用户未设置封面~
.info-area
.avatar-area
a.plain.pointer(@click='showUserMore = true')
img(:src='user.imageBig')
.username-header.flex
h1.username {{ user.name }}
.flex-1
.user-folow(v-if='!isSelfUserPage')
NButton(
:loading='loadingUserFollow',
:type='user.isFollowed ? "success" : undefined'
@click='handleUserFollow'
round
size='small'
)
template(#icon)
IFasCheck(v-if='user.isFollowed')
IFasPlus(v-else)
| {{ user.isFollowed ? '已关注' : '关注' }}
.user-folow(v-else)
NButton(round size='small' type='info')
| 我真棒
.following
RouterLink(:to='{ name: "following", params: { id: user.userId } }') 关注了 <strong>{{ user.following }}</strong>
.gender(v-if='user.gender?.name')
IFasVenusMars(data-icon)
| {{ user.gender.name }}
.birthday(v-if='user.birthDay?.name')
IFasBirthdayCake(data-icon)
| {{ user.birthDay?.name }}
.region(v-if='user.region?.name')
IFasMapMarkerAlt(data-icon)
| {{ user.region?.name }}
.webpage(v-if='user.webpage')
IFasHome(data-icon)
a(:href='user.webpage' rel='noopener noreferrer' target='_blank') {{ user.webpage }}
.flex
.comment.flex-1 {{ user.comment }}
.user-more
a(@click='userMore' href='javascript:;') 查看更多
NModal(closable preset='card' title='用户资料' v-model:show='showUserMore')
.info-modal
.top
h3
a.avatar(:href='user.imageBig' target='_blank' title='查看头像')
img(:src='user.imageBig')
.premium-icon(title='该用户订阅了高级会员' v-if='user.premium')
IFasParking(data-icon)
.title {{ user.name }}
.bottom
section.user-comment
h4 个人简介
.comment.pre {{ user.comment || '-' }}
section.user-workspace(v-if='user.workspace')
hr
h4 工作环境
NImage(
:preview-src='user.workspace.wsBigUrl',
:src='user.workspace.wsUrl'
lazy
v-if='user.workspace.wsUrl'
)
NTable
tbody
tr(v-for='(val, key) in user.workspace')
th {{ workspaceNameMap[key] || key }}
td {{ val }}
section.dev-only
hr
h4 Debug Info
details
pre(style='overflow: auto; background: #efefef; padding: 4px') {{ JSON.stringify(user, null, 2) }}
#user-artworks
NTabs(
:bar-width='32'
justify-content='space-evenly'
type='line'
v-model:value='tab'
)
NTabPane(display-directive='show:lazy' :name='UserTabs.illusts' tab='插画')
NEmpty(
description='用户没有插画作品 (。•́︿•̀。)'
v-if='user.illusts && !user.illusts.length'
)
.user-illust.body-inner(v-else)
ArtworksByUser(:user-id='user.userId' work-category='illust')
NTabPane(display-directive='show:lazy' :name='UserTabs.mangas' tab='漫画')
NEmpty(description='用户没有漫画作品 (*/ω\*)' v-if='!user.manga?.length')
.user-manga.body-inner(v-else)
ArtworksByUser(:user-id='user.userId' work-category='manga')
NTabPane(:name='UserTabs.public_bookmarks' tab='公开收藏')
ArtworkList(
:list='[]',
:loading='8'
v-if='!publicBookmarks?.length && loadingPublicBookmarks'
)
NEmpty(
:description='isSelfUserPage ? `收藏夹是空的 Σ(⊙▽⊙"a` : `${user.name}没有公开的收藏 ${"(❁´◡`❁)"}`'
v-else-if='!publicBookmarks?.length'
)
.user-bookmarks.body-inner(v-else)
ArtworkList(:list='publicBookmarks')
.more-btn.align-center(
v-if='publicBookmarks.length && hasMorePublicBookmarks'
)
ShowMore(
:loading='loadingPublicBookmarks',
:method='() => getBookmarks(false)',
:text='loadingPublicBookmarks ? "正在加载" : "加载更多"'
)
NTabPane(:name='UserTabs.hidden_bookmarks' tab='秘密收藏' v-if='isSelfUserPage')
ArtworkList(
:list='[]',
:loading='8'
v-if='!hiddenBookmarks?.length && loadingHiddenBookmarks'
)
NEmpty(
description='没有隐藏的小秘密 இ௰இ'
v-else-if='!hiddenBookmarks?.length'
)
.user-bookmarks.body-inner(v-else)
ArtworkList(:list='hiddenBookmarks')
.more-btn.align-center(
v-if='hiddenBookmarks.length && hasMoreHiddenBookmarks'
)
ShowMore(
:loading='loadingHiddenBookmarks',
:method='() => getBookmarks(true)',
:text='loadingHiddenBookmarks ? "正在加载" : "加载更多"'
)
</template>
<script lang="ts" setup>
import { addUserFollow, removeUserFollow } from '@/utils/userActions'
import { ajax } from '@/utils/ajax'
import ArtworkList from '@/components/ArtworksList/ArtworkList.vue'
import ErrorPage from '@/components/ErrorPage.vue'
import ShowMore from '@/components/ShowMore.vue'
import IFasBirthdayCake from '~icons/fa-solid/birthday-cake'
import IFasCheck from '~icons/fa-solid/check'
import IFasHome from '~icons/fa-solid/home'
import IFasMapMarkerAlt from '~icons/fa-solid/map-marker-alt'
import IFasParking from '~icons/fa-solid/parking'
import IFasPlus from '~icons/fa-solid/plus'
import IFasVenusMars from '~icons/fa-solid/venus-mars'
import { getCache, setCache } from './siteCache'
import { sortArtList } from '@/utils/artworkActions'
import { useUserStore } from '@/composables/states'
import type { ArtworkInfo, User } from '@/types'
import {
NButton,
NEmpty,
NImage,
NModal,
NSkeleton,
NTabPane,
NTable,
NTabs,
} from 'naive-ui'
import { setTitle } from '@/utils/setTitle'
import { effect } from 'vue'
const loadingUser = ref(true)
const user = ref<User>()
const isSelfUserPage = computed(() => user.value?.userId === userStore.userId)
const publicBookmarks = ref<ArtworkInfo[]>([])
const loadingPublicBookmarks = ref(false)
const totalPublicBookmarks = ref(0)
const hasMorePublicBookmarks = computed(
() =>
publicBookmarks.value.length &&
publicBookmarks.value.length < totalPublicBookmarks.value
)
const hiddenBookmarks = ref<ArtworkInfo[]>([])
const loadingHiddenBookmarks = ref(false)
const totalHiddenBookmarks = ref(0)
const hasMoreHiddenBookmarks = computed(
() =>
hiddenBookmarks.value.length &&
hiddenBookmarks.value.length < totalHiddenBookmarks.value
)
enum UserTabs {
illusts = 'illusts',
mangas = 'mangas',
public_bookmarks = 'public_bookmarks',
hidden_bookmarks = 'hidden_bookmarks'
}
const tab = ref<UserTabs>()
const error = ref('')
const showUserMore = ref(false)
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const workspaceNameMap = {
userWorkspacePc: '个人电脑',
userWorkspaceMonitor: '显示器',
userWorkspaceTool: '创作工具',
userWorkspaceScanner: '扫描仪',
userWorkspaceTablet: '平板电脑',
userWorkspaceMouse: '鼠标',
userWorkspacePrinter: '打印机',
userWorkspaceDesktop: '桌面',
userWorkspaceMusic: '音乐播放器',
userWorkspaceDesk: '桌子',
userWorkspaceChair: '椅子',
userWorkspaceComment: '说明',
wsUrl: '工作空间图片URL',
wsBigUrl: '工作空间大图片URL',
}
async function init(id: string | number, initTab?: UserTabs): Promise<void> {
// reset states
user.value = undefined
tab.value = undefined
error.value = ''
publicBookmarks.value = []
hiddenBookmarks.value = []
totalPublicBookmarks.value = 0
totalHiddenBookmarks.value = 0
const cache = getCache(`users.${id}`)
if (cache) {
loadingUser.value = false
user.value = cache
tab.value = initTab || UserTabs.illusts
return
}
try {
loadingUser.value = true
const [{ data }, { data: profileData }] = await Promise.all([
ajax.get<User>(`/ajax/user/${id}?full=1`),
ajax.get<{
illusts: Record<string, ArtworkInfo>
manga: Record<string, ArtworkInfo>
novels: Record<string, ArtworkInfo>
}>(`/ajax/user/${id}/profile/top`),
])
const userValue = {
...data,
illusts: sortArtList(profileData.illusts),
manga: sortArtList(profileData.manga),
novels: sortArtList(profileData.novels),
}
user.value = userValue
tab.value = initTab || UserTabs.illusts
setCache(`users.${id}`, userValue)
} catch (err) {
if (err instanceof Error) {
error.value = err.message
} else {
error.value = '未知错误'
}
} finally {
loadingUser.value = false
}
}
const loadingUserFollow = ref(false)
function handleUserFollow() {
const target = user.value
if (!target) return
loadingUserFollow.value = true
const isFollowed = target.isFollowed
const handler = isFollowed ? removeUserFollow : addUserFollow
handler(target.userId)
.then(() => {
target.isFollowed = !isFollowed
})
.finally(() => {
loadingUserFollow.value = false
})
}
function userMore(): void {
showUserMore.value = true
}
async function getBookmarks(hidden?: boolean): Promise<void> {
const curUser = user.value
if (!curUser) return
const curLoading = hidden ? loadingHiddenBookmarks : loadingPublicBookmarks
const curList = hidden ? hiddenBookmarks : publicBookmarks
const curTotal = hidden ? totalHiddenBookmarks : totalPublicBookmarks
if (curLoading.value) return
try {
curLoading.value = true
const { data } = await ajax.get<{ works: ArtworkInfo; total: number }>(
`/ajax/user/${curUser.userId}/illusts/bookmarks`,
{
params: new URLSearchParams({
tag: '',
offset: `${curList.value.length}`,
limit: '48',
rest: hidden ? 'hide' : 'show',
}),
}
)
curTotal.value = data.total
curList.value = curList.value.concat(data.works)
} catch (err) {
console.warn('failed to fetch bookmarks', err)
} finally {
curLoading.value = false
}
}
watch(tab, (newTab) => {
if (!newTab) return
const isPublicBookmarkEmpty = !publicBookmarks.value.length
const isHiddenBookmarkEmpty = !hiddenBookmarks.value.length
const url = new URL(location.href)
url.searchParams.set('tab', newTab)
history.replaceState(history.state, '', '' + url)
if (newTab === UserTabs.public_bookmarks && isPublicBookmarkEmpty) {
getBookmarks(false)
}
if (newTab === UserTabs.hidden_bookmarks && isHiddenBookmarkEmpty) {
getBookmarks(true)
}
})
onBeforeRouteUpdate((to) => {
if (to.name !== 'users') {
return
}
init(to.params.id as string, to.query.tab as UserTabs)
})
effect(() => setTitle(user.value?.name, 'Users'))
onMounted(async () => {
init(route.params.id as string, route.query.tab as UserTabs)
})
</script>
<style scoped lang="sass">
.user-info
position: relative
// margin: -1rem -1rem 1rem -1rem
// box-shadow: 0 4px 16px var(--theme-box-shadow-color)
.bg-area
position: fixed
left: 0
top: 50px
height: 40vh
width: 100%
z-index: 1
@media (max-width: 800px)
height: 20vh
.bg-container
position: relative
width: 100%
height: 100%
background-color: #efefef
background-position: center
background-repeat: no-repeat
background-size: cover
> span
user-select: none
color: #ccc
display: inline-block
position: absolute
left: 50%
top: 50%
transform: translateX(-50%) translateY(-50%)
.info-area
position: relative
background-color: #fff
box-shadow: 0 0 0.5rem #aaa
padding-left: calc(2rem + 100px + 2rem)
padding-top: 1rem
padding-right: 1rem
padding-bottom: 3rem
margin-top: 40vh
z-index: 2
> div
margin: 0.4rem auto
[data-icon]
width: 1rem
margin-right: .4rem
@media (max-width: 800px)
margin-top: 20vh
padding-left: 1rem
padding-top: 60px
.avatar-area
position: absolute
top: -50px
left: 2rem
@media (max-width: 800px)
left: 50%
transform: translateX(-50%)
img
box-sizing: border-box
width: 100px
height: 100px
border-radius: 50%
background-color: #fff
border: 4px solid #fff
box-shadow: 0 0.2rem 0.4rem #cdcdcd
.username
font-size: 1.6rem
font-weight: 700
margin: 0
.comment
max-height: 4rem
overflow: hidden
white-space: nowrap
text-overflow: ellipsis
.userMore
white-space: nowrap
#user-artworks
position: relative
background-color: #fff
z-index: 2
:deep(.n-tabs)
.n-tabs-nav
background-color: var(--theme-background-color)
z-index: 12
position: sticky
top: 50px
.n-tabs-nav-scroll-wrapper
max-width: 1200px
margin: 0 auto
.user-illust, .user-manga
:deep(.author)
display: none
:deep(.n-empty)
margin: 20vh auto
.info-modal
position: relative
hr
margin: 1.5rem auto
width: 75%
border: none
height: 2px
background-color: #dedede
.top
text-align: center
background-color: #f4f4f4
z-index: 1
padding: 1rem 0
margin: 0 -1.5rem
.avatar
width: 80px
margin: 0 auto
img
border-radius: 50%
width: 80px
.premium-icon
position: absolute
bottom: 0
right: 0
color: #ffa500
cursor: help
.title
font-size: 1rem
font-weight: 600
.user-workspace
:deep(img)
width: 100%
</style>