Compare commits
174 Commits
76304bfe61
...
vite-react
Author | SHA1 | Date | |
---|---|---|---|
b8350602c5 | |||
b4e25a9f54 | |||
5c78896a5d | |||
6bee507d93 | |||
7ec740da82 | |||
e253ae3d20 | |||
84f9dc176e | |||
84e9d70d34 | |||
95f4086c24 | |||
4c81c571a4 | |||
b4b4479e9b | |||
d6eeae4021 | |||
6b05c4fb00 | |||
d785b6e078 | |||
e64f325022 | |||
486f6f60fb | |||
01c732f6a8 | |||
e5641e0690 | |||
b7ee80b4a4 | |||
9f8d874822 | |||
a273af5ed9 | |||
be704d922a | |||
692bf3561b | |||
e0ec64882b | |||
496e6fdde8 | |||
49fe38962d | |||
645d3c8793 | |||
2ef7ee6b39 | |||
e1b590482c | |||
94e58d2d1b | |||
d5c85efcb2 | |||
aed0a562f0 | |||
46729bade3 | |||
fed7ff987e | |||
ddaa066dc7 | |||
a6d2de35cf | |||
7cdc31fedb | |||
dcf596df84 | |||
855fe91e69 | |||
c00c80503c | |||
4e648bd04e | |||
35e2e6ce16 | |||
dbdb3c0168 | |||
dae653c2b4 | |||
e9a2a561cf | |||
5fe3ead54d | |||
6d8d0ec636 | |||
724dc9b986 | |||
a59920f09d | |||
5c5f810127 | |||
181d591344 | |||
0193077377 | |||
9fa57eacdb | |||
c4cc21f866 | |||
f391ace9b2 | |||
a0d15e08a7 | |||
11549a07c3 | |||
79c13ceb1d | |||
0907031583 | |||
00932bf6bd | |||
594103689e | |||
6ab431fde0 | |||
bbad467fe6 | |||
03c78f7b6a | |||
3ad7fbfad2 | |||
6173d16edc | |||
8a07818b89 | |||
6aa53f7ca7 | |||
415e6c606f | |||
178e8b72f3 | |||
65457c289f | |||
6109bb678a | |||
cc14c5adad | |||
8c0f61fc6a | |||
82389c4847 | |||
a91452ee8c | |||
698e16cc2b | |||
4e20b95d77 | |||
18257cd584 | |||
5626a9d0fd | |||
c6dc3cd107 | |||
94c47b904d | |||
10f48af9fa | |||
5e95c34306 | |||
66dcb2f1e6 | |||
19e033995d | |||
99265bacb2 | |||
5385dfd918 | |||
d6e4d1d27f | |||
288cd77049 | |||
e605578a34 | |||
6340c5ad03 | |||
6d402357e7 | |||
9e0eb0262e | |||
63b220bd79 | |||
d480f62e51 | |||
44755675e5 | |||
6b10aa8926 | |||
48b669dc07 | |||
fbb25807e6 | |||
44f43b7152 | |||
68653778c8 | |||
fd4c01203f | |||
2a4316cf1d | |||
d358611133 | |||
b0cbb416d6 | |||
e3ad70c0a9 | |||
f38290b2ed | |||
211db3d4d5 | |||
a3046680ac | |||
1ed376ba47 | |||
63b10600da | |||
5333c025f7 | |||
187dd22027 | |||
2092db2ba3 | |||
ecd76712d8 | |||
60f957ede2 | |||
fcd232bec9 | |||
357fca3bd5 | |||
1d8dd7381d | |||
0b0abb3801 | |||
62665f5660 | |||
007b49c624 | |||
d9bbbed1c0 | |||
42a1e1a2ce | |||
aad18c0195 | |||
1eef710efc | |||
84520d8e18 | |||
89586ef891 | |||
33514ee375 | |||
526e129fb7 | |||
d1dae83387 | |||
9199a7a0e4 | |||
d9fa4185e2 | |||
717c8cd33e | |||
483c2d564b | |||
5807844281 | |||
77d9e6eee1 | |||
e45e7bd5f5 | |||
ccab3a8027 | |||
74b71d57b7 | |||
6a5e340882 | |||
b70dfbf482 | |||
0bc93a7e5c | |||
ddd9512469 | |||
c26e208822 | |||
c656e5face | |||
4addacb229 | |||
c1aece8200 | |||
6bd9a0b1db | |||
c72e152a7e | |||
38572a3a46 | |||
5b52577610 | |||
3b99dbd487 | |||
3eb7b63fa6 | |||
793561dad7 | |||
acb22d770d | |||
e0093c65ff | |||
ecf9e1a243 | |||
5af3005179 | |||
d33ac16c7d | |||
a2e0a2375a | |||
9c0a7a0a50 | |||
d05b8ea76b | |||
e9937c02bf | |||
ef5524d7b6 | |||
8f9700ec02 | |||
4b86cd7104 | |||
61e3fbd500 | |||
657a3d1857 | |||
b4cf22b6eb | |||
2216e13c49 | |||
0a68da2b49 | |||
b87c159823 |
12
.github/workflows/docker-image-cuttingedge.yml
vendored
@ -3,6 +3,8 @@ name: Docker Image CI
|
||||
on:
|
||||
push:
|
||||
branches: [ "cuttingedge" ]
|
||||
pull_request:
|
||||
branches: [ "cuttingedge" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@ -17,12 +19,12 @@ jobs:
|
||||
|
||||
# https://github.com/docker/setup-qemu-action#usage
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
uses: docker/setup-qemu-action@v3.6.0
|
||||
|
||||
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3.8.0
|
||||
uses: docker/setup-buildx-action@v3.11.1
|
||||
|
||||
# https://github.com/docker/login-action#docker-hub
|
||||
- name: Login to Docker Hub
|
||||
@ -33,12 +35,12 @@ jobs:
|
||||
|
||||
# https://github.com/docker/build-push-action#multi-platform-image
|
||||
- name: Build and push Website
|
||||
uses: docker/build-push-action@v6.11.0
|
||||
uses: docker/build-push-action@v6.18.0
|
||||
with:
|
||||
context: ./Website
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
|
10
.github/workflows/docker-image-master.yml
vendored
@ -17,12 +17,12 @@ jobs:
|
||||
|
||||
# https://github.com/docker/setup-qemu-action#usage
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
uses: docker/setup-qemu-action@v3.6.0
|
||||
|
||||
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3.8.0
|
||||
uses: docker/setup-buildx-action@v3.11.1
|
||||
|
||||
# https://github.com/docker/login-action#docker-hub
|
||||
- name: Login to Docker Hub
|
||||
@ -33,12 +33,12 @@ jobs:
|
||||
|
||||
# https://github.com/docker/build-push-action#multi-platform-image
|
||||
- name: Build and push Website
|
||||
uses: docker/build-push-action@v6.11.0
|
||||
uses: docker/build-push-action@v6.18.0
|
||||
with:
|
||||
context: ./Website
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
|
@ -2,7 +2,7 @@ name: Docker Image CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "cuttingedge" ]
|
||||
branches: [ "vite-react-ts" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@ -17,12 +17,12 @@ jobs:
|
||||
|
||||
# https://github.com/docker/setup-qemu-action#usage
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
uses: docker/setup-qemu-action@v3.6.0
|
||||
|
||||
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3.8.0
|
||||
uses: docker/setup-buildx-action@v3.11.1
|
||||
|
||||
# https://github.com/docker/login-action#docker-hub
|
||||
- name: Login to Docker Hub
|
||||
@ -33,13 +33,13 @@ jobs:
|
||||
|
||||
# https://github.com/docker/build-push-action#multi-platform-image
|
||||
- name: Build and push Website
|
||||
uses: docker/build-push-action@v6.11.0
|
||||
uses: docker/build-push-action@v6.18.0
|
||||
with:
|
||||
context: ./Website
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
glax/tranga-website:dev
|
||||
glax/tranga-website:Server-V2
|
19
Dockerfile
@ -1,4 +1,19 @@
|
||||
FROM nginx:alpine3.17-slim
|
||||
COPY . /usr/share/nginx/html
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./tranga-website /app
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
# Serve stage
|
||||
FROM nginx:alpine3.17-slim
|
||||
|
||||
# Copy built files from Vite's dist folder
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
#COPY --from=builder /app/tranga-website/media /usr/share/nginx/html/media
|
||||
COPY ./nginx /etc/nginx
|
||||
|
||||
EXPOSE 80
|
||||
ENV API_URL=http://tranga-api:6531
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
91
README.md
@ -1,16 +1,6 @@
|
||||
<!-- PROJECT SHIELDS -->
|
||||
<!--
|
||||
*** I'm using markdown "reference style" links for readability.
|
||||
*** Reference links are enclosed in brackets [ ] instead of parentheses ( ).
|
||||
*** See the bottom of this document for the declaration of the reference variables
|
||||
*** for contributors-url, forks-url, etc. This is an optional, concise syntax you may use.
|
||||
*** https://www.markdownguide.org/basic-syntax/#reference-style-links
|
||||
-->
|
||||
|
||||
<!-- PROJECT LOGO -->
|
||||
<br />
|
||||
<div align="center">
|
||||
|
||||
<span id="readme-top"></span>
|
||||
<h3 align="center">Tranga-Website</h3>
|
||||
|
||||
<p align="center">
|
||||
@ -19,42 +9,43 @@
|
||||
<p align="center">
|
||||
This is the Website for <a href="https://github.com/C9Glax/tranga">Tranga</a> (API)
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga-website/master?label=master"></th>
|
||||
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga-website%2Factions%2Fworkflows%2Fdocker-image-master.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga-website/cuttingedge?label=cuttingedge"></th>
|
||||
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga-website%2Factions%2Fworkflows%2Fdocker-image-cuttingedge.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga-website/vite-react-ts?label=vite-react-ts"></th>
|
||||
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga-website%2Factions%2Fworkflows%2Fdocker-image-vite-react-ts.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- TABLE OF CONTENTS -->
|
||||
<details>
|
||||
<summary>Table of Contents</summary>
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#about-the-project">About The Project</a>
|
||||
<ul>
|
||||
<li><a href="#built-with">Built With</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#getting-started">Getting Started</a>
|
||||
</li>
|
||||
<li><a href="#roadmap">Roadmap</a></li>
|
||||
<li><a href="#contributing">Contributing</a></li>
|
||||
<li><a href="#license">License</a></li>
|
||||
<li><a href="#acknowledgments">Acknowledgments</a></li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
## Screenshots
|
||||
|
||||
|  |  |  |
|
||||
|-------------------------------------------------------------------------------------|----------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|
|
||||
|  |  |  |
|
||||
|
||||
## About The Project
|
||||
|
||||
Tranga-Website is the Web-frontend to [Tranga](https://github.com/C9Glax/tranga) (the API). It displays information aquired from Tranga and can create Jobs (Manga-Downloads).
|
||||
Tranga-Website is the Web-frontend to [Tranga](https://github.com/C9Glax/tranga) (the API).
|
||||
|
||||
### What this does do (and nothing else)
|
||||
|
||||
This repo makes HTTP-requests to the [Tranga-API](https://github.com/C9Glax/tranga) to display it's present configuration.
|
||||
This project makes HTTP-requests to the [Tranga-API](https://github.com/C9Glax/tranga) to display and modify the present configuration.
|
||||
|
||||
### Built With
|
||||
## Built With
|
||||
|
||||
- nginx
|
||||
- vite
|
||||
@ -67,39 +58,17 @@ This repo makes HTTP-requests to the [Tranga-API](https://github.com/C9Glax/tran
|
||||
<!-- GETTING STARTED -->
|
||||
## Getting Started
|
||||
|
||||
There is a single release:
|
||||
|
||||
|
||||
### Docker
|
||||
|
||||
Download [docker-compose.yaml](https://github.com/C9Glax/tranga-website/blob/cuttingedge/docker-compose.yaml) and configure to your needs.
|
||||
The `docker-compose` also includes [Tranga](https://github.com/C9Glax/tranga) as backend. For its configuration refer to the repo README.
|
||||
|
||||
Go to [Tranga](https://github.com/C9Glax/tranga?tab=readme-ov-file#getting-started) and read the README there.
|
||||
|
||||
<!-- CONTRIBUTING -->
|
||||
## Contributing
|
||||
|
||||
The following is copy & pasted:
|
||||
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement".
|
||||
Don't forget to give the project a star! Thanks again!
|
||||
|
||||
1. Fork the Project
|
||||
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
|
||||
Go to [Tranga](https://github.com/C9Glax/tranga?tab=readme-ov-file#contributing) and read the README there.
|
||||
|
||||
<!-- LICENSE -->
|
||||
## License
|
||||
|
||||
Distributed under the GNU GPLv3 License. See `LICENSE.txt` for more information.
|
||||
See `LICENSE.txt` for more information.
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
|
BIN
Screenshots/Screenshot 2025-06-17 at 01-15-08 Tranga.png
Normal file
After Width: | Height: | Size: 1.7 MiB |
BIN
Screenshots/Screenshot 2025-06-17 at 01-15-23 Tranga.png
Normal file
After Width: | Height: | Size: 522 KiB |
BIN
Screenshots/Screenshot 2025-06-17 at 01-15-36 Tranga.png
Normal file
After Width: | Height: | Size: 876 KiB |
BIN
Screenshots/Screenshot 2025-06-17 at 01-16-32 Tranga.png
Normal file
After Width: | Height: | Size: 890 KiB |
BIN
Screenshots/Screenshot 2025-06-17 at 01-16-43 Tranga.png
Normal file
After Width: | Height: | Size: 718 KiB |
BIN
Screenshots/Screenshot 2025-06-17 at 01-16-54 Tranga.png
Normal file
After Width: | Height: | Size: 456 KiB |
BIN
Screenshots/Screenshot 2025-06-17 at 01-17-16 Tranga.png
Normal file
After Width: | Height: | Size: 555 KiB |
BIN
Screenshots/Screenshot 2025-06-17 at 01-17-37 Tranga.png
Normal file
After Width: | Height: | Size: 544 KiB |
132
Website/App.tsx
@ -1,132 +0,0 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import Footer from "./modules/Footer";
|
||||
import Search from "./modules/Search";
|
||||
import Header from "./modules/Header";
|
||||
import MonitorJobsList from "./modules/MonitorJobsList";
|
||||
import './styles/index.css'
|
||||
import IFrontendSettings, {LoadFrontendSettings} from "./modules/interfaces/IFrontendSettings";
|
||||
import {useCookies} from "react-cookie";
|
||||
|
||||
export default function App(){
|
||||
const [, setCookie] = useCookies(['apiUri', 'jobInterval']);
|
||||
const [connected, setConnected] = React.useState(false);
|
||||
const [showSearch, setShowSearch] = React.useState(false);
|
||||
const [frontendSettings, setFrontendSettings] = useState<IFrontendSettings>(LoadFrontendSettings());
|
||||
const [updateInterval, setUpdateInterval] = React.useState<number>();
|
||||
const [updateMonitorList, setUpdateMonitorList] = React.useState<Date>(new Date());
|
||||
|
||||
const apiUri = frontendSettings.apiUri;
|
||||
|
||||
useEffect(() => {
|
||||
checkConnection(apiUri).then(res => setConnected(res)).catch(() => setConnected(false));
|
||||
if(updateInterval === undefined){
|
||||
setUpdateInterval(setInterval(() => {
|
||||
checkConnection(apiUri).then(res => setConnected(res)).catch(() => setConnected(false));
|
||||
}, 500));
|
||||
}else{
|
||||
clearInterval(updateInterval);
|
||||
setUpdateInterval(undefined);
|
||||
}
|
||||
}, [frontendSettings]);
|
||||
|
||||
function ChangeSettings(settings: IFrontendSettings) {
|
||||
setFrontendSettings(settings);
|
||||
setCookie('apiUri', settings.apiUri);
|
||||
setCookie('jobInterval', settings.jobInterval);
|
||||
}
|
||||
|
||||
const UpdateList = () => {setUpdateMonitorList(new Date())}
|
||||
|
||||
return(<div>
|
||||
<Header apiUri={apiUri} backendConnected={connected} settings={frontendSettings} changeSettings={ChangeSettings}/>
|
||||
{connected
|
||||
? <>
|
||||
{showSearch
|
||||
? <>
|
||||
<Search apiUri={apiUri} jobInterval={frontendSettings.jobInterval} onJobsChanged={UpdateList} closeSearch={() => setShowSearch(false)} />
|
||||
<hr/>
|
||||
</>
|
||||
: <></>}
|
||||
<MonitorJobsList updateList={updateMonitorList} apiUri={apiUri} onStartSearch={() => setShowSearch(true)} onJobsChanged={UpdateList} connectedToBackend={connected} />
|
||||
</>
|
||||
: <>
|
||||
<h1>No connection to the Backend.</h1>
|
||||
<h3>Check the Settings ApiUri.</h3>
|
||||
</>}
|
||||
<Footer apiUri={apiUri} connectedToBackend={connected} />
|
||||
</div>)
|
||||
}
|
||||
|
||||
export function getData(uri: string) : Promise<object> {
|
||||
return fetch(uri,
|
||||
{
|
||||
method: 'GET',
|
||||
headers : {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response){
|
||||
if(!response.ok) throw new Error("Could not fetch data");
|
||||
return response.json();
|
||||
})
|
||||
.catch(function(err){
|
||||
console.error(`Error GETting Data ${uri}\n${err}`);
|
||||
return Promise.reject();
|
||||
});
|
||||
}
|
||||
|
||||
export function postData(uri: string, content: object) : Promise<object> {
|
||||
return fetch(uri,
|
||||
{
|
||||
method: 'POST',
|
||||
headers : {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(content)
|
||||
})
|
||||
.then(function(response){
|
||||
if(!response.ok)
|
||||
throw new Error("Could not fetch data");
|
||||
let json = response.json();
|
||||
return json.then((json) => json).catch(() => null);
|
||||
})
|
||||
.catch(function(err){
|
||||
console.error(`Error POSTing Data ${uri}\n${err}`);
|
||||
return Promise.reject();
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteData(uri: string) : Promise<void> {
|
||||
return fetch(uri,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers : {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(() =>{
|
||||
return Promise.resolve();
|
||||
})
|
||||
.catch(function(err){
|
||||
console.error(`Error DELETEing Data ${uri}\n${err}`);
|
||||
return Promise.reject();
|
||||
});
|
||||
}
|
||||
|
||||
export function isValidUri(uri: string) : boolean{
|
||||
try {
|
||||
new URL(uri);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const checkConnection = async (apiUri: string): Promise<boolean> =>{
|
||||
return getData(`${apiUri}/v2/Ping`).then((result) => {
|
||||
return result != null;
|
||||
}).catch(() => Promise.reject());
|
||||
}
|
Before Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 63 KiB |
Before Width: | Height: | Size: 440 B |
@ -1,113 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
height="512pt"
|
||||
viewBox="0 0 512 512"
|
||||
width="512pt"
|
||||
version="1.1"
|
||||
id="svg4586"
|
||||
sodipodi:docname="komga - Copy.svg"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
|
||||
<metadata
|
||||
id="metadata4592">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs4590">
|
||||
<linearGradient
|
||||
id="linearGradient6082"
|
||||
osb:paint="solid">
|
||||
<stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop6080"/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linearGradient6076"
|
||||
osb:paint="solid">
|
||||
<stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop6074"/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient6082"
|
||||
id="linearGradient6084"
|
||||
x1="77.866812"
|
||||
y1="386.00679"
|
||||
x2="217.20259"
|
||||
y2="386.00679"
|
||||
gradientUnits="userSpaceOnUse"/>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1656"
|
||||
inkscape:window-height="1368"
|
||||
id="namedview4588"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1.2512475"
|
||||
inkscape:cx="264.73114"
|
||||
inkscape:cy="305.20589"
|
||||
inkscape:window-x="-7"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg4586"/>
|
||||
<path
|
||||
d="m512 256c0 141.386719-114.613281 256-256 256s-256-114.613281-256-256 114.613281-256 256-256 256 114.613281 256 256zm0 0"
|
||||
fill="#005ed3"
|
||||
id="path4556"/>
|
||||
<path
|
||||
d="m 512,256 c 0,-11.71094 -0.80469,-23.23047 -2.32422,-34.52344 L 382.48047,94.28125 320.52344,121.85938 256,56.933594 212.69531,131.30469 129.51953,94.28125 141.86719,178.42187 49.949219,193.81641 114.32031,256 l -64.371091,62.18359 82.121091,82.16016 -2.55078,17.375 91.95703,91.95703 C 232.76953,511.19531 244.28906,512 256,512 397.38672,512 512,397.38672 512,256 Z"
|
||||
id="path4558"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#00459f"
|
||||
sodipodi:nodetypes="scccccccccccccss"/>
|
||||
<path
|
||||
d="m256 86.742188 37.109375 63.738281 70.574219-31.414063-10.527344 71.71875 77.078125 12.910156-54.144531 52.304688 54.144531 52.304688-77.078125 12.910156 10.527344 71.71875-70.574219-31.414063-37.109375 63.738281-37.109375-63.738281-70.574219 31.414063 10.527344-71.71875-77.078125-12.910156 54.144531-52.304688-54.144531-52.304688 77.078125-12.910156-10.527344-71.71875 70.574219 31.414063zm0 0"
|
||||
fill="#ff0335"
|
||||
id="path4560"/>
|
||||
<path
|
||||
d="m430.230469 308.300781-77.070313 12.910157 10.519532 71.71875-70.570313-31.410157-37.109375 63.742188v-338.523438l37.109375 63.742188 70.570313-31.410157-6.757813 46.101563-3.761719 25.617187 58.800782 9.851563 18.269531 3.058594-13.390625 12.929687-40.75 39.371094 11.378906 10.988281zm0 0"
|
||||
fill="#c2001b"
|
||||
id="path4562"/>
|
||||
<path
|
||||
d="m256 455.066406-43.304688-74.371094-83.175781 37.023438 12.347657-84.140625-91.917969-15.394531 64.371093-62.183594-64.371093-62.183594 91.917969-15.394531-12.347657-84.140625 83.179688 37.023438 43.300781-74.371094 43.304688 74.371094 83.175781-37.023438-12.347657 84.140625 91.917969 15.394531-64.371093 62.183594 64.371093 62.183594-91.917969 15.398437 12.347657 84.136719-83.175781-37.023438zm-30.917969-112.722656 30.917969 53.101562 30.917969-53.101562 57.964843 25.800781-8.703124-59.292969 62.238281-10.425781-43.917969-42.425781 43.917969-42.425781-62.238281-10.425781 8.703124-59.292969-57.964843 25.800781-30.917969-53.101562-30.917969 53.101562-57.964843-25.800781 8.703124 59.292969-62.238281 10.425781 43.917969 42.425781-43.917969 42.425781 62.238281 10.425781-8.703124 59.292969zm0 0"
|
||||
fill="#ffdf47"
|
||||
id="path4564"/>
|
||||
<path
|
||||
d="m403.308594 261.441406-5.628906-5.441406 25.160156-24.300781 39.210937-37.878907-55.75-9.339843-36.171875-6.058594 2.800782-19.09375 9.550781-65.046875-83.179688 37.019531-43.300781-74.371093v59.621093l30.921875 53.109375 57.957031-25.808594-3.910156 26.667969-2.546875 17.378907-2.242187 15.25 2.480468.421874 59.761719 10.007813-43.921875 42.421875 16.96875 16.390625 26.953125 26.03125-62.242187 10.429687 8.699218 59.296876-57.957031-25.808594-30.921875 53.109375v59.621093l43.300781-74.371093 83.179688 37.019531-12.351563-84.140625 91.921875-15.398437zm0 0"
|
||||
fill="#fec000"
|
||||
id="path4566"/>
|
||||
<g
|
||||
aria-label="K"
|
||||
transform="matrix(1.1590846,-0.34467221,0.22789693,0.794981,0,0)"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:296.55969238px;line-height:125%;font-family:Impact;-inkscape-font-specification:Impact;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="text4596">
|
||||
<path
|
||||
d="m 220.91497,266.9035 -34.89789,105.85211 38.2284,128.58643 H 161.2555 L 136.63873,400.84769 V 501.34204 H 75.676021 V 266.9035 h 60.962709 v 91.08205 l 27.07845,-91.08205 z"
|
||||
style="font-size:296.55969238px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path824"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 6.7 KiB |
Before Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 477 B |
Before Width: | Height: | Size: 9.8 KiB |
Before Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 5.1 KiB |
@ -1,40 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="50mm" height="50mm" viewBox="0 0 50 50">
|
||||
<defs>
|
||||
<linearGradient id="b">
|
||||
<stop offset="0" style="stop-color:#348878;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#52bca6;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="a">
|
||||
<stop offset="0" style="stop-color:#348878;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#56bda8;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient xlink:href="#a" id="e" x1="160.722" x2="168.412" y1="128.533" y2="134.326" gradientTransform="matrix(3.74959 0 0 3.74959 -541.79 -387.599)" gradientUnits="userSpaceOnUse"/>
|
||||
<linearGradient xlink:href="#b" id="c" x1=".034" x2="50.319" y1="0" y2="50.285" gradientTransform="matrix(.99434 0 0 .99434 -.034 0)" gradientUnits="userSpaceOnUse"/>
|
||||
<filter id="d" width="1.176" height="1.211" x="-.076" y="-.092" style="color-interpolation-filters:sRGB">
|
||||
<feFlood flood-color="#fff" flood-opacity=".192" result="flood"/>
|
||||
<feComposite in="flood" in2="SourceGraphic" operator="in" result="composite1"/>
|
||||
<feGaussianBlur in="composite1" result="blur" stdDeviation="4"/>
|
||||
<feOffset dx="3" dy="2.954" result="offset"/>
|
||||
<feComposite in="SourceGraphic" in2="offset" result="composite2"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<g style="display:inline">
|
||||
<path d="M0 0h50v50H0z" style="fill:url(#c);fill-opacity:1;stroke:none;stroke-width:.286502;stroke-linejoin:bevel"/>
|
||||
</g>
|
||||
<g style="display:inline">
|
||||
<path d="M50.4 46.883c-9.168 0-17.023 7.214-17.023 16.387v.007l.09 71.37-2.303 16.992 31.313-8.319h77.841c9.17 0 17.024-7.224 17.024-16.396V63.27c0-9.17-7.85-16.383-17.016-16.387h-.008zm0 11.566h89.926c3.222.004 5.45 2.347 5.45 4.82v63.655c0 2.475-2.232 4.82-5.457 4.82h-79.54l-15.908 4.807.162-.938-.088-72.343c0-2.476 2.23-4.82 5.455-4.82z" style="color:#fff;display:inline;fill:#fff;stroke:none;stroke-width:1.93113;-inkscape-stroke:none;filter:url(#d)" transform="scale(.26458)"/>
|
||||
</g>
|
||||
<g style="display:inline">
|
||||
<path d="M88.2 95.309H64.92c-1.601 0-2.91 1.236-2.91 2.746l.022 18.602-.435 2.506 6.231-1.881H88.2c1.6 0 2.91-1.236 2.91-2.747v-16.48c0-1.51-1.31-2.746-2.91-2.746z" style="color:#fff;fill:url(#e);stroke:none;stroke-width:2.49558;-inkscape-stroke:none" transform="translate(-51.147 -81.516)"/>
|
||||
<path d="M50.4 46.883c-9.168 0-17.023 7.214-17.023 16.387v.007l.09 71.37-2.303 16.992 31.313-8.319h77.841c9.17 0 17.024-7.224 17.024-16.396V63.27c0-9.17-7.85-16.383-17.016-16.387h-.008zm0 11.566h89.926c3.222.004 5.45 2.347 5.45 4.82v63.655c0 2.475-2.232 4.82-5.457 4.82h-79.54l-15.908 4.807.162-.938-.088-72.343c0-2.476 2.23-4.82 5.455-4.82z" style="color:#fff;fill:#fff;stroke:none;stroke-width:1.93113;-inkscape-stroke:none" transform="scale(.26458)"/>
|
||||
<g style="font-size:8.48274px;font-family:sans-serif;letter-spacing:0;word-spacing:0;fill:#fff;stroke:none;stroke-width:.525121">
|
||||
<path d="M62.57 116.77v-1.312l3.28-1.459q.159-.068.306-.102.158-.045.283-.068l.271-.022v-.09q-.136-.012-.271-.046-.125-.023-.283-.057-.147-.045-.306-.113l-3.28-1.459v-1.323l5.068 2.319v1.413z" style="color:#fff;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.45366 0 0 1.72815 -75.122 -171.953)"/>
|
||||
<path d="M62.309 110.31v1.903l3.437 1.53.022.007-.022.008-3.437 1.53v1.892l.37-.17 5.221-2.39v-1.75zm.525.817 4.541 2.08v1.076l-4.541 2.078v-.732l3.12-1.389.003-.002a1.56 1.56 0 0 1 .258-.086h.006l.008-.002c.094-.027.176-.047.246-.06l.498-.041v-.574l-.24-.02a1.411 1.411 0 0 1-.231-.04l-.008-.001-.008-.002a9.077 9.077 0 0 1-.263-.053 2.781 2.781 0 0 1-.266-.097l-.004-.002-3.119-1.39z"
|
||||
style="color:#fff;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.45366 0 0 1.72815 -75.122 -171.953)"/>
|
||||
</g>
|
||||
<g style="font-size:8.48274px;font-family:sans-serif;letter-spacing:0;word-spacing:0;fill:#fff;stroke:none;stroke-width:.525121">
|
||||
<path d="M69.171 117.754h5.43v1.278h-5.43Z" style="color:#fff;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.44935 0 0 1.66414 -74.104 -166.906)"/>
|
||||
<path d="M68.908 117.492v1.802h5.955v-1.802zm.526.524h4.904v.754h-4.904z" style="color:#fff;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.44935 0 0 1.66414 -74.104 -166.906)"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 66 KiB |
@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Tranga</title>
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="stylesheet" href="styles/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
|
||||
const domNode = document.getElementById('app');
|
||||
const root = createRoot(domNode);
|
||||
root.render(<App />);
|
@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z" fill="#0F1729"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 804 B |
Before Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 63 KiB |
Before Width: | Height: | Size: 440 B |
@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="512pt"
|
||||
viewBox="0 0 512 512"
|
||||
width="512pt"
|
||||
version="1.1"
|
||||
id="svg4586">
|
||||
<path
|
||||
d="m512 256c0 141.386719-114.613281 256-256 256s-256-114.613281-256-256 114.613281-256 256-256 256 114.613281 256 256zm0 0"
|
||||
fill="#005ed3"
|
||||
id="path4556"/>
|
||||
<path
|
||||
d="m 512,256 c 0,-11.71094 -0.80469,-23.23047 -2.32422,-34.52344 L 382.48047,94.28125 320.52344,121.85938 256,56.933594 212.69531,131.30469 129.51953,94.28125 141.86719,178.42187 49.949219,193.81641 114.32031,256 l -64.371091,62.18359 82.121091,82.16016 -2.55078,17.375 91.95703,91.95703 C 232.76953,511.19531 244.28906,512 256,512 397.38672,512 512,397.38672 512,256 Z"
|
||||
id="path4558"
|
||||
style="fill:#00459f"/>
|
||||
<path
|
||||
d="m256 86.742188 37.109375 63.738281 70.574219-31.414063-10.527344 71.71875 77.078125 12.910156-54.144531 52.304688 54.144531 52.304688-77.078125 12.910156 10.527344 71.71875-70.574219-31.414063-37.109375 63.738281-37.109375-63.738281-70.574219 31.414063 10.527344-71.71875-77.078125-12.910156 54.144531-52.304688-54.144531-52.304688 77.078125-12.910156-10.527344-71.71875 70.574219 31.414063zm0 0"
|
||||
fill="#ff0335"
|
||||
id="path4560"/>
|
||||
<path
|
||||
d="m430.230469 308.300781-77.070313 12.910157 10.519532 71.71875-70.570313-31.410157-37.109375 63.742188v-338.523438l37.109375 63.742188 70.570313-31.410157-6.757813 46.101563-3.761719 25.617187 58.800782 9.851563 18.269531 3.058594-13.390625 12.929687-40.75 39.371094 11.378906 10.988281zm0 0"
|
||||
fill="#c2001b"
|
||||
id="path4562"/>
|
||||
<path
|
||||
d="m256 455.066406-43.304688-74.371094-83.175781 37.023438 12.347657-84.140625-91.917969-15.394531 64.371093-62.183594-64.371093-62.183594 91.917969-15.394531-12.347657-84.140625 83.179688 37.023438 43.300781-74.371094 43.304688 74.371094 83.175781-37.023438-12.347657 84.140625 91.917969 15.394531-64.371093 62.183594 64.371093 62.183594-91.917969 15.398437 12.347657 84.136719-83.175781-37.023438zm-30.917969-112.722656 30.917969 53.101562 30.917969-53.101562 57.964843 25.800781-8.703124-59.292969 62.238281-10.425781-43.917969-42.425781 43.917969-42.425781-62.238281-10.425781 8.703124-59.292969-57.964843 25.800781-30.917969-53.101562-30.917969 53.101562-57.964843-25.800781 8.703124 59.292969-62.238281 10.425781 43.917969 42.425781-43.917969 42.425781 62.238281 10.425781-8.703124 59.292969zm0 0"
|
||||
fill="#ffdf47"
|
||||
id="path4564"/>
|
||||
<path
|
||||
d="m403.308594 261.441406-5.628906-5.441406 25.160156-24.300781 39.210937-37.878907-55.75-9.339843-36.171875-6.058594 2.800782-19.09375 9.550781-65.046875-83.179688 37.019531-43.300781-74.371093v59.621093l30.921875 53.109375 57.957031-25.808594-3.910156 26.667969-2.546875 17.378907-2.242187 15.25 2.480468.421874 59.761719 10.007813-43.921875 42.421875 16.96875 16.390625 26.953125 26.03125-62.242187 10.429687 8.699218 59.296876-57.957031-25.808594-30.921875 53.109375v59.621093l43.300781-74.371093 83.179688 37.019531-12.351563-84.140625 91.921875-15.398437zm0 0"
|
||||
fill="#fec000"
|
||||
id="path4566"/>
|
||||
<g
|
||||
aria-label="K"
|
||||
transform="matrix(1.1590846,-0.34467221,0.22789693,0.794981,0,0)"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:296.55969238px;line-height:125%;font-family:Impact;-inkscape-font-specification:Impact;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="text4596">
|
||||
<path
|
||||
d="m 220.91497,266.9035 -34.89789,105.85211 38.2284,128.58643 H 161.2555 L 136.63873,400.84769 V 501.34204 H 75.676021 V 266.9035 h 60.962709 v 91.08205 l 27.07845,-91.08205 z"
|
||||
style="font-size:296.55969238px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path824"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 477 B |
Before Width: | Height: | Size: 9.8 KiB |
Before Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 5.1 KiB |
@ -1,40 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="50mm" height="50mm" viewBox="0 0 50 50">
|
||||
<defs>
|
||||
<linearGradient id="b">
|
||||
<stop offset="0" style="stop-color:#348878;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#52bca6;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="a">
|
||||
<stop offset="0" style="stop-color:#348878;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#56bda8;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient xlink:href="#a" id="e" x1="160.722" x2="168.412" y1="128.533" y2="134.326" gradientTransform="matrix(3.74959 0 0 3.74959 -541.79 -387.599)" gradientUnits="userSpaceOnUse"/>
|
||||
<linearGradient xlink:href="#b" id="c" x1=".034" x2="50.319" y1="0" y2="50.285" gradientTransform="matrix(.99434 0 0 .99434 -.034 0)" gradientUnits="userSpaceOnUse"/>
|
||||
<filter id="d" width="1.176" height="1.211" x="-.076" y="-.092" style="color-interpolation-filters:sRGB">
|
||||
<feFlood flood-color="#fff" flood-opacity=".192" result="flood"/>
|
||||
<feComposite in="flood" in2="SourceGraphic" operator="in" result="composite1"/>
|
||||
<feGaussianBlur in="composite1" result="blur" stdDeviation="4"/>
|
||||
<feOffset dx="3" dy="2.954" result="offset"/>
|
||||
<feComposite in="SourceGraphic" in2="offset" result="composite2"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<g style="display:inline">
|
||||
<path d="M0 0h50v50H0z" style="fill:url(#c);fill-opacity:1;stroke:none;stroke-width:.286502;stroke-linejoin:bevel"/>
|
||||
</g>
|
||||
<g style="display:inline">
|
||||
<path d="M50.4 46.883c-9.168 0-17.023 7.214-17.023 16.387v.007l.09 71.37-2.303 16.992 31.313-8.319h77.841c9.17 0 17.024-7.224 17.024-16.396V63.27c0-9.17-7.85-16.383-17.016-16.387h-.008zm0 11.566h89.926c3.222.004 5.45 2.347 5.45 4.82v63.655c0 2.475-2.232 4.82-5.457 4.82h-79.54l-15.908 4.807.162-.938-.088-72.343c0-2.476 2.23-4.82 5.455-4.82z" style="color:#fff;display:inline;fill:#fff;stroke:none;stroke-width:1.93113;-inkscape-stroke:none;filter:url(#d)" transform="scale(.26458)"/>
|
||||
</g>
|
||||
<g style="display:inline">
|
||||
<path d="M88.2 95.309H64.92c-1.601 0-2.91 1.236-2.91 2.746l.022 18.602-.435 2.506 6.231-1.881H88.2c1.6 0 2.91-1.236 2.91-2.747v-16.48c0-1.51-1.31-2.746-2.91-2.746z" style="color:#fff;fill:url(#e);stroke:none;stroke-width:2.49558;-inkscape-stroke:none" transform="translate(-51.147 -81.516)"/>
|
||||
<path d="M50.4 46.883c-9.168 0-17.023 7.214-17.023 16.387v.007l.09 71.37-2.303 16.992 31.313-8.319h77.841c9.17 0 17.024-7.224 17.024-16.396V63.27c0-9.17-7.85-16.383-17.016-16.387h-.008zm0 11.566h89.926c3.222.004 5.45 2.347 5.45 4.82v63.655c0 2.475-2.232 4.82-5.457 4.82h-79.54l-15.908 4.807.162-.938-.088-72.343c0-2.476 2.23-4.82 5.455-4.82z" style="color:#fff;fill:#fff;stroke:none;stroke-width:1.93113;-inkscape-stroke:none" transform="scale(.26458)"/>
|
||||
<g style="font-size:8.48274px;font-family:sans-serif;letter-spacing:0;word-spacing:0;fill:#fff;stroke:none;stroke-width:.525121">
|
||||
<path d="M62.57 116.77v-1.312l3.28-1.459q.159-.068.306-.102.158-.045.283-.068l.271-.022v-.09q-.136-.012-.271-.046-.125-.023-.283-.057-.147-.045-.306-.113l-3.28-1.459v-1.323l5.068 2.319v1.413z" style="color:#fff;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.45366 0 0 1.72815 -75.122 -171.953)"/>
|
||||
<path d="M62.309 110.31v1.903l3.437 1.53.022.007-.022.008-3.437 1.53v1.892l.37-.17 5.221-2.39v-1.75zm.525.817 4.541 2.08v1.076l-4.541 2.078v-.732l3.12-1.389.003-.002a1.56 1.56 0 0 1 .258-.086h.006l.008-.002c.094-.027.176-.047.246-.06l.498-.041v-.574l-.24-.02a1.411 1.411 0 0 1-.231-.04l-.008-.001-.008-.002a9.077 9.077 0 0 1-.263-.053 2.781 2.781 0 0 1-.266-.097l-.004-.002-3.119-1.39z"
|
||||
style="color:#fff;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.45366 0 0 1.72815 -75.122 -171.953)"/>
|
||||
</g>
|
||||
<g style="font-size:8.48274px;font-family:sans-serif;letter-spacing:0;word-spacing:0;fill:#fff;stroke:none;stroke-width:.525121">
|
||||
<path d="M69.171 117.754h5.43v1.278h-5.43Z" style="color:#fff;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.44935 0 0 1.66414 -74.104 -166.906)"/>
|
||||
<path d="M68.908 117.492v1.802h5.955v-1.802zm.526.524h4.904v.754h-4.904z" style="color:#fff;-inkscape-font-specification:"JetBrains Mono, Bold";fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.44935 0 0 1.66414 -74.104 -166.906)"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 4.6 KiB |
@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="800px" height="800px" viewBox="0 0 971.986 971.986"
|
||||
xml:space="preserve">
|
||||
<g>
|
||||
<path d="M370.216,459.3c10.2,11.1,15.8,25.6,15.8,40.6v442c0,26.601,32.1,40.101,51.1,21.4l123.3-141.3
|
||||
c16.5-19.8,25.6-29.601,25.6-49.2V500c0-15,5.7-29.5,15.8-40.601L955.615,75.5c26.5-28.8,6.101-75.5-33.1-75.5h-873
|
||||
c-39.2,0-59.7,46.6-33.1,75.5L370.216,459.3z"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 714 B |
@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
|
||||
<title>
|
||||
external link
|
||||
</title>
|
||||
<path fill="#fff" d="M6 1h5v5L8.86 3.85 4.7 8 4 7.3l4.15-4.16L6 1Z M2 3h2v1H2v6h6V8h1v2a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 304 B |
@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="800px" height="800px" viewBox="0 0 93.5 93.5" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M93.5,40.899c0-2.453-1.995-4.447-4.448-4.447H81.98c-0.74-2.545-1.756-5.001-3.035-7.331l4.998-5
|
||||
c0.826-0.827,1.303-1.973,1.303-3.146c0-1.19-0.462-2.306-1.303-3.146L75.67,9.555c-1.613-1.615-4.673-1.618-6.29,0l-5,5
|
||||
c-2.327-1.28-4.786-2.296-7.332-3.037v-7.07C57.048,1.995,55.053,0,52.602,0H40.899c-2.453,0-4.447,1.995-4.447,4.448v7.071
|
||||
c-2.546,0.741-5.005,1.757-7.333,3.037l-5-5c-1.68-1.679-4.609-1.679-6.288,0L9.555,17.83c-1.734,1.734-1.734,4.555,0,6.289
|
||||
l4.999,5c-1.279,2.33-2.295,4.788-3.036,7.333h-7.07C1.995,36.452,0,38.447,0,40.899V52.6c0,2.453,1.995,4.447,4.448,4.447h7.071
|
||||
c0.74,2.545,1.757,5.003,3.036,7.332l-4.998,4.999c-0.827,0.827-1.303,1.974-1.303,3.146c0,1.189,0.462,2.307,1.302,3.146
|
||||
l8.274,8.273c1.614,1.615,4.674,1.619,6.29,0l5-5c2.328,1.279,4.786,2.297,7.333,3.037v7.071c0,2.453,1.995,4.448,4.447,4.448
|
||||
h11.702c2.453,0,4.446-1.995,4.446-4.448V81.98c2.546-0.74,5.005-1.756,7.332-3.037l5,5c1.681,1.68,4.608,1.68,6.288,0
|
||||
l8.275-8.273c1.734-1.734,1.734-4.555,0-6.289l-4.998-5.001c1.279-2.329,2.295-4.787,3.035-7.332h7.071
|
||||
c2.453,0,4.448-1.995,4.448-4.446V40.899z M62.947,46.75c0,8.932-7.266,16.197-16.197,16.197c-8.931,0-16.197-7.266-16.197-16.197
|
||||
c0-8.931,7.266-16.197,16.197-16.197C55.682,30.553,62.947,37.819,62.947,46.75z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.7 KiB |
@ -1,48 +0,0 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import '../styles/footer.css';
|
||||
import Job from './Job';
|
||||
import Icon from '@mdi/react';
|
||||
import { mdiRun, mdiCounter, mdiEyeCheck, mdiTrayFull } from '@mdi/js';
|
||||
import QueuePopUp from "./QueuePopUp";
|
||||
|
||||
export default function Footer({connectedToBackend, apiUri} : {connectedToBackend: boolean, apiUri: string}) {
|
||||
const [MonitoringJobsCount, setMonitoringJobsCount] = React.useState(0);
|
||||
const [AllJobsCount, setAllJobsCount] = React.useState(0);
|
||||
const [RunningJobsCount, setRunningJobsCount] = React.useState(0);
|
||||
const [StandbyJobsCount, setStandbyJobsCount] = React.useState(0);
|
||||
const [countUpdateInterval, setCountUpdateInterval] = React.useState<number>();
|
||||
|
||||
function UpdateBackendState(){
|
||||
Job.GetMonitoringJobs(apiUri).then((jobs) => setMonitoringJobsCount(jobs.length));
|
||||
Job.GetAllJobs(apiUri).then((jobs) => setAllJobsCount(jobs.length));
|
||||
Job.GetRunningJobs(apiUri).then((jobs) => setRunningJobsCount(jobs.length));
|
||||
Job.GetStandbyJobs(apiUri).then((jobs) => setStandbyJobsCount(jobs.length));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if(connectedToBackend){
|
||||
UpdateBackendState();
|
||||
setCountUpdateInterval(setInterval(() => {
|
||||
UpdateBackendState();
|
||||
}, 2000));
|
||||
}else{
|
||||
clearInterval(countUpdateInterval);
|
||||
setCountUpdateInterval(undefined);
|
||||
}
|
||||
}, [connectedToBackend]);
|
||||
|
||||
return (
|
||||
<footer>
|
||||
<div className="statusBadge" ><Icon path={mdiEyeCheck} size={1}/> <span>{MonitoringJobsCount}</span></div>
|
||||
<span>+</span>
|
||||
<QueuePopUp connectedToBackend={connectedToBackend} apiUri={apiUri}>
|
||||
<div className="statusBadge hoverHand"><Icon path={mdiRun} size={1}/> <span>{RunningJobsCount}</span>
|
||||
</div>
|
||||
<span>+</span>
|
||||
<div className="statusBadge hoverHand"><Icon path={mdiTrayFull} size={1}/><span>{StandbyJobsCount}</span></div>
|
||||
</QueuePopUp>
|
||||
<span>=</span>
|
||||
<div className="statusBadge"><Icon path={mdiCounter} size={1}/> <span>{AllJobsCount}</span></div>
|
||||
<p id="madeWith">Made with Blåhaj 🦈</p>
|
||||
</footer>)
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import '../styles/header.css'
|
||||
import Settings from "./Settings";
|
||||
import IFrontendSettings from "./interfaces/IFrontendSettings";
|
||||
|
||||
export default function Header({backendConnected, apiUri, settings, changeSettings} : {backendConnected: boolean, apiUri: string, settings: IFrontendSettings, changeSettings(settings: IFrontendSettings): void}){
|
||||
return (
|
||||
<header>
|
||||
<div id="titlebox">
|
||||
<img alt="website image is Blahaj" src="../media/blahaj.png"/>
|
||||
<span>Tranga</span>
|
||||
</div>
|
||||
<Settings settings={settings} changeSettings={changeSettings} backendConnected={backendConnected} apiUri={apiUri}/>
|
||||
</header>)
|
||||
}
|
@ -1,142 +0,0 @@
|
||||
import {deleteData, getData, postData} from '../App';
|
||||
import IJob from "./interfaces/IJob";
|
||||
import IProgressToken from "./interfaces/IProgressToken";
|
||||
|
||||
export default class Job
|
||||
{
|
||||
static IntervalStringFromDate(date: Date) : string {
|
||||
let x = new Date(date);
|
||||
return `${x.getDay()}.${x.getHours()}:${x.getMinutes()}:${x.getSeconds()}`;
|
||||
}
|
||||
|
||||
static async GetAllJobs(apiUri: string): Promise<string[]> {
|
||||
//console.info("Getting all Jobs");
|
||||
return getData(`${apiUri}/v2/Jobs`)
|
||||
.then((json) => {
|
||||
//console.info("Got all Jobs");
|
||||
const ret = json as string[];
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static async GetRunningJobs(apiUri: string): Promise<string[]> {
|
||||
//console.info("Getting all running Jobs");
|
||||
return getData(`${apiUri}/v2/Jobs/Running`)
|
||||
.then((json) => {
|
||||
//console.info("Got all running Jobs");
|
||||
const ret = json as string[];
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static async GetWaitingJobs(apiUri: string): Promise<string[]> {
|
||||
//console.info("Getting all waiting Jobs");
|
||||
return getData(`${apiUri}/v2/Jobs/Waiting`)
|
||||
.then((json) => {
|
||||
//console.info("Got all waiting Jobs");
|
||||
const ret = json as string[];
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static async GetStandbyJobs(apiUri: string): Promise<string[]> {
|
||||
//console.info("Getting all standby Jobs");
|
||||
return getData(`${apiUri}/v2/Jobs/Standby`)
|
||||
.then((json) => {
|
||||
//console.info("Got all standby Jobs");
|
||||
const ret = json as string[];
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static async GetMonitoringJobs(apiUri: string): Promise<string[]> {
|
||||
//console.info("Getting all monitoring Jobs");
|
||||
return getData(`${apiUri}/v2/Jobs/Monitoring`)
|
||||
.then((json) => {
|
||||
//console.info("Got all monitoring Jobs");
|
||||
const ret = json as string[];
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static async GetJob(apiUri: string, jobId: string): Promise<IJob>{
|
||||
if(jobId === undefined || jobId === null || jobId.length < 1) {
|
||||
console.error(`JobId was not provided`);
|
||||
return Promise.reject();
|
||||
}
|
||||
//console.info(`Getting Job ${jobId}`);
|
||||
return getData(`${apiUri}/v2/Job/${jobId}`)
|
||||
.then((json) => {
|
||||
//console.info(`Got Job ${jobId}`);
|
||||
const ret = json as IJob;
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static async GetJobs(apiUri: string, jobIds: string[]): Promise<IJob[]> {
|
||||
if(jobIds === undefined || jobIds === null || jobIds.length < 1) {
|
||||
console.error(`JobIds was not provided`);
|
||||
return Promise.reject();
|
||||
}
|
||||
let reqStr = jobIds.join(",");
|
||||
//console.info(`Getting Jobs ${reqStr}`);
|
||||
return getData(`${apiUri}/v2/Job?jobIds=${reqStr}`)
|
||||
.then((json) => {
|
||||
//console.info(`Got Jobs ${reqStr}`);
|
||||
const ret = json as IJob[];
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static async GetJobProgress(apiUri: string, jobId: string): Promise<IProgressToken> {
|
||||
//console.info(`Getting Job ${jobId} Progress`);
|
||||
return getData(`${apiUri}/v2/Job/${jobId}/Progress`)
|
||||
.then((json) => {
|
||||
//console.info(`Got Job ${jobId} Progress`);
|
||||
const ret = json as IProgressToken;
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static async CreateJobDateInterval(apiUri: string, internalId: string, jobType: string, interval: Date) : Promise<null> {
|
||||
return this.CreateJob(apiUri, internalId, jobType, this.IntervalStringFromDate(interval));
|
||||
}
|
||||
|
||||
static async CreateJob(apiUri: string, internalId: string, jobType: string, interval: string): Promise<null> {
|
||||
const validate = /(?:[0-9]{1,2}\.)?[0-9]{1,2}:[0-9]{1,2}(?::[0-9]{1,2})?/
|
||||
//console.info(`Creating Job for Manga ${internalId} at ${interval} interval`);
|
||||
if(!validate.test(interval)){
|
||||
console.error("Interval was in incorrect format.");
|
||||
return Promise.reject();
|
||||
}
|
||||
const data = {
|
||||
internalId: internalId,
|
||||
interval: interval
|
||||
};
|
||||
return postData(`${apiUri}/v2/Job/Create/${jobType}`, data)
|
||||
.then((json) => {
|
||||
//console.info(`Created Job for Manga ${internalId} at ${interval} interval`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
static DeleteJob(apiUri: string, jobId: string) : Promise<void> {
|
||||
return deleteData(`${apiUri}/v2/Job/${jobId}`);
|
||||
}
|
||||
|
||||
static StartJob(apiUri: string, jobId: string) : Promise<object> {
|
||||
return postData(`${apiUri}/v2/Job/${jobId}/StartNow`, {});
|
||||
}
|
||||
|
||||
static CancelJob(apiUri: string, jobId: string) : Promise<object> {
|
||||
return postData(`${apiUri}/v2/Job/${jobId}/Cancel`, {});
|
||||
}
|
||||
}
|
@ -1,135 +0,0 @@
|
||||
import {deleteData, getData, postData} from "../App";
|
||||
import ILibraryConnector from "./interfaces/ILibraryConnector";
|
||||
|
||||
export default abstract class LibraryConnector
|
||||
{
|
||||
static async GetLibraryConnectors(apiUri: string) : Promise<ILibraryConnector[]> {
|
||||
//console.info("Getting Library Connectors");
|
||||
return getData(`${apiUri}/v2/LibraryConnector`)
|
||||
.then((json) => {
|
||||
//console.info("Got Library Connectors");
|
||||
const ret = json as ILibraryConnector[];
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
})
|
||||
.catch(Promise.reject);
|
||||
}
|
||||
public url = "";
|
||||
|
||||
protected constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public SetUrl(url: string){
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public abstract Test(apiUri: string) : Promise<boolean>;
|
||||
public abstract Reset(apiUri: string) : Promise<boolean>;
|
||||
public abstract Create(apiUri: string) : Promise<boolean>;
|
||||
protected abstract CheckConnector() : boolean;
|
||||
|
||||
protected async TestConnector(apiUri: string, connectorType: string, data: object): Promise<boolean> {
|
||||
if(!this.CheckConnector())
|
||||
return Promise.reject("Connector not fully configured.");
|
||||
//console.info(`Testing ${connectorType}`);
|
||||
return postData(`${apiUri}/v2/LibraryConnector/${connectorType}/Test`, data)
|
||||
.then((json) => {
|
||||
//console.info(`Successfully tested ${connectorType}`);
|
||||
return true;
|
||||
})
|
||||
.catch(Promise.reject);
|
||||
}
|
||||
|
||||
protected async ResetConnector(apiUri: string, connectorType: string): Promise<boolean> {
|
||||
//console.info(`Deleting ${connectorType}`);
|
||||
return deleteData(`${apiUri}/v2/LibraryConnector/${connectorType}`)
|
||||
.then((json) => {
|
||||
//console.info(`Successfully deleted ${connectorType}`);
|
||||
return true;
|
||||
})
|
||||
.catch(Promise.reject);
|
||||
}
|
||||
|
||||
protected async CreateConnector(apiUri: string, connectorType: string, data: object): Promise<boolean> {
|
||||
if(!this.CheckConnector())
|
||||
return Promise.reject("Connector not fully configured.");
|
||||
//console.info(`Creating ${connectorType}`);
|
||||
return postData(`${apiUri}/v2/LibraryConnector/${connectorType}`, data)
|
||||
.then((json) => {
|
||||
//console.info(`Successfully created ${connectorType}`);
|
||||
return true;
|
||||
})
|
||||
.catch(Promise.reject);
|
||||
}
|
||||
}
|
||||
|
||||
export class Komga extends LibraryConnector
|
||||
{
|
||||
private username = "";
|
||||
private password = "";
|
||||
|
||||
constructor({url, username, password} : {url: string, username: string, password: string}){
|
||||
super(url);
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public async Test(apiUri: string) : Promise<boolean> {
|
||||
return this.TestConnector(apiUri, "Komga", {url: this.url, username: this.username, password: this.password}).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
public async Reset(apiUri: string) : Promise<boolean> {
|
||||
return this.ResetConnector(apiUri, "Komga").then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
public async Create(apiUri: string) : Promise<boolean> {
|
||||
return this.CreateConnector(apiUri, "Komga", {url: this.url, username: this.username, password: this.password}).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
protected CheckConnector(): boolean {
|
||||
try{
|
||||
new URL(this.url)
|
||||
}catch{
|
||||
return false;
|
||||
}
|
||||
if(this.username.length < 1 || this.password.length < 1)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class Kavita extends LibraryConnector
|
||||
{
|
||||
private username = "";
|
||||
private password = "";
|
||||
|
||||
constructor({url, username, password} : {url: string, username: string, password: string}) {
|
||||
super(url);
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public async Test(apiUri: string) : Promise<boolean> {
|
||||
return this.TestConnector(apiUri, "Kavita", {url: this.url, username: this.username, password: this.password}).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
public async Reset(apiUri: string) : Promise<boolean> {
|
||||
return this.ResetConnector(apiUri, "Kavita").then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
public async Create(apiUri: string) : Promise<boolean> {
|
||||
return this.CreateConnector(apiUri, "Kavita", {url: this.url, username: this.username, password: this.password}).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
protected CheckConnector(): boolean {
|
||||
try{
|
||||
new URL(this.url)
|
||||
}catch{
|
||||
return false;
|
||||
}
|
||||
if(this.username.length < 1 || this.password.length < 1)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
import IManga from './interfaces/IManga';
|
||||
import { getData } from '../App';
|
||||
|
||||
export default class Manga
|
||||
{
|
||||
static async GetAllManga(apiUri: string): Promise<IManga[]> {
|
||||
//console.info("Getting all Manga");
|
||||
return getData(`${apiUri}/v2/Mangas`)
|
||||
.then((json) => {
|
||||
//console.info("Got all Manga");
|
||||
const ret = json as IManga[];
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static async SearchManga(apiUri: string, name: string): Promise<IManga[]> {
|
||||
//console.info(`Getting Manga ${name} from all Connectors`);
|
||||
return await getData(`${apiUri}/v2/Manga/Search?title=${name}`)
|
||||
.then((json) => {
|
||||
//console.info(`Got Manga ${name}`);
|
||||
const ret = json as IManga[];
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static async GetMangaById(apiUri: string, internalId: string): Promise<IManga> {
|
||||
//console.info(`Getting Manga ${internalId}`);
|
||||
return await getData(`${apiUri}/v2/Manga/${internalId}`)
|
||||
.then((json) => {
|
||||
//console.info(`Got Manga ${internalId}`);
|
||||
const ret = json as IManga;
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static async GetMangaByIds(apiUri: string, internalIds: string[]): Promise<IManga[]> {
|
||||
//console.debug(`Getting Mangas ${internalIds.join(",")}`);
|
||||
return await getData(`${apiUri}/v2/Manga?mangaIds=${internalIds.join(",")}`)
|
||||
.then((json) => {
|
||||
//console.debug(`Got Manga ${internalIds.join(",")}`);
|
||||
const ret = json as IManga[];
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
});
|
||||
}
|
||||
|
||||
static GetMangaCoverUrl(apiUri: string, internalId: string, ref: HTMLElement): string {
|
||||
//console.debug(`Getting Manga Cover-Url ${internalId}`);
|
||||
return `${apiUri}/v2/Manga/${internalId}/Cover?dimensions=${ref.clientWidth*1.5}x${ref.clientHeight*1.5}`;
|
||||
}
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
import IMangaConnector from './interfaces/IMangaConnector';
|
||||
import IManga from './interfaces/IManga';
|
||||
import { getData } from '../App';
|
||||
|
||||
export class MangaConnector
|
||||
{
|
||||
static async GetAllConnectors(): Promise<IMangaConnector[]> {
|
||||
//console.info("Getting all MangaConnectors");
|
||||
return getData("http://127.0.0.1:6531/v2/Connector/Types")
|
||||
.then((json) => {
|
||||
//console.info("Got all MangaConnectors");
|
||||
return (json as IMangaConnector[]);
|
||||
});
|
||||
}
|
||||
|
||||
static async GetMangaFromConnectorByTitle(connector: IMangaConnector, name: string): Promise<IManga[]> {
|
||||
//console.info(`Getting Manga ${name}`);
|
||||
return await getData(`http://127.0.0.1:6531/v2/Connector/${connector.name}/GetManga?title=${name}`)
|
||||
.then((json) => {
|
||||
//console.info(`Got Manga ${name}`);
|
||||
return (json as IManga[]);
|
||||
});
|
||||
}
|
||||
|
||||
static async GetMangaFromConnectorByUrl(connector: IMangaConnector, url: string): Promise<IManga> {
|
||||
//console.info(`Getting Manga ${url}`);
|
||||
return await getData(`http://127.0.0.1:6531/v2/Connector/${connector.name}/GetManga?url=${url}`)
|
||||
.then((json) => {
|
||||
//console.info(`Got Manga ${url}`);
|
||||
return (json as IManga);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,100 +0,0 @@
|
||||
import React, {EventHandler, MouseEventHandler, ReactElement, useEffect, useState} from 'react';
|
||||
import Job from './Job';
|
||||
import '../styles/monitorMangaList.css';
|
||||
import IJob from "./interfaces/IJob";
|
||||
import IManga, {CoverCard} from "./interfaces/IManga";
|
||||
import Manga from './Manga';
|
||||
import '../styles/MangaCoverCard.css'
|
||||
import Icon from '@mdi/react';
|
||||
import { mdiTrashCanOutline, mdiPlayBoxOutline } from '@mdi/js';
|
||||
|
||||
export default function MonitorJobsList({onStartSearch, onJobsChanged, connectedToBackend, apiUri, updateList} : {onStartSearch() : void, onJobsChanged: EventHandler<any>, connectedToBackend: boolean, apiUri: string, updateList: Date}) {
|
||||
const [MonitoringJobs, setMonitoringJobs] = useState<IJob[]>([]);
|
||||
const [AllManga, setAllManga] = useState<IManga[]>([]);
|
||||
const [joblistUpdateInterval, setJoblistUpdateInterval] = React.useState<number>();
|
||||
|
||||
useEffect(() => {
|
||||
//console.debug("Updating display list.");
|
||||
//Remove all Manga that are not associated with a Job
|
||||
setAllManga(AllManga.filter(manga => MonitoringJobs.find(job => job.mangaInternalId == manga.internalId)));
|
||||
//Fetch Manga that are missing (from Jobs)
|
||||
if(MonitoringJobs === null)
|
||||
return;
|
||||
MonitoringJobs.forEach(job => {
|
||||
if(AllManga.find(manga => manga.internalId == job.mangaInternalId))
|
||||
return;
|
||||
Manga.GetMangaById(apiUri, job.mangaInternalId != undefined ? job.mangaInternalId : job.chapter != undefined ? job.chapter.parentManga.internalId : "").
|
||||
then((manga: IManga) => setAllManga([...AllManga, manga]));
|
||||
});
|
||||
}, [MonitoringJobs]);
|
||||
|
||||
useEffect(() => {
|
||||
if(connectedToBackend){
|
||||
UpdateMonitoringJobsList();
|
||||
setJoblistUpdateInterval(setInterval(() => {
|
||||
UpdateMonitoringJobsList();
|
||||
}, 1000));
|
||||
}else{
|
||||
clearInterval(joblistUpdateInterval);
|
||||
setJoblistUpdateInterval(undefined);
|
||||
}
|
||||
}, [connectedToBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
UpdateMonitoringJobsList();
|
||||
}, [updateList]);
|
||||
|
||||
function UpdateMonitoringJobsList(){
|
||||
if(!connectedToBackend)
|
||||
return;
|
||||
//console.debug("Updating MonitoringJobsList");
|
||||
Job.GetMonitoringJobs(apiUri)
|
||||
.then((jobs) => {
|
||||
if(jobs.length > 0)
|
||||
return Job.GetJobs(apiUri, jobs)
|
||||
return [];
|
||||
})
|
||||
.then((jobs) => setMonitoringJobs(jobs));
|
||||
}
|
||||
|
||||
function StartSearchMangaEntry() : ReactElement {
|
||||
return (<div key="monitorMangaEntry.StartSearch" className="monitorMangaEntry" onClick={onStartSearch}>
|
||||
<div className="Manga" key="StartSearch.Manga">
|
||||
<img src="../media/blahaj.png" alt="Blahaj"></img>
|
||||
<div>
|
||||
<p style={{textAlign: "center", width: "100%"}} className="Manga-name">Add new Manga</p>
|
||||
<p style={{fontSize: "42pt", textAlign: "center"}}>+</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
const DeleteJob : MouseEventHandler = (e) => {
|
||||
const jobId = e.currentTarget.id.slice(e.currentTarget.id.indexOf("-")+1);
|
||||
//console.info(`Pressed ${e.currentTarget.id} => ${jobId}`);
|
||||
Job.DeleteJob(apiUri, jobId).then(() => onJobsChanged(jobId));
|
||||
}
|
||||
|
||||
const StartJob : MouseEventHandler = (e) => {
|
||||
const jobId = e.currentTarget.id.slice(e.currentTarget.id.indexOf("-")+1);
|
||||
//console.info(`Pressed ${e.currentTarget.id} => ${jobId}`);
|
||||
Job.StartJob(apiUri, jobId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="MonitorMangaList">
|
||||
{StartSearchMangaEntry()}
|
||||
{AllManga.map((manga: IManga) => {
|
||||
const job = MonitoringJobs.find(job => job.mangaInternalId == manga.internalId);
|
||||
if (job === undefined || job == null)
|
||||
return <div>Error. Could not find matching job for {manga.internalId}</div>
|
||||
return <div key={"monitorMangaEntry-" + manga.internalId} className="monitorMangaEntry">
|
||||
{CoverCard(apiUri, manga)}
|
||||
<div className="MangaActionButtons">
|
||||
<div id={"Delete-"+job.id} className="DeleteJobButton" onClick={DeleteJob}><Icon path={mdiTrashCanOutline} size={1.5} /></div>
|
||||
<div id={"Start-"+job.id} className="StartJobNowButton" onClick={StartJob}><Icon path={mdiPlayBoxOutline} size={1.5} /></div>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>)
|
||||
}
|
@ -1,164 +0,0 @@
|
||||
import INotificationConnector from "./interfaces/INotificationConnector";
|
||||
import {deleteData, getData, postData} from "../App";
|
||||
|
||||
export default abstract class NotificationConnector {
|
||||
|
||||
static async GetNotificationConnectors(apiUri: string) : Promise<INotificationConnector[]> {
|
||||
//console.info("Getting Notification Connectors");
|
||||
return getData(`${apiUri}/v2/NotificationConnector`)
|
||||
.then((json) => {
|
||||
//console.info("Got Notification Connectors");
|
||||
const ret = json as INotificationConnector[];
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
})
|
||||
.catch(Promise.reject);
|
||||
}
|
||||
|
||||
protected constructor() {
|
||||
|
||||
}
|
||||
|
||||
public abstract Test(apiUri: string) : Promise<boolean>;
|
||||
public abstract Reset(apiUri: string) : Promise<boolean>;
|
||||
public abstract Create(apiUri: string) : Promise<boolean>;
|
||||
protected abstract CheckConnector() : boolean;
|
||||
|
||||
protected async TestConnector(apiUri: string, connectorType: string, data: object): Promise<boolean> {
|
||||
if(!this.CheckConnector())
|
||||
return Promise.reject("Connector not fully configured.");
|
||||
//console.info(`Testing ${connectorType}`);
|
||||
return postData(`${apiUri}/v2/NotificationConnector/${connectorType}/Test`, data)
|
||||
.then((json) => {
|
||||
//console.info(`Successfully tested ${connectorType}`);
|
||||
return true;
|
||||
})
|
||||
.catch(Promise.reject);
|
||||
}
|
||||
|
||||
protected async ResetConnector(apiUri: string, connectorType: string): Promise<boolean> {
|
||||
if(!this.CheckConnector())
|
||||
return Promise.reject("Connector not fully configured.");
|
||||
//console.info(`Deleting ${connectorType}`);
|
||||
return deleteData(`${apiUri}/v2/NotificationConnector/${connectorType}`)
|
||||
.then((json) => {
|
||||
//console.info(`Successfully deleted ${connectorType}`);
|
||||
return true;
|
||||
})
|
||||
.catch(Promise.reject);
|
||||
}
|
||||
|
||||
protected async CreateConnector(apiUri: string, connectorType: string, data: object): Promise<boolean> {
|
||||
if(!this.CheckConnector())
|
||||
return Promise.reject("Connector not fully configured.");
|
||||
//console.info(`Creating ${connectorType}`);
|
||||
return postData(`${apiUri}/v2/NotificationConnector/${connectorType}`, data)
|
||||
.then((json) => {
|
||||
//console.info(`Successfully created ${connectorType}`);
|
||||
return true;
|
||||
})
|
||||
.catch(Promise.reject);
|
||||
}
|
||||
}
|
||||
|
||||
export class Gotify extends NotificationConnector
|
||||
{
|
||||
public url = "";
|
||||
private appToken = "";
|
||||
|
||||
constructor({url, appToken} : {url: string, appToken:string}){
|
||||
super();
|
||||
this.url = url;
|
||||
this.appToken = appToken;
|
||||
}
|
||||
|
||||
public async Test(apiUri: string) : Promise<boolean> {
|
||||
return this.TestConnector(apiUri, "Gotify", {url: this.url, appToken: this.appToken}).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
public async Reset(apiUri: string) : Promise<boolean> {
|
||||
return this.ResetConnector(apiUri, "Gotify").then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
public async Create(apiUri: string) : Promise<boolean> {
|
||||
return this.CreateConnector(apiUri, "Gotify", {url: this.url, appToken: this.appToken}).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
protected CheckConnector(): boolean {
|
||||
try{
|
||||
new URL(this.url)
|
||||
}catch{
|
||||
return false;
|
||||
}
|
||||
if(this.appToken.length < 1 || this.appToken.length < 1)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class Lunasea extends NotificationConnector
|
||||
{
|
||||
private webhook = "";
|
||||
|
||||
constructor({webhook} : {webhook: string}){
|
||||
super();
|
||||
this.webhook = webhook;
|
||||
}
|
||||
|
||||
public async Test(apiUri: string) : Promise<boolean> {
|
||||
return this.TestConnector(apiUri, "LunaSea", {webhook: this.webhook}).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
public async Reset(apiUri: string) : Promise<boolean> {
|
||||
return this.ResetConnector(apiUri, "LunaSea").then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
public async Create(apiUri: string) : Promise<boolean> {
|
||||
return this.CreateConnector(apiUri, "LunaSea", {webhook: this.webhook}).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
protected CheckConnector(): boolean {
|
||||
if(this.webhook.length < 1 || this.webhook.length < 1)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class Ntfy extends NotificationConnector
|
||||
{
|
||||
public url = "";
|
||||
private username = "";
|
||||
private password = "";
|
||||
public topic:string | undefined = undefined;
|
||||
|
||||
constructor({url, username, password, topic} : {url: string, username: string, password: string, topic : string | undefined}){
|
||||
super();
|
||||
this.url = url;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.topic = topic;
|
||||
}
|
||||
|
||||
public async Test(apiUri: string) : Promise<boolean> {
|
||||
return this.TestConnector(apiUri, "Ntfy", {url: this.url, username: this.username, password: this.password, topic: this.topic}).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
public async Reset(apiUri: string) : Promise<boolean> {
|
||||
return this.ResetConnector(apiUri, "Ntfy").then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
public async Create(apiUri: string) : Promise<boolean> {
|
||||
return this.CreateConnector(apiUri, "Ntfy", {url: this.url, username: this.username, password: this.password, topic: this.topic}).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
protected CheckConnector(): boolean {
|
||||
try{
|
||||
new URL(this.url)
|
||||
}catch{
|
||||
return false;
|
||||
}
|
||||
if(this.username.length < 1 || this.password.length < 1)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
@ -1,120 +0,0 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import IJob from "./interfaces/IJob";
|
||||
import '../styles/queuePopUp.css';
|
||||
import '../styles/popup.css';
|
||||
import Job from "./Job";
|
||||
import IManga, {QueueItem} from "./interfaces/IManga";
|
||||
import Manga from "./Manga";
|
||||
|
||||
export default function QueuePopUp({connectedToBackend, children, apiUri} : {connectedToBackend: boolean, children: JSX.Element[], apiUri: string}) {
|
||||
|
||||
const [StandbyJobs, setStandbyJobs] = React.useState<IJob[]>([]);
|
||||
const [StandbyJobsManga, setStandbyJobsManga] = React.useState<IManga[]>([]);
|
||||
const [RunningJobs, setRunningJobs] = React.useState<IJob[]>([]);
|
||||
const [RunningJobsManga, setRunningJobsManga] = React.useState<IManga[]>([]);
|
||||
const [showQueuePopup, setShowQueuePopup] = useState<boolean>(false);
|
||||
const [queueListInterval, setQueueListInterval] = React.useState<number>();
|
||||
|
||||
useEffect(() => {
|
||||
if(!showQueuePopup)
|
||||
return;
|
||||
UpdateMonitoringJobsList();
|
||||
}, [showQueuePopup]);
|
||||
|
||||
useEffect(() => {
|
||||
if(connectedToBackend){
|
||||
UpdateMonitoringJobsList();
|
||||
setQueueListInterval(setInterval(() => {
|
||||
UpdateMonitoringJobsList();
|
||||
}, 2000));
|
||||
}else{
|
||||
clearInterval(queueListInterval);
|
||||
setQueueListInterval(undefined);
|
||||
}
|
||||
}, [connectedToBackend]);
|
||||
|
||||
function UpdateMonitoringJobsList(){
|
||||
Job.GetStandbyJobs(apiUri)
|
||||
.then((jobs:string[]) => {
|
||||
if(jobs.length > 0)
|
||||
return Job.GetJobs(apiUri, jobs);
|
||||
return [];
|
||||
})
|
||||
.then((jobs:IJob[]) => {
|
||||
//console.debug("Removing Metadata Jobs");
|
||||
//console.log(StandbyJobs)
|
||||
setStandbyJobs(jobs.filter(job => job.jobType <= 2));
|
||||
//console.log(StandbyJobs)
|
||||
});
|
||||
Job.GetRunningJobs(apiUri)
|
||||
.then((jobs:string[]) => {
|
||||
if(jobs.length > 0)
|
||||
return Job.GetJobs(apiUri, jobs);
|
||||
return [];
|
||||
})
|
||||
.then((jobs:IJob[]) =>{
|
||||
//console.debug("Removing Metadata Jobs");
|
||||
setRunningJobs(jobs.filter(job => job.jobType <= 2));
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if(StandbyJobs.length < 1)
|
||||
return;
|
||||
const mangaIds = StandbyJobs.filter(job => job.jobType<=2).map((job) => job.mangaInternalId != undefined ? job.mangaInternalId : job.chapter != undefined ? job.chapter.parentManga.internalId : "");
|
||||
//console.debug(`Waiting mangaIds: ${mangaIds.join(",")}`);
|
||||
Manga.GetMangaByIds(apiUri, mangaIds)
|
||||
.then(setStandbyJobsManga);
|
||||
}, [StandbyJobs]);
|
||||
|
||||
useEffect(() => {
|
||||
if(RunningJobs.length < 1)
|
||||
return;
|
||||
//console.log(RunningJobs);
|
||||
const mangaIds = RunningJobs.filter(job => job.jobType<=2).map((job) => job.mangaInternalId != undefined ? job.mangaInternalId : job.chapter != undefined ? job.chapter.parentManga.internalId : "");
|
||||
//console.debug(`Running mangaIds: ${mangaIds.join(",")}`);
|
||||
Manga.GetMangaByIds(apiUri, mangaIds)
|
||||
.then(setRunningJobsManga);
|
||||
}, [RunningJobs]);
|
||||
|
||||
return (<>
|
||||
<div onClick={() => setShowQueuePopup(true)}>
|
||||
{children}
|
||||
</div>
|
||||
{showQueuePopup
|
||||
? <div className="popup" id="QueuePopUp">
|
||||
<div className="popupHeader">
|
||||
<h1>Queue Status</h1>
|
||||
<img alt="Close Search" className="close" src="../media/close-x.svg"
|
||||
onClick={() => setShowQueuePopup(false)}/>
|
||||
</div>
|
||||
<div id="QueuePopUpBody" className="popupBody">
|
||||
<div id="RunningJobQueue">
|
||||
<h1>Running</h1>
|
||||
<div className="JobQueue">
|
||||
{RunningJobs.map((job: IJob) => {
|
||||
const manga = RunningJobsManga.find(manga => manga.internalId == job.mangaInternalId || manga.internalId == job.chapter?.parentManga.internalId);
|
||||
if (manga === undefined || manga === null)
|
||||
return <div key={"QueueJob-" + job.id}>Error. Could not find matching manga for {job.id}</div>
|
||||
return QueueItem(apiUri, manga, job, UpdateMonitoringJobsList);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div id="WaitingJobQueue">
|
||||
<h1>Standby</h1>
|
||||
<div className="JobQueue">
|
||||
{StandbyJobs.map((job: IJob) => {
|
||||
const manga = StandbyJobsManga.find(manga => manga.internalId == job.mangaInternalId || manga.internalId == job.chapter?.parentManga.internalId);
|
||||
if (manga === undefined || manga === null)
|
||||
return <div key={"QueueJob-" + job.id}>Error. Could not find matching manga for {job.id}</div>
|
||||
return QueueItem(apiUri, manga, job, UpdateMonitoringJobsList);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
: <></>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
import React, {ChangeEventHandler, EventHandler, useEffect, useState} from 'react';
|
||||
import {MangaConnector} from "./MangaConnector";
|
||||
import IMangaConnector from "./interfaces/IMangaConnector";
|
||||
import {isValidUri} from "../App";
|
||||
import IManga, {SearchResult} from "./interfaces/IManga";
|
||||
import '../styles/search.css';
|
||||
import '../styles/MangaSearchResult.css'
|
||||
|
||||
export default function Search({apiUri, jobInterval, onJobsChanged, closeSearch} : {apiUri: string, jobInterval: Date, onJobsChanged: (internalId: string) => void, closeSearch(): void}) {
|
||||
const [mangaConnectors, setConnectors] = useState<IMangaConnector[]>();
|
||||
const [selectedConnector, setSelectedConnector] = useState<IMangaConnector>();
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string>();
|
||||
const [searchBoxValue, setSearchBoxValue] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<IManga[]>();
|
||||
|
||||
const pattern = /https:\/\/([a-z0-9.]+\.[a-z0-9]{2,})(?:\/.*)?/i
|
||||
|
||||
useEffect(() => {
|
||||
if(mangaConnectors === undefined) {
|
||||
MangaConnector.GetAllConnectors().then(setConnectors);
|
||||
return;
|
||||
}
|
||||
}, [mangaConnectors]);
|
||||
|
||||
const selectedConnectorChanged : ChangeEventHandler<HTMLSelectElement> = (event) => {
|
||||
event.preventDefault();
|
||||
if(mangaConnectors === undefined)
|
||||
return;
|
||||
const selectedConnector = mangaConnectors.find((con: IMangaConnector) => con.name == event.target.value);
|
||||
if(selectedConnector === undefined)
|
||||
return;
|
||||
setSelectedConnector(selectedConnector);
|
||||
setSelectedLanguage(selectedConnector.SupportedLanguages[0]);
|
||||
}
|
||||
|
||||
const searchBoxValueChanged : ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
event.currentTarget.style.width = event.target.value.length + "ch";
|
||||
if(mangaConnectors === undefined)
|
||||
return;
|
||||
var str : string = event.target.value;
|
||||
setSearchBoxValue(str);
|
||||
if(isValidUri(str))
|
||||
setSelectedConnector(undefined);
|
||||
const match = str.match(pattern);
|
||||
if(match === null)
|
||||
return;
|
||||
let baseUri = match[1];
|
||||
const selectCon = mangaConnectors.find((con: IMangaConnector) => {
|
||||
return con.BaseUris.find(uri => uri == baseUri);
|
||||
});
|
||||
if(selectCon != undefined){
|
||||
setSelectedConnector(selectCon);
|
||||
setSelectedLanguage(selectCon.SupportedLanguages[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const ExecuteSearch : EventHandler<any> = () => {
|
||||
if(searchBoxValue.length < 1 || selectedConnector === undefined || selectedLanguage === ""){
|
||||
console.error("Tried initiating search while not all fields where submitted.")
|
||||
return;
|
||||
}
|
||||
//console.info(`Searching Name: ${searchBoxValue} Connector: ${selectedConnector.name} Language: ${selectedLanguage}`);
|
||||
if(isValidUri(searchBoxValue) && !selectedConnector.BaseUris.find((uri: string) => {
|
||||
const match = searchBoxValue.match(pattern);
|
||||
if(match === null)
|
||||
return false;
|
||||
return match[1] == uri
|
||||
}))
|
||||
{
|
||||
console.error("URL in Searchbox detected, but does not match selected connector.");
|
||||
return;
|
||||
}
|
||||
if(!isValidUri(searchBoxValue)){
|
||||
MangaConnector.GetMangaFromConnectorByTitle(selectedConnector, searchBoxValue)
|
||||
.then((mangas: IManga[]) => {
|
||||
setSearchResults(mangas);
|
||||
});
|
||||
}else{
|
||||
MangaConnector.GetMangaFromConnectorByUrl(selectedConnector, searchBoxValue)
|
||||
.then((manga: IManga) => {
|
||||
setSearchResults([manga]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const changeSelectedLanguage : ChangeEventHandler<HTMLSelectElement> = (event) => setSelectedLanguage(event.target.value);
|
||||
|
||||
return (<div id="Search">
|
||||
<div id="SearchBox">
|
||||
<input type="text" placeholder="Manganame" id="Searchbox-Manganame" onKeyDown={(e) => {if(e.key == "Enter") ExecuteSearch(null);}} onChange={searchBoxValueChanged}></input>
|
||||
<select id="Searchbox-Connector" value={selectedConnector === undefined ? "" : selectedConnector.name} onChange={selectedConnectorChanged}>
|
||||
<option value="" disabled hidden>Select</option>
|
||||
{mangaConnectors === undefined
|
||||
? <option value="Loading">Loading</option>
|
||||
: mangaConnectors.map(con => <option value={con.name} key={con.name}>{con.name}</option>)}
|
||||
</select>
|
||||
<select id="Searchbox-language" onChange={changeSelectedLanguage} value={selectedLanguage === null ? "" : selectedLanguage}>
|
||||
{selectedConnector === undefined
|
||||
? <option value="" disabled hidden>Select Connector</option>
|
||||
: selectedConnector.SupportedLanguages.map(language => <option value={language} key={language}>{language}</option>)}
|
||||
</select>
|
||||
<button id="Searchbox-button" type="submit" onClick={ExecuteSearch}>Search</button>
|
||||
</div>
|
||||
<img alt="Close Search" id="closeSearch" src="../media/close-x.svg" onClick={closeSearch} />
|
||||
<div id="SearchResults">
|
||||
{searchResults === undefined
|
||||
? <p></p>
|
||||
: searchResults.map(result => SearchResult(apiUri, result, jobInterval, onJobsChanged))}
|
||||
</div>
|
||||
</div>)
|
||||
}
|
@ -1,318 +0,0 @@
|
||||
import React, {ChangeEventHandler, KeyboardEventHandler, MouseEventHandler, useEffect, useState} from 'react';
|
||||
import IFrontendSettings from "./interfaces/IFrontendSettings";
|
||||
import '../styles/settings.css';
|
||||
import IBackendSettings from "./interfaces/IBackendSettings";
|
||||
import {getData, postData} from "../App";
|
||||
import LibraryConnector, {Kavita, Komga} from "./LibraryConnector";
|
||||
import NotificationConnector, {Gotify, Lunasea, Ntfy} from "./NotificationConnector";
|
||||
import ILibraryConnector from "./interfaces/ILibraryConnector";
|
||||
import INotificationConnector from "./interfaces/INotificationConnector";
|
||||
import Toggle from 'react-toggle';
|
||||
import '../styles/react-toggle.css';
|
||||
|
||||
export default function Settings({backendConnected, apiUri, settings, changeSettings} : {backendConnected: boolean, apiUri: string, settings: IFrontendSettings, changeSettings: (settings: IFrontendSettings) => void}) {
|
||||
const [frontendSettings, setFrontendSettings] = useState<IFrontendSettings>(settings);
|
||||
const [backendSettings, setBackendSettings] = useState<IBackendSettings>();
|
||||
const [showSettings, setShowSettings] = useState<boolean>(false);
|
||||
const [libraryConnectors, setLibraryConnectors] = useState<ILibraryConnector[]>();
|
||||
const [notificationConnectors, setNotificationConnectors] = useState<INotificationConnector[]>();
|
||||
const [komgaSettings, setKomgaSettings] = useState<{url: string, username: string, password: string}>({url: "", username: "", password: ""});
|
||||
const [kavitaSettings, setKavitaSettings] = useState<{url: string, username: string, password: string}>({url: "", username: "", password: ""});
|
||||
const [gotifySettings, setGotifySettings] = useState<{url: string, appToken: string}>({url: "", appToken: ""});
|
||||
const [lunaseaSettings, setLunaseaSettings] = useState<{webhook: string}>({webhook: ""});
|
||||
const [ntfySettings, setNtfySettings] = useState<{url: string, username: string, password: string, topic: string | undefined}>({url: "", username: "", password: "", topic: undefined});
|
||||
|
||||
useEffect(() => {
|
||||
console.debug(`${showSettings ? "Showing" : "Not showing"} settings.`);
|
||||
if(!showSettings || !backendConnected)
|
||||
return;
|
||||
UpdateBackendSettings();
|
||||
LibraryConnector.GetLibraryConnectors(apiUri).then(setLibraryConnectors).catch(console.error);
|
||||
NotificationConnector.GetNotificationConnectors(apiUri).then(setNotificationConnectors).catch(console.error);
|
||||
}, [showSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
changeSettings(frontendSettings);
|
||||
}, [frontendSettings]);
|
||||
|
||||
async function GetSettings(apiUri: string) : Promise<IBackendSettings> {
|
||||
//console.info("Getting Settings");
|
||||
return getData(`${apiUri}/v2/Settings`)
|
||||
.then((json) => {
|
||||
//console.info("Got Settings");
|
||||
const ret = json as IBackendSettings;
|
||||
//console.debug(ret);
|
||||
return (ret);
|
||||
})
|
||||
.catch(Promise.reject);
|
||||
}
|
||||
|
||||
function UpdateBackendSettings() {
|
||||
GetSettings(apiUri).then(setBackendSettings).catch(console.error);
|
||||
}
|
||||
|
||||
const GetKomga = () : ILibraryConnector | undefined =>
|
||||
libraryConnectors?.find(con => con.libraryType == 0);
|
||||
|
||||
const KomgaConnected = () : boolean => GetKomga() != undefined;
|
||||
|
||||
const GetKavita = () : ILibraryConnector | undefined =>
|
||||
libraryConnectors?.find(con => con.libraryType == 1);
|
||||
|
||||
const KavitaConnected = () : boolean => GetKavita() != undefined;
|
||||
|
||||
const GetGotify = () : INotificationConnector | undefined =>
|
||||
notificationConnectors?.find(con => con.notificationConnectorType == 0);
|
||||
|
||||
const GotifyConnected = () : boolean => GetGotify() != undefined;
|
||||
|
||||
const GetLunasea = () : INotificationConnector | undefined =>
|
||||
notificationConnectors?.find(con => con.notificationConnectorType == 1);
|
||||
|
||||
const LunaseaConnected = () : boolean => GetLunasea() != undefined;
|
||||
|
||||
const GetNtfy = () : INotificationConnector | undefined =>
|
||||
notificationConnectors?.find(con => con.notificationConnectorType == 2);
|
||||
|
||||
const NtfyConnected = () : boolean => GetNtfy() != undefined;
|
||||
|
||||
const SubmitApiUri : KeyboardEventHandler<HTMLInputElement> = (e) => {
|
||||
if(e.currentTarget.value.length < 1)
|
||||
return;
|
||||
if(e.key == "Enter"){
|
||||
setFrontendSettings({...frontendSettings, apiUri: e.currentTarget.value});
|
||||
RefreshInputs();
|
||||
}
|
||||
}
|
||||
|
||||
const SubmitUserAgent: KeyboardEventHandler<HTMLInputElement> = (e) => {
|
||||
if(e.currentTarget.value.length < 1 || backendSettings === undefined)
|
||||
return;
|
||||
if(e.key == "Enter"){
|
||||
//console.info(`Updating Useragent ${e.currentTarget.value}`);
|
||||
postData(`${apiUri}/v2/Settings/UserAgent`, {value: e.currentTarget.value})
|
||||
.then((json) => {
|
||||
//console.info(`Successfully updated Useragent ${e.currentTarget.value}`);
|
||||
UpdateBackendSettings();
|
||||
RefreshInputs();
|
||||
})
|
||||
.catch(() => alert("Failed to update Useragent."));
|
||||
}
|
||||
}
|
||||
|
||||
const ResetUserAgent: MouseEventHandler<HTMLSpanElement> = () => {
|
||||
//console.info(`Resetting Useragent`);
|
||||
postData(`${apiUri}/v2/Settings/UserAgent`, {value: undefined})
|
||||
.then((json) => {
|
||||
//console.info(`Successfully reset Useragent`);
|
||||
UpdateBackendSettings();
|
||||
RefreshInputs();
|
||||
})
|
||||
.catch(() => alert("Failed to update Useragent."));
|
||||
}
|
||||
|
||||
const SetAprilFoolsMode : ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
//console.info(`Updating AprilFoolsMode ${e.target.checked}`);
|
||||
postData(`${apiUri}/v2/Settings/AprilFoolsMode`, {value: e.target.checked})
|
||||
.then((json) => {
|
||||
//console.info(`Successfully updated AprilFoolsMode ${e.currentTarget.value}`);
|
||||
UpdateBackendSettings();
|
||||
})
|
||||
}
|
||||
|
||||
const SetCompressImages : MouseEventHandler<HTMLInputElement> = (e) => {
|
||||
//console.info(`Updating ImageCompression ${e.currentTarget.value}`);
|
||||
postData(`${apiUri}/v2/Settings/CompressImages`, {value: e.currentTarget.value})
|
||||
.then((json) => {
|
||||
//console.info(`Successfully updated ImageCompression ${e.currentTarget.value}`);
|
||||
UpdateBackendSettings();
|
||||
})
|
||||
}
|
||||
|
||||
const SetBWImages : ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
//console.info(`Updating B/W Images ${e.target.checked}`);
|
||||
postData(`${apiUri}/v2/Settings/BWImages`, {value: e.target.checked})
|
||||
.then((json) => {
|
||||
//console.info(`Successfully updated B/W Images ${e.target.checked}`);
|
||||
UpdateBackendSettings();
|
||||
})
|
||||
}
|
||||
|
||||
function RefreshInputs(){
|
||||
alert("Saved.");
|
||||
setShowSettings(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="Settings">
|
||||
<img id="SettingsIcon" src="../media/settings-cogwheel.svg" alt="cogwheel" onClick={() => setShowSettings(true)}/>
|
||||
{showSettings
|
||||
? <div className="popup">
|
||||
<div className="popupHeader">
|
||||
<h1>Settings</h1>
|
||||
<img alt="Close Settings" className="close" src="../media/close-x.svg" onClick={() => setShowSettings(false)}/>
|
||||
</div>
|
||||
<div id="settingsPopupBody" className="popupBody">
|
||||
<div className="settings-section">
|
||||
TRANGA
|
||||
<div className="settings-section-content">
|
||||
<div className="section-item">
|
||||
<span className="settings-section-title">API Settings</span>
|
||||
<label className="tooltip" data-tooltip="Set the URI of the Backend. Include https:// and if necessary port." htmlFor="settingApiUri">API URI:</label>
|
||||
<input placeholder={frontendSettings.apiUri} type="text" id="settingApiUri"
|
||||
onKeyDown={SubmitApiUri}/>
|
||||
<label htmlFor="userAgent" className="tooltip" data-tooltip="Set a custom User-Agent. This will allow more frequent requests to sites.">User Agent:</label>
|
||||
<input id="userAgent" type="text"
|
||||
placeholder={backendSettings != undefined ? backendSettings.userAgent : "UserAgent"}
|
||||
onKeyDown={SubmitUserAgent}/>
|
||||
<span id="resetUserAgent" onClick={ResetUserAgent}>Reset</span>
|
||||
<label htmlFor="aprilFoolsMode" className="tooltip" data-tooltip="Disable all downloads on April 1st">April Fools Mode</label>
|
||||
<Toggle id="aprilFoolsMode"
|
||||
checked={backendSettings?.aprilFoolsMode ?? false}
|
||||
onChange={SetAprilFoolsMode}/>
|
||||
<label htmlFor="compression" className="tooltip" data-tooltip="JPEG Compression Quality">Image Compression</label>
|
||||
<input type="range" min="1" max="100" defaultValue={backendSettings?.compression ?? 50} className="slider" id="compression" onMouseUp={SetCompressImages}/>
|
||||
<label htmlFor="bwImages">B/W Images</label>
|
||||
<Toggle id="bwImages"
|
||||
checked={backendSettings?.bwImages ?? false}
|
||||
onChange={SetBWImages}/>
|
||||
</div>
|
||||
<div className="section-item">
|
||||
<span className="settings-section-title">Rate Limits</span>
|
||||
<label htmlFor="DefaultRL">Default:</label>
|
||||
<input id="defaultRL" type="text" placeholder={backendSettings != undefined ? backendSettings.requestLimits.Default.toString() : "-1"} />
|
||||
<label htmlFor="CoverRL">Manga Covers:</label>
|
||||
<input id="coverRL" type="text" placeholder={backendSettings != undefined ? backendSettings.requestLimits.MangaCover.toString() : "-1"} />
|
||||
<label htmlFor="ImageRL">Manga Images:</label>
|
||||
<input id="imageRL" type="text" placeholder={backendSettings != undefined ? backendSettings.requestLimits.MangaImage.toString() : "-1"} />
|
||||
<label htmlFor="InfoRL">Manga Info:</label>
|
||||
<input id="infoRL" type="text" placeholder={backendSettings != undefined ? backendSettings.requestLimits.MangaInfo.toString() : "-1"} />
|
||||
</div>
|
||||
<div className="section-item">
|
||||
<span className="settings-section-title">Appearance</span>
|
||||
<label htmlFor="cssStyle">Library Style:</label>
|
||||
<select id="cssStyle">
|
||||
<option id="card_compact" value="card_compact">Cards (Compact)</option>
|
||||
<option id="card_hover" value="card_hover">Cards (Hover)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<span className="settings-section-title">Sources</span>
|
||||
<div className="settings-section-content">
|
||||
<div className="section-item">
|
||||
<span className="settings-section-title">
|
||||
<img src="../media/connector-icons/mangadex-logo.svg" alt="Mangadex Logo" />
|
||||
<a href="https://mangadex.org">MangaDex</a>
|
||||
</span>
|
||||
<label htmlFor="mDexFeedRL">Feed Rate Limit:</label>
|
||||
<input id="mDexFeedRL" type="text" placeholder={backendSettings != undefined ? backendSettings.requestLimits.MangaDexFeed.toString() : "-1"} />
|
||||
<label htmlFor="mDexImageRL">Image Rate Limit:</label>
|
||||
<input id="mDexImageRL" type="number" placeholder={backendSettings != undefined ? backendSettings.requestLimits.MangaDexImage.toString() : "-1"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section" >
|
||||
LIBRARY CONNECTORS
|
||||
<div className="settings-section-content">
|
||||
<div className="section-item" connector-status={KomgaConnected() ? "Configured" : "Not Configured"}>
|
||||
<span className="settings-section-title">
|
||||
<img src='../media/connector-icons/komga.svg' alt="Komga Logo"/>
|
||||
Komga
|
||||
</span>
|
||||
<label htmlFor="komgaUrl">URL</label>
|
||||
<input placeholder={GetKomga()?.baseUrl ?? "URL"} id="komgaUrl" type="text" onChange={(e) => setKomgaSettings(s => ({...s, url: e.target.value}))} />
|
||||
<label htmlFor="komgaUsername">Username</label>
|
||||
<input placeholder={KomgaConnected() ? "***" : "Username"} id="komgaUsername" type="text" onChange={(e) => setKomgaSettings(s => ({...s, username: e.target.value}))} />
|
||||
<label htmlFor="komgaPassword">Password</label>
|
||||
<input placeholder={KomgaConnected() ? "***" : "Password"} id="komgaPassword" type="password" onChange={(e) => setKomgaSettings(s => ({...s, password: e.target.value}))} />
|
||||
<div className="section-actions">
|
||||
<span onClick={() => new Komga(komgaSettings).Test(apiUri).then(()=>alert("Test successful"))}>Test</span>
|
||||
<span onClick={() => new Komga(komgaSettings).Reset(apiUri).then(RefreshInputs)}>Reset</span>
|
||||
<span onClick={() => new Komga(komgaSettings).Create(apiUri).then(RefreshInputs)}>Apply</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-item" connector-status={KavitaConnected() ? "Configured" : "Not Configured" }>
|
||||
<span className="settings-section-title">
|
||||
<img src='../media/connector-icons/kavita.png' alt="Kavita Logo"/>
|
||||
Kavita
|
||||
</span>
|
||||
<label htmlFor="kavitaUrl">URL</label>
|
||||
<input placeholder={GetKavita()?.baseUrl ?? "URL"} id="kavitaUrl" type="text" onChange={(e) => setKavitaSettings(s => ({...s, url: e.target.value}))} />
|
||||
<label htmlFor="kavitaUsername">Username</label>
|
||||
<input placeholder={KavitaConnected() ? "***" : "Username"} id="kavitaUsername" type="text" onChange={(e) => setKavitaSettings(s => ({...s, username: e.target.value}))} />
|
||||
<label htmlFor="kavitaPassword">Password</label>
|
||||
<input placeholder={KavitaConnected() ? "***" : "Password"} id="kavitaPassword" type="password" onChange={(e) => setKavitaSettings(s => ({...s, password: e.target.value}))} />
|
||||
<div className="section-actions">
|
||||
<span onClick={() => new Kavita(kavitaSettings).Test(apiUri).then(()=>alert("Test successful"))}>Test</span>
|
||||
<span onClick={() => new Kavita(kavitaSettings).Reset(apiUri).then(RefreshInputs)}>Reset</span>
|
||||
<span onClick={() => new Kavita(kavitaSettings).Create(apiUri).then(RefreshInputs)}>Apply</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
NOTIFICATION CONNECTORS
|
||||
<div className="settings-section-content">
|
||||
<div className="section-item" connector-status={GotifyConnected() ? "Configured" : "Not Configured"}>
|
||||
<span className="settings-section-title">
|
||||
<img src='../media/connector-icons/gotify-logo.png' alt="Gotify Logo"/>
|
||||
Gotify
|
||||
</span>
|
||||
<label htmlFor="gotifyUrl">URL</label>
|
||||
<input placeholder={GetGotify()?.endpoint ?? "URL"} id="gotifyUrl" type="text" onChange={(e) => setGotifySettings(s => ({...s, url: e.target.value}))} />
|
||||
<label htmlFor="gotifyAppToken">AppToken</label>
|
||||
<input placeholder={GotifyConnected() ? "***" : "AppToken"} id="gotifyAppToken" type="text" onChange={(e) => setGotifySettings(s => ({...s, appToken: e.target.value}))} />
|
||||
<div className="section-actions">
|
||||
<span onClick={() => new Gotify(gotifySettings).Test(apiUri).then(()=>alert("Test successful"))}>Test</span>
|
||||
<span onClick={() => new Gotify(gotifySettings).Reset(apiUri).then(RefreshInputs)}>Reset</span>
|
||||
<span onClick={() => new Gotify(gotifySettings).Create(apiUri).then(RefreshInputs)}>Apply</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-item"
|
||||
connector-status={LunaseaConnected() ? "Configured" : "Not Configured"}>
|
||||
<span className="settings-section-title">
|
||||
<img src='../media/connector-icons/lunasea.png' alt="Lunasea Logo"/>
|
||||
LunaSea
|
||||
</span>
|
||||
<label htmlFor="lunaseaWebhook">Webhook id</label>
|
||||
<input placeholder={GetLunasea() != undefined ? "***" : "device/:id or user/:id"} id="lunaseaWebhook" type="text" onChange={(e) => setLunaseaSettings(s => ({...s, webhook: e.target.value}))} />
|
||||
<div className="section-actions">
|
||||
<span onClick={() => new Lunasea(lunaseaSettings).Test(apiUri).then(()=>alert("Test successful"))}>Test</span>
|
||||
<span onClick={() => new Lunasea(lunaseaSettings).Reset(apiUri).then(RefreshInputs)}>Reset</span>
|
||||
<span onClick={() => new Lunasea(lunaseaSettings).Create(apiUri).then(RefreshInputs)}>Apply</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-item"
|
||||
connector-status={NtfyConnected() ? "Configured" : "Not Configured"}>
|
||||
<span className="settings-section-title">
|
||||
<img src='../media/connector-icons/ntfy.svg' alt="ntfy Logo"/>
|
||||
Ntfy
|
||||
</span>
|
||||
<label htmlFor="ntfyEndpoint">URL</label>
|
||||
<input placeholder={GetNtfy()?.endpoint ?? "URL"} id="ntfyEndpoint" type="text" onChange={(e) => setNtfySettings(s => ({...s, url: e.target.value}))} />
|
||||
<label htmlFor="ntfyUsername">Username</label>
|
||||
<input placeholder={NtfyConnected() ? "***" : "Username"} id="ntfyUsername" type="text" onChange={(e) => setNtfySettings(s => ({...s, username: e.target.value}))} />
|
||||
<label htmlFor="ntfyPassword">Password</label>
|
||||
<input placeholder={NtfyConnected() ? "***" : "Password"} id="ntfyPassword" type="password" onChange={(e) => setNtfySettings(s => ({...s, password: e.target.value}))} />
|
||||
<label htmlFor="ntfyTopic">Topic</label>
|
||||
<input placeholder={GetNtfy()?.topic ?? "Topic"} id="ntfyTopic" type="text" onChange={(e) => setNtfySettings(s => ({...s, topic: e.target.value}))} />
|
||||
<div className="section-actions">
|
||||
<span onClick={() => new Ntfy(ntfySettings).Test(apiUri).then(()=>alert("Test successful"))}>Test</span>
|
||||
<span onClick={() => new Ntfy(ntfySettings).Reset(apiUri).then(RefreshInputs)}>Reset</span>
|
||||
<span onClick={() => new Ntfy(ntfySettings).Create(apiUri).then(RefreshInputs)}>Apply</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
: <></>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
export default interface IBackendSettings {
|
||||
"downloadLocation": string;
|
||||
"workingDirectory": string;
|
||||
"apiPortNumber": number;
|
||||
"userAgent": string;
|
||||
"bufferLibraryUpdates": boolean;
|
||||
"bufferNotifications": boolean;
|
||||
"version": number;
|
||||
"aprilFoolsMode": boolean;
|
||||
"compression": number;
|
||||
"bwImages": boolean;
|
||||
"requestLimits": {
|
||||
"MangaInfo": number;
|
||||
"MangaDexFeed": number;
|
||||
"MangaDexImage": number;
|
||||
"MangaImage": number;
|
||||
"MangaCover": number;
|
||||
"Default": number
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import IManga from "./IManga";
|
||||
|
||||
export default interface IChapter{
|
||||
parentManga: IManga;
|
||||
name: string | undefined;
|
||||
volumeNumber: string;
|
||||
chapterNumber: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import {Cookies} from "react-cookie";
|
||||
|
||||
export default interface IFrontendSettings {
|
||||
jobInterval: Date;
|
||||
apiUri: string;
|
||||
}
|
||||
|
||||
export function LoadFrontendSettings(): IFrontendSettings {
|
||||
const cookies = new Cookies();
|
||||
return {
|
||||
jobInterval: cookies.get('jobInterval') === undefined
|
||||
? new Date(0,0,0,3)
|
||||
: cookies.get('jobInterval'),
|
||||
apiUri: cookies.get('apiUri') === undefined
|
||||
? `${window.location.protocol}//${window.location.host}/api`
|
||||
: cookies.get('apiUri')
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import IMangaConnector from "./IMangaConnector";
|
||||
import IProgressToken from "./IProgressToken";
|
||||
import IChapter from "./IChapter";
|
||||
|
||||
export default interface IJob{
|
||||
progressToken: IProgressToken;
|
||||
recurring: boolean;
|
||||
recurrenceTime: string;
|
||||
lastExecution: Date;
|
||||
nextExecution: Date;
|
||||
id: string;
|
||||
jobType: number;
|
||||
parentJobId: string | null;
|
||||
mangaConnector: IMangaConnector;
|
||||
mangaInternalId: string | undefined; //only on DownloadNewChapters
|
||||
translatedLanguage: string | undefined; //only on DownloadNewChapters
|
||||
chapter: IChapter | undefined; //only on DownloadChapter
|
||||
}
|
||||
|
||||
export function JobTypeFromNumber(n: number): string {
|
||||
switch(n) {
|
||||
case 0: return "Download Chapter";
|
||||
case 1: return "Download New Chapters";
|
||||
case 2: return "Update Metadata";
|
||||
case 3: return "Monitor";
|
||||
}
|
||||
return "";
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
export default interface ILibraryConnector {
|
||||
libraryType: number;
|
||||
baseUrl: string;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
export function GetLibraryConnectorNameFromNumber(n: number): string {
|
||||
switch(n){
|
||||
case 0: return "Komga";
|
||||
case 1: return "Kavita";
|
||||
}
|
||||
return "";
|
||||
}
|
@ -1,130 +0,0 @@
|
||||
import IMangaConnector from "./IMangaConnector";
|
||||
import KeyValuePair from "./KeyValuePair";
|
||||
import Manga from "../Manga";
|
||||
import React, {EventHandler, ReactElement, ReactEventHandler} from "react";
|
||||
import Icon from '@mdi/react';
|
||||
import { mdiTagTextOutline, mdiAccountEdit } from '@mdi/js';
|
||||
import MarkdownPreview from '@uiw/react-markdown-preview';
|
||||
import IJob, {JobTypeFromNumber} from "./IJob";
|
||||
import Job from "../Job";
|
||||
import ProgressBar from "@ramonak/react-progress-bar";
|
||||
|
||||
export default interface IManga{
|
||||
"sortName": string,
|
||||
"authors": string[],
|
||||
"altTitles": KeyValuePair[],
|
||||
"description": string,
|
||||
"tags": string[],
|
||||
"coverUrl": string,
|
||||
"coverFileNameInCache": string,
|
||||
"links": KeyValuePair[],
|
||||
"year": number,
|
||||
"originalLanguage": string,
|
||||
"releaseStatus": number,
|
||||
"folderName": string,
|
||||
"publicationId": string,
|
||||
"internalId": string,
|
||||
"ignoreChaptersBelow": number,
|
||||
"latestChapterDownloaded": number,
|
||||
"latestChapterAvailable": number,
|
||||
"websiteUrl": string,
|
||||
"mangaConnector": IMangaConnector
|
||||
}
|
||||
|
||||
export function ReleaseStatusFromNumber(n: number): string {
|
||||
switch(n) {
|
||||
case 0: return "Ongoing";
|
||||
case 1: return "Completed";
|
||||
case 2: return "OnHiatus";
|
||||
case 3: return "Cancelled";
|
||||
case 4: return "Unreleased";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function CoverCard(apiUri: string, manga: IManga) : ReactElement {
|
||||
const MangaCover : ReactEventHandler<HTMLImageElement> = (e) => {
|
||||
if(e.currentTarget.src != Manga.GetMangaCoverUrl(apiUri, manga.internalId, e.currentTarget))
|
||||
e.currentTarget.src = Manga.GetMangaCoverUrl(apiUri, manga.internalId, e.currentTarget);
|
||||
}
|
||||
|
||||
return(
|
||||
<div className="Manga" key={manga.internalId}>
|
||||
<img src="../../media/blahaj.png" onLoad={MangaCover} alt="Manga Cover"></img>
|
||||
<div>
|
||||
<p className="pill connector-name">{manga.mangaConnector.name}</p>
|
||||
<div className="Manga-status" release-status={ReleaseStatusFromNumber(manga.releaseStatus)}></div>
|
||||
<p className="Manga-name">{manga.sortName}</p>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
export function SearchResult(apiUri: string, manga: IManga, interval: Date, onJobsChanged: (internalId: string) => void) : ReactElement {
|
||||
const MangaCover : ReactEventHandler<HTMLImageElement> = (e) => {
|
||||
if(e.currentTarget.src != Manga.GetMangaCoverUrl(apiUri, manga.internalId, e.currentTarget))
|
||||
e.currentTarget.src = Manga.GetMangaCoverUrl(apiUri, manga.internalId, e.currentTarget);
|
||||
}
|
||||
|
||||
return(
|
||||
<div className="SearchResult" key={manga.internalId}>
|
||||
<img src="../../media/blahaj.png" onLoad={MangaCover} alt="Manga Cover"></img>
|
||||
<p className="connector-name">{manga.mangaConnector.name}</p>
|
||||
<div className="Manga-status" release-status={ReleaseStatusFromNumber(manga.releaseStatus)}></div>
|
||||
<p className="Manga-name"><a href={manga.websiteUrl}>{manga.sortName}<img src="../../media/link.svg"
|
||||
alt=""/></a></p>
|
||||
<div className="Manga-tags">
|
||||
{manga.authors.map(author => <p className="Manga-author" key={manga.internalId + "-author-" + author}>
|
||||
<Icon path={mdiAccountEdit} size={0.5}/> {author}</p>)}
|
||||
{manga.tags.map(tag => <p className="Manga-tag" key={manga.internalId + "-tag-" + tag}><Icon
|
||||
path={mdiTagTextOutline} size={0.5}/> {tag}</p>)}
|
||||
</div>
|
||||
<MarkdownPreview className="Manga-description" source={manga.description}
|
||||
style={{backgroundColor: "transparent", color: "black"}}/>
|
||||
<button className="Manga-AddButton" onClick={() => {
|
||||
Job.CreateJobDateInterval(apiUri, manga.internalId, "MonitorManga", interval).then(() => onJobsChanged(manga.internalId));
|
||||
}}>Monitor
|
||||
</button>
|
||||
</div>);
|
||||
}
|
||||
|
||||
function ProgressbarStr(job: IJob): string {
|
||||
return job.progressToken.timeRemaining.substring(0,job.progressToken.timeRemaining.indexOf(".")).concat(" ", ToPercentString(job.progressToken.progress));
|
||||
}
|
||||
|
||||
function ToPercentString(n: number): string {
|
||||
return n.toString().substring(2,4).concat("%");
|
||||
}
|
||||
|
||||
export function QueueItem(apiUri: string, manga: IManga, job: IJob, triggerUpdate: () => void){
|
||||
const MangaCover : ReactEventHandler<HTMLImageElement> = (e) => {
|
||||
if(e.currentTarget.src != Manga.GetMangaCoverUrl(apiUri, manga.internalId, e.currentTarget))
|
||||
e.currentTarget.src = Manga.GetMangaCoverUrl(apiUri, manga.internalId, e.currentTarget);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="QueueJob" key={"QueueJob-" + job.id}>
|
||||
<img src="../../media/blahaj.png" onLoad={MangaCover} alt="Manga Cover"></img>
|
||||
<p className="QueueJob-Name">{manga.sortName}</p>
|
||||
<p className="QueueJob-JobType">{JobTypeFromNumber(job.jobType)}</p>
|
||||
<p className="QueueJob-additional">{job.jobType == 0 ? `Vol.${job.chapter?.volumeNumber} Ch.${job.chapter?.chapterNumber}` : ""}</p>
|
||||
{job.progressToken.state === 0
|
||||
? <ProgressBar labelColor={"#000"} height={"10px"} labelAlignment={"outside"}
|
||||
className="QueueJob-Progressbar" completed={job.progressToken.progress} maxCompleted={1}
|
||||
customLabel={ProgressbarStr(job)}/>
|
||||
: <div className="QueueJob-Progressbar"></div>}
|
||||
<div className="QueueJob-actions">
|
||||
<button className="QueueJob-Cancel"
|
||||
onClick={() => Job.CancelJob(apiUri, job.id).then(triggerUpdate)}>Cancel
|
||||
</button>
|
||||
{job.parentJobId != null
|
||||
? <button className="QueueJob-Cancel"
|
||||
onClick={() => Job.CancelJob(apiUri, job.parentJobId!).then(triggerUpdate)}>Cancel all
|
||||
related</button>
|
||||
: <></>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
export default interface IMangaConnector {
|
||||
SupportedLanguages: string[];
|
||||
name: string;
|
||||
BaseUris: string[];
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
export default interface INotificationConnector {
|
||||
"notificationConnectorType": number; //see NotificationConnectorType
|
||||
"endpoint": string | undefined;//only on Ntfy, Gotify
|
||||
"appToken": string | undefined;//only on Gotify
|
||||
"auth": string | undefined;//only on Ntfy
|
||||
"topic": string | undefined;//only on Ntfy
|
||||
"id": string | undefined;//only on LunaSea
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
export default interface IProgressToken{
|
||||
cancellationRequested: boolean;
|
||||
increments: number;
|
||||
incrementsCompleted: number;
|
||||
progress: number;
|
||||
lastUpdate: Date;
|
||||
executionStarted: Date;
|
||||
timeRemaining: string;
|
||||
state: number;
|
||||
}
|
||||
|
||||
export function GetProgressStateFromNumber(n: number): string {
|
||||
switch (n){
|
||||
case 0: return "Running";
|
||||
case 1: return "Complete";
|
||||
case 2: return "Standby";
|
||||
case 3: return "Cancelled";
|
||||
case 4: return "Waiting";
|
||||
}
|
||||
return "";
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
export default interface KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
@ -1,127 +0,0 @@
|
||||
.pill {
|
||||
flex-grow: 0;
|
||||
height: 14pt;
|
||||
font-size: 12pt;
|
||||
border-radius: 9pt;
|
||||
background-color: var(--primary-color);
|
||||
padding: 2pt 17px;
|
||||
color: black;
|
||||
width: fit-content;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.Manga{
|
||||
cursor: pointer;
|
||||
background-color: var(--secondary-color);
|
||||
width: 180px;
|
||||
height: 300px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 10px;
|
||||
padding: 14px 20px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.Manga::after{
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0;
|
||||
border-radius: 5px;
|
||||
width: 100%; height: 100%;
|
||||
background: linear-gradient(rgba(0,0,0,0.8), rgba(0, 0, 0, 0.7),rgba(0, 0, 0, 0.2));
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.Manga-name{
|
||||
width: fit-content;
|
||||
font-size: 16pt;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.Manga-status {
|
||||
display:block;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
border-radius: 50%;
|
||||
margin: 5px;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
z-index: 2;
|
||||
box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 10px, rgb(51, 51, 51) 0px 0px 10px 3px;
|
||||
}
|
||||
|
||||
.Manga-status::after {
|
||||
content: attr(release-status);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
visibility: hidden;
|
||||
|
||||
/*Text Properties*/
|
||||
font-size:10pt;
|
||||
font-weight:bold;
|
||||
color:white;
|
||||
text-align: center;
|
||||
|
||||
/*Size*/
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
border: 0px;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.Manga-status:hover::after{
|
||||
visibility:visible;
|
||||
}
|
||||
|
||||
|
||||
.Manga-status[release-status="Ongoing"]{
|
||||
background-color: limegreen;
|
||||
}
|
||||
|
||||
.Manga-status[release-status="Completed"]{
|
||||
background-color: blueviolet;
|
||||
}
|
||||
|
||||
.Manga-status[release-status="On Hiatus"]{
|
||||
background-color: darkorange;
|
||||
}
|
||||
|
||||
.Manga-status[release-status="Cancelled"]{
|
||||
background-color: firebrick;
|
||||
}
|
||||
|
||||
.Manga-status[release-status="Upcoming"]{
|
||||
background-color: aqua;
|
||||
}
|
||||
|
||||
.Manga-status[release-status="Status Unavailable"]{
|
||||
background-color: gray;
|
||||
}
|
||||
|
||||
.Manga img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.Manga p {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.Manga > div {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
@ -1,119 +0,0 @@
|
||||
.SearchResult {
|
||||
background-color: var(--second-background-color);
|
||||
border-radius: 2px;
|
||||
padding: 5px 5px 9px 5px;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
width: fit-content;
|
||||
height: 328px;
|
||||
display: grid;
|
||||
grid-template-columns: 220px 600px 80px;
|
||||
grid-template-rows: 55px 55px 190px auto;
|
||||
column-gap: 10px;
|
||||
grid-template-areas:
|
||||
"cover header header"
|
||||
"cover alltags alltags"
|
||||
"cover description description"
|
||||
"cover footer button";
|
||||
}
|
||||
|
||||
.SearchResult p {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.SearchResult > img {
|
||||
grid-area: cover;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.SearchResult > .connector-name {
|
||||
grid-area: cover;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
width: 100%;
|
||||
background-color: var(--accent-color);
|
||||
margin: 0;
|
||||
padding: 2px 0;
|
||||
text-align: center;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.SearchResult > .Manga-status {
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
.SearchResult > .Manga-name {
|
||||
grid-area: header;
|
||||
color: black;
|
||||
padding: 0 30px 0 0;
|
||||
}
|
||||
|
||||
.SearchResult > .Manga-tags {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
grid-area: alltags;
|
||||
color: white;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.SearchResult > .Manga-tags p {
|
||||
margin: 0 2px;
|
||||
padding: 5px;
|
||||
font-size: 10pt;
|
||||
height: fit-content;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.SearchResult .Manga-author {
|
||||
background-color: green;
|
||||
}
|
||||
|
||||
.SearchResult .Manga-tag {
|
||||
background-color: blue;
|
||||
}
|
||||
|
||||
.SearchResult > .Manga-description {
|
||||
grid-area: description;
|
||||
color: black;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.SearchResult > .Manga-AddButton {
|
||||
grid-area: button;
|
||||
background-color: white;
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.SearchResult > .Manga-AddButton:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.SearchResult a, .SearchResult a:visited {
|
||||
color: initial;
|
||||
}
|
||||
|
||||
.SearchResult a img {
|
||||
filter: brightness(0) saturate(100%) invert(0%) sepia(0%) saturate(7480%) hue-rotate(141deg) brightness(111%) contrast(99%);
|
||||
position: relative;
|
||||
bottom: 7px;
|
||||
}
|
||||
|
||||
.monitorMangaEntry {
|
||||
position: relative;
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--primary-color);
|
||||
align-content: center;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
color: white;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#madeWith {
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
margin-right: 20px;
|
||||
cursor: url("Website/media/blahaj.png"), grab;
|
||||
}
|
||||
|
||||
footer .statusBadge {
|
||||
margin: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
background-color: rgba(255,255,255, 0.3);
|
||||
border-radius: 10px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
footer > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
footer .hoverHand {
|
||||
cursor: pointer;
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--topbar-height);
|
||||
background-color: var(--secondary-color);
|
||||
z-index: 100;
|
||||
box-shadow: 0 0 20px black;
|
||||
}
|
||||
|
||||
header > #titlebox {
|
||||
position: relative;
|
||||
display: flex;
|
||||
margin: 0 0 0 40px;
|
||||
height: 100%;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
}
|
||||
|
||||
header > #titlebox > span{
|
||||
cursor: default;
|
||||
font-size: 24pt;
|
||||
font-weight: bold;
|
||||
background: linear-gradient(150deg, var(--primary-color), var(--accent-color));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
header > #titlebox > img {
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
:root{
|
||||
--background-color: #030304;
|
||||
--second-background-color: white;
|
||||
--primary-color: #f5a9b8;
|
||||
--secondary-color: #5bcefa;
|
||||
--blur-background: rgba(245, 169, 184, 0.58);
|
||||
--accent-color: #fff;
|
||||
/* --primary-color: green;
|
||||
--secondary-color: gold;
|
||||
--blur-background: rgba(86, 131, 36, 0.8);
|
||||
--accent-color: olive; */
|
||||
--topbar-height: 60px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body{
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
background-color: var(--background-color);
|
||||
font-family: "Inter", sans-serif;
|
||||
overflow-x: hidden;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip:hover:before {
|
||||
display: block;
|
||||
content: attr(data-tooltip, "tooltip");
|
||||
background-color: var(--second-background-color);
|
||||
color: var(--secondary-color);
|
||||
border: 1px solid var(--secondary-color);
|
||||
border-radius: 6px;
|
||||
bottom: 1em;
|
||||
max-width: 90%;
|
||||
position: absolute;
|
||||
padding: 3px 7px 1px;
|
||||
z-index: 999;
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
#MonitorMangaList {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: nowrap;
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
scrollbar-color: var(--accent-color) var(--primary-color);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.MangaActionButtons {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.MangaActionButtons > div {
|
||||
position: absolute;
|
||||
margin: 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.DeleteJobButton {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
filter: invert(21%) sepia(63%) saturate(7443%) hue-rotate(355deg) brightness(93%) contrast(118%);
|
||||
}
|
||||
|
||||
.StartJobNowButton {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
filter: invert(58%) sepia(16%) saturate(4393%) hue-rotate(103deg) brightness(102%) contrast(103%);
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
.popup {
|
||||
position: fixed;
|
||||
left: 10%;
|
||||
top: 7.5%;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
margin: auto;
|
||||
z-index: 100;
|
||||
background-color: var(--second-background-color);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popup .popupHeader {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.popup .popupHeader h1 {
|
||||
margin: 4px 10px;
|
||||
font-size: 20pt;
|
||||
}
|
||||
|
||||
.popup .close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popup .popupBody {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: calc(100% - 40px);
|
||||
}
|
@ -1,81 +0,0 @@
|
||||
#QueuePopUp #QueuePopUpBody {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#QueuePopUp #QueuePopUpBody > * {
|
||||
padding: 20px;
|
||||
width: calc(50% - 40px);
|
||||
height: calc(100% - 40px);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
#QueuePopUp #QueuePopUpBody h1 {
|
||||
padding: 0;
|
||||
margin: 0 0 5px 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
#QueuePopUp #QueuePopUpBody > *:first-child {
|
||||
border-right: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
#QueuePopUp #QueuePopUpBody .JobQueue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.QueueJob {
|
||||
color: black;
|
||||
margin: 5px 0;
|
||||
position: relative;
|
||||
height: 200px;
|
||||
display: grid;
|
||||
grid-template-columns: 150px auto;
|
||||
grid-template-rows: 25% 20% auto 15% 12%;
|
||||
column-gap: 10px;
|
||||
grid-template-areas:
|
||||
"cover name"
|
||||
"cover jobType"
|
||||
"cover additionalInfo"
|
||||
"cover progress"
|
||||
"cover actions"
|
||||
}
|
||||
|
||||
.QueueJob p {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.QueueJob img{
|
||||
grid-area: cover;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.QueueJob .QueueJob-Name{
|
||||
grid-area: name;
|
||||
font-weight: bold;
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.QueueJob .JobType{
|
||||
grid-area: jobType;
|
||||
}
|
||||
|
||||
.QueueJob .QueueJob-additionalInfo {
|
||||
grid-area: additionalInfo;
|
||||
}
|
||||
|
||||
.QueueJob .QueueJob-actions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.QueueJob .QueueJob-Cancel {
|
||||
grid-area: actions;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.QueueJob .QueueJob-Progressbar {
|
||||
grid-area: progress;
|
||||
}
|
@ -1,143 +0,0 @@
|
||||
/* https://raw.githubusercontent.com/instructure-react/react-toggle/master/style.css */
|
||||
|
||||
.react-toggle {
|
||||
touch-action: pan-x;
|
||||
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
-webkit-tap-highlight-color: rgba(0,0,0,0);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.react-toggle-screenreader-only {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.react-toggle--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
-webkit-transition: opacity 0.25s;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.react-toggle-track {
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border-radius: 30px;
|
||||
background-color: #4D4D4D;
|
||||
-webkit-transition: all 0.2s ease;
|
||||
-moz-transition: all 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.react-toggle--checked .react-toggle-track {
|
||||
background-color: #19AB27;
|
||||
}
|
||||
|
||||
.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
|
||||
background-color: #128D15;
|
||||
}
|
||||
|
||||
.react-toggle-track-check {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 10px;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
line-height: 0;
|
||||
left: 8px;
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.25s ease;
|
||||
-moz-transition: opacity 0.25s ease;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.react-toggle--checked .react-toggle-track-check {
|
||||
opacity: 1;
|
||||
-webkit-transition: opacity 0.25s ease;
|
||||
-moz-transition: opacity 0.25s ease;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.react-toggle-track-x {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
line-height: 0;
|
||||
right: 10px;
|
||||
opacity: 1;
|
||||
-webkit-transition: opacity 0.25s ease;
|
||||
-moz-transition: opacity 0.25s ease;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.react-toggle--checked .react-toggle-track-x {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.react-toggle-thumb {
|
||||
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid #4D4D4D;
|
||||
border-radius: 50%;
|
||||
background-color: #FAFAFA;
|
||||
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
|
||||
-webkit-transition: all 0.25s ease;
|
||||
-moz-transition: all 0.25s ease;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.react-toggle--checked .react-toggle-thumb {
|
||||
left: 27px;
|
||||
border-color: #19AB27;
|
||||
}
|
||||
|
||||
.react-toggle--focus .react-toggle-thumb {
|
||||
-webkit-box-shadow: 0px 0px 3px 2px #0099E0;
|
||||
-moz-box-shadow: 0px 0px 3px 2px #0099E0;
|
||||
box-shadow: 0px 0px 2px 3px #0099E0;
|
||||
}
|
||||
|
||||
.react-toggle:active:not(.react-toggle--disabled) .react-toggle-thumb {
|
||||
-webkit-box-shadow: 0px 0px 5px 5px #0099E0;
|
||||
-moz-box-shadow: 0px 0px 5px 5px #0099E0;
|
||||
box-shadow: 0px 0px 5px 5px #0099E0;
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
#Search{
|
||||
position: relative;
|
||||
width: 98vw;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#SearchBox{
|
||||
display: flex;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#SearchResults {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#SearchBox select, #SearchBox button, #SearchBox input {
|
||||
border-color: var(--primary-color);
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
border-bottom-width: 2px;
|
||||
border-top-width: 2px;
|
||||
padding: 2px 5px;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
#Searchbox-Manganame {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-top-left-radius: 2px;
|
||||
border-left-width: 2px !important;
|
||||
min-width: 300px;
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
#Searchbox-connector {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
#Searchbox-language {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
#Searchbox-button {
|
||||
border-bottom-right-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
border-right-width: 2px !important;
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
#closeSearch {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
filter: brightness(0) saturate(100%) invert(100%) sepia(100%) saturate(1%) hue-rotate(20deg) brightness(103%) contrast(101%);
|
||||
}
|
@ -1,101 +0,0 @@
|
||||
#Settings {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#SettingsIcon {
|
||||
height: calc(100% - 26px);
|
||||
margin: 13px;
|
||||
filter: invert(100%) sepia(65%) saturate(2%) hue-rotate(215deg) brightness(113%) contrast(101%);
|
||||
}
|
||||
|
||||
#settingsPopupBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin: 5px 5px 10px;
|
||||
font-size: 10pt;
|
||||
font-weight: 100;
|
||||
display: block;
|
||||
border-top-style: solid;
|
||||
border-top-width: 1px;
|
||||
border-top-color: lightgray;
|
||||
width: calc(100% - 30px);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.settings-section-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
|
||||
}
|
||||
|
||||
.section-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 22%;
|
||||
min-width: 300px;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-color: lightgray;
|
||||
margin: 7px;
|
||||
padding: 5px 5px 35px;
|
||||
}
|
||||
|
||||
.section-item > * {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.section-item[connector-status="Not Configured"]{
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.section-item[connector-status="Configured"]{
|
||||
border-color: green;
|
||||
}
|
||||
|
||||
.section-item > .settings-section-title {
|
||||
font-weight: bold;
|
||||
vertical-align: bottom;
|
||||
line-height: 32px;
|
||||
font-size: 12pt;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-item > .settings-section-title > img {
|
||||
width: auto;
|
||||
height: 32px;
|
||||
margin: 5px;
|
||||
vertical-align: middle;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.section-item .section-actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 5px;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.section-actions > span, #resetUserAgent {
|
||||
border: 1px solid lightgray;
|
||||
padding: 3px 5px 2px;
|
||||
width: 10ch;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#resetUserAgent{
|
||||
align-self: flex-end;
|
||||
margin-top: 3px;
|
||||
}
|
26
nginx/fastcgi.conf
Normal file
@ -0,0 +1,26 @@
|
||||
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param QUERY_STRING $query_string;
|
||||
fastcgi_param REQUEST_METHOD $request_method;
|
||||
fastcgi_param CONTENT_TYPE $content_type;
|
||||
fastcgi_param CONTENT_LENGTH $content_length;
|
||||
|
||||
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
fastcgi_param DOCUMENT_URI $document_uri;
|
||||
fastcgi_param DOCUMENT_ROOT $document_root;
|
||||
fastcgi_param SERVER_PROTOCOL $server_protocol;
|
||||
fastcgi_param REQUEST_SCHEME $scheme;
|
||||
fastcgi_param HTTPS $https if_not_empty;
|
||||
|
||||
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
|
||||
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
|
||||
|
||||
fastcgi_param REMOTE_ADDR $remote_addr;
|
||||
fastcgi_param REMOTE_PORT $remote_port;
|
||||
fastcgi_param SERVER_ADDR $server_addr;
|
||||
fastcgi_param SERVER_PORT $server_port;
|
||||
fastcgi_param SERVER_NAME $server_name;
|
||||
|
||||
# PHP only, required if PHP was built with --enable-force-cgi-redirect
|
||||
fastcgi_param REDIRECT_STATUS 200;
|
25
nginx/fastcgi_params
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
fastcgi_param QUERY_STRING $query_string;
|
||||
fastcgi_param REQUEST_METHOD $request_method;
|
||||
fastcgi_param CONTENT_TYPE $content_type;
|
||||
fastcgi_param CONTENT_LENGTH $content_length;
|
||||
|
||||
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
fastcgi_param DOCUMENT_URI $document_uri;
|
||||
fastcgi_param DOCUMENT_ROOT $document_root;
|
||||
fastcgi_param SERVER_PROTOCOL $server_protocol;
|
||||
fastcgi_param REQUEST_SCHEME $scheme;
|
||||
fastcgi_param HTTPS $https if_not_empty;
|
||||
|
||||
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
|
||||
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
|
||||
|
||||
fastcgi_param REMOTE_ADDR $remote_addr;
|
||||
fastcgi_param REMOTE_PORT $remote_port;
|
||||
fastcgi_param SERVER_ADDR $server_addr;
|
||||
fastcgi_param SERVER_PORT $server_port;
|
||||
fastcgi_param SERVER_NAME $server_name;
|
||||
|
||||
# PHP only, required if PHP was built with --enable-force-cgi-redirect
|
||||
fastcgi_param REDIRECT_STATUS 200
|
99
nginx/mimes.types
Normal file
@ -0,0 +1,99 @@
|
||||
|
||||
types {
|
||||
text/html html htm shtml;
|
||||
text/css css;
|
||||
text/xml xml;
|
||||
image/gif gif;
|
||||
image/jpeg jpeg jpg;
|
||||
application/javascript js;
|
||||
application/atom+xml atom;
|
||||
application/rss+xml rss;
|
||||
|
||||
text/mathml mml;
|
||||
text/plain txt;
|
||||
text/vnd.sun.j2me.app-descriptor jad;
|
||||
text/vnd.wap.wml wml;
|
||||
text/x-component htc;
|
||||
|
||||
image/avif avif;
|
||||
image/png png;
|
||||
image/svg+xml svg svgz;
|
||||
image/tiff tif tiff;
|
||||
image/vnd.wap.wbmp wbmp;
|
||||
image/webp webp;
|
||||
image/x-icon ico;
|
||||
image/x-jng jng;
|
||||
image/x-ms-bmp bmp;
|
||||
|
||||
font/woff woff;
|
||||
font/woff2 woff2;
|
||||
|
||||
application/java-archive jar war ear;
|
||||
application/json json;
|
||||
application/mac-binhex40 hqx;
|
||||
application/msword doc;
|
||||
application/pdf pdf;
|
||||
application/postscript ps eps ai;
|
||||
application/rtf rtf;
|
||||
application/vnd.apple.mpegurl m3u8;
|
||||
application/vnd.google-earth.kml+xml kml;
|
||||
application/vnd.google-earth.kmz kmz;
|
||||
application/vnd.ms-excel xls;
|
||||
application/vnd.ms-fontobject eot;
|
||||
application/vnd.ms-powerpoint ppt;
|
||||
application/vnd.oasis.opendocument.graphics odg;
|
||||
application/vnd.oasis.opendocument.presentation odp;
|
||||
application/vnd.oasis.opendocument.spreadsheet ods;
|
||||
application/vnd.oasis.opendocument.text odt;
|
||||
application/vnd.openxmlformats-officedocument.presentationml.presentation
|
||||
pptx;
|
||||
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
xlsx;
|
||||
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||
docx;
|
||||
application/vnd.wap.wmlc wmlc;
|
||||
application/wasm wasm;
|
||||
application/x-7z-compressed 7z;
|
||||
application/x-cocoa cco;
|
||||
application/x-java-archive-diff jardiff;
|
||||
application/x-java-jnlp-file jnlp;
|
||||
application/x-makeself run;
|
||||
application/x-perl pl pm;
|
||||
application/x-pilot prc pdb;
|
||||
application/x-rar-compressed rar;
|
||||
application/x-redhat-package-manager rpm;
|
||||
application/x-sea sea;
|
||||
application/x-shockwave-flash swf;
|
||||
application/x-stuffit sit;
|
||||
application/x-tcl tcl tk;
|
||||
application/x-x509-ca-cert der pem crt;
|
||||
application/x-xpinstall xpi;
|
||||
application/xhtml+xml xhtml;
|
||||
application/xspf+xml xspf;
|
||||
application/zip zip;
|
||||
|
||||
application/octet-stream bin exe dll;
|
||||
application/octet-stream deb;
|
||||
application/octet-stream dmg;
|
||||
application/octet-stream iso img;
|
||||
application/octet-stream msi msp msm;
|
||||
|
||||
audio/midi mid midi kar;
|
||||
audio/mpeg mp3;
|
||||
audio/ogg ogg;
|
||||
audio/x-m4a m4a;
|
||||
audio/x-realaudio ra;
|
||||
|
||||
video/3gpp 3gpp 3gp;
|
||||
video/mp2t ts;
|
||||
video/mp4 mp4;
|
||||
video/mpeg mpeg mpg;
|
||||
video/quicktime mov;
|
||||
video/webm webm;
|
||||
video/x-flv flv;
|
||||
video/x-m4v m4v;
|
||||
video/x-mng mng;
|
||||
video/x-ms-asf asx asf;
|
||||
video/x-ms-wmv wmv;
|
||||
video/x-msvideo avi;
|
||||
}
|
32
nginx/nginx.conf
Normal file
@ -0,0 +1,32 @@
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
#tcp_nopush on;
|
||||
|
||||
keepalive_timeout 65;
|
||||
|
||||
#gzip on;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
17
nginx/scgi_params
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
scgi_param REQUEST_METHOD $request_method;
|
||||
scgi_param REQUEST_URI $request_uri;
|
||||
scgi_param QUERY_STRING $query_string;
|
||||
scgi_param CONTENT_TYPE $content_type;
|
||||
|
||||
scgi_param DOCUMENT_URI $document_uri;
|
||||
scgi_param DOCUMENT_ROOT $document_root;
|
||||
scgi_param SCGI 1;
|
||||
scgi_param SERVER_PROTOCOL $server_protocol;
|
||||
scgi_param REQUEST_SCHEME $scheme;
|
||||
scgi_param HTTPS $https if_not_empty;
|
||||
|
||||
scgi_param REMOTE_ADDR $remote_addr;
|
||||
scgi_param REMOTE_PORT $remote_port;
|
||||
scgi_param SERVER_PORT $server_port;
|
||||
scgi_param SERVER_NAME $server_name;
|
29
nginx/templates/default.conf.template
Normal file
@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
http2 on;
|
||||
|
||||
server_name _;
|
||||
|
||||
access_log /dev/stdout main;
|
||||
error_log /dev/stderr;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass_request_headers on;
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
proxy_pass $API_URL;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
# redirect server error pages to the static page /50x.html
|
||||
#
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
17
nginx/uwsgi_params
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
uwsgi_param QUERY_STRING $query_string;
|
||||
uwsgi_param REQUEST_METHOD $request_method;
|
||||
uwsgi_param CONTENT_TYPE $content_type;
|
||||
uwsgi_param CONTENT_LENGTH $content_length;
|
||||
|
||||
uwsgi_param REQUEST_URI $request_uri;
|
||||
uwsgi_param PATH_INFO $document_uri;
|
||||
uwsgi_param DOCUMENT_ROOT $document_root;
|
||||
uwsgi_param SERVER_PROTOCOL $server_protocol;
|
||||
uwsgi_param REQUEST_SCHEME $scheme;
|
||||
uwsgi_param HTTPS $https if_not_empty;
|
||||
|
||||
uwsgi_param REMOTE_ADDR $remote_addr;
|
||||
uwsgi_param REMOTE_PORT $remote_port;
|
||||
uwsgi_param SERVER_PORT $server_port;
|
||||
uwsgi_param SERVER_NAME $server_name;
|
3213
package-lock.json
generated
23
package.json
@ -1,23 +0,0 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-toggle": "^4.0.5",
|
||||
"react": "^18.3.1",
|
||||
"react-cookie": "^7.2.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-toggle": "^4.1.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.9"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ramonak/react-progress-bar": "^5.3.0",
|
||||
"@uiw/react-markdown-preview": "^5.1.3"
|
||||
}
|
||||
}
|
24
tranga-website/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
54
tranga-website/README.md
Normal file
@ -0,0 +1,54 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
```
|
28
tranga-website/eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
13
tranga-website/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/blahaj.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tranga</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
6394
tranga-website/package-lock.json
generated
Normal file
35
tranga-website/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "tranga-website",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fontsource/inter": "^5.2.5",
|
||||
"@mui/icons-material": "^7.0.1",
|
||||
"@mui/joy": "^5.0.0-beta.52",
|
||||
"@uiw/react-markdown-preview": "^5.1.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
13
tranga-website/src/App.css
Normal file
@ -0,0 +1,13 @@
|
||||
.app {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
}
|
107
tranga-website/src/App.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import Sheet from '@mui/joy/Sheet';
|
||||
import './App.css'
|
||||
import Settings from "./Settings.tsx";
|
||||
import Header from "./Header.tsx";
|
||||
import {Badge, Button} from "@mui/joy";
|
||||
import {useEffect, useState} from "react";
|
||||
import {ApiUriContext} from "./api/fetchApi.tsx";
|
||||
import Search from './Components/Search.tsx';
|
||||
import MangaList from "./Components/MangaList.tsx";
|
||||
import {MangaConnectorContext} from "./api/Contexts/MangaConnectorContext.tsx";
|
||||
import IMangaConnector from "./api/types/IMangaConnector.ts";
|
||||
import {GetAllConnectors} from "./api/MangaConnector.tsx";
|
||||
import JobsDrawer from "./Components/Jobs.tsx";
|
||||
import {MangaContext} from "./api/Contexts/MangaContext.tsx";
|
||||
import IManga from "./api/types/IManga.ts";
|
||||
import {GetMangaById} from "./api/Manga.tsx";
|
||||
import IChapter from "./api/types/IChapter.ts";
|
||||
import {GetChapterFromId} from "./api/Chapter.tsx";
|
||||
import {ChapterContext} from "./api/Contexts/ChapterContext.tsx";
|
||||
|
||||
export default function App () {
|
||||
|
||||
const [showSettings, setShowSettings] = useState<boolean>(false);
|
||||
const [showSearch, setShowSearch] = useState<boolean>(false);
|
||||
const [showJobs, setShowJobs] = useState<boolean>(false);
|
||||
const [apiConnected, setApiConnected] = useState<boolean>(false);
|
||||
|
||||
const apiUriStr = localStorage.getItem("apiUri") ?? window.location.href.substring(0, window.location.href.lastIndexOf("/")) + "/api";
|
||||
|
||||
const [apiUri, setApiUri] = useState<string>(apiUriStr);
|
||||
const [mangas, setMangas] = useState<IManga[]>([]);
|
||||
const [chapters, setChapters] = useState<IChapter[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("apiUri", apiUri);
|
||||
}, [apiUri]);
|
||||
|
||||
const [mangaPromises, setMangaPromises] = useState(new Map<string, Promise<IManga | undefined>>());
|
||||
const GetManga = (mangaId: string) : Promise<IManga | undefined> => {
|
||||
const promise = mangaPromises.get(mangaId);
|
||||
if(promise) return promise;
|
||||
const p = new Promise<IManga | undefined>((resolve, reject) => {
|
||||
let ret = mangas?.find(m => m.mangaId == mangaId);
|
||||
if (ret) resolve(ret);
|
||||
|
||||
console.log(`Fetching manga ${mangaId}`);
|
||||
GetMangaById(apiUri, mangaId).then(manga => {
|
||||
if(manga && mangas?.find(m => m.mangaId == mangaId) === undefined)
|
||||
setMangas([...mangas, manga]);
|
||||
resolve(manga);
|
||||
}).catch(reject);
|
||||
});
|
||||
setMangaPromises(mangaPromises.set(mangaId, p));
|
||||
return p;
|
||||
}
|
||||
|
||||
const [chapterPromises, setChapterPromises] = useState(new Map<string, Promise<IChapter | undefined>>());
|
||||
const GetChapter = (chapterId: string) : Promise<IChapter | undefined> => {
|
||||
const promise = chapterPromises.get(chapterId);
|
||||
if(promise) return promise;
|
||||
const p = new Promise<IChapter | undefined>((resolve, reject) => {
|
||||
let ret = chapters?.find(c => c.chapterId == chapterId);
|
||||
if (ret) resolve(ret);
|
||||
|
||||
console.log(`Fetching chapter ${chapterId}`);
|
||||
GetChapterFromId(apiUri, chapterId).then(chapter => {
|
||||
if(chapter && chapters?.find(c => c.chapterId == chapterId) === undefined)
|
||||
setChapters([...chapters, chapter]);
|
||||
resolve(chapter);
|
||||
}).catch(reject);
|
||||
});
|
||||
setChapterPromises(chapterPromises.set(chapterId, p));
|
||||
return p;
|
||||
}
|
||||
|
||||
const [mangaConnectors, setMangaConnectors] = useState<IMangaConnector[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if(!apiConnected) return;
|
||||
GetAllConnectors(apiUri).then(setMangaConnectors);
|
||||
}, [apiConnected]);
|
||||
|
||||
return (
|
||||
<ApiUriContext.Provider value={apiUri}>
|
||||
<MangaConnectorContext.Provider value={mangaConnectors}>
|
||||
<MangaContext.Provider value={{mangas, GetManga}}>
|
||||
<ChapterContext.Provider value={{chapters, GetChapter}}>
|
||||
<Sheet className={"app"}>
|
||||
<Header>
|
||||
<Badge color={"danger"} invisible={apiConnected} badgeContent={"!"}>
|
||||
<Button onClick={() => setShowSettings(true)}>Settings</Button>
|
||||
</Badge>
|
||||
<Button onClick={() => setShowJobs(true)}>Jobs</Button>
|
||||
</Header>
|
||||
<Settings open={showSettings} setOpen={setShowSettings} setApiUri={setApiUri} setConnected={setApiConnected} />
|
||||
<Search open={showSearch} setOpen={setShowSearch} />
|
||||
<JobsDrawer open={showJobs} connected={apiConnected} setOpen={setShowJobs} />
|
||||
<Sheet className={"app-content"}>
|
||||
<MangaList connected={apiConnected} setShowSearch={setShowSearch} />
|
||||
</Sheet>
|
||||
</Sheet>
|
||||
</ChapterContext.Provider>
|
||||
</MangaContext.Provider>
|
||||
</MangaConnectorContext.Provider>
|
||||
</ApiUriContext.Provider>
|
||||
);
|
||||
}
|
50
tranga-website/src/Components/Chapter.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, {ReactElement, useContext, useState} from "react";
|
||||
import IChapter from "../api/types/IChapter.ts";
|
||||
import {Box, Chip, Link, Stack, Tooltip, Typography} from "@mui/joy";
|
||||
import {MangaFromId} from "./Manga.tsx";
|
||||
import {ChapterContext} from "../api/Contexts/ChapterContext.tsx";
|
||||
import Drawer from "@mui/joy/Drawer";
|
||||
import ModalClose from "@mui/joy/ModalClose";
|
||||
import {Archive} from "@mui/icons-material";
|
||||
|
||||
export function ChapterPopupFromId({chapterId, open, setOpen, children}: { chapterId: string | null, open: boolean, setOpen: React.Dispatch<React.SetStateAction<boolean>>, children?: ReactElement<any, any> | ReactElement<any, any>[] | undefined }) {
|
||||
return (
|
||||
<Drawer anchor={"bottom"} open={open} onClose={() => setOpen(false)}>
|
||||
<ModalClose />
|
||||
{
|
||||
chapterId !== null ?
|
||||
<ChapterFromId chapterId={chapterId}>{children}</ChapterFromId>
|
||||
: null
|
||||
}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChapterFromId({chapterId, children} : { chapterId: string, children?: ReactElement<any, any> | ReactElement<any, any>[] | undefined }){
|
||||
const chapterContext = useContext(ChapterContext);
|
||||
|
||||
const [chapter, setChapter] = useState<IChapter | undefined>(undefined);
|
||||
chapterContext.GetChapter(chapterId).then(setChapter);
|
||||
|
||||
return (
|
||||
chapter === undefined ?
|
||||
null
|
||||
:
|
||||
<Chapter chapter={chapter}>{children}</Chapter>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chapter({chapter, children} : { chapter: IChapter, children?: ReactElement<any, any> | ReactElement<any, any>[] | undefined }){
|
||||
return (
|
||||
<Stack direction={"row"} spacing={5} sx={{paddingTop: "10px"}}>
|
||||
<MangaFromId mangaId={chapter.parentMangaId} />
|
||||
<Box>
|
||||
<Link target={"_blank"} level={"title-lg"} href={chapter.url}>{chapter.title}</Link>
|
||||
<Typography>Volume <Chip>{chapter.volumeNumber}</Chip></Typography>
|
||||
<Typography>Chapter <Chip>{chapter.chapterNumber}</Chip></Typography>
|
||||
<Tooltip title={chapter.fullArchiveFilePath} placement={"bottom-start"}><Archive /></Tooltip>
|
||||
</Box>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
163
tranga-website/src/Components/Jobs.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import {
|
||||
Button,
|
||||
DialogContent, DialogTitle,
|
||||
Drawer,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Typography
|
||||
} from "@mui/joy";
|
||||
import {GetJobsInState, GetJobsOfTypeAndWithState, GetJobsWithType, StartJob} from "../api/Job.tsx";
|
||||
import * as React from "react";
|
||||
import {useCallback, useContext, useEffect, useState} from "react";
|
||||
import {ApiUriContext} from "../api/fetchApi.tsx";
|
||||
import IJob, {JobState, JobStateToString, JobType, JobTypeToString} from "../api/types/Jobs/IJob.ts";
|
||||
import ModalClose from "@mui/joy/ModalClose";
|
||||
import {MangaPopupFromId} from "./MangaPopup.tsx";
|
||||
import IJobWithMangaId from "../api/types/Jobs/IJobWithMangaId.ts";
|
||||
import {ChapterPopupFromId} from "./Chapter.tsx";
|
||||
import IJobWithChapterId from "../api/types/Jobs/IJobWithChapterId.tsx";
|
||||
|
||||
export default function JobsDrawer({open, connected, setOpen} : {open: boolean, connected: boolean, setOpen:React.Dispatch<React.SetStateAction<boolean>>}) {
|
||||
const apiUri = useContext(ApiUriContext);
|
||||
|
||||
const [allJobs, setAllJobs] = useState<IJob[]>([]);
|
||||
|
||||
const [filterState, setFilterState] = useState<string|null>(null);
|
||||
const [filterType, setFilterType] = useState<string|null>(null);
|
||||
|
||||
const pageSize = 10;
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const updateDisplayJobs = useCallback(() => {
|
||||
if(!connected)
|
||||
return;
|
||||
if (filterState === null && filterType === null)
|
||||
setAllJobs([]);
|
||||
else if (filterState === null && filterType != null)
|
||||
GetJobsWithType(apiUri, filterType as unknown as JobType).then(setAllJobs);
|
||||
else if (filterState != null && filterType === null)
|
||||
GetJobsInState(apiUri, filterState as unknown as JobState).then(setAllJobs);
|
||||
else if (filterState != null && filterType != null)
|
||||
GetJobsOfTypeAndWithState(apiUri, filterType as unknown as JobType, filterState as unknown as JobState).then(setAllJobs);
|
||||
}, [connected, filterType, filterState]);
|
||||
|
||||
const timerRef = React.useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
useEffect(() => {
|
||||
clearTimeout(timerRef.current);
|
||||
updateDisplayJobs();
|
||||
timerRef.current = setInterval(updateDisplayJobs, 2000);
|
||||
}, [filterState, filterType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !connected)
|
||||
clearTimeout(timerRef.current);
|
||||
}, [open, connected]);
|
||||
|
||||
const handleChangeState = (
|
||||
_: React.SyntheticEvent | null,
|
||||
newValue: string | null,
|
||||
) => {
|
||||
setFilterState(newValue);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleChangeType = (
|
||||
_: React.SyntheticEvent | null,
|
||||
newValue: string | null,
|
||||
) => {
|
||||
setFilterType(newValue);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const [mangaPopupOpen, setMangaPopupOpen] = React.useState(false);
|
||||
const [selectedMangaId, setSelectedMangaId] = useState<string | null>(null);
|
||||
const OpenMangaPopupDrawer = (mangaId: string) => {
|
||||
setSelectedMangaId(mangaId);
|
||||
setMangaPopupOpen(true);
|
||||
}
|
||||
|
||||
const [chapterPopupOpen, setChapterPopupOpen] = React.useState(false);
|
||||
const [selectedChapterId, setSelectedChapterId] = React.useState<string | null>(null);
|
||||
const OpenChapterPopupDrawer = (chapterId: string) => {
|
||||
setSelectedChapterId(chapterId);
|
||||
setChapterPopupOpen(true);
|
||||
}
|
||||
|
||||
const ReRunJob = useCallback((jobId: string) => {
|
||||
StartJob(apiUri, jobId, false);
|
||||
}, [apiUri]);
|
||||
|
||||
return (
|
||||
<Drawer size={"lg"} anchor={"left"} open={open} onClose={() => setOpen(false)}>
|
||||
<ModalClose />
|
||||
<DialogTitle><Typography level={"h2"}>Jobs</Typography></DialogTitle>
|
||||
<Stack direction={"row"} spacing={2}>
|
||||
<Select placeholder={"State"} value={filterState} onChange={handleChangeState} startDecorator={
|
||||
<Typography>State</Typography>
|
||||
}>
|
||||
<Option value={null}>None</Option>
|
||||
{Object.keys(JobState).map((state) => <Option value={state}>{JobStateToString(state)}</Option>)}
|
||||
</Select>
|
||||
<Select placeholder={"Type"} value={filterType} onChange={handleChangeType} startDecorator={
|
||||
<Typography>Type</Typography>
|
||||
}>
|
||||
<Option value={null}>None</Option>
|
||||
{Object.keys(JobType).map((type) => <Option value={type}>{JobTypeToString(type)}</Option>)}
|
||||
</Select>
|
||||
<Input type={"number"}
|
||||
value={page}
|
||||
onChange={(e) => setPage(parseInt(e.target.value))}
|
||||
slotProps={{input: { min: 1, max: Math.ceil(allJobs.length / pageSize)}}}
|
||||
startDecorator={<Typography>Page</Typography>}
|
||||
endDecorator={<Typography>/{Math.ceil(allJobs.length / pageSize)}</Typography>}/>
|
||||
</Stack>
|
||||
<DialogContent>
|
||||
<Table borderAxis={"bothBetween"} stickyHeader sx={{tableLayout: "auto", width: "100%"}}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>State</th>
|
||||
<th>Last Execution</th>
|
||||
<th>Next Execution</th>
|
||||
<th></th>
|
||||
<th>Extra</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allJobs.slice((page-1)*pageSize, page*pageSize).map((job) => (
|
||||
<tr key={job.jobId}>
|
||||
<td>{JobTypeToString(job.jobType)}</td>
|
||||
<td>{JobStateToString(job.state)}</td>
|
||||
<td>{new Date(job.lastExecution).toLocaleString()}</td>
|
||||
<td>{new Date(job.nextExecution).toLocaleString()}</td>
|
||||
<td style={{whiteSpace: "nowrap"}}><Button onClick={() => ReRunJob(job.jobId)}>Re-Run</Button></td>
|
||||
<td>{ExtraContent(job, OpenMangaPopupDrawer, OpenChapterPopupDrawer)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</DialogContent>
|
||||
<MangaPopupFromId mangaId={selectedMangaId} open={mangaPopupOpen} setOpen={setMangaPopupOpen} />
|
||||
<ChapterPopupFromId chapterId={selectedChapterId} open={chapterPopupOpen} setOpen={setChapterPopupOpen} />
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function ExtraContent(job: IJob, OpenMangaPopupDrawer: (mangaId: string) => void, OpenChapterPopupDrawer: (IJobWithChapterId: string) => void){
|
||||
switch(job.jobType){
|
||||
case JobType.DownloadAvailableChaptersJob:
|
||||
case JobType.DownloadMangaCoverJob:
|
||||
case JobType.RetrieveChaptersJob:
|
||||
case JobType.UpdateChaptersDownloadedJob:
|
||||
case JobType.UpdateCoverJob:
|
||||
case JobType.MoveMangaLibraryJob:
|
||||
return <Button onClick={() => OpenMangaPopupDrawer((job as IJobWithMangaId).mangaId)}>Open Manga</Button>
|
||||
case JobType.DownloadSingleChapterJob:
|
||||
return <Button onClick={() => OpenChapterPopupDrawer((job as IJobWithChapterId).chapterId)}>Show Chapter</Button>
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
119
tranga-website/src/Components/Manga.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import {Badge, Box, Card, CardContent, CardCover, Skeleton, Tooltip, Typography,} from "@mui/joy";
|
||||
import IManga from "../api/types/IManga.ts";
|
||||
import {CSSProperties, ReactElement, useCallback, useContext, useEffect, useRef, useState} from "react";
|
||||
import {GetMangaCoverImageUrl} from "../api/Manga.tsx";
|
||||
import {ApiUriContext, getData} from "../api/fetchApi.tsx";
|
||||
import {MangaReleaseStatus, ReleaseStatusToPalette} from "../api/types/EnumMangaReleaseStatus.ts";
|
||||
import {SxProps} from "@mui/joy/styles/types";
|
||||
import MangaPopup from "./MangaPopup.tsx";
|
||||
import {MangaConnectorContext} from "../api/Contexts/MangaConnectorContext.tsx";
|
||||
import {MangaContext} from "../api/Contexts/MangaContext.tsx";
|
||||
|
||||
export const CardWidth = 190;
|
||||
export const CardHeight = 300;
|
||||
|
||||
const coverSx : SxProps = {
|
||||
height: CardHeight + "px",
|
||||
width: CardWidth + "px",
|
||||
position: "relative",
|
||||
}
|
||||
|
||||
const coverCss : CSSProperties = {
|
||||
maxHeight: "calc("+CardHeight+"px + 2rem)",
|
||||
maxWidth: "calc("+CardWidth+"px + 2rem)",
|
||||
objectFit: "cover",
|
||||
width: "calc("+CardHeight+"px + 2rem)",
|
||||
height: "calc("+CardHeight+"px + 2rem)",
|
||||
}
|
||||
|
||||
export function MangaFromId({mangaId, children} : { mangaId: string, children?: ReactElement<any, any> | ReactElement<any, any>[] | undefined }){
|
||||
const mangaContext = useContext(MangaContext);
|
||||
|
||||
const [manga, setManga] = useState<IManga | undefined>(undefined);
|
||||
mangaContext.GetManga(mangaId).then(setManga);
|
||||
|
||||
return (
|
||||
manga === undefined ?
|
||||
<Badge sx={{margin:"8px !important"}} badgeContent={<Skeleton><Tooltip title={"Loading"}><img width={"24pt"} height={"24pt"} src={"/blahaj.png"} /></Tooltip></Skeleton>} color={ReleaseStatusToPalette(MangaReleaseStatus.Completed)} size={"lg"}>
|
||||
<Card sx={{height:"fit-content",width:"fit-content"}}>
|
||||
<CardCover>
|
||||
<img loading={"lazy"} style={coverCss} src={"/blahaj.png"} alt="Manga Cover"/>
|
||||
</CardCover>
|
||||
<CardCover sx={{
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(0,0,0,0.4), rgba(0,0,0,0) 200px), linear-gradient(to bottom, rgba(0,0,0,0.8), rgba(0,0,0,0) 300px)',
|
||||
}}/>
|
||||
<CardContent sx={{display: "flex", alignItems: "center", flexFlow: "row nowrap"}}>
|
||||
<Box sx={coverSx}>
|
||||
<Typography level={"h3"} sx={{height:"min-content",width:"fit-content",color:"white",margin:"0 0 0 10px"}}>
|
||||
<Skeleton loading={true} animation={"wave"}>
|
||||
{mangaId.split("").splice(0,mangaId.length/2).join(" ")}
|
||||
</Skeleton>
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Badge>
|
||||
:
|
||||
<Manga manga={manga} children={children} />
|
||||
);
|
||||
}
|
||||
|
||||
export function Manga({manga: manga, children} : { manga: IManga, children?: ReactElement<any, any> | ReactElement<any, any>[] | undefined}) {
|
||||
const CoverRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const apiUri = useContext(ApiUriContext);
|
||||
const mangaConnector = useContext(MangaConnectorContext).find(all => all.name == manga.mangaConnectorName);
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
LoadMangaCover();
|
||||
}, [manga, apiUri]);
|
||||
|
||||
const LoadMangaCover = useCallback(() => {
|
||||
if(CoverRef.current == null)
|
||||
return;
|
||||
const coverUrl = GetMangaCoverImageUrl(apiUri, manga.mangaId, CoverRef.current);
|
||||
if(CoverRef.current.src == coverUrl)
|
||||
return;
|
||||
|
||||
//Check if we can fetch the image exists (by fetching it), only then update
|
||||
getData(coverUrl).then(() => {
|
||||
if(CoverRef.current) CoverRef.current.src = coverUrl;
|
||||
});
|
||||
}, [manga, apiUri]);
|
||||
|
||||
const interactiveElements = ["button", "input", "textarea", "a", "select", "option", "li"];
|
||||
|
||||
const maxLength = 50;
|
||||
const mangaName = manga.name.length > maxLength ? manga.name.substring(0, maxLength-3) + "..." : manga.name;
|
||||
|
||||
return (
|
||||
<Badge sx={{margin:"8px !important"}} badgeContent={mangaConnector ? <Tooltip color={ReleaseStatusToPalette(manga.releaseStatus)} title={manga.releaseStatus}><img width={"24pt"} height={"24pt"} src={mangaConnector.iconUrl} /></Tooltip> : manga.mangaConnectorName} color={ReleaseStatusToPalette(manga.releaseStatus)} size={"lg"}>
|
||||
<Card sx={{height:"fit-content",width:"fit-content"}} onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if(interactiveElements.find(x => x == target.localName) == undefined)
|
||||
setExpanded(!expanded)}
|
||||
}>
|
||||
<CardCover>
|
||||
<img loading={"lazy"} style={coverCss} src={GetMangaCoverImageUrl(apiUri, manga.mangaId, CoverRef.current)} alt="Manga Cover"
|
||||
ref={CoverRef}
|
||||
onError={LoadMangaCover}/>
|
||||
</CardCover>
|
||||
<CardCover sx={{
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(0,0,0,0.4), rgba(0,0,0,0) 200px), linear-gradient(to bottom, rgba(0,0,0,0.8), rgba(0,0,0,0) 300px)',
|
||||
}}/>
|
||||
<CardContent sx={{display: "flex", alignItems: "center", flexFlow: "row nowrap"}}>
|
||||
<Box sx={coverSx}>
|
||||
<Typography level={"h3"} sx={{height:"min-content",width:"fit-content",color:"white",margin:"0 0 0 10px"}}>
|
||||
{mangaName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
<MangaPopup manga={manga} open={expanded} setOpen={setExpanded}>{children}</MangaPopup>
|
||||
</Card>
|
||||
</Badge>
|
||||
);
|
||||
}
|
77
tranga-website/src/Components/MangaList.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import {Badge, Box, Button, Card, CardContent, CardCover, Stack, Tooltip, Typography} from "@mui/joy";
|
||||
import {Dispatch, SetStateAction, useCallback, useContext, useEffect, useState} from "react";
|
||||
import {ApiUriContext} from "../api/fetchApi.tsx";
|
||||
import {DeleteJob, GetJobsWithType, StartJob} from "../api/Job.tsx";
|
||||
import {JobType} from "../api/types/Jobs/IJob.ts";
|
||||
import IDownloadAvailableChaptersJob from "../api/types/Jobs/IDownloadAvailableChaptersJob.ts";
|
||||
import {CardHeight, CardWidth, MangaFromId} from "./Manga.tsx";
|
||||
import {PlayArrow, Remove} from "@mui/icons-material";
|
||||
import * as React from "react";
|
||||
|
||||
export default function MangaList({connected, setShowSearch}: {connected: boolean, setShowSearch: Dispatch<SetStateAction<boolean>>} ) {
|
||||
const apiUri = useContext(ApiUriContext);
|
||||
|
||||
const [jobList, setJobList] = useState<IDownloadAvailableChaptersJob[]>([]);
|
||||
|
||||
const getJobList = useCallback(() => {
|
||||
if(!connected)
|
||||
return;
|
||||
GetJobsWithType(apiUri, JobType.DownloadAvailableChaptersJob).then((jl) => setJobList(jl as IDownloadAvailableChaptersJob[]));
|
||||
},[apiUri,connected]);
|
||||
|
||||
const deleteJob = useCallback((jobId: string) => {
|
||||
DeleteJob(apiUri, jobId).finally(() => getJobList());
|
||||
},[apiUri]);
|
||||
|
||||
const startJob = useCallback((jobId: string) => {
|
||||
StartJob(apiUri, jobId, true).finally(() => getJobList());
|
||||
},[apiUri]);
|
||||
|
||||
useEffect(() => {
|
||||
updateTimer();
|
||||
}, [connected, apiUri]);
|
||||
|
||||
const timerRef = React.useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const updateTimer = useCallback(() => {
|
||||
if(!connected){
|
||||
clearTimeout(timerRef.current);
|
||||
return;
|
||||
}else{
|
||||
if(timerRef.current === undefined) {
|
||||
console.log("Added timer!");
|
||||
getJobList();
|
||||
timerRef.current = setInterval(getJobList, 2000);
|
||||
}
|
||||
}
|
||||
}, [getJobList, connected, timerRef]);
|
||||
|
||||
return(
|
||||
<Stack direction="row" spacing={1} flexWrap={"wrap"} sx={{overflowX: 'hidden', overflowY: 'auto' /* Badge overflow */}} paddingTop={"6px" /* Badge overflow */}>
|
||||
<Badge invisible sx={{margin: "8px !important"}}>
|
||||
<Card onClick={() => setShowSearch(true)} sx={{height:"fit-content",width:"fit-content"}}>
|
||||
<CardCover sx={{margin:"var(--Card-padding)"}}>
|
||||
<img src={"/blahaj.png"} style={{height: CardHeight + "px", width: CardWidth + "px"}} />
|
||||
</CardCover>
|
||||
<CardCover sx={{
|
||||
background: 'rgba(234, 119, 246, 0.14)',
|
||||
backdropFilter: 'blur(6.9px)',
|
||||
webkitBackdropFilter: 'blur(6.9px)',
|
||||
}}/>
|
||||
<CardContent>
|
||||
<Box style={{height: CardHeight + "px", width: CardWidth + "px"}} >
|
||||
<Typography level={"h1"}>Search</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Badge>
|
||||
{jobList?.map((job) => (
|
||||
<MangaFromId key={job.mangaId} mangaId={job.mangaId}>
|
||||
<Tooltip title={"Last run: " + new Date(job.lastExecution).toLocaleString()}>
|
||||
<Button color={"success"} endDecorator={<PlayArrow />} onClick={() => startJob(job.jobId)}>Start</Button>
|
||||
</Tooltip>
|
||||
<Button color={"danger"} endDecorator={<Remove />} onClick={() => deleteJob(job.jobId)}>Delete</Button>
|
||||
</MangaFromId>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
186
tranga-website/src/Components/MangaPopup.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import IManga from "../api/types/IManga.ts";
|
||||
import {Badge, Box, Chip, CircularProgress, Drawer, Input, Link, Skeleton, Stack, Typography} from "@mui/joy";
|
||||
import React, {ReactElement, useCallback, useContext, useEffect, useRef, useState} from "react";
|
||||
import {
|
||||
GetLatestChapterAvailable,
|
||||
GetLatestChapterDownloaded,
|
||||
GetMangaCoverImageUrl,
|
||||
SetIgnoreThreshold
|
||||
} from "../api/Manga.tsx";
|
||||
import {ApiUriContext, getData} from "../api/fetchApi.tsx";
|
||||
import MarkdownPreview from "@uiw/react-markdown-preview";
|
||||
import {CardHeight} from "./Manga.tsx";
|
||||
import IChapter from "../api/types/IChapter.ts";
|
||||
import {MangaReleaseStatus, ReleaseStatusToPalette} from "../api/types/EnumMangaReleaseStatus.ts";
|
||||
import {MangaConnectorContext} from "../api/Contexts/MangaConnectorContext.tsx";
|
||||
import {MangaContext} from "../api/Contexts/MangaContext.tsx";
|
||||
import ModalClose from "@mui/joy/ModalClose";
|
||||
|
||||
|
||||
export function MangaPopupFromId({mangaId, open, setOpen, children} : {mangaId: string | null, open: boolean, setOpen: React.Dispatch<React.SetStateAction<boolean>>, children?: ReactElement<any, any> | ReactElement<any, any>[] | undefined}) {
|
||||
const mangaContext = useContext(MangaContext);
|
||||
|
||||
const [manga, setManga] = useState<IManga | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || mangaId === null)
|
||||
return;
|
||||
mangaContext.GetManga(mangaId).then(setManga);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
manga === undefined ?
|
||||
<Drawer anchor="bottom" size="lg" open={open} onClose={() => setOpen(false)}>
|
||||
<ModalClose />
|
||||
<Stack direction="column" spacing={2} margin={"10px"}>
|
||||
{ /* Cover and Description */ }
|
||||
<Stack direction="row" spacing={2} margin={"10px"}>
|
||||
<Badge sx={{margin:"8px !important"}} color={ReleaseStatusToPalette(MangaReleaseStatus.Unreleased)} size={"lg"}>
|
||||
<img src="/blahaj.png" alt="Manga Cover"/>
|
||||
</Badge>
|
||||
<Box>
|
||||
<Skeleton loading={true} animation={"wave"}>
|
||||
{mangaId?.split("").splice(0,mangaId.length/2).join(" ")}
|
||||
</Skeleton>
|
||||
<Stack direction={"row"} flexWrap={"wrap"} useFlexGap={true} spacing={0.3} sx={{maxHeight:CardHeight*0.3+"px", overflowY:"auto", scrollbarWidth: "thin"}}>
|
||||
{mangaId?.split("").filter(x => Number.isNaN(x)).map(_ =>
|
||||
<Skeleton loading={true} animation={"wave"}>
|
||||
<Chip>Wow</Chip>
|
||||
</Skeleton>
|
||||
)}
|
||||
</Stack>
|
||||
<MarkdownPreview style={{backgroundColor: "transparent", color: "var(--joy-palette-neutral-50)", maxHeight:CardHeight*0.7+"px", overflowY:"auto", marginTop:"10px", scrollbarWidth: "thin"}} />
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{ /* Actions */ }
|
||||
<Stack direction="row" spacing={2}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
:
|
||||
<MangaPopup manga={manga} open={open} setOpen={setOpen}>{children}</MangaPopup>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MangaPopup({manga, open, setOpen, children} : {manga: IManga | null, open: boolean, setOpen:React.Dispatch<React.SetStateAction<boolean>>, children?: ReactElement<any, any> | ReactElement<any, any>[] | undefined}) {
|
||||
|
||||
const apiUri = useContext(ApiUriContext);
|
||||
|
||||
const CoverRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const LoadMangaCover = useCallback(() => {
|
||||
if(CoverRef.current == null || manga == null)
|
||||
return;
|
||||
if (!open)
|
||||
return;
|
||||
const coverUrl = GetMangaCoverImageUrl(apiUri, manga.mangaId, CoverRef.current);
|
||||
if(CoverRef.current.src == coverUrl)
|
||||
return;
|
||||
|
||||
//Check if we can fetch the image exists (by fetching it), only then update
|
||||
getData(coverUrl).then(() => {
|
||||
if(CoverRef.current) CoverRef.current.src = coverUrl;
|
||||
});
|
||||
}, [manga, apiUri, open])
|
||||
|
||||
useEffect(() => {
|
||||
if(!open)
|
||||
return;
|
||||
LoadMaxChapter();
|
||||
LoadDownloadedChapter();
|
||||
LoadMangaCover();
|
||||
}, [open]);
|
||||
|
||||
const [mangaMaxChapter, setMangaMaxChapter] = useState<IChapter>();
|
||||
const [maxChapterLoading, setMaxChapterLoading] = useState<boolean>(true);
|
||||
const LoadMaxChapter = useCallback(() => {
|
||||
if(manga == null)
|
||||
return;
|
||||
setMaxChapterLoading(true);
|
||||
GetLatestChapterAvailable(apiUri, manga.mangaId)
|
||||
.then(setMangaMaxChapter)
|
||||
.finally(() => setMaxChapterLoading(false));
|
||||
}, [manga, apiUri]);
|
||||
|
||||
const [mangaDownloadedChapter, setMangaDownloadedChapter] = useState<IChapter>();
|
||||
const [downloadedChapterLoading, setDownloadedChapterLoading] = useState<boolean>(true);
|
||||
const LoadDownloadedChapter = useCallback(() => {
|
||||
if(manga == null)
|
||||
return;
|
||||
setDownloadedChapterLoading(true);
|
||||
GetLatestChapterDownloaded(apiUri, manga.mangaId)
|
||||
.then(setMangaDownloadedChapter)
|
||||
.finally(() => setDownloadedChapterLoading(false));
|
||||
}, [manga, apiUri]);
|
||||
|
||||
const [updatingThreshold, setUpdatingThreshold] = useState<boolean>(false);
|
||||
const updateIgnoreThreshold = useCallback((value: number) => {
|
||||
if(manga == null)
|
||||
return;
|
||||
setUpdatingThreshold(true);
|
||||
SetIgnoreThreshold(apiUri, manga.mangaId, value).finally(() => setUpdatingThreshold(false));
|
||||
},[manga, apiUri])
|
||||
|
||||
const mangaConnector = useContext(MangaConnectorContext).find(all => all.name == manga?.mangaConnectorName);
|
||||
|
||||
return (
|
||||
<Drawer anchor="bottom" size="lg" open={open} onClose={() => setOpen(false)}>
|
||||
<ModalClose />
|
||||
<Stack direction="column" spacing={2} margin={"10px"}>
|
||||
{ /* Cover and Description */ }
|
||||
<Stack direction="row" spacing={2} margin={"10px"}>
|
||||
<Badge sx={{margin:"8px !important"}} badgeContent={mangaConnector ? <img width={"24pt"} height={"24pt"} src={mangaConnector.iconUrl} /> : manga?.mangaConnectorName} color={ReleaseStatusToPalette(manga?.releaseStatus??MangaReleaseStatus.Unreleased)} size={"lg"}>
|
||||
<img src="/blahaj.png" alt="Manga Cover"
|
||||
ref={CoverRef}
|
||||
onLoad={LoadMangaCover}/>
|
||||
</Badge>
|
||||
<Box>
|
||||
<Link target={"_blank"} href={manga?.websiteUrl} level={"h2"}>
|
||||
{manga?.name}
|
||||
</Link>
|
||||
<Stack direction={"row"} flexWrap={"wrap"} useFlexGap={true} spacing={0.3} sx={{maxHeight:CardHeight*0.3+"px", overflowY:"auto", scrollbarWidth: "thin"}}>
|
||||
{manga?.authors?.map(author => <Chip key={author.authorId} variant={"outlined"} size={"md"} color={"success"}>{author.authorName}</Chip>)}
|
||||
{manga?.mangaTags?.map(tag => <Chip key={tag.tag} variant={"soft"} size={"md"} color={"primary"}>{tag.tag}</Chip>)}
|
||||
{manga?.links?.map(link =>
|
||||
<Chip key={link.linkId} variant={"soft"} size={"md"} color={"warning"}>
|
||||
<Link target={"_blank"} sx={{textDecoration:"underline"}} level={"body-xs"} href={link?.linkUrl}>{link?.linkProvider??"Load Failed"}</Link>
|
||||
</Chip>
|
||||
)}
|
||||
</Stack>
|
||||
<MarkdownPreview source={manga?.description} style={{backgroundColor: "transparent", color: "var(--joy-palette-neutral-50)", maxHeight:CardHeight*0.7+"px", overflowY:"auto", marginTop:"10px", scrollbarWidth: "thin"}} />
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{ /* Actions */ }
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Input
|
||||
type={"number"}
|
||||
placeholder={downloadedChapterLoading ? "" : mangaDownloadedChapter?.chapterNumber??"0.0"}
|
||||
startDecorator={
|
||||
<>
|
||||
{
|
||||
updatingThreshold ?
|
||||
<CircularProgress color={"primary"} size={"sm"} />
|
||||
: <Typography>Ch.</Typography>
|
||||
}
|
||||
</>
|
||||
}
|
||||
endDecorator={
|
||||
<Typography>
|
||||
<Skeleton loading={maxChapterLoading}>
|
||||
/{mangaMaxChapter?.chapterNumber??"-"}
|
||||
</Skeleton>
|
||||
</Typography>
|
||||
}
|
||||
sx={{width:"min-content"}}
|
||||
size={"md"}
|
||||
onChange={(e) => updateIgnoreThreshold(e.currentTarget.valueAsNumber)}
|
||||
/>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|