refactor: move project to frontend directory
This commit is contained in:
25
frontend/.eslintrc.cjs
Normal file
25
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,25 @@
|
||||
/* eslint-env node */
|
||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
'extends': [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
'@vue/eslint-config-prettier/skip-formatting'
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}'
|
||||
],
|
||||
'extends': [
|
||||
'plugin:cypress/recommended'
|
||||
]
|
||||
}
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest'
|
||||
}
|
||||
}
|
||||
8
frontend/.vscode/extensions.json
vendored
Normal file
8
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"Vue.vscode-typescript-vue-plugin",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
68
frontend/README.md
Normal file
68
frontend/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# MangaStatus
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||
|
||||
1. Disable the built-in TypeScript Extension
|
||||
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||
|
||||
```sh
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Run End-to-End Tests with [Cypress](https://www.cypress.io/)
|
||||
|
||||
```sh
|
||||
npm run test:e2e:dev
|
||||
```
|
||||
|
||||
This runs the end-to-end tests against the Vite development server.
|
||||
It is much faster than the production build.
|
||||
|
||||
But it's still recommended to test the production build with `test:e2e` before deploying (e.g. in CI environments):
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
8
frontend/cypress.config.ts
Normal file
8
frontend/cypress.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'cypress'
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
|
||||
baseUrl: 'http://localhost:4173'
|
||||
}
|
||||
})
|
||||
8
frontend/cypress/e2e/example.cy.ts
Normal file
8
frontend/cypress/e2e/example.cy.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// https://on.cypress.io/api
|
||||
|
||||
describe('My First Test', () => {
|
||||
it('visits the app root url', () => {
|
||||
cy.visit('/')
|
||||
cy.contains('h1', 'You did it!')
|
||||
})
|
||||
})
|
||||
10
frontend/cypress/e2e/tsconfig.json
Normal file
10
frontend/cypress/e2e/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["./**/*", "../support/**/*"],
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false,
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"types": ["cypress"]
|
||||
}
|
||||
}
|
||||
5
frontend/cypress/fixtures/example.json
Normal file
5
frontend/cypress/fixtures/example.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
39
frontend/cypress/support/commands.ts
Normal file
39
frontend/cypress/support/commands.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************
|
||||
// This example commands.ts shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
//
|
||||
// declare global {
|
||||
// namespace Cypress {
|
||||
// interface Chainable {
|
||||
// login(email: string, password: string): Chainable<void>
|
||||
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
export {}
|
||||
20
frontend/cypress/support/e2e.ts
Normal file
20
frontend/cypress/support/e2e.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// ***********************************************************
|
||||
// This example support/index.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
1
frontend/env.d.ts
vendored
Normal file
1
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MangaStatus</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
60
frontend/package.json
Normal file
60
frontend/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "mangastatus",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
|
||||
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||
"@intlify/unplugin-vue-i18n": "^0.11.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.2",
|
||||
"bootstrap-vue-next": "^0.14.8",
|
||||
"idb": "^7.1.1",
|
||||
"pinia": "^2.1.6",
|
||||
"pinia-class-component": "^0.9.4",
|
||||
"string-similarity-js": "^2.1.4",
|
||||
"vue": "^3.3.4",
|
||||
"vue-class-component": "^8.0.0-rc.1",
|
||||
"vue-good-table-next": "^0.2.1",
|
||||
"vue-i18n": "^9.5.0",
|
||||
"vue-property-decorator": "^10.0.0-rc.3",
|
||||
"vue-router": "^4.2.5",
|
||||
"vue3-toastify": "^0.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.5.1",
|
||||
"@tsconfig/node18": "^18.2.2",
|
||||
"@types/bootstrap": "^5.2.7",
|
||||
"@types/jsdom": "^21.1.3",
|
||||
"@types/node": "^18.18.4",
|
||||
"@vitejs/plugin-vue": "^4.4.0",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/test-utils": "^2.4.1",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"cypress": "^13.3.0",
|
||||
"eslint": "^8.51.0",
|
||||
"eslint-plugin-cypress": "^2.15.1",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"npm-run-all2": "^6.1.1",
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.69.0",
|
||||
"start-server-and-test": "^2.0.1",
|
||||
"typescript": "~5.2.0",
|
||||
"vite": "^4.4.11",
|
||||
"vitest": "^0.34.6",
|
||||
"vue-tsc": "^1.8.16"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
39
frontend/src/App.vue
Normal file
39
frontend/src/App.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import {RouterView} from 'vue-router';
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import DocumentLocaleSetter from '@/components/locale/DocumentLocaleSetter.vue';
|
||||
import NavBar from '@/components/navbar/NavBar.vue';
|
||||
import LocaleSaver from '@/components/locale/LocaleSaver.vue';
|
||||
import StoragePersist from '@/components/StoragePersist.vue';
|
||||
import SideBar from '@/components/sidebar/SideBar.vue';
|
||||
|
||||
@Options({
|
||||
name: 'App',
|
||||
components: {
|
||||
DocumentLocaleSetter,
|
||||
LocaleSaver,
|
||||
NavBar,
|
||||
RouterView,
|
||||
SideBar,
|
||||
StoragePersist,
|
||||
},
|
||||
})
|
||||
export default class App extends Vue {
|
||||
sidebarToggled = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-100 w-100">
|
||||
<LocaleSaver/>
|
||||
<DocumentLocaleSetter/>
|
||||
<StoragePersist/>
|
||||
<SideBar ref="sidebar" class="h-100 w-100" :toggled="sidebarToggled"
|
||||
@close="sidebarToggled=false"/>
|
||||
<div class="d-flex flex-column h-100 w-100">
|
||||
<NavBar @toggleSidebar="sidebarToggled = !sidebarToggled"/>
|
||||
<RouterView class="flex-grow-1"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
8
frontend/src/assets/bootstrap.extensions.scss
vendored
Normal file
8
frontend/src/assets/bootstrap.extensions.scss
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
//@use 'sass:map';
|
||||
//@import '~bootstrap/scss/bootstrap';
|
||||
//@import '~bootstrap/scss/variables';
|
||||
//@import '~bootstrap/scss/mixins';
|
||||
|
||||
.c-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
11
frontend/src/assets/main.css
Normal file
11
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,11 @@
|
||||
body {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
18
frontend/src/components/StoragePersist.vue
Normal file
18
frontend/src/components/StoragePersist.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {toast} from 'vue3-toastify';
|
||||
|
||||
@Options({name: 'StoragePersist'})
|
||||
export default class StoragePersist extends Vue {
|
||||
async mounted(): Promise<void> {
|
||||
if (!navigator.storage) {
|
||||
toast.error(this.$t('localStorage.notSupported'));
|
||||
} else if (!await navigator.storage.persist()) {
|
||||
toast.warning(this.$t('localStorage.persistent.notSupported'));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
</template>
|
||||
11
frontend/src/components/__tests__/HelloWorld.spec.ts
Normal file
11
frontend/src/components/__tests__/HelloWorld.spec.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// import {describe, expect, it} from 'vitest';
|
||||
//
|
||||
// import {mount} from '@vue/test-utils';
|
||||
// import HelloWorld from '../HelloWorld.vue';
|
||||
//
|
||||
// describe('HelloWorld', () => {
|
||||
// it('renders properly', () => {
|
||||
// const wrapper = mount(HelloWorld, {props: {msg: 'Hello Vitest'}});
|
||||
// expect(wrapper.text()).toContain('Hello Vitest');
|
||||
// });
|
||||
// });
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {reactive} from 'vue';
|
||||
import {Watch} from 'vue-property-decorator';
|
||||
|
||||
const sharedDarkMode = reactive({
|
||||
internalDarkMode: false,
|
||||
internalLoaded: false,
|
||||
|
||||
get darkMode() {
|
||||
if (!this.internalLoaded) {
|
||||
this.internalDarkMode = localStorage.getItem('darkmode') === ('' + true);
|
||||
this.internalLoaded = true;
|
||||
}
|
||||
return this.internalDarkMode;
|
||||
},
|
||||
|
||||
set darkMode(value: boolean) {
|
||||
this.internalDarkMode = value;
|
||||
localStorage.setItem('darkmode', '' + this.internalDarkMode);
|
||||
},
|
||||
});
|
||||
|
||||
@Options({
|
||||
name: 'BootstrapThemeSwitch',
|
||||
})
|
||||
export default class BootstrapThemeSwitch extends Vue {
|
||||
get sharedDarkMode() {
|
||||
return sharedDarkMode;
|
||||
}
|
||||
|
||||
mounted(): void {
|
||||
this.onThemeChanged(this.dark);
|
||||
}
|
||||
|
||||
get dark(): boolean {
|
||||
return this.sharedDarkMode.darkMode;
|
||||
}
|
||||
|
||||
set dark(value: boolean) {
|
||||
this.sharedDarkMode.darkMode = value;
|
||||
}
|
||||
|
||||
@Watch('dark', {immediate: true})
|
||||
private onThemeChanged(isDarkMode: boolean): void {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-bs-theme');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-check form-switch light-dark-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="dark">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.light-dark-switch .form-check-input {
|
||||
--bs-form-switch-bg: url(assets/sun.svg) !important;
|
||||
}
|
||||
|
||||
.light-dark-switch .form-check-input:focus {
|
||||
--bs-form-switch-bg: url(assets/sun_focus.svg) !important;
|
||||
}
|
||||
|
||||
.light-dark-switch .form-check-input:checked,
|
||||
.light-dark-switch .form-check-input:checked:focus {
|
||||
--bs-form-switch-bg: url(assets/moon.svg) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<g transform="scale(0.75 0.75) translate(92 92)" fill="#fff">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
|
||||
<!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
|
||||
<path d="M144.7 98.7c-21 34.1-33.1 74.3-33.1 117.3c0 98 62.8 181.4 150.4 211.7c-12.4 2.8-25.3 4.3-38.6 4.3C126.6 432 48 353.3 48 256c0-68.9 39.4-128.4 96.8-157.3zm62.1-66C91.1 41.2 0 137.9 0 256C0 379.7 100 480 223.5 480c47.8 0 92-15 128.4-40.6c1.9-1.3 3.7-2.7 5.5-4c4.8-3.6 9.4-7.4 13.9-11.4c2.7-2.4 5.3-4.8 7.9-7.3c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-3.7 .6-7.4 1.2-11.1 1.6c-5 .5-10.1 .9-15.3 1c-1.2 0-2.5 0-3.7 0c-.1 0-.2 0-.3 0c-96.8-.2-175.2-78.9-175.2-176c0-54.8 24.9-103.7 64.1-136c1-.9 2.1-1.7 3.2-2.6c4-3.2 8.2-6.2 12.5-9c3.1-2 6.3-4 9.6-5.8c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-3.6-.3-7.1-.5-10.7-.6c-2.7-.1-5.5-.1-8.2-.1c-3.3 0-6.5 .1-9.8 .2c-2.3 .1-4.6 .2-6.9 .4z"/>
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<g transform="scale(0.75 0.75) translate(92 92)" fill="rgba(0,0,0,0.25)">
|
||||
<svg viewBox="0 0 512 512">
|
||||
<!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
|
||||
<path d="M375.7 19.7c-1.5-8-6.9-14.7-14.4-17.8s-16.1-2.2-22.8 2.4L256 61.1 173.5 4.2c-6.7-4.6-15.3-5.5-22.8-2.4s-12.9 9.8-14.4 17.8l-18.1 98.5L19.7 136.3c-8 1.5-14.7 6.9-17.8 14.4s-2.2 16.1 2.4 22.8L61.1 256 4.2 338.5c-4.6 6.7-5.5 15.3-2.4 22.8s9.8 13 17.8 14.4l98.5 18.1 18.1 98.5c1.5 8 6.9 14.7 14.4 17.8s16.1 2.2 22.8-2.4L256 450.9l82.5 56.9c6.7 4.6 15.3 5.5 22.8 2.4s12.9-9.8 14.4-17.8l18.1-98.5 98.5-18.1c8-1.5 14.7-6.9 17.8-14.4s2.2-16.1-2.4-22.8L450.9 256l56.9-82.5c4.6-6.7 5.5-15.3 2.4-22.8s-9.8-12.9-17.8-14.4l-98.5-18.1L375.7 19.7zM269.6 110l65.6-45.2 14.4 78.3c1.8 9.8 9.5 17.5 19.3 19.3l78.3 14.4L402 242.4c-5.7 8.2-5.7 19 0 27.2l45.2 65.6-78.3 14.4c-9.8 1.8-17.5 9.5-19.3 19.3l-14.4 78.3L269.6 402c-8.2-5.7-19-5.7-27.2 0l-65.6 45.2-14.4-78.3c-1.8-9.8-9.5-17.5-19.3-19.3L64.8 335.2 110 269.6c5.7-8.2 5.7-19 0-27.2L64.8 176.8l78.3-14.4c9.8-1.8 17.5-9.5 19.3-19.3l14.4-78.3L242.4 110c8.2 5.7 19 5.7 27.2 0zM256 368a112 112 0 1 0 0-224 112 112 0 1 0 0 224zM192 256a64 64 0 1 1 128 0 64 64 0 1 1 -128 0z"/>
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<g transform="scale(0.75 0.75) translate(92 92)" fill="#86b7fe">
|
||||
<svg viewBox="0 0 512 512">
|
||||
<!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
|
||||
<path d="M375.7 19.7c-1.5-8-6.9-14.7-14.4-17.8s-16.1-2.2-22.8 2.4L256 61.1 173.5 4.2c-6.7-4.6-15.3-5.5-22.8-2.4s-12.9 9.8-14.4 17.8l-18.1 98.5L19.7 136.3c-8 1.5-14.7 6.9-17.8 14.4s-2.2 16.1 2.4 22.8L61.1 256 4.2 338.5c-4.6 6.7-5.5 15.3-2.4 22.8s9.8 13 17.8 14.4l98.5 18.1 18.1 98.5c1.5 8 6.9 14.7 14.4 17.8s16.1 2.2 22.8-2.4L256 450.9l82.5 56.9c6.7 4.6 15.3 5.5 22.8 2.4s12.9-9.8 14.4-17.8l18.1-98.5 98.5-18.1c8-1.5 14.7-6.9 17.8-14.4s2.2-16.1-2.4-22.8L450.9 256l56.9-82.5c4.6-6.7 5.5-15.3 2.4-22.8s-9.8-12.9-17.8-14.4l-98.5-18.1L375.7 19.7zM269.6 110l65.6-45.2 14.4 78.3c1.8 9.8 9.5 17.5 19.3 19.3l78.3 14.4L402 242.4c-5.7 8.2-5.7 19 0 27.2l45.2 65.6-78.3 14.4c-9.8 1.8-17.5 9.5-19.3 19.3l-14.4 78.3L269.6 402c-8.2-5.7-19-5.7-27.2 0l-65.6 45.2-14.4-78.3c-1.8-9.8-9.5-17.5-19.3-19.3L64.8 335.2 110 269.6c5.7-8.2 5.7-19 0-27.2L64.8 176.8l78.3-14.4c9.8-1.8 17.5-9.5 19.3-19.3l14.4-78.3L242.4 110c8.2 5.7 19 5.7 27.2 0zM256 368a112 112 0 1 0 0-224 112 112 0 1 0 0 224zM192 256a64 64 0 1 1 128 0 64 64 0 1 1 -128 0z"/>
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
23
frontend/src/components/locale/DocumentLocaleSetter.vue
Normal file
23
frontend/src/components/locale/DocumentLocaleSetter.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {Watch} from 'vue-property-decorator';
|
||||
|
||||
@Options({name: 'DocumentLocaleSetter'})
|
||||
export default class DocumentLocaleSetter extends Vue {
|
||||
mounted(): void {
|
||||
this.onLocaleChanged(this.$i18n.locale);
|
||||
}
|
||||
|
||||
@Watch('$i18n.locale')
|
||||
onLocaleChanged(value: string): void {
|
||||
this.setDocumentLocale(value);
|
||||
}
|
||||
|
||||
setDocumentLocale(locale: string): void {
|
||||
document.documentElement.setAttribute('lang', locale);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
</template>
|
||||
21
frontend/src/components/locale/LocaleSaver.vue
Normal file
21
frontend/src/components/locale/LocaleSaver.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {Watch} from 'vue-property-decorator';
|
||||
|
||||
@Options({name: 'LocaleSaver'})
|
||||
export default class LocaleSaver extends Vue {
|
||||
mounted(): void {
|
||||
const locale = window.localStorage.getItem('locale');
|
||||
if (locale)
|
||||
this.$i18n.locale = locale;
|
||||
}
|
||||
|
||||
@Watch('$i18n.locale')
|
||||
onLocaleChanged(value: string): void {
|
||||
window.localStorage.setItem('locale', value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
</template>
|
||||
37
frontend/src/components/locale/LocaleSelector.vue
Normal file
37
frontend/src/components/locale/LocaleSelector.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
|
||||
@Options({name: 'LocaleSelector'})
|
||||
export default class LocaleSelector extends Vue {
|
||||
get locale(): string {
|
||||
return this.$i18n.locale;
|
||||
}
|
||||
|
||||
set locale(value: string) {
|
||||
this.$i18n.locale = value;
|
||||
}
|
||||
|
||||
get supportedLocales(): { value: string, text: string } [] {
|
||||
return [
|
||||
{value: 'en', text: this.$t('locales.en')},
|
||||
{value: 'de', text: this.$t('locales.de')},
|
||||
];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dropdown">
|
||||
<button aria-expanded="false" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" type="button">
|
||||
<i class="fa fa-language"/>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-lg-start">
|
||||
<li v-for="l in supportedLocales" :key="l.value">
|
||||
<a :class="{active: locale === l.value}" class="dropdown-item" href="#"
|
||||
@click.prevent="locale = l.value">
|
||||
{{ l.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
126
frontend/src/components/manga/MangaEntryDetailsModal.vue
Normal file
126
frontend/src/components/manga/MangaEntryDetailsModal.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-property-decorator';
|
||||
import {BModal} from 'bootstrap-vue-next';
|
||||
import type {ViewEntry} from '@/components/manga/MangaList.vue';
|
||||
import {Modal} from 'bootstrap';
|
||||
import {latestChaptersSorted, latestChapterString, newChapterCount} from './util.manga';
|
||||
|
||||
@Options({
|
||||
name: 'MangaEntryDetailsModal',
|
||||
components: {BModal},
|
||||
})
|
||||
export default class MangaEntryDetailsModal extends Vue {
|
||||
entry: ViewEntry | null = null;
|
||||
|
||||
//functions
|
||||
latestChaptersSorted = latestChaptersSorted;
|
||||
latestChapterString = latestChapterString;
|
||||
newChapterCount = newChapterCount;
|
||||
|
||||
open(entry: ViewEntry): void {
|
||||
this.entry = entry;
|
||||
new Modal(this.$refs.modal as Element).show();
|
||||
}
|
||||
|
||||
dismiss(): void {
|
||||
this.entry = null;
|
||||
new Modal(this.$refs.modal as Element).hide();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="modal" class="modal manga-details-modal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" v-if="entry">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<div class="mb-0">{{ entry.media?.title?.native }}</div>
|
||||
<div style="font-size: 0.5em">{{ entry.media?.title?.english ?? entry.media?.title?.romaji }}</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex flex-row">
|
||||
<img :src="entry.media?.coverImage.large as string" alt="cover-img" class="modal-cover"/>
|
||||
<div class="ms-1">
|
||||
|
||||
<!-- <div>-->
|
||||
<!-- {{ $t('manga.title') + ': ' }}-->
|
||||
<!-- <span class="mb-0">{{ entry.media?.title?.native }}</span>-->
|
||||
<!-- <span style="font-size: 0.5em">{{ entry.media?.title?.english ?? entry.media?.title?.romaji }}</span>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<div>
|
||||
{{ $t('manga.score') + ':' }}
|
||||
<i class="fa fa-star" style="color: var(--bs-orange)"/>
|
||||
{{ entry.entry.score }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ $t('manga.progress') + ': ' + entry.entry.progress + '/' + latestChapterString(entry) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ $t('manga.chapters.newCount') + ': ' + newChapterCount(entry) }}
|
||||
</div>
|
||||
|
||||
<div v-if="entry.chapters.length">
|
||||
<div>{{ $t('manga.chapters.latest') + ': ' }}</div>
|
||||
<ul class="mb-0 ps-3">
|
||||
<li v-for="c in latestChaptersSorted(entry)">
|
||||
{{ c.chapter + ' (' + c.group + ')' }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="entry.relation">
|
||||
<span>
|
||||
MangaUpdates:
|
||||
</span>
|
||||
<template v-if="entry.series">
|
||||
<a :href="entry.series!.url" target="_blank">
|
||||
{{ entry.series!.title }}
|
||||
</a>
|
||||
<!-- <span class="ms-auto">-->
|
||||
<!-- {{ formatDateTimeSeconds(new Date(entry.series!.last_updated.as_rfc3339)) }}-->
|
||||
<!-- </span>-->
|
||||
</template>
|
||||
<span v-else>{{ $t('mangaupdates.relation.found') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'bootstrap/scss/functions';
|
||||
@import 'bootstrap/scss/variables';
|
||||
@import 'bootstrap/scss/mixins/breakpoints';
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
.modal.manga-details-modal {
|
||||
--manga-cover-size: 3rem;
|
||||
font-size: 0.8em;
|
||||
--bs-modal-header-padding: 0.5rem;
|
||||
--bs-modal-padding: 0.5rem;
|
||||
--bs-modal-footer-padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.manga-details-modal {
|
||||
--manga-cover-size: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.manga-details-modal .modal-cover {
|
||||
max-width: var(--manga-cover-size);
|
||||
max-height: calc(var(--manga-cover-size) * 2);
|
||||
object-fit: cover;
|
||||
border-radius: calc(var(--manga-cover-size) / 16);
|
||||
}
|
||||
</style>
|
||||
69
frontend/src/components/manga/MangaList.vue
Normal file
69
frontend/src/components/manga/MangaList.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {Prop} from 'vue-property-decorator';
|
||||
import MangaListTable from '@/components/manga/MangaListTable.vue';
|
||||
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
|
||||
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
|
||||
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
|
||||
import type {MangaUpdatesRelation} from '@/data/models/mangaupdates/MangaUpdatesRelation';
|
||||
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
|
||||
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
|
||||
|
||||
export type ViewList = {
|
||||
list: AniListMangaList,
|
||||
entries: ViewEntry[],
|
||||
}
|
||||
export type ViewEntry = {
|
||||
entry: AniListMangaListEntry,
|
||||
media: AniListMedia | null,
|
||||
relation: MangaUpdatesRelation | null,
|
||||
series: MangaUpdatesSeries | null,
|
||||
chapters: MangaUpdatesChapter[],
|
||||
};
|
||||
|
||||
@Options({
|
||||
name: 'MangaList',
|
||||
components: {MangaListTable},
|
||||
})
|
||||
export default class MangaList extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly viewList!: ViewList;
|
||||
|
||||
get localeListName(): string {
|
||||
const key = 'manga.status.' + this.viewList.list.name.toLocaleLowerCase();
|
||||
const res = this.$t(key);
|
||||
return res == 'key' ? this.viewList.list.name : res;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card manga-list">
|
||||
<h3 class="card-title manga-list-title mb-0">{{ localeListName }}</h3>
|
||||
<MangaListTable :viewList="viewList"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'bootstrap/scss/functions';
|
||||
@import 'bootstrap/scss/variables';
|
||||
@import 'bootstrap/scss/mixins/breakpoints';
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
.manga-list {
|
||||
border: 0 !important;
|
||||
|
||||
.manga-list-title {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.manga-list {
|
||||
.manga-list-title {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
234
frontend/src/components/manga/MangaListTable.vue
Normal file
234
frontend/src/components/manga/MangaListTable.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {Prop, Watch} from 'vue-property-decorator';
|
||||
import {BTable, type TableItem} from 'bootstrap-vue-next';
|
||||
import type {TableFieldObject} from 'bootstrap-vue-next/dist/src/types';
|
||||
import type {ViewEntry, ViewList} from '@/components/manga/MangaList.vue';
|
||||
import MangaEntryDetailsModal from '@/components/manga/MangaEntryDetailsModal.vue';
|
||||
import {latestChaptersSorted, latestChapterString, newChapterCount} from '@/components/manga/util.manga';
|
||||
|
||||
type HeadData<I> = {
|
||||
label: string,
|
||||
column: string,
|
||||
field: TableFieldObject<I>,
|
||||
'is-foot': boolean,
|
||||
}
|
||||
|
||||
type CellData<I, V> = {
|
||||
value: V,
|
||||
index: number,
|
||||
item: TableItem<I>,
|
||||
field: TableFieldObject<I>,
|
||||
items: TableItem<I>[],
|
||||
toggleDetails: () => void,
|
||||
detailsShowing: boolean | undefined,
|
||||
}
|
||||
|
||||
@Options({
|
||||
name: 'MangaListTable',
|
||||
components: {BTable, MangaEntryDetailsModal},
|
||||
})
|
||||
export default class MangaListTable extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly viewList!: ViewList;
|
||||
|
||||
bTableRefreshHack = true;
|
||||
|
||||
//methods
|
||||
latestChaptersSorted = latestChaptersSorted;
|
||||
latestChapterString = latestChapterString;
|
||||
newChapterCount = newChapterCount;
|
||||
|
||||
get fields(): TableFieldObject<ViewEntry>[] {
|
||||
return [{
|
||||
key: 'media.coverImage.large',
|
||||
label: '',
|
||||
sortable: true,
|
||||
tdClass: 'c-pointer',
|
||||
}, {
|
||||
key: 'media.title.userPreferred',
|
||||
label: this.$t('manga.title'),
|
||||
sortable: true,
|
||||
tdClass: 'c-pointer',
|
||||
}, {
|
||||
key: 'entry.score',
|
||||
label: this.$t('manga.score'),
|
||||
sortable: true,
|
||||
tdClass: 'text-center manga-column-score c-pointer',
|
||||
thClass: 'text-center manga-column-score',
|
||||
}, {
|
||||
key: 'entry.progress',
|
||||
label: this.$t('manga.progress'),
|
||||
sortable: true,
|
||||
tdClass: 'text-end text-nowrap c-pointer',
|
||||
thClass: 'text-end',
|
||||
}, {
|
||||
key: 'newChapters',
|
||||
formatter: (value: never, key: never, item: ViewEntry) => this.newChapterCount(item),
|
||||
label: this.$t('manga.chapters.newCount'),
|
||||
sortable: true,
|
||||
tdClass: 'text-end c-pointer',
|
||||
thClass: 'text-end',
|
||||
}, {
|
||||
key: 'latestChapters',
|
||||
label: this.$t('manga.chapters.latest'),
|
||||
sortable: true,
|
||||
tdClass: 'manga-column-latest-chapters c-pointer',
|
||||
thClass: 'manga-column-latest-chapters',
|
||||
}];
|
||||
}
|
||||
|
||||
get tableEntries(): ViewEntry[] {
|
||||
return this.viewList.entries;
|
||||
}
|
||||
|
||||
cd<V = any>(data: V): CellData<ViewEntry, V> {
|
||||
return (data as CellData<ViewEntry, V>);
|
||||
}
|
||||
|
||||
hd<V = any>(data: V): HeadData<V> {
|
||||
return (data as HeadData<V>);
|
||||
}
|
||||
|
||||
onRowClicked(entry: TableItem<ViewEntry>): void {
|
||||
(this.$refs.detailsModal as MangaEntryDetailsModal).open(entry);
|
||||
}
|
||||
|
||||
@Watch('viewList', {deep: true})
|
||||
private onViewListChanged(): void {
|
||||
this.bTableRefreshHack = false;
|
||||
this.$nextTick(() => {
|
||||
this.bTableRefreshHack = true;
|
||||
// (this.$refs.table as any).refresh();
|
||||
// (this.$refs.table as any).$forceUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BTable ref="table" v-if="bTableRefreshHack" :fields="fields" :items="tableEntries" :primary-key="'id'"
|
||||
class="manga-table" hover striped responsive no-sort-reset sort-by="newChapters" sort-desc
|
||||
@row-clicked="onRowClicked">
|
||||
<template #cell(media.coverImage.large)="data">
|
||||
<img :src="data.value as string" alt="cover-img" class="list-cover"/>
|
||||
</template>
|
||||
<template #cell(media.title.userPreferred)="data">
|
||||
<div class="flex-grow-1">
|
||||
<div>{{ cd(data).item.media?.title.native }}</div>
|
||||
<div>{{ cd(data).item.media?.title.english ?? cd(data).item.media?.title.romaji }}</div>
|
||||
</div>
|
||||
<div v-if="cd(data).item.relation" class="d-flex flex-row" style="font-size: 0.5em">
|
||||
<span>
|
||||
MangaUpdates:
|
||||
</span>
|
||||
<template v-if="cd(data).item.series">
|
||||
<a :href="cd(data).item.series!.url" target="_blank">
|
||||
{{ cd(data).item.series!.title }}
|
||||
</a>
|
||||
</template>
|
||||
<span v-else>{{ $t('mangaupdates.relation.found') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #head(entry.score)="data">
|
||||
<div class="table-header-mobile"><i class="fa fa-star" style="color: var(--bs-orange)"/></div>
|
||||
<div class="table-header-desktop"> {{ hd(data).label }}</div>
|
||||
</template>
|
||||
<template #cell(entry.score)="data">
|
||||
<div class="table-data-mobile">{{ cd(data).value }}</div>
|
||||
<div class="table-data-desktop">
|
||||
<i class="fa fa-star" style="color: var(--bs-orange)"/> {{ cd(data).value }}
|
||||
</div>
|
||||
</template>
|
||||
<template #head(entry.progress)="data">
|
||||
<div class="table-header-mobile"><i class="fa fa-bars-progress"/></div>
|
||||
<div class="table-header-desktop"> {{ hd(data).label }}</div>
|
||||
</template>
|
||||
<template #cell(entry.progress)="data">
|
||||
{{ cd(data).value + '/' + latestChapterString(cd(data).item) }}
|
||||
</template>
|
||||
<template #head(newChapters)="data">
|
||||
<div class="table-header-mobile"><i class="fa fa-plus" style="color: var(--bs-success)"/></div>
|
||||
<div class="table-header-desktop"> {{ hd(data).label }}</div>
|
||||
</template>
|
||||
<template #cell(latestChapters)="data">
|
||||
<div v-for="c in latestChaptersSorted(cd(data).item)">
|
||||
{{ c.chapter + ' (' + c.group + ')' }}
|
||||
</div>
|
||||
</template>
|
||||
</BTable>
|
||||
<MangaEntryDetailsModal ref="detailsModal"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'bootstrap/scss/functions';
|
||||
@import 'bootstrap/scss/variables';
|
||||
@import 'bootstrap/scss/mixins/breakpoints';
|
||||
|
||||
//disable wrapping of table headers
|
||||
.manga-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
//cover
|
||||
.manga-table .list-cover {
|
||||
max-width: var(--manga-cover-size);
|
||||
max-height: calc(var(--manga-cover-size) * 2);
|
||||
object-fit: cover;
|
||||
border-radius: calc(var(--manga-cover-size) / 16);
|
||||
}
|
||||
|
||||
//disable bottom border on last row
|
||||
.manga-table > :not(caption) tr:last-child td {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
.manga-table {
|
||||
--manga-cover-size: 3rem;
|
||||
font-size: 0.8em;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.manga-table th,
|
||||
.manga-table td {
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
|
||||
//.manga-table .manga-column-score,
|
||||
.manga-table .manga-column-latest-chapters {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.manga-table .table-header-desktop,
|
||||
.manga-table .table-data-desktop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.manga-table {
|
||||
--manga-cover-size: 4rem;
|
||||
}
|
||||
|
||||
//more horizontal space usage at the start of the row
|
||||
.manga-table tr td:first-child,
|
||||
.manga-table tr th:first-child {
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
//more horizontal space usage at the end of the row
|
||||
.manga-table tr td:last-child,
|
||||
.manga-table tr th:last-child {
|
||||
padding-inline-end: 1rem;
|
||||
}
|
||||
|
||||
.manga-table .table-header-mobile,
|
||||
.manga-table .table-data-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
82
frontend/src/components/manga/MangaLists.vue
Normal file
82
frontend/src/components/manga/MangaLists.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {MangaStore} from '@/stores/MangaStore';
|
||||
import MangaList, {type ViewEntry, type ViewList} from '@/components/manga/MangaList.vue';
|
||||
import {BTable} from 'bootstrap-vue-next';
|
||||
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
|
||||
import type {MangaUpdatesRelation} from '@/data/models/mangaupdates/MangaUpdatesRelation';
|
||||
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
|
||||
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
|
||||
|
||||
@Options({
|
||||
name: 'MangaLists',
|
||||
components: {MangaList, BTable},
|
||||
})
|
||||
export default class MangaLists extends Vue {
|
||||
get mangaStore(): MangaStore {
|
||||
return new MangaStore();
|
||||
};
|
||||
|
||||
get mediaById(): Map<number, AniListMedia> {
|
||||
const media = this.mangaStore.aniListMedia;
|
||||
return new Map(media.map(e => [e.id, e]));
|
||||
}
|
||||
|
||||
get relationsByAniListMediaId(): Map<number, MangaUpdatesRelation> {
|
||||
const relations = this.mangaStore.mangaUpdatesRelations;
|
||||
return new Map(relations.map(e => [e.aniListMediaId, e]));
|
||||
}
|
||||
|
||||
private get seriesById(): Map<number, MangaUpdatesSeries> {
|
||||
const series = this.mangaStore.mangaUpdatesSeries;
|
||||
return new Map(series.map(e => [e.series_id, e]));
|
||||
}
|
||||
|
||||
get chaptersBySeriesId(): Map<number, MangaUpdatesChapter[]> {
|
||||
const chapters = this.mangaStore.mangaUpdatesChapters;
|
||||
return Map.groupBy(chapters, e => e.series_id);
|
||||
}
|
||||
|
||||
get viewLists(): ViewList[] {
|
||||
const order = ['reading', 'paused', 'planning', 'completed', 'dropped'];
|
||||
const lists = this.mangaStore.aniListLists;
|
||||
const manga = this.mangaStore.aniListManga;
|
||||
|
||||
return lists.map(l => ({
|
||||
list: l,
|
||||
entries: (manga.get(l.name) ?? []).map(e => {
|
||||
const media = this.mediaById.get(e.mediaId) ?? null;
|
||||
const relation = this.relationsByAniListMediaId.get(e.mediaId) ?? null;
|
||||
const series = this.seriesById.get(relation?.mangaUpdatesSeriesId as any) ?? null;
|
||||
const chapters = this.chaptersBySeriesId.get(relation?.mangaUpdatesSeriesId as any) ?? [];
|
||||
return ({
|
||||
entry: e,
|
||||
media: media,
|
||||
relation: relation,
|
||||
series: series,
|
||||
chapters: chapters,
|
||||
} as ViewEntry);
|
||||
}),
|
||||
}))
|
||||
.sort((l, r) => order.indexOf(l.list.name.toLowerCase()) - order.indexOf(r.list.name.toLowerCase()));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-y-auto manga-lists">
|
||||
<MangaList v-for="viewList in viewLists" :key="viewList.list.name" :viewList="viewList" class="mb-3"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'bootstrap/scss/functions';
|
||||
@import 'bootstrap/scss/variables';
|
||||
@import 'bootstrap/scss/mixins/breakpoints';
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.manga-lists {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
20
frontend/src/components/manga/util.manga.ts
Normal file
20
frontend/src/components/manga/util.manga.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type {ViewEntry} from '@/components/manga/MangaList.vue';
|
||||
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
|
||||
|
||||
export function latestChaptersSorted(entry: ViewEntry): MangaUpdatesChapter[] {
|
||||
return entry.chapters.sort((l, r) => l.chapter - r.chapter);
|
||||
}
|
||||
|
||||
export function latestChapterString(entry: ViewEntry): string {
|
||||
if (entry.media?.chapters) {
|
||||
return '' + entry.media.chapters;
|
||||
} else if (entry.chapters.length) {
|
||||
return entry.chapters.reduce((l, r) => Math.max(l, r.chapter), 0) + '+';
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
export function newChapterCount(entry: ViewEntry): number {
|
||||
const max = entry.media?.chapters || entry.chapters.reduce((l, r) => Math.max(l, r.chapter), 0);
|
||||
return Math.max(0, max - entry.entry.progress);
|
||||
}
|
||||
56
frontend/src/components/navbar/AniListUserSearch.vue
Normal file
56
frontend/src/components/navbar/AniListUserSearch.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {MangaStore} from '@/stores/MangaStore';
|
||||
import {ServiceStore} from '@/stores/ServiceStore';
|
||||
import {BSpinner} from 'bootstrap-vue-next';
|
||||
|
||||
@Options({
|
||||
name: 'AniListUserSearch',
|
||||
components: {
|
||||
BSpinner,
|
||||
},
|
||||
})
|
||||
export default class AniListUserSearch extends Vue {
|
||||
searching = false;
|
||||
|
||||
get mangaStore(): MangaStore {
|
||||
return new MangaStore();
|
||||
}
|
||||
|
||||
get serviceStore(): ServiceStore {
|
||||
return new ServiceStore();
|
||||
}
|
||||
|
||||
get userName(): string {
|
||||
return this.mangaStore.userName ?? '';
|
||||
}
|
||||
|
||||
set userName(val: string) {
|
||||
this.mangaStore.updateUserName(val);
|
||||
}
|
||||
|
||||
mounted(): void {
|
||||
if (this.userName) {
|
||||
this.mangaStore.reloadCache();
|
||||
}
|
||||
}
|
||||
|
||||
onSearch(): void {
|
||||
this.searching = true;
|
||||
this.serviceStore.aniListDataService.updateDb()
|
||||
.finally(() => this.searching = false);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="input-group flex-nowrap">
|
||||
<input v-model="userName" :disabled="searching" :placeholder="$t('aniList.username')" aria-label="Search" class="form-control"
|
||||
type="search" @keydown.enter="onSearch">
|
||||
<button :disabled="searching" class="btn btn-primary" @click="onSearch">
|
||||
<BSpinner v-if="searching" small/>
|
||||
<i v-else class="fa fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
111
frontend/src/components/navbar/MangaUpdatesUpdater.vue
Normal file
111
frontend/src/components/navbar/MangaUpdatesUpdater.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import {Progress} from '@/data/service/MangaUpdatesDataService';
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {ServiceStore} from '@/stores/ServiceStore';
|
||||
import {BSpinner} from 'bootstrap-vue-next';
|
||||
import {toast} from 'vue3-toastify';
|
||||
|
||||
@Options({
|
||||
name: 'MangaUpdatesUpdater',
|
||||
components: {
|
||||
BSpinner,
|
||||
},
|
||||
})
|
||||
export default class MangaUpdatesUpdater extends Vue {
|
||||
//vars
|
||||
progress: Progress = new Progress(this.onProgress.bind(this), this.onFinished.bind(this));
|
||||
progressType: string | null = null;
|
||||
progressValue: number | null = null;
|
||||
progressMax: number | null = null;
|
||||
|
||||
wakeLock: WakeLockSentinel | null = null;
|
||||
|
||||
get serviceStore(): ServiceStore {
|
||||
return new ServiceStore();
|
||||
}
|
||||
|
||||
get progressStyle(): any {
|
||||
if (this.progressValue === null || this.progressMax === null) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'--progress-value': this.progressValue / this.progressMax,
|
||||
};
|
||||
}
|
||||
|
||||
//event handler
|
||||
async onUpdateMangaUpdatesDb(): Promise<void> {
|
||||
this.progressType = 'starting';
|
||||
await this.tryAcquireWakeLock();
|
||||
this.serviceStore.mangaUpdatesDataService.updateDb(this.progress);
|
||||
};
|
||||
|
||||
onProgress(type: string, progress: number, max: number): void {
|
||||
this.progressType = type;
|
||||
this.progressValue = progress;
|
||||
this.progressMax = max;
|
||||
}
|
||||
|
||||
onFinished(): void {
|
||||
this.wakeLock?.release();
|
||||
this.wakeLock = null;
|
||||
|
||||
this.progressType = 'finished';
|
||||
this.progressValue = null;
|
||||
this.progressMax = null;
|
||||
|
||||
setTimeout(() => this.onReset(), 3000);
|
||||
}
|
||||
|
||||
onReset(): void {
|
||||
this.wakeLock?.release();
|
||||
this.wakeLock = null;
|
||||
|
||||
this.progressType = null;
|
||||
this.progressValue = null;
|
||||
this.progressMax = null;
|
||||
}
|
||||
|
||||
//functions
|
||||
private async tryAcquireWakeLock(): Promise<void> {
|
||||
try {
|
||||
if (navigator.wakeLock) {
|
||||
this.wakeLock = await navigator.wakeLock.request('screen');
|
||||
} else {
|
||||
toast.warning(this.$t('wakeLock.permissionDenied'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.warning(this.$t('wakelock.notSupported'));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :disabled="!!progressType" class="btn btn-secondary progress-btn" :style="progressStyle"
|
||||
@click="onUpdateMangaUpdatesDb">
|
||||
<i v-if="!progressType" class="fa fa-refresh"/>
|
||||
<template v-else>
|
||||
<span class="text-nowrap">
|
||||
<BSpinner small class="me-2"/>
|
||||
<template v-if="progressValue === null || progressMax === null">
|
||||
{{ $t('fetch.mangaUpdates.' + progressType) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('fetch.mangaUpdates.' + progressType) + ': ' + (progressValue ?? 0) + '/' + (progressMax ?? 1) }}
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.progress-btn {
|
||||
--progress-value: 0;
|
||||
background: linear-gradient(to right,
|
||||
var(--bs-success),
|
||||
var(--bs-success) calc(var(--progress-value) * 100%),
|
||||
var(--bs-btn-bg) calc(var(--progress-value) * 100%),
|
||||
var(--bs-btn-bg));
|
||||
}
|
||||
</style>
|
||||
74
frontend/src/components/navbar/NavBar.vue
Normal file
74
frontend/src/components/navbar/NavBar.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import BootstrapThemeSwitch from '@/components/bootstrapThemeSwitch/BootstrapThemeSwitch.vue';
|
||||
import AniListUserSearch from '@/components/navbar/AniListUserSearch.vue';
|
||||
import MangaUpdatesUpdater from '@/components/navbar/MangaUpdatesUpdater.vue';
|
||||
import LocaleSelector from '@/components/locale/LocaleSelector.vue';
|
||||
|
||||
@Options({
|
||||
name: 'NavBar',
|
||||
components: {
|
||||
AniListUserSearch,
|
||||
BootstrapThemeSwitch,
|
||||
LocaleSelector,
|
||||
MangaUpdatesUpdater,
|
||||
},
|
||||
emits: {
|
||||
'toggleSidebar': undefined,
|
||||
},
|
||||
})
|
||||
export default class NavBar extends Vue {
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- z-index needed, otherwise shadow not showing -->
|
||||
<nav class="navbar border-bottom shadow flex-nowrap" style="z-index: 1">
|
||||
<div>
|
||||
<div class="navbar-sidebar-toggler mx-1 c-pointer">
|
||||
<i class="fa fa-bars me-2" @click="$emit('toggleSidebar')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="d-flex flex-row">
|
||||
<AniListUserSearch class="me-2"/>
|
||||
<MangaUpdatesUpdater class=""/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="d-flex flex-row align-items-center">
|
||||
<LocaleSelector class="navbar-locale-select"/>
|
||||
<BootstrapThemeSwitch class="navbar-theme-switch ms-2"/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'bootstrap/scss/functions';
|
||||
@import 'bootstrap/scss/variables';
|
||||
@import 'bootstrap/scss/mixins/breakpoints';
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
nav.navbar {
|
||||
--bs-navbar-padding-x: 0.5rem;
|
||||
}
|
||||
|
||||
nav.navbar .navbar-locale-select,
|
||||
nav.navbar .navbar-theme-switch {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
nav.navbar {
|
||||
--bs-navbar-padding-x: 1rem;
|
||||
}
|
||||
|
||||
nav.navbar .navbar-sidebar-toggler {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
87
frontend/src/components/sidebar/SideBar.vue
Normal file
87
frontend/src/components/sidebar/SideBar.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import SideBarNavItem from '@/components/sidebar/SideBarNavItem.vue';
|
||||
import {Prop} from 'vue-property-decorator';
|
||||
import LocaleSelector from '@/components/locale/LocaleSelector.vue';
|
||||
import BootstrapThemeSwitch from '@/components/bootstrapThemeSwitch/BootstrapThemeSwitch.vue';
|
||||
import SideBarHead from '@/components/sidebar/SideBarHead.vue';
|
||||
|
||||
@Options({
|
||||
name: 'SideBar',
|
||||
components: {
|
||||
BootstrapThemeSwitch,
|
||||
LocaleSelector,
|
||||
SideBarHead,
|
||||
SideBarNavItem,
|
||||
},
|
||||
emits: {
|
||||
'close': undefined,
|
||||
},
|
||||
})
|
||||
export default class SideBar extends Vue {
|
||||
@Prop({default: false})
|
||||
toggled!: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ toggled: toggled }" class="sidebar border-right shadow bg-body">
|
||||
<div class="d-flex flex-column">
|
||||
<ul class="nav flex-column mb-auto">
|
||||
<li>
|
||||
<SideBarHead @close="$emit('close')"/>
|
||||
</li>
|
||||
<SideBarNavItem :text="$t('locale')" faIcon="fa-language">
|
||||
<template #end>
|
||||
<LocaleSelector class="navbar-locale-select me-1"/>
|
||||
</template>
|
||||
</SideBarNavItem>
|
||||
<SideBarNavItem :text="$t('design')" faIcon="fa-moon">
|
||||
<template #end>
|
||||
<BootstrapThemeSwitch class="navbar-theme-switch"/>
|
||||
</template>
|
||||
</SideBarNavItem>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'bootstrap/scss/functions';
|
||||
@import 'bootstrap/scss/variables';
|
||||
@import 'bootstrap/scss/mixins/breakpoints';
|
||||
|
||||
$width: 15em;
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: $width;
|
||||
max-width: #{'clamp(0em, '}$width#{', 100%)'};
|
||||
transition: width ease-in-out 0.25s;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
z-index: 10000;
|
||||
height: 100%;
|
||||
|
||||
&:not(.toggled) {
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
24
frontend/src/components/sidebar/SideBarHead.vue
Normal file
24
frontend/src/components/sidebar/SideBarHead.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
|
||||
@Options({
|
||||
name: 'SideBar',
|
||||
components: {},
|
||||
emits: {
|
||||
'close': undefined,
|
||||
},
|
||||
})
|
||||
export default class SideBar extends Vue {
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="navbar border-bottom shadow flex-nowrap">
|
||||
<div class="sidebar-closer mx-1 c-pointer">
|
||||
<i class="fa fa-arrow-left me-2" @click="$emit('close')"/>
|
||||
|
||||
<!-- 'hack' to ensure this nav is the same height as the navbar -->
|
||||
<input class="form-control d-inline mx-0 px-0" style="visibility: hidden; width: 0;"/>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
41
frontend/src/components/sidebar/SideBarNavItem.vue
Normal file
41
frontend/src/components/sidebar/SideBarNavItem.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {Prop} from 'vue-property-decorator';
|
||||
|
||||
@Options({
|
||||
name: 'SideBarNavItem',
|
||||
components: {},
|
||||
})
|
||||
export default class SideBarNavItem extends Vue {
|
||||
@Prop({required: false})
|
||||
faIcon!: string;
|
||||
@Prop({required: false})
|
||||
text!: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="nav-item sidebar-nav-item">
|
||||
<slot>
|
||||
<div class="d-flex flex-row p-2 align-items-center">
|
||||
<i :class="[faIcon]" class="sidebar-nav-item-icon text-center fa"></i>
|
||||
<span class="sidebar-nav-item-text me-auto">{{ text }}</span>
|
||||
<slot name="end"/>
|
||||
</div>
|
||||
</slot>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebar-nav-item-text {
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
max-width: 10em;
|
||||
transition: display ease-in-out 0.25s, opacity ease-in-out 0.25s, max-width ease-in-out 0.25s;
|
||||
}
|
||||
|
||||
.sidebar-nav-item-icon {
|
||||
width: 2em;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
33
frontend/src/data/api/AniListApi.ts
Normal file
33
frontend/src/data/api/AniListApi.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {AniListUser} from '@/data/models/anilist/AniListUser';
|
||||
import {handleJsonResponse} from '@/data/api/ApiUtils';
|
||||
import type {AniListMangaListCollection} from '@/data/models/anilist/AniListMangaListCollection';
|
||||
|
||||
export default class AniListApi {
|
||||
async fetchUser(userName: string): Promise<AniListUser> {
|
||||
const res = fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'query': 'query($id:Int,$name:String){User(id:$id,name:$name){id name previousNames{name updatedAt}avatar{large}bannerImage about isFollowing isFollower donatorTier donatorBadge createdAt moderatorRoles isBlocked bans options{profileColor restrictMessagesToFollowing}mediaListOptions{scoreFormat}statistics{anime{count meanScore standardDeviation minutesWatched episodesWatched genrePreview:genres(limit:10,sort:COUNT_DESC){genre count}}manga{count meanScore standardDeviation chaptersRead volumesRead genrePreview:genres(limit:10,sort:COUNT_DESC){genre count}}}stats{activityHistory{date amount level}}favourites{anime{edges{favouriteOrder node{id type status(version:2)format isAdult bannerImage title{userPreferred}coverImage{large}startDate{year}}}}manga{edges{favouriteOrder node{id type status(version:2)format isAdult bannerImage title{userPreferred}coverImage{large}startDate{year}}}}characters{edges{favouriteOrder node{id name{userPreferred}image{large}}}}staff{edges{favouriteOrder node{id name{userPreferred}image{large}}}}studios{edges{favouriteOrder node{id name}}}}}}',
|
||||
'variables': {'name': userName},
|
||||
}),
|
||||
});
|
||||
return (await handleJsonResponse(res)).data.User;
|
||||
}
|
||||
|
||||
async fetchManga(userId: number): Promise<AniListMangaListCollection> {
|
||||
const res = fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'query': 'query($userId:Int,$userName:String,$type:MediaType){MediaListCollection(userId:$userId,userName:$userName,type:$type){lists{name isCustomList isCompletedList:isSplitCompletedList entries{...mediaListEntry}}user{id name avatar{large}mediaListOptions{scoreFormat rowOrder animeList{sectionOrder customLists splitCompletedSectionByFormat theme}mangaList{sectionOrder customLists splitCompletedSectionByFormat theme}}}}}fragment mediaListEntry on MediaList{id mediaId status score progress progressVolumes repeat priority private hiddenFromStatusLists customLists advancedScores notes updatedAt startedAt{year month day}completedAt{year month day}media{id title{userPreferred romaji english native}coverImage{extraLarge large}type format status(version:2)episodes volumes chapters averageScore popularity isAdult countryOfOrigin genres bannerImage startDate{year month day}}}',
|
||||
'variables': {'userId': userId, 'type': 'MANGA'},
|
||||
}),
|
||||
});
|
||||
return (await handleJsonResponse(res)).data.MediaListCollection;
|
||||
}
|
||||
}
|
||||
17
frontend/src/data/api/ApiUtils.ts
Normal file
17
frontend/src/data/api/ApiUtils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export class ApiError extends Error {
|
||||
readonly statusCode: number;
|
||||
|
||||
constructor(statusCode: number, message?: string) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleJsonResponse<T>(res: Promise<Response>): Promise<T> {
|
||||
return res.then(async e => {
|
||||
if (e.status === 200) {
|
||||
return await e.json();
|
||||
}
|
||||
throw new ApiError(e.status, 'Api error ' + e.status + ': ' + await e.text());
|
||||
});
|
||||
}
|
||||
30
frontend/src/data/api/MangaUpdatesApi.ts
Normal file
30
frontend/src/data/api/MangaUpdatesApi.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {handleJsonResponse} from '@/data/api/ApiUtils';
|
||||
|
||||
import type {MangaUpdatesSearchResult} from '@/data/models/mangaupdates/MangaUpdatesSearchResult';
|
||||
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
|
||||
import type {MangaUpdatesSeriesGroups} from '@/data/models/mangaupdates/MangaUpdatesSeriesGroups';
|
||||
|
||||
export default class MangaUpdatesApi {
|
||||
search(name: string): Promise<MangaUpdatesSearchResult> {
|
||||
const res = fetch('/mangaupdates/v1/series/search', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
search: name,
|
||||
stype: 'title',
|
||||
type: 'Manga',
|
||||
}),
|
||||
});
|
||||
return handleJsonResponse(res);
|
||||
}
|
||||
|
||||
get(id: number): Promise<MangaUpdatesSeries> {
|
||||
const res = fetch(`/mangaupdates/v1/series/${id}`);
|
||||
return handleJsonResponse(res);
|
||||
}
|
||||
|
||||
groups(id: number): Promise<MangaUpdatesSeriesGroups> {
|
||||
const res = fetch(`/mangaupdates/v1/series/${id}/groups`);
|
||||
return handleJsonResponse(res);
|
||||
}
|
||||
}
|
||||
101
frontend/src/data/db/AniListDb.ts
Normal file
101
frontend/src/data/db/AniListDb.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {type IDBPDatabase, openDB} from 'idb';
|
||||
import type {AniListUser} from '@/data/models/anilist/AniListUser';
|
||||
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
|
||||
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
|
||||
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
|
||||
|
||||
export type AlDb = IDBPDatabase<AniListDBSchema>;
|
||||
|
||||
export default class AniListDb {
|
||||
private intDb: IDBPDatabase<AniListDBSchema> | null = null;
|
||||
|
||||
get db(): IDBPDatabase<AniListDBSchema> | null {
|
||||
return this.intDb;
|
||||
}
|
||||
|
||||
static async withDb<T>(fn: (db: AlDb) => Promise<T> | T): Promise<T> {
|
||||
const db = new AniListDb();
|
||||
const idb = await db.open();
|
||||
try {
|
||||
return await fn(idb);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async open(): Promise<IDBPDatabase<AniListDBSchema>> {
|
||||
this.intDb = await openDB('anilist', 1, {
|
||||
upgrade(db, oldVersion, newVersion, transaction, event) {
|
||||
switch (oldVersion) {
|
||||
case 0:
|
||||
const user = db.createObjectStore('user', {keyPath: 'id'});
|
||||
user.createIndex('id', 'id', {unique: true});
|
||||
user.createIndex('name', 'name', {unique: true});
|
||||
|
||||
const list = db.createObjectStore('list', {keyPath: '_userId_listName'});
|
||||
list.createIndex('_userId', '_userId', {multiEntry: true});
|
||||
list.createIndex('_userId_listName', '_userId_listName', {unique: true}); // TODO multiprop indexes
|
||||
|
||||
const manga = db.createObjectStore('manga', {keyPath: 'id'});
|
||||
manga.createIndex('id', 'id', {unique: true});
|
||||
manga.createIndex('_userId', '_userId', {multiEntry: true});
|
||||
manga.createIndex('_listName', '_listName', {multiEntry: true});
|
||||
manga.createIndex('_userId_listName', '_userId_listName', {multiEntry: true});
|
||||
|
||||
const media = db.createObjectStore('media', {keyPath: 'id'});
|
||||
media.createIndex('id', 'id', {unique: true});
|
||||
//fall through
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
return this.intDb;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.intDb?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export type AniListDBSchema = {
|
||||
user: {
|
||||
key: 'id',
|
||||
value: AniListUser,
|
||||
indexes: {
|
||||
id: number,
|
||||
name: string
|
||||
}
|
||||
},
|
||||
list: {
|
||||
value: AniListMangaList & {
|
||||
_userId: number,
|
||||
_userId_listName: string
|
||||
},
|
||||
indexes: {
|
||||
_userId: number,
|
||||
_userId_listName: string
|
||||
}
|
||||
},
|
||||
manga: {
|
||||
key: 'id';
|
||||
value: AniListMangaListEntry & {
|
||||
_userId: number,
|
||||
_listName: string,
|
||||
_userId_listName: string
|
||||
},
|
||||
indexes: {
|
||||
id: number,
|
||||
_userId: number,
|
||||
_listName: string,
|
||||
_userId_listName: string
|
||||
}
|
||||
},
|
||||
media: {
|
||||
key: 'id';
|
||||
value: AniListMedia;
|
||||
indexes: {
|
||||
id: number
|
||||
}
|
||||
},
|
||||
}
|
||||
4
frontend/src/data/db/DbUtil.ts
Normal file
4
frontend/src/data/db/DbUtil.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function strip_<T extends {} | object>(obj: T): T {
|
||||
Object.keys(obj).filter(e => e.startsWith('_')).forEach(k => delete (obj as any)[k]);
|
||||
return obj;
|
||||
}
|
||||
73
frontend/src/data/db/MangaUpdatesDb.ts
Normal file
73
frontend/src/data/db/MangaUpdatesDb.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {type IDBPDatabase, openDB} from 'idb';
|
||||
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
|
||||
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
|
||||
|
||||
export type MuDb = IDBPDatabase<MangaUpdatesDBSchema>;
|
||||
|
||||
export default class MangaUpdatesDb {
|
||||
private intDb: IDBPDatabase<MangaUpdatesDBSchema> | null = null;
|
||||
|
||||
get db(): IDBPDatabase<MangaUpdatesDBSchema> | null {
|
||||
return this.intDb;
|
||||
}
|
||||
|
||||
static async withDb<T>(fn: (db: MuDb) => Promise<T> | T): Promise<T> {
|
||||
const db = new MangaUpdatesDb();
|
||||
const idb = await db.open();
|
||||
try {
|
||||
return await fn(idb);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async open(): Promise<IDBPDatabase<MangaUpdatesDBSchema>> {
|
||||
this.intDb = await openDB('mangaupdates', 1, {
|
||||
upgrade(db, oldVersion, newVersion, transaction, event) {
|
||||
switch (oldVersion) {
|
||||
case 0:
|
||||
const relation = db.createObjectStore('relation', {autoIncrement: true});
|
||||
relation.createIndex('unique', ['aniListMediaId', 'mangaUpdatesSeriesId'], {unique: true});
|
||||
|
||||
const media = db.createObjectStore('series', {keyPath: 'series_id'});
|
||||
media.createIndex('series_id', 'series_id', {unique: true});
|
||||
|
||||
const chapter = db.createObjectStore('chapter', {autoIncrement: true});
|
||||
chapter.createIndex('series_id', 'series_id', {multiEntry: true});
|
||||
//fall through
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
return this.intDb;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.intDb?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export type MangaUpdatesDBSchema = {
|
||||
relation: {
|
||||
key: 'id',
|
||||
value: MangaUpdatesRelation,
|
||||
indexes: {
|
||||
unique: [number, number]
|
||||
}
|
||||
},
|
||||
series: {
|
||||
key: 'series_id';
|
||||
value: MangaUpdatesSeries;
|
||||
indexes: {
|
||||
series_id: number
|
||||
}
|
||||
},
|
||||
chapter: {
|
||||
key: 'id';
|
||||
value: MangaUpdatesChapter;
|
||||
indexes: {
|
||||
series_id: number
|
||||
}
|
||||
},
|
||||
}
|
||||
8
frontend/src/data/models/anilist/AniListMangaList.ts
Normal file
8
frontend/src/data/models/anilist/AniListMangaList.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
|
||||
|
||||
export type AniListMangaList = {
|
||||
name: 'Paused' | 'Completed' | 'Planning' | 'Dropped' | 'Reading',
|
||||
isCustomList: boolean,
|
||||
isCompletedList: boolean,
|
||||
entries: AniListMangaListEntry[]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
|
||||
|
||||
export type AniListMangaListCollection = {
|
||||
lists: AniListMangaList[]
|
||||
}
|
||||
|
||||
21
frontend/src/data/models/anilist/AniListMangaListEntry.ts
Normal file
21
frontend/src/data/models/anilist/AniListMangaListEntry.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
|
||||
|
||||
export type AniListMangaListEntry = {
|
||||
id: number,
|
||||
mediaId: number,
|
||||
status: 'PAUSED' | 'COMPLETED' | 'PLANNING' | 'DROPPED' | 'READING',
|
||||
score: number,
|
||||
progress: number,
|
||||
progressVolumes: number,
|
||||
repeat: number,
|
||||
priority: number,
|
||||
private: boolean,
|
||||
hiddenFromStatusLists: boolean,
|
||||
customLists: any,
|
||||
advancedScores: any,
|
||||
notes: any,
|
||||
updatedAt: number
|
||||
startedAt: any,
|
||||
completedAt: any,
|
||||
media: AniListMedia,
|
||||
}
|
||||
26
frontend/src/data/models/anilist/AniListMedia.ts
Normal file
26
frontend/src/data/models/anilist/AniListMedia.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type AniListMedia = {
|
||||
id: number,
|
||||
title: {
|
||||
userPreferred: string,
|
||||
romaji: string,
|
||||
english: string,
|
||||
native: string,
|
||||
}
|
||||
coverImage: {
|
||||
extraLarge: string, //url
|
||||
large: string, //url
|
||||
}
|
||||
type: 'MANGA' | string,
|
||||
format: 'MANGA' | string,
|
||||
status: 'RELEASING' | string,
|
||||
episodes: number | null,
|
||||
volumes: number | null,
|
||||
chapters: number | null,
|
||||
averageScore: number,
|
||||
popularity: number,
|
||||
isAdult: boolean,
|
||||
countryOfOrigin: 'JP' | 'KR' | string,
|
||||
genres: string[],
|
||||
bannerImage: string, //url
|
||||
startDate: { year: number, month: number, day: number },
|
||||
}
|
||||
5
frontend/src/data/models/anilist/AniListUser.ts
Normal file
5
frontend/src/data/models/anilist/AniListUser.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type AniListUser = {
|
||||
id: number,
|
||||
name: string,
|
||||
//...
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type MangaUpdatesChapter = {
|
||||
series_id: number,
|
||||
group: string,
|
||||
chapter: number
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type MangaUpdatesRelation = {
|
||||
aniListMediaId: number,
|
||||
mangaUpdatesSeriesId: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {MangaUpdatesSearchResultRecord} from '@/data/models/mangaupdates/MangaUpdatesSearchResultRecord';
|
||||
import type {MangaUpdatesSearchResultMetaData} from '@/data/models/mangaupdates/MangaUpdatesSearchResultMetaData';
|
||||
|
||||
export type MangaUpdatesSearchResult = {
|
||||
'total_hits': number,
|
||||
'page': number,
|
||||
'per_page': number,
|
||||
'results': [{
|
||||
'record': MangaUpdatesSearchResultRecord,
|
||||
'hit_title': string,
|
||||
'metadata': MangaUpdatesSearchResultMetaData
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type MangaUpdatesSearchResultMetaData = {
|
||||
'user_list': {
|
||||
'series': {
|
||||
'id': number,
|
||||
'title': string
|
||||
},
|
||||
'list_id': number,
|
||||
'list_type': string,
|
||||
'list_icon': string,
|
||||
'status': {
|
||||
'volume': number,
|
||||
'chapter': number
|
||||
},
|
||||
'priority': number,
|
||||
'time_added': {
|
||||
'timestamp': number,
|
||||
'as_rfc3339': string,
|
||||
'as_string': string
|
||||
}
|
||||
},
|
||||
'user_genre_highlights': [{
|
||||
'genre': string,
|
||||
'color': string
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
export type MangaUpdatesSearchResultRecord = {
|
||||
'series_id': number,
|
||||
'title': string,
|
||||
'url': string,
|
||||
'description': string,
|
||||
'image': {
|
||||
'url': {
|
||||
'original': string,
|
||||
'thumb': string
|
||||
},
|
||||
'height': number,
|
||||
'width': number
|
||||
},
|
||||
'type': 'Artbook' | 'Doujinshi' | 'Drama CD' | 'Filipino' | 'Indonesian' | 'Manga' | 'Manhwa' | 'Manhua' | 'Novel'
|
||||
| 'OEL' | 'Thai' | 'Vietnamese' | 'Malaysian' | 'Nordic' | 'French' | 'Spanish',
|
||||
'year': string,
|
||||
'bayesian_rating': number,
|
||||
'rating_votes': number,
|
||||
'genres': [{
|
||||
'genre': string
|
||||
}],
|
||||
'latest_chapter': number,
|
||||
'rank': {
|
||||
'position': {
|
||||
'week': number,
|
||||
'month': number,
|
||||
'three_months': number,
|
||||
'six_months': number,
|
||||
'year': number
|
||||
},
|
||||
'old_position': {
|
||||
'week': number,
|
||||
'month': number,
|
||||
'three_months': number,
|
||||
'six_months': number,
|
||||
'year': number
|
||||
},
|
||||
'lists': {
|
||||
'reading': number,
|
||||
'wish': number,
|
||||
'complete': number,
|
||||
'unfinished': number,
|
||||
'custom': number
|
||||
}
|
||||
},
|
||||
'last_updated': {
|
||||
'timestamp': number,
|
||||
'as_rfc3339': string,
|
||||
'as_string': string
|
||||
},
|
||||
'admin': {
|
||||
'added_by': {
|
||||
'user_id': number,
|
||||
'username': string,
|
||||
'url': string,
|
||||
'avatar': {
|
||||
'id': number,
|
||||
'url': string,
|
||||
'height': number,
|
||||
'width': number
|
||||
},
|
||||
'time_joined': {
|
||||
'timestamp': number,
|
||||
'as_rfc3339': string,
|
||||
'as_string': string
|
||||
},
|
||||
'signature': string,
|
||||
'forum_title': string,
|
||||
'folding_at_home': true,
|
||||
'profile': {
|
||||
'upgrade': {
|
||||
'requested': true,
|
||||
'reason': string
|
||||
}
|
||||
},
|
||||
'stats': {
|
||||
'forum_posts': number,
|
||||
'added_authors': number,
|
||||
'added_groups': number,
|
||||
'added_publishers': number,
|
||||
'added_releases': number,
|
||||
'added_series': number
|
||||
},
|
||||
'user_group': string,
|
||||
'user_group_name': string
|
||||
},
|
||||
'approved': true
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
67
frontend/src/data/models/mangaupdates/MangaUpdatesSeries.ts
Normal file
67
frontend/src/data/models/mangaupdates/MangaUpdatesSeries.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export type MangaUpdatesSeries = {
|
||||
'series_id': number,
|
||||
'title': string,
|
||||
'url': string,
|
||||
'associated': { 'title': string }[],
|
||||
'description': string,
|
||||
'image': {
|
||||
'url': {
|
||||
'original': string,
|
||||
'thumb': string
|
||||
},
|
||||
'height': number,
|
||||
'width': number
|
||||
},
|
||||
'type': 'Artbook' | 'Doujinshi' | 'Drama CD' | 'Filipino' | 'Indonesian' | 'Manga' | 'Manhwa' | 'Manhua' | 'Novel'
|
||||
| 'OEL' | 'Thai' | 'Vietnamese' | 'Malaysian' | 'Nordic' | 'French' | 'Spanish',
|
||||
'year': string,
|
||||
'bayesian_rating': number,
|
||||
'rating_votes': number,
|
||||
'genres': { 'genre': string }[],
|
||||
'categories': { 'series_id': number, 'category': string, 'votes': number, 'votes_plus': number, 'votes_minus': number, 'added_by': number }[],
|
||||
'latest_chapter': number,
|
||||
'forum_id': number,
|
||||
'status': string,
|
||||
'licensed': true,
|
||||
'completed': false,
|
||||
'anime': {
|
||||
'start': string,
|
||||
'end': string
|
||||
},
|
||||
'related_series': { 'relation_id': number, 'relation_type': string, 'related_series_id': number, 'related_series_name': string, 'triggered_by_relation_id': number }[],
|
||||
'publishers': { 'publisher_name': string, 'publisher_id': number, 'type': string, 'notes': string }[],
|
||||
'publications': { 'publication_name': string, 'publisher_name': string, 'publisher_id': number }[],
|
||||
'recommendations': { 'series_name': string, 'series_id': number, 'weight': number }[],
|
||||
'category_recommendations': { 'series_name': string, 'series_id': number, 'weight': number }[],
|
||||
'rank': {
|
||||
'position':
|
||||
{
|
||||
'week': number,
|
||||
'month': number,
|
||||
'three_months': number,
|
||||
'six_months': number,
|
||||
'year': number
|
||||
},
|
||||
'old_position':
|
||||
{
|
||||
'week': number,
|
||||
'month': number,
|
||||
'three_months': number,
|
||||
'six_months': number,
|
||||
'year': number
|
||||
},
|
||||
'lists':
|
||||
{
|
||||
'reading': number,
|
||||
'wish': number,
|
||||
'complete': number,
|
||||
'unfinished': number,
|
||||
'custom': number
|
||||
}
|
||||
},
|
||||
'last_updated': {
|
||||
'timestamp': number,
|
||||
'as_rfc3339': string,
|
||||
'as_string': string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type MangaUpdatesSeriesGroups = {
|
||||
'group_list': {
|
||||
'group_id': number,
|
||||
'name': string,
|
||||
'url': string,
|
||||
'social': {
|
||||
'site': string,
|
||||
'facebook': string,
|
||||
'twitter': string,
|
||||
'irc': { 'channel': string, 'server': string },
|
||||
'forum': string,
|
||||
'discord': string
|
||||
},
|
||||
'active': true
|
||||
}[],
|
||||
'release_list': {
|
||||
'id': number,
|
||||
'title': string,
|
||||
'volume': null,
|
||||
'chapter': string,
|
||||
'groups': { 'name': string, 'group_id': number }[],
|
||||
'release_date': string,
|
||||
'time_added': { 'timestamp': number, 'as_rfc3339': string, 'as_string': string }
|
||||
}[]
|
||||
}
|
||||
114
frontend/src/data/repository/aniList/AniListMangaRepository.ts
Normal file
114
frontend/src/data/repository/aniList/AniListMangaRepository.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import AniListDb, {type AlDb} from '@/data/db/AniListDb';
|
||||
import {strip_} from '@/data/db/DbUtil';
|
||||
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
|
||||
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
|
||||
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
|
||||
|
||||
export default class AniListMangaRepository {
|
||||
async getMangaLists(userId: number): Promise<AniListMangaList[]> {
|
||||
return await AniListDb.withDb(async db => {
|
||||
return (await db.getAllFromIndex('list', '_userId', userId)).map(strip_);
|
||||
});
|
||||
}
|
||||
|
||||
async getManga(userId: number): Promise<Map<string, AniListMangaListEntry[]>> {
|
||||
return await AniListDb.withDb(async db => {
|
||||
const lists = (await db.getAllFromIndex('list', '_userId', userId)) as AniListMangaList[];
|
||||
const arr = await Promise.all(lists.map(async list => {
|
||||
let entries = (await db
|
||||
.getAllFromIndex('manga', '_userId_listName', IDBKeyRange.only(userId + '_' + list.name))
|
||||
) as AniListMangaListEntry[];
|
||||
return [list.name, entries.map(strip_)] as [string, AniListMangaListEntry[]];
|
||||
}));
|
||||
return new Map<string, AniListMangaListEntry[]>(arr);
|
||||
});
|
||||
}
|
||||
|
||||
async getMedia(): Promise<AniListMedia[]> {
|
||||
return await AniListDb.withDb(async db => {
|
||||
return (await db.getAll('media')).map(strip_);
|
||||
});
|
||||
}
|
||||
|
||||
async patchLists(userId: number, lists: AniListMangaList[]): Promise<void> {
|
||||
await AniListDb.withDb(async db => {
|
||||
await this.patchMangaDropUnnecessaryDbs(db, userId, lists);
|
||||
|
||||
await Promise.allSettled(lists.map(async list => {
|
||||
await this.patchMangaList(db, userId, list);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async patchManga(userId: number, manga: Map<string, AniListMangaListEntry[]>): Promise<void> {
|
||||
await AniListDb.withDb(async db => {
|
||||
await Promise.allSettled(Array.from(manga.entries()).map(async ([listName, entries]) => {
|
||||
await this.patchMangaListEntries(db, userId, listName, entries);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async updateMedia(media: AniListMedia[]): Promise<void> {
|
||||
await AniListDb.withDb(async db => {
|
||||
await this.patchMangaMedia(db, media);
|
||||
});
|
||||
}
|
||||
|
||||
private async patchMangaDropUnnecessaryDbs(db: AlDb, userId: number, lists: AniListMangaList[]): Promise<void> {
|
||||
//drop lists if necessary
|
||||
const newListNames = new Set(lists.map(e => e.name as string));
|
||||
const listNamesPresent = (await db.getAllKeysFromIndex('list', '_userId', userId)) as string[];
|
||||
const listNamesToDrop = listNamesPresent.filter(listName => !newListNames.has(listName));
|
||||
let txList = db.transaction('list', 'readwrite');
|
||||
await Promise.allSettled([...listNamesToDrop.map(async listName => {
|
||||
await txList.store.delete(listName);
|
||||
}), txList.done]);
|
||||
|
||||
//drop manga if necessary
|
||||
const mangaIdGroupsToDrop = await Promise.all(listNamesToDrop.map(async listName => {
|
||||
const ret = await db.getAllKeysFromIndex('manga', '_userId_listName', IDBKeyRange.only(userId + '_' + listName));
|
||||
return ret as string[];
|
||||
}));
|
||||
const mangaIdsToDrop = mangaIdGroupsToDrop.flat();
|
||||
const txManga = db.transaction('manga', 'readwrite');
|
||||
await Promise.allSettled([...mangaIdsToDrop.map(async mangaId => {
|
||||
await txManga.store.delete(mangaId);
|
||||
}), txManga.done]);
|
||||
}
|
||||
|
||||
private async patchMangaList(db: AlDb, userId: number, list: AniListMangaList): Promise<void> {
|
||||
const o = {...list, _userId: userId, _userId_listName: userId + '_' + list.name} as any;
|
||||
delete o.entries;
|
||||
await db.put('list', o).catch(err => console.error(err));
|
||||
}
|
||||
|
||||
private async patchMangaListEntries(db: AlDb, userId: number, listName: string, entries: AniListMangaListEntry[]): Promise<void> {
|
||||
// console.log("patchMangaListEntries", list);
|
||||
|
||||
//drop entries if necessary
|
||||
const newEntryIds = new Set(entries.map(e => e.id));
|
||||
const entryIdsPresent = (await db
|
||||
.getAllKeysFromIndex('manga', '_userId_listName', IDBKeyRange.only(userId + '_' + listName))) as number[];
|
||||
const entryIdsToDrop = entryIdsPresent.filter(e => !newEntryIds.has(e));
|
||||
let tx = db.transaction('manga', 'readwrite');
|
||||
await Promise.allSettled([...entryIdsToDrop.map(async entryId => {
|
||||
await tx.store.delete(entryId).catch(err => console.error(err));
|
||||
}), tx.done]);
|
||||
|
||||
//create/update entries
|
||||
tx = db.transaction('manga', 'readwrite');
|
||||
await Promise.allSettled([...entries.map(async entry => {
|
||||
const o = {...entry, _userId: userId, _listName: listName, _userId_listName: userId + '_' + listName} as any;
|
||||
delete o.media;
|
||||
await tx.store.put(o).catch(err => console.error(err));
|
||||
}), tx.done]);
|
||||
}
|
||||
|
||||
private async patchMangaMedia(db: AlDb, media: AniListMedia[]): Promise<void> {
|
||||
//create/update entries
|
||||
let tx = db.transaction('media', 'readwrite');
|
||||
await Promise.allSettled([...media.map(async entry => {
|
||||
await tx!.store.put(entry).catch(err => console.error(err));
|
||||
}), tx.done]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type {AniListUser} from '@/data/models/anilist/AniListUser';
|
||||
import AniListDb from '@/data/db/AniListDb';
|
||||
|
||||
export default class AniListUserRepository {
|
||||
async getUser(userName: string): Promise<AniListUser | null> {
|
||||
return await AniListDb.withDb(async db => {
|
||||
return await db.getFromIndex('user', 'name', IDBKeyRange.only(userName)) ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(user: AniListUser): Promise<void> {
|
||||
return await AniListDb.withDb(async db => {
|
||||
await db.put('user', user);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import MangaUpdatesDb from '@/data/db/MangaUpdatesDb';
|
||||
import type {MangaUpdatesRelation} from '@/data/models/mangaupdates/MangaUpdatesRelation';
|
||||
import type {MangaUpdatesMedia} from '@/data/models/mangaupdates/MangaUpdatesMedia';
|
||||
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
|
||||
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
|
||||
|
||||
export default class MangaUpdatesRepository {
|
||||
async getChapters(): Promise<MangaUpdatesChapter[]> {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
return await db.getAll('chapter');
|
||||
});
|
||||
}
|
||||
|
||||
async getRelations(): Promise<MangaUpdatesRelation[]> {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
return await db.getAll('relation');
|
||||
});
|
||||
}
|
||||
|
||||
async getSeries(): Promise<MangaUpdatesSeries[]> {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
return await db.getAll('series');
|
||||
});
|
||||
}
|
||||
|
||||
async getSeriesById(id: number) {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
return await db.get('series', id);
|
||||
});
|
||||
}
|
||||
|
||||
async addRelations(newRelations: MangaUpdatesRelation[]) {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
let txList = db.transaction('relation', 'readwrite');
|
||||
await Promise.allSettled([
|
||||
...newRelations.map(relation => txList.store.add(relation)),
|
||||
txList.done]);
|
||||
});
|
||||
}
|
||||
|
||||
async updateChapters(newChapters: MangaUpdatesChapter[]) {
|
||||
const seriesIds = Array.from(new Set(newChapters.map(e => e.series_id)).values());
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
const keysToDelete = await Promise.all(seriesIds.map(series_id => {
|
||||
return db.getAllKeysFromIndex('chapter', 'series_id', IDBKeyRange.only(series_id)) as any as Promise<number[]>;
|
||||
}));
|
||||
|
||||
let txList = db.transaction('chapter', 'readwrite');
|
||||
await Promise.allSettled([
|
||||
...keysToDelete.flat().map(key => txList.store.delete(IDBKeyRange.only(key))),
|
||||
...newChapters.map(chapter => txList.store.add(chapter)),
|
||||
txList.done]);
|
||||
});
|
||||
}
|
||||
|
||||
async updateSeries(newSeries: MangaUpdatesMedia[]) {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
let txList = db.transaction('series', 'readwrite');
|
||||
await Promise.allSettled([
|
||||
...newSeries.map(series => txList.store.put(series)),
|
||||
txList.done]);
|
||||
});
|
||||
}
|
||||
}
|
||||
46
frontend/src/data/service/AniListDataService.ts
Normal file
46
frontend/src/data/service/AniListDataService.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import AniListApi from '@/data/api/AniListApi';
|
||||
import {MangaStore} from '@/stores/MangaStore';
|
||||
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
|
||||
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
|
||||
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
|
||||
|
||||
export default class AniListDataService {
|
||||
async updateDb(): Promise<void> {
|
||||
const store = new MangaStore();
|
||||
const userName = store.userName;
|
||||
if (!userName) {
|
||||
return Promise.reject('No username set');
|
||||
}
|
||||
|
||||
const api = new AniListApi();
|
||||
|
||||
const user = await api.fetchUser(userName);
|
||||
await store.updateAniListUser(user);
|
||||
|
||||
const manga = await api.fetchManga(user.id);
|
||||
const lists = manga.lists
|
||||
.map(e => {
|
||||
const ret = {...e} as AniListMangaList; //shallow copy
|
||||
delete (ret as any).entries;
|
||||
return ret;
|
||||
});
|
||||
const mangaByList = new Map<string, AniListMangaListEntry[]>(manga.lists
|
||||
.map(list => {
|
||||
const entries = [...list.entries.map(manga => {
|
||||
const ret = {...manga} as AniListMangaListEntry; //shallow copy
|
||||
delete (ret as any).media;
|
||||
return ret;
|
||||
})];
|
||||
return [list.name, entries];
|
||||
}));
|
||||
|
||||
const media = manga.lists
|
||||
.map(e => e.entries.map(e => e.media)).flat()
|
||||
.filter(e => e)//not null
|
||||
.map(e => ({...e} as AniListMedia)); //shallow copy
|
||||
|
||||
await store.updateAniListLists(user.id, lists);
|
||||
await store.updateAniListManga(user.id, mangaByList);
|
||||
await store.updateAniListMedia(media);
|
||||
}
|
||||
}
|
||||
180
frontend/src/data/service/MangaUpdatesDataService.ts
Normal file
180
frontend/src/data/service/MangaUpdatesDataService.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import MangaUpdatesApi from '@/data/api/MangaUpdatesApi';
|
||||
import {DbStore} from '@/stores/DbStore';
|
||||
import {MangaStore} from '@/stores/MangaStore';
|
||||
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
|
||||
import type {MangaUpdatesSearchResultRecord} from '@/data/models/mangaupdates/MangaUpdatesSearchResultRecord';
|
||||
import stringSimilarity from 'string-similarity-js';
|
||||
import {ApiError} from '@/data/api/ApiUtils';
|
||||
|
||||
export default class MangaUpdatesDataService {
|
||||
private readonly mangaUpdatesApi = new MangaUpdatesApi();
|
||||
|
||||
updateDb(progress: Progress): Progress {
|
||||
const mangaStore = new MangaStore();
|
||||
const dbStore = new DbStore();
|
||||
this.findMissingRelations(mangaStore, dbStore, progress)
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
})
|
||||
.then(_ => this.fetchSeriesUpdates(mangaStore, dbStore, progress))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
})
|
||||
.then(_ => this.fetchSeriesChapterUpdates(mangaStore, dbStore, progress))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
})
|
||||
.then(_ => progress.onFinished());
|
||||
return progress;
|
||||
}
|
||||
|
||||
private async findMissingRelations(mangaStore: MangaStore, dbStore: DbStore, progress: Progress): Promise<void> {
|
||||
const allowTypes = new Set(['Manga', 'Manhwa', 'Manhua'].map(e => e.toLowerCase()));
|
||||
|
||||
const media = await dbStore.aniListMangaRepository.getMedia();
|
||||
const relations = await dbStore.mangaUpdatesRepository.getRelations();
|
||||
const presentRelationIds = new Set(relations.map(e => e.aniListMediaId));
|
||||
|
||||
let i = 0;
|
||||
for (const m of media) {
|
||||
try {
|
||||
if (presentRelationIds.has(m.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let matching: { record: MangaUpdatesSearchResultRecord }[] = [];
|
||||
for (let title of [m.title.native, m.title.romaji, m.title.english]) {
|
||||
if (!title?.trim().length) {
|
||||
continue;
|
||||
}
|
||||
let results;
|
||||
try {
|
||||
results = await this.mangaUpdatesApi.search(title);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && [400, 500].includes(err.statusCode)) {
|
||||
console.debug(err);
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
continue;
|
||||
} finally {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
matching = results.results
|
||||
.filter(e => stringSimilarity(title, e.record.title, 2, false) >= 0.95)
|
||||
.filter(e => allowTypes.has(e.record.type.toLowerCase())) //check if a manga or similar but not novel
|
||||
.filter(e => m.startDate.year - 1 <= e.record.year && e.record.year <= m.startDate.year + 1); //check year +-1
|
||||
if (matching.length === 0) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (matching.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await mangaStore.addMangaUpdatesRelations([{aniListMediaId: m.id, mangaUpdatesSeriesId: matching[0].record.series_id}]);
|
||||
} finally {
|
||||
await progress.onProgress('relations', ++i, media.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchSeriesUpdates(mangaStore: MangaStore, dbStore: DbStore, progress: Progress): Promise<void> {
|
||||
const relations = await dbStore.mangaUpdatesRepository.getRelations();
|
||||
|
||||
let i = 0;
|
||||
for (const relation of relations) {
|
||||
try {
|
||||
const dbSeries = await dbStore.mangaUpdatesRepository.getSeriesById(relation.mangaUpdatesSeriesId);
|
||||
if (dbSeries) { // TODO check for now - lastUpdated < 1d or sth
|
||||
continue;
|
||||
}
|
||||
|
||||
let series;
|
||||
try {
|
||||
series = await this.mangaUpdatesApi.get(relation.mangaUpdatesSeriesId);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && [400, 500].includes(err.statusCode)) {
|
||||
console.debug(err);
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
continue;
|
||||
} finally {
|
||||
// await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
await mangaStore.updateMangaUpdatesSeries([series]);
|
||||
} finally {
|
||||
await progress.onProgress('series', ++i, relations.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchSeriesChapterUpdates(mangaStore: MangaStore, dbStore: DbStore, progress: Progress): Promise<void> {
|
||||
const series = await dbStore.mangaUpdatesRepository.getSeries();
|
||||
|
||||
let i = 0;
|
||||
for (const s of series) {
|
||||
try {
|
||||
// const dbSeries = await dbStore.mangaUpdatesRepository.getSeriesById(s.series_id);
|
||||
// if (dbSeries) { // TODO check for now - lastUpdated < 1d or sth
|
||||
// continue;
|
||||
// }
|
||||
|
||||
let groups;
|
||||
try {
|
||||
groups = await this.mangaUpdatesApi.groups(s.series_id);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && [400, 500].includes(err.statusCode)) {
|
||||
console.debug(err);
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
continue;
|
||||
} finally {
|
||||
// await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
const updates = groups.release_list
|
||||
.map(r => {
|
||||
const match = r.chapter.match(/([0-9]+?)[^0-9]*$/);
|
||||
if (!match) {
|
||||
return [];
|
||||
}
|
||||
const chapter = parseInt(match[1]);
|
||||
return r.groups.map(g => ({series_id: s.series_id, group: g.name, chapter: chapter} as MangaUpdatesChapter));
|
||||
})
|
||||
.flat();
|
||||
|
||||
//only keep chapter with the highest chapter number per group
|
||||
const filtered = Array.from(Map.groupBy(updates, c => c.group).values())
|
||||
.map(chaptersOfGroup => chaptersOfGroup.reduce((l, r) => l.chapter > r.chapter ? l : r, chaptersOfGroup[0]));
|
||||
await mangaStore.updateMangaUpdatesChapters(filtered);
|
||||
} finally {
|
||||
await progress.onProgress('chapters', ++i, series.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ProgressCallBackFn = (type: string, progress: number, max: number) => (Promise<void> | void);
|
||||
export type FinishCallBackFn = () => (Promise<void> | void);
|
||||
|
||||
export class Progress {
|
||||
readonly progress: ProgressCallBackFn;
|
||||
readonly finish: FinishCallBackFn;
|
||||
|
||||
constructor(onProgress: ProgressCallBackFn, onFinish: FinishCallBackFn) {
|
||||
this.progress = onProgress;
|
||||
this.finish = onFinish;
|
||||
}
|
||||
|
||||
async onProgress(type: string, progress: number, max: number): Promise<void> {
|
||||
return (this.progress ? this.progress(type, progress, max) : Promise.resolve());
|
||||
}
|
||||
|
||||
async onFinished(): Promise<void> {
|
||||
return (this.finish ? this.finish() : Promise.resolve());
|
||||
}
|
||||
}
|
||||
74
frontend/src/locale/de.ts
Normal file
74
frontend/src/locale/de.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {messagesEn} from '@/locale/en';
|
||||
|
||||
export const messagesDe = {
|
||||
aniList: {
|
||||
username: 'AniList Benutzername',
|
||||
},
|
||||
design: 'Design',
|
||||
fetch: {
|
||||
mangaUpdates: {
|
||||
chapters: 'Kapitel',
|
||||
finished: 'Fertig',
|
||||
relations: 'Relationen',
|
||||
series: 'Kapitel',
|
||||
starting: 'Starte',
|
||||
},
|
||||
},
|
||||
locale: 'Sprache',
|
||||
locales: messagesEn.locales,
|
||||
localStorage: {
|
||||
notSupported: 'Lokaler Speicher wird nicht unterstützt. Diese Anwendung funktioniert möglicherweise nicht!',
|
||||
persistent: {
|
||||
notSupported: 'Lokaler Speicher wird nicht persistent. Das könnte in längere Wartezeiten verursachen!',
|
||||
},
|
||||
},
|
||||
manga: {
|
||||
chapters: {
|
||||
newCount: 'Neue Kapitel',
|
||||
latest: 'Letzte Kapitel',
|
||||
},
|
||||
progress: 'Fortschritt',
|
||||
score: 'Bewertung',
|
||||
status: {
|
||||
'paused': 'Pausiert',
|
||||
'completed': 'Vollendet',
|
||||
'reading': 'Lesen',
|
||||
'planning': 'Geplant',
|
||||
'dropped': 'Abgesetzt',
|
||||
},
|
||||
title: 'Titel',
|
||||
},
|
||||
mangaupdates: {
|
||||
relation: {
|
||||
found: 'gefunden',
|
||||
},
|
||||
},
|
||||
search: 'Suche',
|
||||
wakeLock: {
|
||||
notSupported: 'Wake lock wird nicht unterstützt! Das Gerät könnte Sync-Funktion unterbrechen.',
|
||||
permissionDenied: 'Wake lock ist nicht erlaubt! Das Gerät könnte Sync-Funktion unterbrechen.',
|
||||
},
|
||||
};
|
||||
|
||||
export const datetimeFormatsDe = {
|
||||
date: {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
},
|
||||
datetime: {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
},
|
||||
datetimeSeconds: {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
seconds: 'numeric',
|
||||
},
|
||||
};
|
||||
75
frontend/src/locale/en.ts
Normal file
75
frontend/src/locale/en.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export const messagesEn = {
|
||||
aniList: {
|
||||
username: 'AniList username',
|
||||
},
|
||||
design: 'Design',
|
||||
fetch: {
|
||||
mangaUpdates: {
|
||||
chapters: 'Chapters',
|
||||
finished: 'Finished',
|
||||
relations: 'Relations',
|
||||
series: 'Series',
|
||||
starting: 'Starting',
|
||||
},
|
||||
},
|
||||
locale: 'Language',
|
||||
locales: {
|
||||
'de': 'Deutsch',
|
||||
'en': 'English',
|
||||
},
|
||||
localStorage: {
|
||||
notSupported: 'Local storage is not supported. The application will probably not work!',
|
||||
persistent: {
|
||||
notSupported: 'Local storage will not be persisted. This could result in longer loading times!',
|
||||
},
|
||||
},
|
||||
manga: {
|
||||
chapters: {
|
||||
newCount: 'New chapters',
|
||||
latest: 'Latest chapters',
|
||||
},
|
||||
progress: 'Progress',
|
||||
score: 'Score',
|
||||
status: {
|
||||
'paused': 'Paused',
|
||||
'completed': 'Completed',
|
||||
'reading': 'Reading',
|
||||
'planning': 'Planning',
|
||||
'dropped': 'Dropped',
|
||||
},
|
||||
title: 'Title',
|
||||
},
|
||||
mangaupdates: {
|
||||
relation: {
|
||||
found: 'found',
|
||||
},
|
||||
},
|
||||
search: 'Search',
|
||||
wakeLock: {
|
||||
notSupported: 'Wake lock is not supported! The device could pause the sync function.',
|
||||
permissionDenied: 'Wake lock is not allowed! The device could pause the sync function.',
|
||||
},
|
||||
};
|
||||
|
||||
export const datetimeFormatsEn = {
|
||||
date: {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
},
|
||||
datetime: {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
},
|
||||
datetimeSeconds: {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
seconds: 'numeric',
|
||||
},
|
||||
};
|
||||
31
frontend/src/locale/locale.ts
Normal file
31
frontend/src/locale/locale.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {I18n} from 'vue-i18n';
|
||||
import {createI18n as vueCreateI18n} from 'vue-i18n';
|
||||
import {datetimeFormatsEn, messagesEn} from '@/locale/en';
|
||||
import {datetimeFormatsDe, messagesDe} from '@/locale/de';
|
||||
|
||||
const messages = {
|
||||
en: messagesEn,
|
||||
de: messagesDe,
|
||||
};
|
||||
|
||||
const datetimeFormats = {
|
||||
en: datetimeFormatsEn,
|
||||
de: datetimeFormatsDe,
|
||||
};
|
||||
|
||||
export function createI18n(): I18n {
|
||||
let browserLocale = (navigator.language ?? '').toLowerCase();
|
||||
const match = browserLocale.match(/^([a-z][a-z])/);
|
||||
if (match) {
|
||||
browserLocale = match[1];
|
||||
} else {
|
||||
browserLocale = 'en';
|
||||
}
|
||||
return vueCreateI18n({
|
||||
locale: browserLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages: messages,
|
||||
datetimeFormats: datetimeFormats,
|
||||
legacy: true,
|
||||
});
|
||||
}
|
||||
26
frontend/src/main.ts
Normal file
26
frontend/src/main.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {createApp} from 'vue';
|
||||
import {createPinia} from 'pinia';
|
||||
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
import './assets/main.css';
|
||||
import './assets/bootstrap.extensions.scss';
|
||||
import 'bootstrap/dist/css/bootstrap.css';
|
||||
// noinspection ES6UnusedImports
|
||||
import * as bootstrap from 'bootstrap';
|
||||
// noinspection ES6UnusedImports
|
||||
import * as popper from '@popperjs/core';
|
||||
import '@fortawesome/fontawesome-free/css/all.css';
|
||||
import {createI18n} from '@/locale/locale';
|
||||
import 'vue3-toastify/dist/index.css';
|
||||
|
||||
import './polyfill'
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.use(createI18n());
|
||||
|
||||
app.mount('#app');
|
||||
5
frontend/src/polyfill.ts
Normal file
5
frontend/src/polyfill.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/// reference <../types/polyfill.d.ts>
|
||||
|
||||
import groupBy from '@/util';
|
||||
|
||||
Map.groupBy = groupBy;
|
||||
23
frontend/src/router/index.ts
Normal file
23
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {createRouter, createWebHistory} from 'vue-router';
|
||||
import HomeView from '../views/HomeView.vue';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('../views/AboutView.vue'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
28
frontend/src/stores/DbStore.ts
Normal file
28
frontend/src/stores/DbStore.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {Pinia, Store} from 'pinia-class-component';
|
||||
import AniListMangaRepository from '@/data/repository/aniList/AniListMangaRepository';
|
||||
import AniListUserRepository from '@/data/repository/aniList/AniListUserRepository';
|
||||
import MangaUpdatesRepository from '@/data/repository/mangaUpdates/MangaUpdatesRepository';
|
||||
|
||||
@Store({
|
||||
id: 'DbStore',
|
||||
name: 'DbStore',
|
||||
})
|
||||
export class DbStore extends Pinia {
|
||||
//data
|
||||
private readonly intAniListMangaRepository = new AniListMangaRepository();
|
||||
private readonly intAniListUserRepository = new AniListUserRepository();
|
||||
private readonly intMangaUpdatesRepository = new MangaUpdatesRepository();
|
||||
|
||||
//getter
|
||||
get aniListMangaRepository(): AniListMangaRepository {
|
||||
return this.intAniListMangaRepository;
|
||||
}
|
||||
|
||||
get aniListUserRepository(): AniListUserRepository {
|
||||
return this.intAniListUserRepository;
|
||||
}
|
||||
|
||||
get mangaUpdatesRepository(): MangaUpdatesRepository {
|
||||
return this.intMangaUpdatesRepository;
|
||||
}
|
||||
}
|
||||
162
frontend/src/stores/MangaStore.ts
Normal file
162
frontend/src/stores/MangaStore.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {Pinia, Store} from 'pinia-class-component';
|
||||
import type {AniListUser} from '@/data/models/anilist/AniListUser';
|
||||
import {DbStore} from '@/stores/DbStore';
|
||||
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
|
||||
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
|
||||
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
|
||||
import type {MangaUpdatesRelation} from '@/data/models/mangaupdates/MangaUpdatesRelation';
|
||||
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
|
||||
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
|
||||
|
||||
@Store({
|
||||
id: 'MangaStore',
|
||||
name: 'MangaStore',
|
||||
})
|
||||
export class MangaStore extends Pinia {
|
||||
//update trigger
|
||||
private cachedAniListLists: AniListMangaList[] = [];
|
||||
private cachedAniListManga = new Map<string, AniListMangaListEntry[]>();
|
||||
private cachedAniListMedia: AniListMedia[] = [];
|
||||
private cachedAniListUser: AniListUser | null = null;
|
||||
private cachedMangaUpdatesChapters: MangaUpdatesChapter[] = [];
|
||||
private cachedMangaUpdatesSeries: MangaUpdatesSeries[] = [];
|
||||
private cachedMangaUpdatesRelations: MangaUpdatesRelation[] = [];
|
||||
private cachedUserName: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.updateUserName(window.localStorage.getItem('userName'));
|
||||
}
|
||||
|
||||
//getter
|
||||
get aniListLists(): AniListMangaList[] {
|
||||
return this.cachedAniListLists;
|
||||
}
|
||||
|
||||
get aniListManga(): Map<string, AniListMangaListEntry[]> {
|
||||
return this.cachedAniListManga;
|
||||
}
|
||||
|
||||
get aniListMedia(): AniListMedia[] {
|
||||
return this.cachedAniListMedia;
|
||||
}
|
||||
|
||||
get aniListUser(): AniListUser | null {
|
||||
return this.cachedAniListUser;
|
||||
}
|
||||
|
||||
get mangaUpdatesChapters(): MangaUpdatesChapter[] {
|
||||
return this.cachedMangaUpdatesChapters;
|
||||
}
|
||||
|
||||
get mangaUpdatesRelations(): MangaUpdatesRelation[] {
|
||||
return this.cachedMangaUpdatesRelations;
|
||||
}
|
||||
|
||||
get mangaUpdatesSeries(): MangaUpdatesSeries[] {
|
||||
return this.cachedMangaUpdatesSeries;
|
||||
}
|
||||
|
||||
get userName(): string | null {
|
||||
return this.cachedUserName;
|
||||
}
|
||||
|
||||
//stores
|
||||
private get dbStore(): DbStore {
|
||||
return new DbStore();
|
||||
}
|
||||
|
||||
//actions
|
||||
async updateAniListLists(userId: number, lists: AniListMangaList[]): Promise<void> {
|
||||
await this.dbStore.aniListMangaRepository.patchLists(userId, lists);
|
||||
if (this.aniListUser?.id === userId) {
|
||||
this.cachedAniListLists.splice(0);
|
||||
this.cachedAniListLists.push(...lists);
|
||||
}
|
||||
}
|
||||
|
||||
async updateAniListManga(userId: number, manga: Map<string, AniListMangaListEntry[]>): Promise<void> {
|
||||
await this.dbStore.aniListMangaRepository.patchManga(userId, manga);
|
||||
if (this.aniListUser?.id === userId) {
|
||||
this.cachedAniListManga.clear();
|
||||
manga.forEach((v, k) => this.cachedAniListManga.set(k, v));
|
||||
}
|
||||
}
|
||||
|
||||
async updateAniListMedia(media: AniListMedia[]): Promise<void> {
|
||||
await this.dbStore.aniListMangaRepository.updateMedia(media);
|
||||
this.cachedAniListMedia.splice(0);
|
||||
this.cachedAniListMedia.push(...media);
|
||||
}
|
||||
|
||||
async updateAniListUser(user: AniListUser): Promise<void> {
|
||||
await this.dbStore.aniListUserRepository.updateUser(user);
|
||||
if (user.name === this.userName) {
|
||||
this.cachedAniListUser = user;
|
||||
}
|
||||
}
|
||||
|
||||
async updateMangaUpdatesChapters(chapters: MangaUpdatesChapter[]): Promise<void> {
|
||||
await this.dbStore.mangaUpdatesRepository.updateChapters(chapters);
|
||||
|
||||
// update cache
|
||||
const cachedById = Map.groupBy(this.cachedMangaUpdatesChapters, c => c.series_id);
|
||||
const chaptersById = Map.groupBy(chapters, c => c.series_id);
|
||||
chaptersById.forEach((v, k) => cachedById.set(k, v));
|
||||
this.cachedMangaUpdatesChapters.splice(0);
|
||||
this.cachedMangaUpdatesChapters.push(...Array.from(cachedById.values()).flat());
|
||||
}
|
||||
|
||||
async updateMangaUpdatesSeries(media: MangaUpdatesSeries[]): Promise<void> {
|
||||
await this.dbStore.mangaUpdatesRepository.updateSeries(media);
|
||||
|
||||
// update cache
|
||||
const cachedById = new Map<number, MangaUpdatesSeries>(this.cachedMangaUpdatesSeries.map(e => [e.series_id, e]));
|
||||
media.forEach(m => cachedById.set(m.series_id, m));
|
||||
this.cachedMangaUpdatesSeries.splice(0);
|
||||
this.cachedMangaUpdatesSeries.push(...Array.from(cachedById.values()));
|
||||
}
|
||||
|
||||
async addMangaUpdatesRelations(relations: MangaUpdatesRelation[]): Promise<void> {
|
||||
await this.dbStore.mangaUpdatesRepository.addRelations(relations);
|
||||
this.cachedMangaUpdatesRelations.push(...relations);
|
||||
}
|
||||
|
||||
updateUserName(userName: string | null): void {
|
||||
if (userName) {
|
||||
window.localStorage.setItem('userName', userName ?? '');
|
||||
} else {
|
||||
window.localStorage.removeItem('userName');
|
||||
}
|
||||
this.clearCache();
|
||||
this.cachedUserName = userName;
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cachedAniListManga.clear();
|
||||
this.cachedAniListLists.splice(0);
|
||||
this.cachedAniListMedia.splice(0);
|
||||
this.cachedAniListUser = null;
|
||||
this.cachedMangaUpdatesChapters.splice(0);
|
||||
this.cachedMangaUpdatesSeries.splice(0);
|
||||
this.cachedMangaUpdatesRelations.splice(0);
|
||||
}
|
||||
|
||||
async reloadCache(): Promise<void> {
|
||||
this.clearCache();
|
||||
|
||||
this.cachedAniListMedia.push(...await this.dbStore.aniListMangaRepository.getMedia());
|
||||
if (this.userName) {
|
||||
this.cachedAniListUser = await this.dbStore.aniListUserRepository.getUser(this.userName);
|
||||
if (this.aniListUser) {
|
||||
this.cachedAniListLists.push(...await this.dbStore.aniListMangaRepository.getMangaLists(this.aniListUser.id));
|
||||
const manga = await this.dbStore.aniListMangaRepository.getManga(this.aniListUser.id);
|
||||
manga.forEach((v, k) => this.cachedAniListManga.set(k, v));
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedMangaUpdatesChapters.push(...await this.dbStore.mangaUpdatesRepository.getChapters());
|
||||
this.cachedMangaUpdatesSeries.push(...await this.dbStore.mangaUpdatesRepository.getSeries());
|
||||
this.cachedMangaUpdatesRelations.push(...await this.dbStore.mangaUpdatesRepository.getRelations());
|
||||
}
|
||||
}
|
||||
22
frontend/src/stores/ServiceStore.ts
Normal file
22
frontend/src/stores/ServiceStore.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {Pinia, Store} from 'pinia-class-component';
|
||||
import AniListDataService from '@/data/service/AniListDataService';
|
||||
import MangaUpdatesDataService from '@/data/service/MangaUpdatesDataService';
|
||||
|
||||
@Store({
|
||||
id: 'ServiceStore',
|
||||
name: 'ServiceStore',
|
||||
})
|
||||
export class ServiceStore extends Pinia {
|
||||
//data
|
||||
private readonly intAniListDataService = new AniListDataService();
|
||||
private readonly intMangaUpdatesDataService = new MangaUpdatesDataService();
|
||||
|
||||
//getter
|
||||
get aniListDataService(): AniListDataService {
|
||||
return this.intAniListDataService;
|
||||
}
|
||||
|
||||
get mangaUpdatesDataService(): MangaUpdatesDataService {
|
||||
return this.intMangaUpdatesDataService;
|
||||
}
|
||||
}
|
||||
11
frontend/src/util.ts
Normal file
11
frontend/src/util.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function groupBy<K, V>(arr: V[], fn: (v: V) => K): Map<K, V[]> {
|
||||
const map = new Map<K, V[]>();
|
||||
arr.forEach(e => {
|
||||
const key = fn(e);
|
||||
if (!map.has(key)) {
|
||||
map.set(key, [] as V[]);
|
||||
}
|
||||
map.get(key)!.push(e);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
15
frontend/src/views/AboutView.vue
Normal file
15
frontend/src/views/AboutView.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.about {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
frontend/src/views/HomeView.vue
Normal file
15
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import MangaLists from '@/components/manga/MangaLists.vue';
|
||||
|
||||
@Options({
|
||||
name: 'HomeView',
|
||||
components: {MangaLists},
|
||||
})
|
||||
export default class HomeView extends Vue {
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MangaLists/>
|
||||
</template>
|
||||
13
frontend/tsconfig.app.json
Normal file
13
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
frontend/tsconfig.json
Normal file
14
frontend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
17
frontend/tsconfig.node.json
Normal file
17
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "@tsconfig/node18/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
10
frontend/tsconfig.vitest.json
Normal file
10
frontend/tsconfig.vitest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"composite": true,
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
3
frontend/types/polyfill.d.ts
vendored
Normal file
3
frontend/types/polyfill.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare interface MapConstructor {
|
||||
groupBy<K, V>(arr: V[], fn: (v: V) => K): Map<K, V[]>;
|
||||
}
|
||||
48
frontend/vite.config.ts
Normal file
48
frontend/vite.config.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {fileURLToPath, URL} from 'node:url';
|
||||
|
||||
import {defineConfig} from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(), VueI18nPlugin({compositionOnly: false}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
manifest: true,
|
||||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: ['./src/main.ts', './index.html'],
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
proxy: {
|
||||
'/graphql': {
|
||||
target: 'https://graphql.anilist.co',
|
||||
changeOrigin: true,
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('proxyRes', proxyRes => {
|
||||
delete proxyRes.headers['set-cookie'];
|
||||
});
|
||||
},
|
||||
},
|
||||
'^/mangaupdates/.*$': {
|
||||
target: 'https://api.mangaupdates.com',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/mangaupdates/, ''),
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('proxyRes', proxyRes => {
|
||||
delete proxyRes.headers['set-cookie'];
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
14
frontend/vitest.config.ts
Normal file
14
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {configDefaults, defineConfig, mergeConfig} from 'vitest/config';
|
||||
import viteConfig from './vite.config';
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/*'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user