Compare commits

86 Commits

Author SHA1 Message Date
211db3d4d5 Only make 1 request to an endpoint concurrently 2025-03-20 00:58:12 +01:00
a3046680ac Update Job-Footer only every 5 seconds 2025-03-20 00:57:53 +01:00
1ed376ba47 Do not update Queue-preview when not shown 2025-03-20 00:57:23 +01:00
63b10600da README 2025-03-20 00:43:58 +01:00
5333c025f7 Fix loaders 2025-03-20 00:41:35 +01:00
187dd22027 Backend Settings 2025-03-20 00:07:20 +01:00
2092db2ba3 Local Libraries in Settings 2025-03-19 22:44:11 +01:00
ecd76712d8 Add all types of Notification Connectors to settings 2025-03-19 22:01:07 +01:00
60f957ede2 Fix readme 2025-03-19 02:54:07 +01:00
fcd232bec9 Update Screenshots and readme 2025-03-19 02:51:32 +01:00
357fca3bd5 Fix DELETE functions
Fix ADDing new Manga
2025-03-19 02:44:55 +01:00
1d8dd7381d Add Loader-Spinner
Style Settings, re-add api-url-field
2025-03-19 02:37:36 +01:00
0b0abb3801 Add Settings Popup
Add NotificationConnector Setting (need to add styling)
2025-03-17 21:07:27 +01:00
62665f5660 Naming 2025-03-17 20:20:45 +01:00
007b49c624 Single Style for Manga 2025-03-16 22:25:43 +01:00
d9bbbed1c0 Multiple Base-Paths support 2025-03-16 16:48:38 +01:00
42a1e1a2ce Add Readme Screenshots 2025-03-14 01:00:10 +01:00
aad18c0195 Update Readme 2025-03-14 00:50:38 +01:00
1eef710efc Adding a removing jobs.
Having a list of running and waiting jobs
2025-03-14 00:41:07 +01:00
84520d8e18 Adjust all endpoints and methods to tranga/postgres-Server-V2.
Search working.
2025-03-13 20:08:37 +01:00
0402f9e6d0 Change Cover to request 1.5 times the size of actual view 2024-10-31 21:59:41 +01:00
1f8cacb668 Request scaled images 2024-10-31 21:40:01 +01:00
e58d3f8741 Tooltips for some settings 2024-10-27 04:15:25 +01:00
7eca06332a Change compression option to integer range 2024-10-27 03:54:24 +01:00
363d4d7518 Add Compression options for Images 2024-10-27 03:44:06 +01:00
4660f9a402 IntervalStringFromDate: Fix date being a string 2024-10-27 03:43:47 +01:00
07dfee15eb Footer: Change order of statuses 2024-10-27 03:43:24 +01:00
6b3e2899f0 Include UpdateMetaData in queue popup 2024-10-27 03:01:05 +01:00
251ec0a978 Fix Aprilfoolsmode toggle 2024-10-27 02:32:05 +01:00
a486b5bdfc Added April Fools Mode toggle 2024-10-23 03:01:23 +02:00
f426a77f25 Simplified frontend setting creation.
Added ability to reset UserAgent
2024-10-23 02:50:26 +02:00
24417ae180 Added settings Changes. 2024-10-23 02:28:36 +02:00
3cd64b9bfb Fix default export of Manga and Job classes 2024-10-22 18:51:34 +02:00
c525957b2e Fix width of settings section 2024-10-22 18:45:08 +02:00
46ea39245e Settings styling 2024-10-22 18:32:20 +02:00
d621422ae3 Clear inputs when settings changed 2024-10-22 18:12:24 +02:00
f4011a7cbc Fix Settings not updating ApiUri 2024-10-22 18:06:12 +02:00
a383ded819 Update JobsList when adding/removing Jobs 2024-10-20 21:29:07 +02:00
7364a20d5d Add Progressbar to jobs
Add Cancel-Buttons to running jobs
Auto-update Queue
2024-10-20 21:20:18 +02:00
f3a091f09d Make settings-gear icon white 2024-10-20 20:27:47 +02:00
fc3fa627c1 Add "alt" tags to images
Remove unused code
2024-10-20 20:24:52 +02:00
5e4e64f019 Fix covers not loading 2024-10-20 20:24:00 +02:00
0a0e901ef7 Favicon :3 2024-10-20 20:22:06 +02:00
746ad1c16c Remove all logging from console 2024-10-20 20:17:31 +02:00
67bed57ab6 Remove all logging from console 2024-10-20 20:16:32 +02:00
d97eff9796 Make apiUri changeable from frontend 2024-10-20 20:12:27 +02:00
d2533ee98f Settings Template and Popup in Header bar 2024-10-20 17:54:38 +02:00
fcc1ff392c Created standardized Popup-Window
Moved Update-functions for Queue-Status and Monitoring-list to their respective modules
2024-10-20 17:27:02 +02:00
1304bc750a Check backend connection at intervals, not just on startup 2024-10-20 16:06:21 +02:00
4aff0ed5e0 Fix height for queuepopup texts 2024-10-20 02:41:35 +02:00
dbd6684faf Fix Max-width for covers in QueuePopup 2024-10-20 02:40:31 +02:00
73a1725eb9 Fix QueuePopUp not showing any jobs 2024-10-20 02:40:17 +02:00
efdcba57ae WWrap tags on search results 2024-10-20 02:34:30 +02:00
5cb638d9c1 Move MonitorList Entry Buttons to correct stylefile,
replace buttons with clickable divs
2024-10-20 02:33:04 +02:00
eef819d140 Fixup Queue Popup 2024-10-20 02:28:26 +02:00
d04e0f6374 Add Basic QueuePopup that opens when clicking in the footer. 2024-10-20 02:09:30 +02:00
ca3aa2e8e8 Add IChapter 2024-10-20 02:09:12 +02:00
442b2ce0cc IMange export the ReleaseStatusFromNumber function 2024-10-20 02:09:03 +02:00
004f96194f Fix GetMangaByIds (change request string to "mangaIds") 2024-10-20 02:08:44 +02:00
c1d333e002 Add GetStandbyJobs call and display to footer.
Fix IJob to represent possible return values.
2024-10-20 02:08:22 +02:00
96b5163486 Add delete and startnow buttons to Monitoring Entries
nowrap tags in searchresults
2024-10-20 00:30:28 +02:00
035d384411 Fix search not working on enter 2024-10-20 00:05:13 +02:00
59dff529ef Add Markdown support to Manga-Description 2024-10-19 23:47:08 +02:00
5aac05ae08 Remove unnecessary imports 2024-10-19 21:10:59 +02:00
db61837457 Increase Interval between updates. 2024-10-19 21:08:59 +02:00
756998847c Change console levels 2024-10-19 21:08:11 +02:00
ad6502d824 Style "Monitor"-Button in Searchresults 2024-10-19 21:06:32 +02:00
f3cb3f209c Added ability to submit inputbox on search 2024-10-19 21:02:00 +02:00
da002df3f2 Add "Close Search" Button 2024-10-19 20:57:03 +02:00
68887d65a7 Make searchbox size adjust to length 2024-10-19 20:50:14 +02:00
6b08123ee5 Change bordercolor of search
Add margin to Searchresults
2024-10-19 20:41:02 +02:00
adf876c37f Add link to Website to MangaSearchResult 2024-10-19 20:37:33 +02:00
d67b1754f9 Auto-update footer counts 2024-10-19 20:15:53 +02:00
09be1c64a3 Style footer Job-counts. 2024-10-19 20:06:40 +02:00
30f3903662 Add .vite to .gitignore 2024-10-19 19:56:02 +02:00
3f26d3bbd6 Add auto-update when adding/removing Manga
Style SearchResults
2024-10-19 19:52:28 +02:00
daa05a0b4d Added button to open Search-dialog 2024-10-19 16:28:49 +02:00
2f9eb61377 Add monitorMangaList to App 2024-10-18 19:46:17 +02:00
65814dd942 Show Job Status in Footer 2024-10-18 19:45:23 +02:00
dbad993c7a Add Job-Interface (and ProgressToken) 2024-10-18 19:45:04 +02:00
ac8ca1f886 Split Styles for modules into separate files 2024-10-18 19:44:15 +02:00
f27f11e7c2 Style Manga-Display
Fix Manga.GetMangaCoverUrl
2024-10-18 19:43:13 +02:00
9d8dadc634 Add postData and deleteData functions.
Return promise.reject() on failure
2024-10-18 19:42:17 +02:00
ca091bac92 Add connection to backend test 2024-10-18 02:20:22 +02:00
cf09bc50fb Add Header, Footer, Basic Search 2024-10-18 02:10:58 +02:00
d5115809ca Clean/remove everything 2024-10-17 19:46:47 +02:00
112 changed files with 6624 additions and 3209 deletions

View File

@ -3,8 +3,6 @@ name: Docker Image CI
on:
push:
branches: [ "cuttingedge" ]
pull_request:
branches: [ "cuttingedge" ]
workflow_dispatch:
jobs:
@ -19,12 +17,12 @@ jobs:
# https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0
uses: docker/setup-qemu-action@v3.2.0
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3.11.1
uses: docker/setup-buildx-action@v3.6.1
# https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub
@ -35,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.18.0
uses: docker/build-push-action@v6.7.0
with:
context: .
context: ./Website
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,linux/arm64
platforms: linux/amd64
pull: true
push: true
tags: |

View File

@ -17,12 +17,12 @@ jobs:
# https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0
uses: docker/setup-qemu-action@v3.2.0
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3.11.1
uses: docker/setup-buildx-action@v3.6.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.18.0
uses: docker/build-push-action@v6.7.0
with:
context: ./
context: ./Website
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,linux/arm64
platforms: linux/amd64
pull: true
push: true
tags: |

View File

@ -17,12 +17,12 @@ jobs:
# https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0
uses: docker/setup-qemu-action@v3.2.0
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3.11.1
uses: docker/setup-buildx-action@v3.6.1
# https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub
@ -33,9 +33,9 @@ jobs:
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push Website
uses: docker/build-push-action@v6.18.0
uses: docker/build-push-action@v6.7.0
with:
context: .
context: ./Website
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

2
.gitignore vendored
View File

@ -24,3 +24,5 @@ cover.png
.vs/tranga-website/FileContentIndex/91a465d3-1190-42e0-95eb-fa3694744e58.vsidx
.vs/tranga-website/v17/.wsuo
.vs/VSWorkspaceState.json
/node_modules/
/.vite/

View File

@ -1,6 +1,4 @@
FROM nginx:alpine3.17-slim
COPY ./Website /usr/share/nginx/html
COPY ./nginx /etc/nginx
COPY . /usr/share/nginx/html
EXPOSE 80
ENV API_URL=http://tranga-api:6531
CMD ["nginx", "-g", "daemon off;"]

112
README.md
View File

@ -1,58 +1,66 @@
# Testers for V2 wanted!
[Details](https://github.com/C9Glax/tranga/pull/355#issuecomment-2764217944)
<!-- PROJECT LOGO -->
<br />
<div align="center">
<span id="readme-top"></span>
<h3 align="center">Tranga-Website</h3>
<p align="center">
Automatic Manga and Metadata downloader
Automatic MangaFunctions and Metadata downloader
</p>
<p align="center">
This is the Website for <a href="https://github.com/C9Glax/tranga">Tranga</a> (API)
</p>
![GitHub License](https://img.shields.io/github/license/C9glax/tranga-website)
<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
| Default View | Search Window | Search Results |
|-------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|
| ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-38-47%20Tranga.png) | ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-39-05%20Tranga.png)<br/>![Image](Screenshots/Screenshot%202025-03-19%20at%2002-39-45%20Tranga.png) | ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-39-52%20Tranga.png)<br/>![Image](Screenshots/Screenshot%202025-03-19%20at%2002-39-58%20Tranga.png) |
| Search opens with click on "Add new Manga".<br/>Settings are on the top right | When selecting different connectors, available languages automatically update.<br/>Spinners to indicate action being performed | Clicking on an Item here will bring up a view with more information |
| | Different Views for Manga | |
|----------------------------------------------------------------------------|----------------------------------------------------------------------------|----------------------------------------------------------------------------|
| ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-41-51%20Tranga.png) | ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-42-02%20Tranga.png) | ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-42-12%20Tranga.png) |
| | Settings Dialog | |
|-|----------------------------------------------------------------------------|-|
| | ![Image](Screenshots/Screenshot%202025-03-20%20at%2000-42-58%20Tranga.png) | |
## 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
- HTML, CSS, and barebones Javascript
- vite
- react
- typescript
- 💙 Blåhaj 🦈
<p align="right">(<a href="#readme-top">back to top</a>)</p>
@ -60,49 +68,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.
<!-- ROADMAP -->
## Roadmap
- [ ]
See the [open issues](https://github.com/C9Glax/tranga-website/issues) for a full list of proposed features (and known issues).
<p align="right">(<a href="#readme-top">back to top</a>)</p>
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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

153
Website/App.tsx Normal file
View File

@ -0,0 +1,153 @@
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";
import Loader from "./modules/Loader";
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 | undefined>(undefined);
const checkConnectedInterval = 1000;
useEffect(() => {
setCookie('apiUri', frontendSettings.apiUri);
setCookie('jobInterval', frontendSettings.jobInterval);
updateConnected(frontendSettings.apiUri, connected, setConnected);
}, [frontendSettings]);
useEffect(() => {
if(updateInterval === undefined){
setUpdateInterval(setInterval(() => {
updateConnected(frontendSettings.apiUri, connected, setConnected);
}, checkConnectedInterval));
}else{
clearInterval(updateInterval);
setUpdateInterval(undefined);
}
}, [connected]);
return(<div>
<Header apiUri={frontendSettings.apiUri} backendConnected={connected} settings={frontendSettings} setFrontendSettings={setFrontendSettings} />
{connected
? <>
{showSearch
? <>
<Search apiUri={frontendSettings.apiUri} jobInterval={frontendSettings.jobInterval} closeSearch={() => setShowSearch(false)} />
<hr/>
</>
: <></>}
<MonitorJobsList apiUri={frontendSettings.apiUri} onStartSearch={() => setShowSearch(true)} connectedToBackend={connected} checkConnectedInterval={checkConnectedInterval} />
</>
: <>
<h1>No connection to the Backend.</h1>
<h3>Check the Settings ApiUri.</h3>
<Loader loading={true} />
</>}
<Footer apiUri={frontendSettings.apiUri} connectedToBackend={connected} checkConnectedInterval={checkConnectedInterval} />
</div>)
}
export function getData(uri: string) : Promise<object> {
return makeRequest("GET", uri, null) as Promise<object>;
}
export function postData(uri: string, content: object | string | number | boolean) : Promise<object> {
return makeRequest("POST", uri, content) as Promise<object>;
}
export function deleteData(uri: string) : Promise<void> {
return makeRequest("DELETE", uri, null) as Promise<void>;
}
export function patchData(uri: string, content: object | string | number | boolean) : Promise<object> {
return makeRequest("patch", uri, content) as Promise<object>;
}
export function putData(uri: string, content: object | string | number | boolean) : Promise<object> {
return makeRequest("PUT", uri, content) as Promise<object>;
}
let currentlyRequestedEndpoints: string[] = [];
function makeRequest(method: string, uri: string, content: object | string | number | null | boolean) : Promise<object | void> {
const id = method + uri;
if(currentlyRequestedEndpoints.find(x => x == id) != undefined)
return Promise.reject(`Already requested: ${method} ${uri}`);
currentlyRequestedEndpoints.push(id);
return fetch(uri,
{
method: method,
headers : {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: content ? JSON.stringify(content) : null
})
.then(function(response){
if(!response.ok){
if(response.status === 503){
let retryHeaderVal = response.headers.get("Retry-After");
let seconds = 10;
if(!retryHeaderVal){
return response.text().then(text => {
seconds = parseInt(text);
return new Promise(resolve => setTimeout(resolve, seconds * 1000))
.then(() => {
return makeRequest(method, uri, content);
});
});
}else {
seconds = parseInt(retryHeaderVal);
return new Promise(resolve => setTimeout(resolve, seconds * 1000))
.then(() => {
return makeRequest(method, uri, content);
});
}
}else
throw new Error(response.statusText);
}
let json = response.json();
return json.then((json) => json).catch(() => null);
})
.catch(function(err : Error){
console.error(`Error ${method}ing Data ${uri}\n${err}`);
return Promise.reject();
}).finally(() => currentlyRequestedEndpoints.splice(currentlyRequestedEndpoints.indexOf(id), 1));
}
export function isValidUri(uri: string) : boolean{
try {
new URL(uri);
return true;
} catch (err) {
return false;
}
}
const updateConnected = (apiUri: string, connected: boolean, setConnected: (c: boolean) => void) => {
checkConnection(apiUri)
.then(res => {
if(connected != res)
setConnected(res);
})
.catch(() => setConnected(false));
}
export const checkConnection = async (apiUri: string): Promise<boolean> =>{
return fetch(`${apiUri}/swagger`,
{
method: 'GET',
})
.then((response) => {
return response.ok;
})
.catch(() => {
return Promise.reject();
});
}

View File

@ -1,344 +0,0 @@
if(getCookie("apiUri") != ""){
apiUri = getCookie("apiUri");
}
setCookie("apiUri", apiUri);
function setCookie(cname, cvalue) {
const d = new Date();
d.setTime(d.getTime() + (365*24*60*60*1000));
let expires = "expires="+ d.toUTCString();
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/;samesite=strict";
}
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for(let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
async function GetData(uri){
let request = await fetch(uri, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
let json = await request.json();
return json;
}
async function PostData(uri){
let request = await fetch(uri, {
method: 'POST'
});
//console.log(request);
}
function DeleteData(uri){
fetch(uri, {
method: 'DELETE'
});
}
async function Ping(){
let ret = await GetData(`${apiUri}/Ping`);
return ret;
}
async function GetAvailableControllers(){
var uri = apiUri + "/Connectors";
let json = await GetData(uri);
return json;
}
async function GetPublicationFromConnector(connector, title){
var uri;
if(title.includes("http")){
uri = `${apiUri}/Manga/FromConnector?connector=${connector}&url=${title}`;
}else{
uri = `${apiUri}/Manga/FromConnector?connector=${connector}&title=${title}`;
}
let json = await GetData(uri);
return json;
}
async function GetChapters(connector, internalId, language){
var uri = `${apiUri}/Manga/Chapters?connector=${connector}&internalId=${internalId}&translatedLanguage=${language}`;
let json = await GetData(uri);
return json;
}
function GetCoverUrl(internalId){
return `${apiUri}/Manga/Cover?internalId=${internalId}`;
}
async function GetAllJobs(){
var uri = `${apiUri}/Jobs`;
let json = await GetData(uri);
return json;
}
async function GetRunningJobs(){
var uri = `${apiUri}/Jobs/Running`;
let json = await GetData(uri);
return json;
}
async function GetWaitingJobs(){
var uri = `${apiUri}/Jobs/Waiting`;
let json = await GetData(uri);
return json;
}
async function GetMonitorJobs(){
var uri = `${apiUri}/Jobs/MonitorJobs`;
let json = await GetData(uri);
return json;
}
async function GetProgress(jobId){
var uri = `${apiUri}/Jobs/Progress?jobId=${jobId}`;
let json = await GetData(uri);
return json;
}
async function GetSettings(){
var uri = `${apiUri}/Settings`;
let json = await GetData(uri);
return json;
}
async function GetAvailableNotificationConnectors(){
var uri = `${apiUri}/NotificationConnectors/Types`;
let json = await GetData(uri);
return json;
}
async function GetNotificationConnectors(){
var uri = `${apiUri}/NotificationConnectors`;
let json = await GetData(uri);
return json;
}
async function GetAvailableLibraryConnectors(){
var uri = `${apiUri}/LibraryConnectors/Types`;
let json = await GetData(uri);
return json;
}
async function GetLibraryConnectors(){
var uri = `${apiUri}/LibraryConnectors`;
let json = await GetData(uri);
return json;
}
async function GetRateLimits() {
var uri = `${apiUri}/Settings/customRequestLimit`
let json = await GetData(uri);
return json;
}
function CreateMonitorJob(connector, internalId, language){
var uri = `${apiUri}/Jobs/MonitorManga?connector=${connector}&internalId=${internalId}&interval=03:00:00&translatedLanguage=${language}`;
PostData(uri);
}
function CreateDownloadNewChaptersJob(connector, internalId, language){
var uri = `${apiUri}/Jobs/DownloadNewChapters?connector=${connector}&internalId=${internalId}&translatedLanguage=${language}`;
PostData(uri);
}
function StartJob(jobId){
var uri = `${apiUri}/Jobs/StartNow?jobId=${jobId}`;
PostData(uri);
}
function UpdateDownloadLocation(downloadLocation){
var uri = `${apiUri}/Settings/UpdateDownloadLocation?downloadLocation=${downloadLocation}`;
PostData(uri);
}
function RefreshLibraryMetadata() {
var uri = `${apiUri}/Jobs/UpdateMetadata`;
PostData(uri);
}
async function DownloadLogs() {
var uri = `${apiUri}/LogFile`;
//Below taken from https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream
fetch(uri)
.then((response) => response.body)
.then((rb) => {
const reader = rb.getReader();
return new ReadableStream({
start(controller) {
// The following function handles each data chunk
function push() {
// "done" is a Boolean and value a "Uint8Array"
reader.read().then(({ done, value }) => {
// If there is no more data to read
if (done) {
console.log("done", done);
controller.close();
return;
}
// Get the data and send it to the browser via the controller
controller.enqueue(value);
// Check chunks by logging to the console
console.log(done, value);
push();
});
}
push();
},
});
})
.then((stream) =>
// Respond with our stream
new Response(stream, { headers: { "Content-Type": "text/html" } }).text(),
)
.then((result) => {
// Do things with result
//console.log(result);
//Below download taken from https://stackoverflow.com/questions/3665115/how-to-create-a-file-in-memory-for-user-to-download-but-not-through-server
var element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset-utf-8,' + encodeURIComponent(result));
var newDate = new Date();
var filename = "Tranga_Logs_" + newDate.today() + "_" + newDate.timeNow() + ".log";
element.setAttribute('download', filename);
element.click();
});
}
//Following date-time code taken from: https://stackoverflow.com/questions/10211145/getting-current-date-and-time-in-javascript
// For todays date;
Date.prototype.today = function () {
return ((this.getDate() < 10)?"0":"") + this.getDate() +"/"+(((this.getMonth()+1) < 10)?"0":"") + (this.getMonth()+1) +"/"+ this.getFullYear();
}
// For the time now
Date.prototype.timeNow = function () {
return ((this.getHours() < 10)?"0":"") + this.getHours() +"_"+ ((this.getMinutes() < 10)?"0":"") + this.getMinutes() +"_"+ ((this.getSeconds() < 10)?"0":"") + this.getSeconds();
}
//Komga
function UpdateKomga(komgaUrl, komgaAuth){
var uri = `${apiUri}/LibraryConnectors/Update?libraryConnector=Komga&komgaUrl=${komgaUrl}&komgaAuth=${komgaAuth}`;
PostData(uri);
}
function ResetKomga(){
var uri = `${apiUri}/LibraryConnectors/Reset?libraryConnector=Komga`;
PostData(uri);
}
function TestKomga(komgaUrl, komgaAuth){
var uri = `${apiUri}/LibraryConnectors/Test?libraryConnector=Komga&komgaUrl=${komgaUrl}&komgaAuth=${komgaAuth}`;
PostData(uri);
}
//Kavita
function UpdateKavita(kavitaUrl, kavitaUsername, kavitaPassword){
var uri = `${apiUri}/LibraryConnectors/Update?libraryConnector=Kavita&kavitaUrl=${kavitaUrl}&kavitaUsername=${kavitaUsername}&kavitaPassword=${kavitaPassword}`;
PostData(uri);
}
function ResetKavita(){
var uri = `${apiUri}/LibraryConnectors/Reset?libraryConnector=Kavita`;
PostData(uri);
}
function TestKavita(kavitaUrl, kavitaUsername, kavitaPassword){
var uri = `${apiUri}/LibraryConnectors/Test?libraryConnector=Kavita&kavitaUrl=${kavitaUrl}&kavitaUsername=${kavitaUsername}&kavitaPassword=${kavitaPassword}`;
PostData(uri);
}
//Gotify
function UpdateGotify(gotifyUrl, gotifyAppToken){
var uri = `${apiUri}/NotificationConnectors/Update?notificationConnector=Gotify&gotifyUrl=${gotifyUrl}&gotifyAppToken=${gotifyAppToken}`;
PostData(uri);
}
function ResetGotify(){
var uri = `${apiUri}/NotificationConnectors/Reset?notificationConnector=Gotify`;
PostData(uri);
}
function TestGotify(gotifyUrl, gotifyAppToken){
var uri = `${apiUri}/NotificationConnectors/Test?notificationConnector=Gotify&gotifyUrl=${gotifyUrl}&gotifyAppToken=${gotifyAppToken}`;
PostData(uri);
}
//LunaSea
function UpdateLunaSea(lunaseaWebhook){
var uri = `${apiUri}/NotificationConnectors/Update?notificationConnector=LunaSea&lunaseaWebhook=${lunaseaWebhook}`;
PostData(uri);
}
function ResetLunaSea(){
var uri = `${apiUri}/NotificationConnectors/Reset?notificationConnector=LunaSea`;
PostData(uri);
}
function TestLunaSea(lunaseaWebhook){
var uri = `${apiUri}/NotificationConnectors/Test?notificationConnector=LunaSea&lunaseaWebhook=${lunaseaWebhook}`;
PostData(uri);
}
//Ntfy
function UpdateNtfy(ntfyEndpoint, ntfyAuth){
var uri = `${apiUri}/NotificationConnectors/Update?notificationConnector=Ntfy&ntfyUrl=${ntfyEndpoint}&ntfyAuth=${ntfyAuth}`;
PostData(uri);
}
function ResetNtfy(){
var uri = `${apiUri}/NotificationConnectors/Reset?notificationConnector=Ntfy`;
PostData(uri);
}
function TestNtfy(ntfyEndpoint, ntfyAuth){
var uri = `${apiUri}/NotificationConnectors/Test?notificationConnector=Ntfy&ntfyUrl=${ntfyEndpoint}&ntfyAuth=${ntfyAuth}`;
PostData(uri);
}
function UpdateUserAgent(userAgent){
var uri = `${apiUri}/Settings/userAgent?userAgent=${userAgent}`;
PostData(uri);
}
function UpdateRateLimit(byteValue, rateLimit) {
var uri = `${apiUri}/Settings/customRequestLimit?requestType=${byteValue}&requestsPerMinute=${rateLimit}`;
PostData(uri);
}
function RemoveJob(jobId){
var uri = `${apiUri}/Jobs?jobId=${jobId}`;
DeleteData(uri);
}
function CancelJob(jobId){
var uri = `${apiUri}/Jobs/Cancel?jobId=${jobId}`;
PostData(uri);
}
async function GetLogmessages(count){
var uri = `${apiUri}/LogMessages?count=${count}`;
let json = await GetData(uri);
console.log(json);
return json;
}

View File

@ -1 +0,0 @@
let apiUri = `${window.location.protocol}//${window.location.host.split(':')[0]}:6531`

View File

@ -1,301 +1,13 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tranga</title>
<link id='basestyle' rel="stylesheet" href="styles/base.css">
<link id='librarystyle' rel="stylesheet" href="styles/style_default.css">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<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>
<wrapper>
<topbar>
<titlebox>
<img alt="website image is Blahaj" src="media/blahaj.png">
<span>Tranga</span>
</titlebox>
<spacer></spacer>
<img id="filterFunnel" src="media/filter-funnel.svg" height="50%" alt="filterFunnel">
<img id="settingscog" src="media/settings-cogwheel.svg" height="100%" alt="settingscog">
</topbar>
<filter-box id="filterBox">
<border-bar>
<popup-title>Filter by: </popup-title>
<popup-close onclick="filterBox.classList.toggle('animate')" >&times</popup-close>
</border-bar>
<popup-content id="filterContent">
<div class="popup-section">
NAME:
<div class="section-content">
<label for="searchbox"></label><input id="searchbox" placeholder="Title" type="text">
</div>
</div>
<div class = "popup-section">
CONNECTOR:
<div class="section-content" id="connectorFilterBox">
</div>
</div>
<div class = "popup-section">
STATUS:
<div class="section-content" id="statusFilterBox">
</div>
</div>
</popup-content>
<border-bar-button onclick="ClearFilter()" class="clearFilter">Clear Filter</border-bar-button>
</filter-box>
<viewport>
<div id="loaderdiv">
<blur-background></blur-background>
<div id="loader"></div>
<p id="loaderText">Check your Settings > API-URI</p>
</div>
<content>
<div id="addPublication">
<p>+</p>
</div>
<publication onclick="ShowNewMangaSearch()">
<img alt="cover" src="media/cover.jpg">
<publication-information>
<connector-name class="pill">Sample</connector-name>
<publication-name>Best Manga there is</publication-name>
</publication-information>
</publication>
</content>
<popup id="newMangaPopup">
<blur-background id="blurBackgroundNewMangaPopup" onclick="newMangaPopup.style.display = 'none';"></blur-background>
<div id="newMangaPopupSelector">
<select id="newMangaConnector" />
<input type="text" placeholder="Title" id="newMangaTitle" />
<select id="newMangaTranslatedLanguage">
<option selected="selected">en</option>
<option>it</option>
<option>de</option>
</select>
</div>
<div id="newMangaResult"></div>
</popup>
<popup id="settingsPopup">
<blur-background id="blurBackgroundSettingsPopup" onclick="settingsPopup.style.display = 'none';"></blur-background>
<popup-window>
<border-bar>
<popup-title>Settings</popup-title>
<popup-close onclick="settingsPopup.style.display = 'none'">&times</popup-close>
</border-bar>
<popup-content>
<div class="popup-section">
TRANGA
<div class="section-content">
<div class="section-item dyn-height">
<span class="title">API Settings</span>
<row><label for="settingApiUri">API URI:</label><input placeholder="https://" type="text" id="settingApiUri"></row>
<row><label for="userAgent">User Agent:</label><input placeholder="UserAgent" id="userAgent" type="text"></row>
<row>
<border-bar-button class="section" onclick="RefreshLibraryMetadata()">Refresh Library Metadata</border-bar-button>
<border-bar-button class="section" onclick="DownloadLogs()">Download Logs</border-bar-button>
</row>
</div>
<div class="section-item dyn-height">
<span class="title">Rate Limits</span>
<row><label for="DefaultRL">Default:</label><input id="defaultRL" type="text" ></row>
<row><label for="CoverRL">Manga Covers:</label><input id="coverRL" type="text"></row>
<row><label for="ImageRL">Manga Images:</label><input id="imageRL" type="text"></row>
<row><label for="InfoRL">Manga Info:</label><input id="infoRL" type="text"></row>
</div>
<div class="section-item dyn-height">
<span class="title">Appearance</span>
<row><label for="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></row>
</div>
</div>
</div>
<div class="popup-section">
MANGA SOURCES
<div class="section-content">
<!-- <div class="section-item dyn-height">
<span class="title"><img src="connector-icons/manganato.png"><a href="https://manganato.com">MangaNato</a></span>
</div> -->
<!-- <div class="section-item dyn-height">
<span class="title"><img src="connector-icons/mangasee.png"><a href="https://mangasee123.com">MangaSee</a></span>
</div> -->
<div class="section-item dyn-height">
<span class="title"><img src="connector-icons/mangadex-logo.svg"><a href="https://mangadex.org">MangaDex</a></span>
<row><label for="mDexFeedRL">Feed Rate Limit:</label><input id="mDexFeedRL" type="text"></row>
<row><label for="mDexImageRL">Image Rate Limit:</label><input id="mDexImageRL" type="text"></row>
</div>
<!-- <div class="section-item dyn-height">
<span class="title"><img src="connector-icons/mangakatana.png"><a href="https://mangakatana.com">MangaKatana</a></span>
</div> -->
<!-- <div class="section-item dyn-height">
<span class="title"><img src="connector-icons/mangaworld.png"><a href="https://www.mangaworld.ac">MangaWorld</a></span>
</div> -->
<!-- <div class="section-item dyn-height">
<span class="title"><img src="connector-icons/bato.ico"><a href="https://bato.to">Bato</a></span>
</div> -->
<!-- <div class="section-item dyn-height">
<span class="title"><img src="connector-icons/mangalife.png"><a href="https://www.manga4life.com">MangaLife</a></span>
</div> -->
</div>
</div>
<div class="popup-section">
LIBRARY CONNECTORS
<div class="section-content">
<div class="section-item">
<span class="title"><img src='connector-icons/komga.svg'>Komga<connector-configured id="komgaConfigured"></connector-configured></span>
<label for="komgaUrl"></label><input placeholder="URL" id="komgaUrl" type="text">
<label for="komgaUsername"></label><input placeholder="Username" id="komgaUsername" type="text">
<label for="komgaPassword"></label><input placeholder="Password" id="komgaPassword" type="password">
<div class="section-buttons-container">
<span onclick="TestKomga(komgaUrl.value, utf8_to_b64(`${komgaUsername.value}:${komgaPassword.value}`))" class='section-button' id="test-connector">Test</span>
<span onclick="ClearKomga()" class='section-button' id="reset">Reset</span>
<span onclick="UpdateKomga(komgaUrl.value, utf8_to_b64(`${komgaUsername.value}:${komgaPassword.value}`))" class='section-button'>Apply</span>
</div>
</div>
<div class="section-item">
<span class="title"><img src='connector-icons/kavita.png'>Kavita<connector-configured id="kavitaConfigured"></connector-configured></span>
<label for="kavitaUrl"></label><input placeholder="URL" id="kavitaUrl" type="text">
<label for="kavitaUsername"></label><input placeholder="Username" id="kavitaUsername" type="text">
<label for="kavitaPassword"></label><input placeholder="Password" id="kavitaPassword" type="password">
<div class="section-buttons-container">
<span onclick="TestKavita(kavitaUrl.value, kavitaUsername.value, kavitaPassword.value)" class='section-button' id="test-connector">Test</span>
<span onclick="ClearKavita()" class='section-button' id="reset">Reset</span>
<span onclick="UpdateKavita(kavitaUrl.value, kavitaUsername.value, kavitaPassword.value)" class='section-button'>Apply</span>
</div>
</div>
</div>
</div>
<div class="popup-section">
NOTIFICATION CONNECTORS
<div class="section-content">
<div class="section-item">
<span class="title"><img src='connector-icons/gotify-logo.png'>Gotify<connector-configured id="gotifyConfigured"></connector-configured></span>
<label for="gotifyUrl"></label><input placeholder="URL" id="gotifyUrl" type="text">
<label for="gotifyAppToken"></label><input placeholder="App-Token" id="gotifyAppToken" type="text">
<div class="section-buttons-container">
<span onclick="TestGotify(gotifyUrl.value, gotifyAppToken.value)" class='section-button' id="test-connector">Test</span>
<span onclick="ClearGotify()" class='section-button' id="reset">Reset</span>
<span onclick="UpdateGotify(gotifyUrl.value, gotifyAppToken.value)" class='section-button'>Apply</span>
</div>
</div>
<div class="section-item">
<span class="title"><img src='connector-icons/lunasea.png'>LunaSea<connector-configured id="lunaseaConfigured"></connector-configured></span>
<label for="lunaseaWebhook"></label><input placeholder="device/:id or user/:id" id="lunaseaWebhook" type="text">
<div class="section-buttons-container">
<span onclick="TestLunaSea(lunaseaWebhook.value);" class='section-button' id="test-connector">Test</span>
<span onclick="ClearLunasea()" class='section-button' id="reset">Reset</span>
<span onclick="UpdateLunaSea(lunaseaWebhook.value);" class='section-button'>Apply</span>
</div>
</div>
<div class="section-item">
<span class="title"><img src='connector-icons/ntfy.svg'>Ntfy<connector-configured id="ntfyConfigured"></connector-configured></span>
<label for="ntfyEndpoint"></label><input placeholder="URL" id="ntfyEndpoint" type="text">
<label for="ntfyAuth"></label><input placeholder="Auth" id="ntfyAuth" type="text">
<div class="section-buttons-container">
<span onclick="TestNtfy(ntfyEndpoint.value, ntfyAuth.value);" class='section-button' id="test-connector">Test</span>
<span onclick="ClearNtfy()" class='section-button' id="reset">Reset</span>
<span onclick="UpdateNtfy(ntfyEndpoint.value, ntfyAuth.value);" class='section-button'>Apply</span>
</div>
</div>
</div>
</div>
</popup-content>
<border-bar>
<div class="button-container">
<border-bar-button class="primary" onclick="UpdateSettings()">Apply Settings</border-bar-button>
</div>
</border-bar>
</popup-window>
</popup>
<popup id="publicationViewerPopup">
<blur-background id="blurBackgroundPublicationPopup" onclick="publicationViewerPopup.style.display= 'none';"></blur-background>
<publication-viewer>
<img id="pubviewcover" src="media/cover.jpg" alt="cover">
<publication-details>
<publication-name id="publicationViewerName">Best Manga there is</publication-name>
<publication-tags id="publicationViewerTags">A Manga</publication-tags>
<publication-author id="publicationViewerAuthor">Glax</publication-author>
<publication-description id="publicationViewerDescription">
An interesting description. The description is very intriguing, yet wholesome.
</publication-description>
<publication-interactions>
<publication-starttask id="startJobButton">Start Job ▶️</publication-starttask>
<publication-canceltask id="cancelJobButton">Cancel Job ❌</publication-canceltask>
<publication-delete id="deleteJobButton">Delete Job 🗑️</publication-delete>
<publication-add id="createMonitorJobButton">Monitor </publication-add>
<publication-add id="createDownloadChapterJobButton">Download Chapter 📥</publication-add>
</publication-interactions>
</publication-details>
</publication-viewer>
</popup>
<popup id="jobStatusView">
<blur-background id="blurBackgroundSettingsPopup" onclick="jobStatusView.style.display = 'none';"></blur-background>
<popup-window>
<border-bar>
<popup-title>Jobs</popup-title>
<popup-close onclick="jobStatusView.style.display = 'none'">&times</popup-close>
</border-bar>
<popup-content>
<div class="popup-section">
RUNNING JOBS
<div class="section-content" id="jobStatusRunning">
</div>
</div>
<div class="popup-section">
QUEUED JOBS
<div class="section-content" id="jobStatusWaiting">
</div>
</div>
</popup-content>
<border-bar>
<!-- <div class="button-container">
<border-bar-button class="primary" onclick="UpdateSettings()">Apply Settings</border-bar-button>
</div> -->
</border-bar>
</popup-window>
</viewport>
<footer>
<div onclick="ShowJobQueue();">
<img src="media/running.svg" alt="running"><div id="jobsRunningTag">0</div>
</div>
<div onclick="ShowJobQueue();">
<img src="media/queue.svg" alt="queue"><div id="jobsQueuedTag">0</div>
</div>
<p id="madeWith">Made with Blåhaj 🦈</p>
</footer>
</wrapper>
<script src="defaultApiUri.js"></script>
<script src="apiConnector.js"></script>
<script src="interaction.js"></script>
<div id="app"></div>
<script type="module" src="index.jsx"></script>
</body>
</html>

7
Website/index.jsx Normal file
View File

@ -0,0 +1,7 @@
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 />);

View File

@ -1,876 +0,0 @@
let monitoringJobsCount = 0;
let runningJobs = [];
let waitingJobs = [];
let notificationConnectorTypes = [];
let libraryConnectorTypes = [];
let selectedManga;
let selectedJob;
let searchMatch;
let connectorMatch = [];
let connectorNameMatch;
let statusMatch = [];
let statusNameMatch = [];
const searchBox = document.querySelector("#searchbox");
const settingsPopup = document.querySelector("#settingsPopup");
const filterBox = document.querySelector("#filterBox");
const settingsCog = document.querySelector("#settingscog");
const filterFunnel = document.querySelector("#filterFunnel");
const tasksContent = document.querySelector("content");
const createMonitorTaskButton = document.querySelector("#createMonitoJobButton");
const createDownloadChapterTaskButton = document.querySelector("#createDownloadChapterJobButton");
const startJobButton = document.querySelector("#startJobButton");
const cancelJobButton = document.querySelector("#cancelJobButton");
const deleteJobButton = document.querySelector("#deleteJobButton");
//Manga viewer popup
const mangaViewerPopup = document.querySelector("#publicationViewerPopup");
const mangaViewerWindow = document.querySelector("publication-viewer");
const mangaViewerDescription = document.querySelector("#publicationViewerDescription");
const mangaViewerName = document.querySelector("#publicationViewerName");
const mangaViewerTags = document.querySelector("#publicationViewerTags");
const mangaViewerAuthor = document.querySelector("#publicationViewerAuthor");
const mangaViewCover = document.querySelector("#pubviewcover");
//General Rate Limits
const defaultRL = document.querySelector("#defaultRL");
const coverRL = document.querySelector("#coverRL");
const imageRL = document.querySelector("#imageRL");
const infoRL = document.querySelector("#infoRL");
//MangaDex Rate Limits
const mDexFeedRL = document.querySelector("#mDexFeedRL");
const mDexImageRL = document.querySelector("#mDexImageRL");
//Komga
const settingKomgaUrl = document.querySelector("#komgaUrl");
const settingKomgaUser = document.querySelector("#komgaUsername");
const settingKomgaPass = document.querySelector("#komgaPassword");
//Kavita
const settingKavitaUrl = document.querySelector("#kavitaUrl");
const settingKavitaUser = document.querySelector("#kavitaUsername");
const settingKavitaPass = document.querySelector("#kavitaPassword");
//Gotify
const settingGotifyUrl = document.querySelector("#gotifyUrl");
const settingGotifyAppToken = document.querySelector("#gotifyAppToken");
//Lunasea
const settingLunaseaWebhook = document.querySelector("#lunaseaWebhook");
//Ntfy
const settingNtfyEndpoint = document.querySelector("#ntfyEndpoint");
const settingNtfyAuth = document.querySelector("#ntfyAuth");
//Connector Configured
const settingKomgaConfigured = document.querySelector("#komgaConfigured");
const settingKavitaConfigured = document.querySelector("#kavitaConfigured");
const settingGotifyConfigured = document.querySelector("#gotifyConfigured");
const settingLunaseaConfigured = document.querySelector("#lunaseaConfigured");
const settingNtfyConfigured = document.querySelector("#ntfyConfigured");
const settingUserAgent = document.querySelector("#userAgent");
const settingApiUri = document.querySelector("#settingApiUri");
const settingCSSStyle = document.querySelector('#cssStyle');
const newMangaPopup = document.querySelector("#newMangaPopup");
const newMangaConnector = document.querySelector("#newMangaConnector");
const newMangaTitle = document.querySelector("#newMangaTitle");
const newMangaResult = document.querySelector("#newMangaResult");
const newMangaTranslatedLanguage = document.querySelector("#newMangaTranslatedLanguage");
//Jobs
const jobsRunningTag = document.querySelector("#jobsRunningTag");
const jobsQueuedTag = document.querySelector("#jobsQueuedTag");
const loaderdiv = document.querySelector('#loaderdiv');
const jobStatusView = document.querySelector("#jobStatusView");
const jobStatusRunning = document.querySelector("#jobStatusRunning");
const jobStatusWaiting = document.querySelector("#jobStatusWaiting");
function Setup(){
Ping().then((ret) => {
loaderdiv.style.display = 'none';
GetAvailableNotificationConnectors().then((json) => {
//console.log(json);
json.forEach(connector => {
notificationConnectorTypes[connector.Key] = connector.Value;
});
});
GetAvailableLibraryConnectors().then((json) => {
//console.log(json);
json.forEach(connector => {
libraryConnectorTypes[connector.Key] = connector.Value;
});
});
GetAvailableControllers().then((json) => {
//console.log(json);
newMangaConnector.replaceChildren();
connectorFilterBox = document.querySelector("#connectorFilterBox");
connectorFilterBox.replaceChildren();
json.forEach(connector => {
//Add the connector to the New Manga dropdown
var option = document.createElement('option');
option.value = connector;
option.innerText = connector;
newMangaConnector.appendChild(option);
//Add the connector to the filter box
connectorFilter = document.createElement('connector-name');
connectorFilter.innerText = connector;
connectorFilter.className = "pill";
connectorFilter.style.backgroundColor = stringToColour(connector);
connectorFilter.addEventListener("click", (event) => {
ToggleFilterConnector(connector, event);
});
connectorFilterBox.appendChild(connectorFilter);
});
});
//Add the publication status options to the filter bar
publicationStatusOptions = ["Ongoing", "Completed", "On Hiatus", "Cancelled", "Upcoming", "Status Unavailable"];
statusFilterBox = document.querySelector("#statusFilterBox");
statusFilterBox.replaceChildren();
publicationStatusOptions.forEach(publicationStatus => {
var releaseStatus = document.createElement('status-filter');
releaseStatus.innerText = publicationStatus;
releaseStatus.setAttribute("release-status", publicationStatus);
releaseStatus.addEventListener("click", (event) => {
ToggleFilterStatus(publicationStatus, event);
});
statusFilterBox.appendChild(releaseStatus);
});
ResetContent();
UpdateJobs();
GetSettings().then((json) => {
//console.log(json);
settingApiUri.placeholder = apiUri;
});
GetRateLimits().then((json) => {
defaultRL.placeholder = json.Default + ' Requests/Minute';
coverRL.placeholder = json.MangaCover + ' Requests/Minute';
imageRL.placeholder = json.MangaImage + ' Requests/Minute';
infoRL.placeholder = json.MangaInfo + ' Requests/Minute';
mDexFeedRL.placeholder = json.MangaDexFeed + ' Requests/Minute';
mDexImageRL.placeholder = json.MangaDexImage + ' Requests/Minute';
});
//If the cssStyle key isn't in the local storage of the browser, then set the css style to the default and load the page
//Otherwise get the style key from storage and set it.
if (!localStorage.getItem('cssStyle')) {
localStorage.setItem('cssStyle', 'card_compact');
document.getElementById('librarystyle').setAttribute('href', 'styles/' + localStorage.getItem('cssStyle') + '.css');
document.getElementById('card_compact').selected = true;
} else {
css_style = localStorage.getItem('cssStyle');
document.getElementById('librarystyle').setAttribute('href', 'styles/' + css_style + '.css');
document.getElementById(css_style).selected = true;
}
setInterval(() => {
UpdateJobs();
}, 5000);
});
//Clear the previous values if any exist.
searchBox.value = "";
connectorMatch.length = 0;
statusMatch.length = 0;
}
Setup();
function ToggleFilterConnector(connector, event) {
//console.log("Initial Array:");
//console.log(connectorMatch);
if (connectorMatch.includes(connector)) {
idx = connectorMatch.indexOf(connector);
connectorMatch.splice(idx, 1);
event.target.style.outline = 'none';
event.target.style.outlineOffset = "0px";
} else {
connectorMatch.push(connector);
event.target.style.outline = '4px solid var(--secondary-color)';
event.target.style.outlineOffset = '3px';
}
//console.log("Final Array");
//console.log(connectorMatch);
FilterResults();
}
function ToggleFilterStatus(status, event) {
//console.log("Initial Array:");
//console.log(statusMatch);
if (statusMatch.includes(status)) {
idx = statusMatch.indexOf(status);
statusMatch.splice(idx, 1);
event.target.style.outline = 'none';
event.target.style.outlineOffset = "0px";
} else {
statusMatch.push(status);
event.target.style.outline = '4px solid var(--secondary-color)';
event.target.style.outlineOffset = '3px';
}
//console.log("Final Array");
//console.log(statusMatch);
FilterResults();
}
function ClearFilter() {
searchBox.value = "";
statusMatch.length = 0;
connectorMatch.length = 0;
FilterResults();
//Get rid of the outlines
connectorFilterBox = document.querySelector("#connectorFilterBox");
connectorFilterBox.childNodes.forEach(connector => {
if (connector.nodeName.toLowerCase() == 'connector-name') {
connector.style.outline = 'none';
connector.style.outlineOffset = "0px";
}
});
statusFilterBox = document.querySelector("#statusFilterBox");
statusFilterBox.childNodes.forEach(publicationStatus => {
if (publicationStatus.nodeName.toLowerCase() == 'status-filter') {
publicationStatus.style.outline = 'none';
publicationStatus.style.outlineOffset = "0px";
}
});
}
settingCSSStyle.addEventListener("change", (event) => {
localStorage.setItem('cssStyle', settingCSSStyle.value);
document.getElementById('librarystyle').setAttribute('href', 'styles/' + localStorage.getItem('cssStyle') + '.css');
});
function ResetContent(){
//Delete everything
tasksContent.replaceChildren();
//Add "Add new Task" Button
var add = document.createElement("div");
add.setAttribute("id", "addPublication")
var plus = document.createElement("p");
plus.innerText = "+";
add.appendChild(plus);
add.addEventListener("click", () => ShowNewMangaSearch());
tasksContent.appendChild(add);
//Populate with the monitored mangas
GetMonitorJobs().then((json) => {
//console.log(json);
json.forEach(job => {
var mangaView = CreateManga(job.manga, job.mangaConnector.name);
mangaView.addEventListener("click", (event) => {
ShowMangaWindow(job, job.manga, event, false);
});
tasksContent.appendChild(mangaView);
});
monitoringJobsCount = json.length;
});
}
function ShowNewMangaSearch(){
newMangaTitle.value = "";
newMangaPopup.style.display = "block";
newMangaResult.replaceChildren();
}
newMangaTitle.addEventListener("keypress", (event) => { if(event.key === "Enter") GetNewMangaItems();});
function GetNewMangaItems(){
if(newMangaTitle.value.length < 4)
return;
newMangaResult.replaceChildren();
newMangaConnector.disabled = true;
newMangaTitle.disabled = true;
newMangaTranslatedLanguage.disabled = true;
GetPublicationFromConnector(newMangaConnector.value, newMangaTitle.value).then((json) => {
//console.log(json);
if(json.length > 0)
newMangaResult.style.display = "flex";
json.forEach(result => {
var mangaElement = CreateManga(result, newMangaConnector.value)
newMangaResult.appendChild(mangaElement);
mangaElement.addEventListener("click", (event) => {
ShowMangaWindow(null, result, event, true);
});
});
newMangaConnector.disabled = false;
newMangaTitle.disabled = false;
newMangaTranslatedLanguage.disabled = false;
});
}
//Returns a new "Publication" Item to display in the jobs section
function CreateManga(manga, connector){
//Create a new publication and set an internal ID
var mangaElement = document.createElement('publication');
mangaElement.id = GetValidSelector(manga.internalId);
//Append the cover image to the publication
var mangaImage = document.createElement('img');
mangaImage.src = GetCoverUrl(manga.internalId);
mangaElement.appendChild(mangaImage);
//Append the publication information to the publication
//console.log(manga);
var info = document.createElement('publication-information');
var connectorName = document.createElement('connector-name');
connectorName.innerText = connector;
connectorName.className = "pill";
connectorName.style.backgroundColor = stringToColour(connector);
info.appendChild(connectorName);
var mangaName = document.createElement('publication-name');
mangaName.innerText = manga.sortName;
//Create the publication status indicator
var releaseStatus = document.createElement('publication-status');
releaseStatus.setAttribute("release-status", manga.releaseStatus);
switch(manga.releaseStatus){
case 0:
releaseStatus.setAttribute("release-status", "Ongoing");
break;
case 1:
releaseStatus.setAttribute("release-status", "Completed");
break;
case 2:
releaseStatus.setAttribute("release-status", "On Hiatus");
break;
case 3:
releaseStatus.setAttribute("release-status", "Cancelled");
break;
case 4:
releaseStatus.setAttribute("release-status", "Upcoming");
break;
default:
releaseStatus.setAttribute("release-status", "Status Unavailable");
break;
}
info.appendChild(mangaName);
mangaElement.appendChild(info);
mangaElement.appendChild(releaseStatus); //Append the release status indicator to the publication element
return mangaElement;
}
createMonitorJobButton.addEventListener("click", () => {
CreateMonitorJob(newMangaConnector.value, selectedManga.internalId, newMangaTranslatedLanguage.value);
UpdateJobs();
mangaViewerPopup.style.display = "none";
});
startJobButton.addEventListener("click", () => {
StartJob(selectedJob.id);
mangaViewerPopup.style.display = "none";
});
cancelJobButton.addEventListener("click", () => {
CancelJob(selectedJob.id);
mangaViewerPopup.style.display = "none";
});
deleteJobButton.addEventListener("click", () => {
RemoveJob(selectedJob.id);
UpdateJobs();
mangaViewerPopup.style.display = "none";
});
function ShowMangaWindow(job, manga, event, add){
selectedManga = manga;
selectedJob = job;
//Show popup
mangaViewerPopup.style.display = "block";
//Set position to mouse-position
if(event.clientY < window.innerHeight - mangaViewerWindow.offsetHeight)
mangaViewerWindow.style.top = `${event.clientY}px`;
else
mangaViewerWindow.style.top = `${event.clientY - mangaViewerWindow.offsetHeight}px`;
if(event.clientX < window.innerWidth - mangaViewerWindow.offsetWidth)
mangaViewerWindow.style.left = `${event.clientX}px`;
else
mangaViewerWindow.style.left = `${event.clientX - mangaViewerWindow.offsetWidth}px`;
//Edit information inside the window
mangaViewerName.innerText = manga.sortName;
mangaViewerTags.innerText = manga.tags.join(", ");
mangaViewerDescription.innerText = manga.description;
mangaViewerAuthor.innerText = manga.authors.join(',');
mangaViewCover.src = GetCoverUrl(manga.internalId);
toEditId = manga.internalId;
//Check what action should be listed
if(add){
createMonitorJobButton.style.display = "initial";
createDownloadChapterJobButton.style.display = "none";
cancelJobButton.style.display = "none";
startJobButton.style.display = "none";
deleteJobButton.style.display = "none";
}
else{
createMonitorJobButton.style.display = "none";
createDownloadChapterJobButton.style.display = "none";
cancelJobButton.style.display = "initial";
startJobButton.style.display = "initial";
deleteJobButton.style.display = "initial";
}
}
function HidePublicationPopup(){
publicationViewerPopup.style.display = "none";
}
searchBox.addEventListener("keyup", () => FilterResults());
//Filter shown jobs
function FilterResults(){
//For each publication
tasksContent.childNodes.forEach(publication => {
//If the search box isn't empty check that the title contains the searchbox content. If it does then
//'searchMatch' is true and the manga is shown. If the search box is empty, then consider this field
//to be true anyways.
if (searchBox.value.length > 0) {
publication.childNodes.forEach(item => {
if (item.nodeName.toLowerCase() == "publication-information"){
item.childNodes.forEach(information => {
if (information.nodeName.toLowerCase() == "publication-name") {
if (information.textContent.toLowerCase().includes(searchBox.value.toLowerCase())){
searchMatch = 1;
} else {
searchMatch = 0;
}
}
});
}
});
} else {
searchMatch = 1;
}
//If the array connectorMatch isn't empty then check that the connector matches one of the ones
//in the array
if (connectorMatch.length > 0) {
publication.childNodes.forEach(item => {
if (item.nodeName.toLowerCase() == "publication-information"){
item.childNodes.forEach(information => {
if (information.nodeName.toLowerCase() == "connector-name") {
if (connectorMatch.includes(information.textContent)){
connectorNameMatch = 1;
} else {
connectorNameMatch = 0;
}
}
});
}
});
} else {
connectorNameMatch = 1;
}
//If the array statusMatch isn't empty then check that the status matches one of the ones
//in the array
if (statusMatch.length > 0) {
publication.childNodes.forEach(item => {
if (item.nodeName.toLowerCase() == "publication-status"){
if (statusMatch.includes(item.getAttribute('release-status'))) {
statusNameMatch = 1;
} else {
statusNameMatch = 0;
}
}
});
} else {
statusNameMatch = 1;
}
//If all of the filtering conditions are met then show the manga, otherwise hide it.
if (searchMatch && connectorNameMatch && statusNameMatch) {
publication.style.display = 'initial';
} else {
publication.style.display = 'none';
}
});
}
settingsCog.addEventListener("click", () => {
OpenSettings();
settingsPopup.style.display = "flex";
});
filterFunnel.addEventListener("click", () => {
filterBox.classList.toggle("animate");
});
settingKomgaUrl.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingKomgaUser.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingKomgaPass.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingKavitaUrl.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingKavitaUser.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingKavitaPass.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingGotifyUrl.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingGotifyAppToken.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingLunaseaWebhook.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingNtfyEndpoint.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingNtfyAuth.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingUserAgent.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
settingApiUri.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings(); });
defaultRL.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings();});
coverRL.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings();});
imageRL.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings();});
infoRL.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings();});
mDexFeedRL.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings();});
mDexImageRL.addEventListener("keypress", (event) => { if(event.key === "Enter") UpdateSettings();});
function OpenSettings(){
settingGotifyConfigured.setAttribute("configuration", "Not Configured");
settingLunaseaConfigured.setAttribute("configuration", "Not Configured");
settingNtfyConfigured.setAttribute("configuration", "Not Configured");
settingKavitaConfigured.setAttribute("configuration", "Not Configured");
settingKomgaConfigured.setAttribute("configuration", "Not Configured");
settingKomgaUrl.value = "";
settingKomgaUser.value = "";
settingKomgaPass.value = "";
settingKavitaUrl.value = "";
settingKavitaUser.value = "";
settingKavitaPass.value = "";
settingGotifyUrl.value = "";
settingGotifyAppToken.value = "";
settingLunaseaWebhook.value = "";
settingNtfyAuth.value = "";
settingNtfyEndpoint.value = "";
settingUserAgent.value = "";
settingApiUri.value = "";
defaultRL.value = "";
coverRL.value = "";
imageRL.value = "";
infoRL.value = "";
mDexFeedRL.value = "";
mDexImageRL.value = "";
GetSettings().then((json) => {
//console.log(json);
settingApiUri.placeholder = apiUri;
settingUserAgent.placeholder = json.userAgent;
//console.log(json.styleSheet);
});
GetRateLimits().then((json) => {
defaultRL.placeholder = json.Default + ' Requests/Minute';
coverRL.placeholder = json.MangaCover + ' Requests/Minute';
imageRL.placeholder = json.MangaImage + ' Requests/Minute';
infoRL.placeholder = json.MangaInfo + ' Requests/Minute';
mDexFeedRL.placeholder = json.MangaDexFeed + ' Requests/Minute';
mDexImageRL.placeholder = json.MangaDexImage + ' Requests/Minute';
});
GetLibraryConnectors().then((json) => {
//console.log(json);
json.forEach(connector => {
switch(libraryConnectorTypes[connector.libraryType]){
case "Kavita":
settingKavitaConfigured.setAttribute("configuration", "Active");
settingKavitaUrl.placeholder = connector.baseUrl;
settingKavitaUser.placeholder = "***";
settingKavitaPass.placeholder = "***";
break;
case "Komga":
settingKomgaConfigured.setAttribute("configuration", "Active");
settingKomgaUrl.placeholder = connector.baseUrl;
settingKomgaUser.placeholder = "***";
settingKomgaPass.placeholder = "***";
break;
default:
console.log("Unknown type");
console.log(connector);
break;
}
});
});
GetNotificationConnectors().then((json) => {
json.forEach(connector => {
switch(notificationConnectorTypes[connector.notificationConnectorType]){
case "Gotify":
settingGotifyUrl.placeholder = connector.endpoint;
settingGotifyAppToken.placeholder = "***";
settingGotifyConfigured.setAttribute("configuration", "Active");
break;
case "LunaSea":
settingLunaseaConfigured.setAttribute("configuration", "Active");
settingLunaseaWebhook.placeholder = connector.id;
break;
case "Ntfy":
settingNtfyConfigured.setAttribute("configuration", "Active");
settingNtfyEndpoint.placeholder = connector.endpoint;
settingNtfyAuth.placeholder = "***";
break;
default:
console.log("Unknown type");
console.log(connector);
break;
}
});
});
}
//Functions for clearing/resetting connectors in the settings pop-up
function ClearKomga(){
settingKomgaUrl.value = "";
settingKomgaUser.value = "";
settingKomgaPass.value = "";
settingKomgaConfigured.setAttribute("configuration", "Not Configured");
ResetKomga();
}
function ClearKavita(){
settingKavitaUrl.value = "";
settingKavitaUser.value = "";
settingKavitaPass.value = "";
settingKavitaConfigured.setAttribute("configuration", "Not Configured");
ResetKavita();
}
function ClearGotify(){
settingGotifyUrl.value = "";
settingGotifyAppToken.value = ""
settingGotifyConfigured.setAttribute("configuration", "Not Configured");
ResetGotify();
}
function ClearLunasea(){
settingLunaseaWebhook.value = "";
settingLunaseaConfigured.setAttribute("configuration", "Not Configured");
ResetLunaSea();
}
function ClearNtfy(){
settingNtfyEndpoint.value = "";
settingNtfyAuth.value = "";
settingNtfyConfigured.setAttribute("configuration", "Not Configured");
ResetNtfy();
}
function UpdateSettings(){
if(settingApiUri.value != ""){
apiUri = settingApiUri.value;
setCookie("apiUri", apiUri);
Setup();
}
if(settingKomgaUrl.value != "" &&
settingKomgaUser.value != "" &&
settingKomgaPass.value != ""){
UpdateKomga(settingKomgaUrl.value, utf8_to_b64(`${settingKomgaUser.value}:${settingKomgaPass.value}`));
}
if(settingKavitaUrl.value != "" &&
settingKavitaUser.value != "" &&
settingKavitaPass.value != ""){
UpdateKavita(settingKavitaUrl.value, settingKavitaUser.value, settingKavitaPass.value);
}
if(settingGotifyUrl.value != "" &&
settingGotifyAppToken.value != ""){
UpdateGotify(settingGotifyUrl.value, settingGotifyAppToken.value);
}
if(settingLunaseaWebhook.value != ""){
UpdateLunaSea(settingLunaseaWebhook.value);
}
if(settingNtfyEndpoint.value != "" &&
settingNtfyAuth.value != ""){
UpdateNtfy(settingNtfyEndpoint.value, settingNtfyAuth.value);
}
if(settingUserAgent.value != ""){
UpdateUserAgent(settingUserAgent.value);
}
if (defaultRL.value != "") {
UpdateRateLimit(0, defaultRL.value);
}
if (coverRL.value != "") {
UpdateRateLimit(3, coverRL.value);
}
if (imageRL.value != "") {
UpdateRateLimit(2, imageRL.value);
}
if (infoRL.value != "") {
UpdateRateLimit(6, infoRL.value);
}
if (mDexFeedRL.value != "") {
UpdateRateLimit(1, mDexFeedRL.value);
}
if (mDexImageRL.value != "") {
UpdateRateLimit(5, mDexImageRL.value);
}
setTimeout(() => {
OpenSettings();
Setup();
}, 100)
}
function utf8_to_b64(str) {
return window.btoa(unescape(encodeURIComponent( str )));
}
function UpdateJobs(){
GetMonitorJobs().then((json) => {
if(monitoringJobsCount != json.length){
ResetContent();
monitoringJobsCount = json.length;
}
});
//Get the jobs that are waiting in the queue
GetWaitingJobs().then((json) => {
jobsQueuedTag.innerText = json.length;
var nowWaitingJobs = [];
json.forEach(job => {
if(!waitingJobs.includes(GetValidSelector(job.id))){
var jobDom = createJob(job);
jobStatusWaiting.appendChild(jobDom);
}
nowWaitingJobs.push(GetValidSelector(job.id));
});
waitingJobs = nowWaitingJobs;
});
jobStatusWaiting.childNodes.forEach(child => {
if(!waitingJobs.includes(child.id))
jobStatusWaiting.removeChild(child);
});
//Get currently running jobs
GetRunningJobs().then((json) => {
jobsRunningTag.innerText = json.length;
var nowRunningJobs = [];
json.forEach(job => {
if(!runningJobs.includes(GetValidSelector(job.id))){
var jobDom = createJob(job);
jobStatusRunning.appendChild(jobDom);
}
nowRunningJobs.push(GetValidSelector(job.id));
UpdateJobProgress(job.id);
});
runningJobs = nowRunningJobs;
});
jobStatusRunning.childNodes.forEach(child => {
if(!runningJobs.includes(child.id))
jobStatusRunning.removeChild(child);
});
}
function createJob(jobjson){
var manga;
if(jobjson.chapter != null)
manga = jobjson.chapter.parentManga;
else if(jobjson.manga != null)
manga = jobjson.manga;
else return null;
var wrapper = document.createElement("div");
wrapper.className = "section-item";
wrapper.id = GetValidSelector(jobjson.id);
var image = document.createElement("img");
image.className = "jobImage";
image.src = GetCoverUrl(manga.internalId);
wrapper.appendChild(image);
var details = document.createElement("div");
details.className = 'jobDetails';
var title = document.createElement("span");
title.className = "jobTitle";
if(jobjson.chapter != null)
title.innerText = `${manga.sortName} - ${jobjson.chapter.fileName}`;
else if(jobjson.manga != null)
title.innerText = manga.sortName;
details.appendChild(title);
var progressBar = document.createElement("progress");
progressBar.className = "jobProgressBar";
progressBar.id = `jobProgressBar${GetValidSelector(jobjson.id)}`;
details.appendChild(progressBar);
var progressSpan = document.createElement("span");
progressSpan.className = "jobProgressSpan";
progressSpan.id = `jobProgressSpan${GetValidSelector(jobjson.id)}`;
progressSpan.innerText = "Pending...";
details.appendChild(progressSpan);
var cancelSpan = document.createElement("span");
cancelSpan.className = "jobCancel";
cancelSpan.innerText = "Cancel";
cancelSpan.addEventListener("click", () => CancelJob(jobjson.id));
details.appendChild(cancelSpan);
wrapper.appendChild(details);
return wrapper;
}
function ShowJobQueue(){
jobStatusView.style.display = "initial";
}
function UpdateJobProgress(jobId){
GetProgress(jobId).then((json) => {
var progressBar = document.querySelector(`#jobProgressBar${GetValidSelector(jobId)}`);
var progressSpan = document.querySelector(`#jobProgressSpan${GetValidSelector(jobId)}`);
if(progressBar != null && json.progress != 0){
progressBar.value = json.progress;
}
if(progressSpan != null){
var percentageStr = "0%";
var timeleftStr = "00:00:00";
if(json.progress != 0){
percentageStr = Intl.NumberFormat("en-US", { style: "percent"}).format(json.progress);
timeleftStr = json.timeRemaining.split('.')[0];
}
progressSpan.innerText = `${percentageStr} ${timeleftStr}`;
}
});
}
function GetValidSelector(str){
var clean = [...str.matchAll(/[a-zA-Z0-9]*-*_*/g)];
return clean.join('');
}
const stringToColour = (str) => {
let hash = 0;
str.split('').forEach(char => {
hash = char.charCodeAt(0) + ((hash << 5) - hash)
})
let colour = '#'
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff
colour += value.toString(16).padStart(2, '0')
}
return colour
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

View File

@ -0,0 +1,43 @@
<?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>

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -0,0 +1,40 @@
<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:&quot;JetBrains Mono, Bold&quot;;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:&quot;JetBrains Mono, Bold&quot;;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:&quot;JetBrains Mono, Bold&quot;;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:&quot;JetBrains Mono, Bold&quot;;fill:#fff;stroke:none;-inkscape-stroke:none" transform="matrix(1.44935 0 0 1.66414 -74.104 -166.906)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

7
Website/media/link.svg Normal file
View File

@ -0,0 +1,7 @@
<?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>

After

Width:  |  Height:  |  Size: 304 B

View File

@ -1,7 +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 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<g fill="#000000">

Before

Width:  |  Height:  |  Size: 545 B

View File

@ -1,53 +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 235.504 235.504"
xml:space="preserve">
<g>
<g>
<path d="M195.209,81.456l-49.227-0.15c0.737-0.886,1.351-1.868,2.284-2.583c3.282-2.497,3.911-7.166,1.427-10.438
c-2.501-3.266-7.161-3.919-10.443-1.423c-4.873,3.715-8.388,8.704-10.255,14.389l-22.191-0.064
c-9.508,0-19.588,7.398-22.938,16.851l-16.877,47.479c-1.775,5.013-1.338,9.966,1.207,13.568
c2.412,3.427,6.384,5.318,11.187,5.358l45.126,0.136c-1.509,5.186-4.701,9.622-9.352,12.424
c-4.891,2.957-10.636,3.814-16.172,2.444c-3.994-0.998-8.031,1.442-9.027,5.418c-0.99,4.012,1.445,8.035,5.432,9.032
c2.927,0.738,5.879,1.091,8.808,1.091c6.516,0,12.93-1.788,18.645-5.23c8.312-5.013,14.172-12.979,16.484-22.409
c0.232-0.905,0.232-1.823,0.124-2.713l28.296,0.092h0.049c2.925,0,5.854-0.89,8.684-2.147c0.2,0.493,0.32,1.014,0.661,1.471
c3.335,4.677,4.629,10.343,3.688,15.993c-0.95,5.627-4.028,10.536-8.688,13.862c-3.351,2.376-4.14,7.037-1.755,10.379
c1.466,2.04,3.751,3.122,6.062,3.122c1.491,0,3.006-0.429,4.312-1.367c7.919-5.61,13.16-13.966,14.771-23.52
c1.603-9.565-0.613-19.203-6.28-27.122c-0.48-0.693-1.134-1.19-1.779-1.659c1.318-1.831,2.501-3.763,3.238-5.854l16.863-47.464
c1.795-5.018,1.351-9.969-1.194-13.58C203.954,83.387,200.015,81.47,195.209,81.456z M201.979,98.405l-16.868,47.464
c-0.981,2.757-2.941,5.214-5.213,7.329c-0.337,0.16-0.706,0.229-1.026,0.465c-0.673,0.485-1.182,1.122-1.639,1.747
c-2.962,1.996-6.288,3.339-9.434,3.339v2.989l-0.044-2.989l-33.194-0.101c-0.232-0.076-0.424-0.261-0.661-0.324
c-1.435-0.353-2.805-0.145-4.095,0.309l-29.768-0.101l1.192-3.358c0.549-1.547-0.269-3.25-1.813-3.795
c-1.521-0.553-3.25,0.24-3.799,1.804l-1.899,5.334l-14.318-0.044c-2.805,0-5.063-0.998-6.336-2.813
c-1.437-2.032-1.603-4.921-0.463-8.144l16.877-47.478c2.48-6.979,10.417-12.868,17.356-12.868l12.217,0.038l-1.963,5.536
c-0.555,1.549,0.262,3.25,1.805,3.797c0.331,0.12,0.661,0.174,0.998,0.174c1.227,0,2.372-0.768,2.793-1.986l2.497-7.019
c0.064-0.164-0.048-0.322-0.016-0.487h2.512c-0.905,7.758,1.163,15.42,5.947,21.638c5.903,7.687,14.852,11.726,23.873,11.726
c6.371,0,12.771-2.001,18.186-6.129c3.266-2.488,3.911-7.167,1.426-10.441c-2.508-3.267-7.161-3.901-10.455-1.415
c-6.612,5.056-16.146,3.775-21.223-2.809c-2.445-3.194-3.487-7.133-2.958-11.117c0.061-0.503,0.353-0.916,0.481-1.402
l52.216,0.156c2.806,0,5.054,1.004,6.324,2.811C202.928,92.241,203.105,95.223,201.979,98.405z"/>
<path d="M107.997,127.194c-1.531-0.553-3.248,0.244-3.799,1.791l-4.302,12.099c-0.551,1.543,0.265,3.242,1.813,3.795
c0.331,0.116,0.659,0.16,0.998,0.16c1.214,0,2.372-0.765,2.801-1.976l4.294-12.099
C110.369,129.446,109.551,127.728,107.997,127.194z"/>
<path d="M116.6,103.014c-1.529-0.541-3.25,0.252-3.805,1.805l-4.298,12.088c-0.547,1.547,0.261,3.252,1.799,3.799
c0.329,0.12,0.659,0.172,1,0.172c1.222,0,2.368-0.769,2.809-1.983l4.294-12.09C118.955,105.268,118.139,103.555,116.6,103.014z"/>
<path d="M232.527,90.428l-14.896-0.038l0,0c-1.639,0-2.974,1.327-2.997,2.976c0,1.639,1.342,2.981,2.981,2.989l14.896,0.042l0,0
c1.643,0,2.978-1.331,2.993-2.979C235.504,91.763,234.17,90.436,232.527,90.428z"/>
<path d="M220.333,80.436c0.629,0,1.242-0.188,1.771-0.583l11.994-8.83c1.326-0.974,1.611-2.842,0.645-4.168
c-0.965-1.327-2.845-1.611-4.163-0.637l-11.998,8.833c-1.323,0.974-1.607,2.841-0.642,4.167
C218.513,80.003,219.418,80.436,220.333,80.436z"/>
<path d="M209.152,56.279c-1.547-0.549-3.25,0.269-3.787,1.805l-4.997,14.036c-0.537,1.547,0.26,3.252,1.803,3.807
c0.337,0.12,0.674,0.172,0.994,0.172c1.242,0,2.385-0.757,2.821-1.986l4.985-14.036C211.516,58.541,210.695,56.846,209.152,56.279
z"/>
<path d="M17.587,100.894h55.208c1.641,0,2.976-1.343,2.976-2.981c0-1.641-1.334-2.988-2.976-2.988H17.587
c-1.641,0-2.988,1.338-2.988,2.988C14.599,99.559,15.946,100.894,17.587,100.894z"/>
<path d="M68.471,119.328c0-1.641-1.345-2.987-2.986-2.987H10.283c-1.639,0-2.981,1.338-2.981,2.987
c0,1.639,1.342,2.974,2.981,2.974h55.202C67.119,122.301,68.471,120.967,68.471,119.328z"/>
<path d="M58.188,137.758H2.974c-1.641,0-2.974,1.335-2.974,2.989c0,1.64,1.333,2.974,2.974,2.974h55.214
c1.639,0,2.981-1.334,2.981-2.974C61.162,139.093,59.827,137.758,58.188,137.758z"/>
<path d="M169.611,28.097c11.821,0,21.403,9.584,21.403,21.41c0,11.82-9.582,21.408-21.403,21.408
c-11.822,0-21.412-9.588-21.412-21.408C148.199,37.681,157.789,28.097,169.611,28.097z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<g id="task">
<path d="M4,23.4l-3.7-3.7l1.4-1.4L4,20.6l4.3-4.3l1.4,1.4L4,23.4z M24,21H12v-2h12V21z M4,15.4l-3.7-3.7l1.4-1.4L4,12.6l4.3-4.3
l1.4,1.4L4,15.4z M24,13H12v-2h12V13z M4,7.4L0.3,3.7l1.4-1.4L4,4.6l4.3-4.3l1.4,1.4L4,7.4z M24,5H12V3h12V5z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 603 B

View File

@ -0,0 +1,61 @@
import {deleteData, getData, patchData} from "../App";
import IRequestLimits, {RequestType} from "./interfaces/IRequestLimits";
import IBackendSettings from "./interfaces/IBackendSettings";
export default class BackendSettings {
static async GetSettings(apiUri: string) : Promise<IBackendSettings> {
return getData(`${apiUri}/v2/Settings`).then((s) => s as IBackendSettings);
}
static async GetUserAgent(apiUri: string) : Promise<string> {
return getData(`${apiUri}/v2/Settings/UserAgent`).then((text) => text as unknown as string);
}
static async UpdateUserAgent(apiUri: string, userAgent: string) {
return patchData(`${apiUri}/v2/Settings/UserAgent`, userAgent);
}
static async ResetUserAgent(apiUri: string) {
return deleteData(`${apiUri}/v2/Settings/UserAgent`);
}
static async GetRequestLimits(apiUri: string) : Promise<IRequestLimits> {
return getData(`${apiUri}/v2/Settings/RequestLimits`).then((limits) => limits as IRequestLimits);
}
static async ResetRequestLimits(apiUri: string) {
return deleteData(`${apiUri}/v2/Settings/RequestLimits`);
}
static async UpdateRequestLimit(apiUri: string, requestType: RequestType, value: number) {
return patchData(`${apiUri}/v2/Settings/RequestLimits/${requestType}`, value);
}
static async ResetRequestLimit(apiUri: string, requestType: RequestType) {
return deleteData(`${apiUri}/v2/Settings/RequestLimits/${requestType}`);
}
static async GetImageCompressionValue(apiUri: string) : Promise<number> {
return getData(`${apiUri}/v2/Settings/ImageCompression`).then((n) => n as unknown as number);
}
static async UpdateImageCompressionValue(apiUri: string, value: number) {
return patchData(`${apiUri}/v2/Settings/ImageCompression`, value);
}
static async GetBWImageToggle(apiUri: string) : Promise<boolean> {
return getData(`${apiUri}/v2/Settings/BWImages`).then((state) => state as unknown as boolean);
}
static async UpdateBWImageToggle(apiUri: string, value: boolean) {
return patchData(`${apiUri}/v2/Settings/BWImages`, value);
}
static async GetAprilFoolsToggle(apiUri: string) : Promise<boolean> {
return getData(`${apiUri}/v2/Settings/AprilFoolsMode`).then((state) => state as unknown as boolean);
}
static async UpdateAprilFoolsToggle(apiUri: string, value: boolean) {
return patchData(`${apiUri}/v2/Settings/AprilFoolsMode`, value);
}
}

View File

@ -0,0 +1,19 @@
import {getData} from "../App";
import IChapter from "./interfaces/IChapter";
export default class ChapterFunctions {
static async GetChapterFromId(apiUri: string, chapterId: string): Promise<IChapter> {
if(chapterId === undefined || chapterId === null) {
console.error(`chapterId was not provided`);
return Promise.reject();
}
return getData(`${apiUri}/v2/Query/Chapter/${chapterId}`)
.then((json) => {
//console.info("Got all MangaFunctions");
const ret = json as IChapter;
//console.debug(ret);
return (ret);
});
}
}

View File

@ -0,0 +1,51 @@
import React, {useEffect} from 'react';
import '../styles/footer.css';
import JobFunctions from './JobFunctions';
import Icon from '@mdi/react';
import {mdiCounter, mdiEyeCheck, mdiRun, mdiTrayFull} from '@mdi/js';
import QueuePopUp from "./QueuePopUp";
import {JobState, JobType} from "./interfaces/Jobs/IJob";
export default function Footer({connectedToBackend, apiUri, checkConnectedInterval} : {connectedToBackend: boolean, apiUri: string, checkConnectedInterval: number}) {
const [MonitoringJobsCount, setMonitoringJobsCount] = React.useState(0);
const [AllJobsCount, setAllJobsCount] = React.useState(0);
const [RunningJobsCount, setRunningJobsCount] = React.useState(0);
const [WaitingJobsCount, setWaitingJobs] = React.useState(0);
const [countUpdateInterval, setCountUpdateInterval] = React.useState<number | undefined>(undefined);
function UpdateBackendState(){
JobFunctions.GetJobsWithType(apiUri, JobType.DownloadAvailableChaptersJob).then((jobs) => setMonitoringJobsCount(jobs.length));
JobFunctions.GetAllJobs(apiUri).then((jobs) => setAllJobsCount(jobs.length));
JobFunctions.GetJobsInState(apiUri, JobState.Running).then((jobs) => setRunningJobsCount(jobs.length));
JobFunctions.GetJobsInState(apiUri, JobState.Waiting).then((jobs) => setWaitingJobs(jobs.length));
}
useEffect(() => {
if(connectedToBackend){
UpdateBackendState();
if(countUpdateInterval === undefined){
setCountUpdateInterval(setInterval(() => {
UpdateBackendState();
}, checkConnectedInterval * 5));
}
}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} checkConnectedInterval={checkConnectedInterval}>
<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>{WaitingJobsCount}</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>)
}

View File

@ -0,0 +1,15 @@
import React from 'react';
import '../styles/header.css'
import IFrontendSettings from "./interfaces/IFrontendSettings";
import Settings from "./Settings";
export default function Header({backendConnected, apiUri, settings, setFrontendSettings} : {backendConnected: boolean, apiUri: string, settings: IFrontendSettings, setFrontendSettings: (settings: IFrontendSettings) => void}){
return (
<header>
<div id="titlebox">
<img alt="website image is Blahaj" src="../media/blahaj.png"/>
<span>Tranga</span>
</div>
<Settings backendConnected={backendConnected} apiUri={apiUri} frontendSettings={settings} setFrontendSettings={setFrontendSettings} />
</header>)
}

View File

@ -0,0 +1,208 @@
import {deleteData, getData, patchData, postData, putData} from '../App';
import IJob, {JobState, JobType} from "./interfaces/Jobs/IJob";
import IModifyJobRecord from "./interfaces/records/IModifyJobRecord";
import IDownloadAvailableJobsRecord from "./interfaces/records/IDownloadAvailableJobsRecord";
export default class JobFunctions
{
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<IJob[]> {
//console.info("Getting all Jobs");
return getData(`${apiUri}/v2/Job`)
.then((json) => {
//console.info("Got all Jobs");
const ret = json as IJob[];
//console.debug(ret);
return (ret);
});
}
static async GetJobsWithIds(apiUri: string, jobIds: string[]): Promise<IJob[]> {
return postData(`${apiUri}/v2/Job/WithIDs`, jobIds)
.then((json) => {
//console.info("Got all Jobs");
const ret = json as IJob[];
//console.debug(ret);
return (ret);
});
}
static async GetJobsInState(apiUri: string, state: JobState): Promise<IJob[]> {
if(state == null || state == undefined) {
console.error(`state was not provided`);
return Promise.reject();
}
return getData(`${apiUri}/v2/Job/State/${state}`)
.then((json) => {
//console.info("Got all Jobs");
const ret = json as IJob[];
//console.debug(ret);
return (ret);
});
}
static async GetJobsWithType(apiUri: string, jobType: JobType): Promise<IJob[]> {
if(jobType == null || jobType == undefined) {
console.error(`jobType was not provided`);
return Promise.reject();
}
return getData(`${apiUri}/v2/Job/Type/${jobType}`)
.then((json) => {
//console.info("Got all Jobs");
const ret = json as IJob[];
//console.debug(ret);
return (ret);
});
}
static async GetJobsOfTypeAndWithState(apiUri: string, jobType: JobType, state: JobState): Promise<IJob[]> {
if(jobType == null || jobType == undefined) {
console.error(`jobType was not provided`);
return Promise.reject();
}
if(state == null || state == undefined) {
console.error(`state was not provided`);
return Promise.reject();
}
return getData(`${apiUri}/v2/Job/TypeAndState/${jobType}/${state}`)
.then((json) => {
//console.info("Got all Jobs");
const ret = json as IJob[];
//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 JobFunctions ${jobId}`);
return getData(`${apiUri}/v2/Job/${jobId}`)
.then((json) => {
//console.info(`Got JobFunctions ${jobId}`);
const ret = json as IJob;
//console.debug(ret);
return (ret);
});
}
static DeleteJob(apiUri: string, jobId: string) : Promise<void> {
if(jobId === undefined || jobId === null || jobId.length < 1) {
console.error(`JobId was not provided`);
return Promise.reject();
}
return deleteData(`${apiUri}/v2/Job/${jobId}`);
}
static async ModifyJob(apiUri: string, jobId: string, modifyData: IModifyJobRecord): Promise<IJob> {
if(jobId === undefined || jobId === null || jobId.length < 1) {
console.error(`JobId was not provided`);
return Promise.reject();
}
if(modifyData === undefined || modifyData === null) {
console.error(`modifyData was not provided`);
return Promise.reject();
}
return patchData(`${apiUri}/v2/Job/${jobId}`, modifyData)
.then((json) => {
//console.info(`Got JobFunctions ${jobId}`);
const ret = json as IJob;
//console.debug(ret);
return (ret);
});
}
static async CreateDownloadAvailableChaptersJob(apiUri: string, mangaId: string, data: IDownloadAvailableJobsRecord): Promise<string[]> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
if(data === undefined || data === null) {
console.error(`recurrenceMs was not provided`);
return Promise.reject();
}
return putData(`${apiUri}/v2/Job/DownloadAvailableChaptersJob/${mangaId}`, data)
.then((json) => {
//console.info(`Got JobFunctions ${jobId}`);
const ret = json as string[];
//console.debug(ret);
return (ret);
});
}
static async CreateDownloadSingleChapterJob(apiUri: string, chapterId: string): Promise<string[]> {
if(chapterId === undefined || chapterId === null || chapterId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
return putData(`${apiUri}/v2/Job/DownloadSingleChapterJob/${chapterId}`, {})
.then((json) => {
//console.info(`Got JobFunctions ${jobId}`);
const ret = json as string[];
//console.debug(ret);
return (ret);
});
}
static async CreateUpdateFilesJob(apiUri: string, mangaId: string): Promise<string[]> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
return putData(`${apiUri}/v2/Job/UpdateFilesJob/${mangaId}`, {})
.then((json) => {
//console.info(`Got JobFunctions ${jobId}`);
const ret = json as string[];
//console.debug(ret);
return (ret);
});
}
static async CreateUpdateAllFilesJob(apiUri: string): Promise<string[]> {
return putData(`${apiUri}/v2/Job/UpdateAllFilesJob`, {})
.then((json) => {
//console.info(`Got JobFunctions ${jobId}`);
const ret = json as string[];
//console.debug(ret);
return (ret);
});
}
static async CreateUpdateMetadataJob(apiUri: string, mangaId: string): Promise<string[]> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
return putData(`${apiUri}/v2/Job/UpdateMetadataJob/${mangaId}`, {})
.then((json) => {
//console.info(`Got JobFunctions ${jobId}`);
const ret = json as string[];
//console.debug(ret);
return (ret);
});
}
static async CreateUpdateAllMetadataJob(apiUri: string): Promise<string[]> {
return putData(`${apiUri}/v2/Job/UpdateAllMetadataJob`, {})
.then((json) => {
//console.info(`Got JobFunctions ${jobId}`);
const ret = json as string[];
//console.debug(ret);
return (ret);
});
}
static StartJob(apiUri: string, jobId: string) : Promise<object> {
return postData(`${apiUri}/v2/Job/${jobId}/Start`, {});
}
static StopJob(apiUri: string, jobId: string) : Promise<object> {
return postData(`${apiUri}/v2/Job/${jobId}/Stop`, {});
}
}

View File

View File

@ -0,0 +1,6 @@
import "../styles/loader.css";
import {CSSProperties} from "react";
export default function Loader({loading, style} : {loading: boolean, style?:CSSProperties|null}) {
return <span is-loading={loading ? "loading" : "done"} style={style ? style : undefined}></span>
}

View File

@ -0,0 +1,57 @@
import ILocalLibrary from "./interfaces/ILocalLibrary";
import {deleteData, getData, patchData, putData} from "../App";
import INewLibraryRecord from "./interfaces/records/INewLibraryRecord";
export default class LocalLibraryFunctions
{
static async GetLibraries(apiUri: string): Promise<ILocalLibrary[]> {
return getData(`${apiUri}/v2/LocalLibraries`)
.then((json) => {
const ret = json as ILocalLibrary[];
return (ret);
});
}
static async GetLibrary(apiUri: string, libraryId: string): Promise<ILocalLibrary> {
return getData(`${apiUri}/v2/LocalLibraries/${libraryId}`)
.then((json) => {
const ret = json as ILocalLibrary;
//console.debug(ret);
return (ret);
});
}
static async CreateLibrary(apiUri: string, data: INewLibraryRecord): Promise<ILocalLibrary> {
return putData(`${apiUri}/v2/LocalLibraries`, data)
.then((json) => {
const ret = json as ILocalLibrary;
//console.debug(ret);
return (ret);
});
}
static async DeleteLibrary(apiUri: string, libraryId: string): Promise<void> {
return deleteData(`${apiUri}/v2/LocalLibraries/${libraryId}`);
}
static async ChangeLibraryPath(apiUri: string, libraryId: string, newPath: string): Promise<void> {
return patchData(`${apiUri}/v2/LocalLibraries/${libraryId}/ChangeBasePath`, newPath)
.then(()=> {
return Promise.resolve()
});
}
static async ChangeLibraryName(apiUri: string, libraryId: string, newName: string): Promise<void> {
return patchData(`${apiUri}/v2/LocalLibraries/${libraryId}/ChangeName`, newName)
.then(()=> {
return Promise.resolve()
});
}
static async UpdateLibrary(apiUri: string, libraryId: string, record: INewLibraryRecord): Promise<void> {
return patchData(`${apiUri}/v2/LocalLibraries/${libraryId}`, record)
.then(()=> {
return Promise.resolve()
});
}
}

View File

@ -0,0 +1,44 @@
import IMangaConnector from './interfaces/IMangaConnector';
import {getData, patchData} from '../App';
export class MangaConnectorFunctions
{
static async GetAllConnectors(apiUri: string): Promise<IMangaConnector[]> {
//console.info("Getting all MangaConnectors");
return getData(`${apiUri}/v2/MangaConnector`)
.then((json) => {
//console.info("Got all MangaConnectors");
return (json as IMangaConnector[]);
});
}
static async GetEnabledConnectors(apiUri: string): Promise<IMangaConnector[]> {
//console.info("Getting all enabled MangaConnectors");
return getData(`${apiUri}/v2/MangaConnector/enabled`)
.then((json) => {
//console.info("Got all enabled MangaConnectors");
return (json as IMangaConnector[]);
});
}
static async GetDisabledConnectors(apiUri: string): Promise<IMangaConnector[]> {
//console.info("Getting all disabled MangaConnectors");
return getData(`${apiUri}/v2/MangaConnector/disabled`)
.then((json) => {
//console.info("Got all disabled MangaConnectors");
return (json as IMangaConnector[]);
});
}
static async SetConnectorEnabled(apiUri: string, connectorName: string, enabled: boolean): Promise<object> {
if(connectorName === undefined || connectorName === null || connectorName.length < 1) {
console.error(`connectorName was not provided`);
return Promise.reject();
}
if(enabled === undefined || enabled === null) {
console.error(`enabled was not provided`);
return Promise.reject();
}
return patchData(`${apiUri}/v2/MangaConnector/${connectorName}/SetEnabled/${enabled}`, {});
}
}

View File

@ -0,0 +1,156 @@
import IManga from './interfaces/IManga';
import {deleteData, getData, patchData, postData} from '../App';
import IChapter from "./interfaces/IChapter";
export default class MangaFunctions
{
static async GetAllManga(apiUri: string): Promise<IManga[]> {
//console.info("Getting all MangaFunctions");
return getData(`${apiUri}/v2/Manga`)
.then((json) => {
//console.info("Got all MangaFunctions");
const ret = json as IManga[];
//console.debug(ret);
return (ret);
});
}
static async GetMangaWithIds(apiUri: string, mangaIds: string[]): Promise<IManga[]> {
if(mangaIds === undefined || mangaIds === null || mangaIds.length < 1) {
console.error(`mangaIds was not provided`);
return Promise.reject();
}
//console.debug(`Getting Mangas ${internalIds.join(",")}`);
return await postData(`${apiUri}/v2/Manga/WithIds`, mangaIds)
.then((json) => {
//console.debug(`Got MangaFunctions ${internalIds.join(",")}`);
const ret = json as IManga[];
//console.debug(ret);
return (ret);
});
}
static async GetMangaById(apiUri: string, mangaId: string): Promise<IManga> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
//console.info(`Getting MangaFunctions ${internalId}`);
return await getData(`${apiUri}/v2/Manga/${mangaId}`)
.then((json) => {
//console.info(`Got MangaFunctions ${internalId}`);
const ret = json as IManga;
//console.debug(ret);
return (ret);
});
}
static async DeleteManga(apiUri: string, mangaId: string): Promise<void> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
return deleteData(`${apiUri}/v2/Manga/${mangaId}`);
}
static GetMangaCoverImageUrl(apiUri: string, mangaId: string, ref: HTMLImageElement | undefined): string {
//console.debug(`Getting MangaFunctions Cover-Url ${internalId}`);
if(ref == null || ref == undefined)
return `${apiUri}/v2/Manga/${mangaId}/Cover?width=64&height=64`;
return `${apiUri}/v2/Manga/${mangaId}/Cover?width=${ref.clientWidth}&height=${ref.clientHeight}`;
}
static async GetChapters(apiUri: string, mangaId: string): Promise<IChapter[]> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
return getData(`${apiUri}/v2/Manga/${mangaId}/Chapters`)
.then((json) => {
//console.info(`Got MangaFunctions ${internalId}`);
const ret = json as IChapter[];
//console.debug(ret);
return (ret);
});
}
static async GetDownloadedChapters(apiUri: string, mangaId: string): Promise<IChapter[]> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
return getData(`${apiUri}/v2/Manga/${mangaId}/Chapters/Downloaded`)
.then((json) => {
//console.info(`Got MangaFunctions ${internalId}`);
const ret = json as IChapter[];
//console.debug(ret);
return (ret);
});
}
static async GetNotDownloadedChapters(apiUri: string, mangaId: string): Promise<IChapter[]> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
return getData(`${apiUri}/v2/Manga/${mangaId}/Chapters/NotDownloaded`)
.then((json) => {
//console.info(`Got MangaFunctions ${internalId}`);
const ret = json as IChapter[];
//console.debug(ret);
return (ret);
});
}
static async GetLatestChapterAvailable(apiUri: string, mangaId: string): Promise<IChapter> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
return getData(`${apiUri}/v2/Manga/${mangaId}/Chapter/LatestAvailable`)
.then((json) => {
//console.info(`Got MangaFunctions ${internalId}`);
const ret = json as IChapter;
//console.debug(ret);
return (ret);
});
}
static async GetLatestChapterDownloaded(apiUri: string, mangaId: string): Promise<IChapter> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
return getData(`${apiUri}/v2/Manga/${mangaId}/Chapter/LatestDownloaded`)
.then((json) => {
//console.info(`Got MangaFunctions ${internalId}`);
const ret = json as IChapter;
//console.debug(ret);
return (ret);
});
}
static async SetIgnoreThreshold(apiUri: string, mangaId: string, chapterThreshold: number): Promise<object> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
if(chapterThreshold === undefined || chapterThreshold === null) {
console.error(`chapterThreshold was not provided`);
return Promise.reject();
}
return patchData(`${apiUri}/v2/Manga/${mangaId}/IgnoreChaptersBefore`, chapterThreshold);
}
static async MoveFolder(apiUri: string, mangaId: string, newPath: string): Promise<object> {
if(mangaId === undefined || mangaId === null || mangaId.length < 1) {
console.error(`mangaId was not provided`);
return Promise.reject();
}
if(newPath === undefined || newPath === null || newPath.length < 1) {
console.error(`newPath was not provided`);
return Promise.reject();
}
return postData(`${apiUri}/v2/Manga/{MangaId}/MoveFolder`, {newPath});
}
}

View File

@ -0,0 +1,65 @@
import React, {ReactElement, useEffect, useState} from 'react';
import JobFunctions from './JobFunctions';
import '../styles/monitorMangaList.css';
import {JobType} from "./interfaces/Jobs/IJob";
import '../styles/mangaCover.css'
import IDownloadAvailableChaptersJob from "./interfaces/Jobs/IDownloadAvailableChaptersJob";
import {MangaItem} from "./interfaces/IManga";
import MangaFunctions from "./MangaFunctions";
export default function MonitorJobsList({onStartSearch, connectedToBackend, apiUri, checkConnectedInterval} : {onStartSearch() : void, connectedToBackend: boolean, apiUri: string, checkConnectedInterval: number}) {
const [MonitoringJobs, setMonitoringJobs] = useState<IDownloadAvailableChaptersJob[]>([]);
const [joblistUpdateInterval, setJoblistUpdateInterval] = React.useState<number | undefined>(undefined);
useEffect(() => {
if(connectedToBackend) {
UpdateMonitoringJobsList();
if(joblistUpdateInterval === undefined){
setJoblistUpdateInterval(setInterval(() => {
UpdateMonitoringJobsList();
}, checkConnectedInterval));
}
}else{
clearInterval(joblistUpdateInterval);
setJoblistUpdateInterval(undefined);
}
}, [connectedToBackend]);
function UpdateMonitoringJobsList(){
if(!connectedToBackend)
return;
//console.debug("Updating MonitoringJobsList");
JobFunctions.GetJobsWithType(apiUri, JobType.DownloadAvailableChaptersJob)
.then((jobs) => jobs as IDownloadAvailableChaptersJob[])
.then((jobs) => {
if(jobs.length != MonitoringJobs.length ||
MonitoringJobs.filter(j => jobs.find(nj => nj.jobId == j.jobId)).length > 1 ||
jobs.filter(nj => MonitoringJobs.find(j => nj.jobId == j.jobId)).length > 1){
setMonitoringJobs(jobs);
}
});
}
function StartSearchMangaEntry() : ReactElement {
return (<div key="monitorMangaEntry.StartSearch" className="startSearchEntry MangaItem" onClick={onStartSearch}>
<img className="MangaItem-Cover" src="../media/blahaj.png" alt="Blahaj"></img>
<div>
<div style={{margin: "30px auto", color: "black", textShadow: "1px 2px #f5a9b8"}} className="MangaItem-Name">Add new Manga</div>
<div style={{fontSize: "42pt", textAlign: "center", textShadow: "1px 2px #5bcefa"}}>+</div>
</div>
</div>);
}
return (
<div id="MonitorMangaList">
<StartSearchMangaEntry />
{MonitoringJobs.map((MonitoringJob) =>
<MangaItem apiUri={apiUri} mangaId={MonitoringJob.mangaId} key={MonitoringJob.mangaId}>
<></>
<button className="Manga-DeleteButton" onClick={() => {
MangaFunctions.DeleteManga(apiUri, MonitoringJob.mangaId);
}}>Delete</button>
</MangaItem>
)}
</div>);
}

View File

@ -0,0 +1,98 @@
import INotificationConnector from "./interfaces/INotificationConnector";
import {deleteData, getData, putData} from "../App";
import IGotifyRecord from "./interfaces/records/IGotifyRecord";
import INtfyRecord from "./interfaces/records/INtfyRecord";
import ILunaseaRecord from "./interfaces/records/ILunaseaRecord";
export default class NotificationConnectorFunctions {
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);
});
}
static async CreateNotificationConnector(apiUri: string, newConnector: INotificationConnector): Promise<string> {
return putData(`${apiUri}/v2/NotificationConnector`, newConnector)
.then((json) => {
//console.info("Got Notification Connectors");
const ret = json as unknown as string;
//console.debug(ret);
return (ret);
});
}
static async GetNotificationConnectorWithId(apiUri: string, notificationConnectorId: string) : Promise<INotificationConnector> {
if(notificationConnectorId === undefined || notificationConnectorId === null || notificationConnectorId.length < 1) {
console.error(`notificationConnectorId was not provided`);
return Promise.reject();
}
//console.info("Getting Notification Connectors");
return getData(`${apiUri}/v2/NotificationConnector/${notificationConnectorId}`)
.then((json) => {
//console.info("Got Notification Connectors");
const ret = json as INotificationConnector;
//console.debug(ret);
return (ret);
});
}
static async DeleteNotificationConnectorWithId(apiUri: string, notificationConnectorId: string) : Promise<void> {
if(notificationConnectorId === undefined || notificationConnectorId === null || notificationConnectorId.length < 1) {
console.error(`notificationConnectorId was not provided`);
return Promise.reject();
}
//console.info("Getting Notification Connectors");
return deleteData(`${apiUri}/v2/NotificationConnector/${notificationConnectorId}`);
}
static async CreateGotify(apiUri: string, gotify: IGotifyRecord) : Promise<string> {
if(gotify === undefined || gotify === null) {
console.error(`gotify was not provided`);
return Promise.reject();
}
//console.info("Getting Notification Connectors");
return putData(`${apiUri}/v2/NotificationConnector/Gotify`, gotify)
.then((json) => {
//console.info("Got Notification Connectors");
const ret = json as unknown as string;
//console.debug(ret);
return (ret);
});
}
static async CreateNtfy(apiUri: string, ntfy: INtfyRecord) : Promise<string> {
if(ntfy === undefined || ntfy === null) {
console.error(`ntfy was not provided`);
return Promise.reject();
}
//console.info("Getting Notification Connectors");
return putData(`${apiUri}/v2/NotificationConnector/Ntfy`, ntfy)
.then((json) => {
//console.info("Got Notification Connectors");
const ret = json as unknown as string;
//console.debug(ret);
return (ret);
});
}
static async CreateLunasea(apiUri: string, lunasea: ILunaseaRecord) : Promise<string> {
if(lunasea === undefined || lunasea === null) {
console.error(`ntfy was not provided`);
return Promise.reject();
}
//console.info("Getting Notification Connectors");
return putData(`${apiUri}/v2/NotificationConnector/Lunasea`, lunasea)
.then((json) => {
//console.info("Got Notification Connectors");
const ret = json as unknown as string;
//console.debug(ret);
return (ret);
});
}
}

View File

@ -0,0 +1,75 @@
import React, {ReactElement, useEffect, useState} from 'react';
import IJob, {JobState, JobType} from "./interfaces/Jobs/IJob";
import '../styles/queuePopUp.css';
import '../styles/popup.css';
import JobFunctions from "./JobFunctions";
import IDownloadSingleChapterJob from "./interfaces/Jobs/IDownloadSingleChapterJob";
import {ChapterItem} from "./interfaces/IChapter";
export default function QueuePopUp({connectedToBackend, children, apiUri, checkConnectedInterval} : {connectedToBackend: boolean, children: ReactElement[], apiUri: string, checkConnectedInterval: number}) {
const [WaitingJobs, setWaitingJobs] = React.useState<IJob[]>([]);
const [RunningJobs, setRunningJobs] = React.useState<IJob[]>([]);
const [showQueuePopup, setShowQueuePopup] = useState<boolean>(false);
const [queueListInterval, setQueueListInterval] = React.useState<number | undefined>(undefined);
useEffect(() => {
if(connectedToBackend && showQueuePopup) {
UpdateMonitoringJobsList();
if(queueListInterval === undefined){
setQueueListInterval(setInterval(() => {
UpdateMonitoringJobsList();
}, checkConnectedInterval));
}
}else{
clearInterval(queueListInterval);
setQueueListInterval(undefined);
}
}, [connectedToBackend, showQueuePopup]);
function UpdateMonitoringJobsList(){
JobFunctions.GetJobsInState(apiUri, JobState.Waiting)
.then((jobs: IJob[]) => {
//console.log(jobs);
return jobs;
})
.then(setWaitingJobs);
JobFunctions.GetJobsInState(apiUri, JobState.Running)
.then((jobs: IJob[]) => {
//console.log(jobs);
return jobs;
})
.then(setRunningJobs);
}
return (<>
<div onClick={() => setShowQueuePopup(true)}>
{children}
</div>
{showQueuePopup
? <div className="popup" id="QueuePopUp">
<div className="popupHeader">
<h1>Queue Status</h1>
<img alt="Close Queue" className="close" src="../media/close-x.svg"
onClick={() => setShowQueuePopup(false)}/>
</div>
<div id="QueuePopUpBody" className="popupBody">
<div>
{RunningJobs.filter(j => j.jobType == JobType.DownloadSingleChapterJob).map(j => {
let job = j as IDownloadSingleChapterJob;
return <ChapterItem apiUri={apiUri} chapterId={job.chapterId} />
})}
</div>
<div>
{WaitingJobs.filter(j => j.jobType == JobType.DownloadSingleChapterJob).map(j =>{
let job = j as IDownloadSingleChapterJob;
return <ChapterItem apiUri={apiUri} chapterId={job.chapterId} />
})}
</div>
</div>
</div>
: null
}
</>
);
}

150
Website/modules/Search.tsx Normal file
View File

@ -0,0 +1,150 @@
import React, {ChangeEventHandler, EventHandler, useEffect, useState} from 'react';
import {MangaConnectorFunctions} from "./MangaConnectorFunctions";
import IMangaConnector from "./interfaces/IMangaConnector";
import {isValidUri} from "../App";
import IManga, {MangaItem} from "./interfaces/IManga";
import '../styles/search.css';
import SearchFunctions from "./SearchFunctions";
import JobFunctions from "./JobFunctions";
import ILocalLibrary from "./interfaces/ILocalLibrary";
import LocalLibraryFunctions from "./LocalLibraryFunctions";
import Loader from "./Loader";
export default function Search({apiUri, jobInterval, closeSearch} : {apiUri: string, jobInterval: Date, 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(() => {
MangaConnectorFunctions.GetAllConnectors(apiUri).then(setConnectors).then(() => setLoading(false));
}, []);
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;
}
setLoading(true);
if(!isValidUri(searchBoxValue)){
SearchFunctions.SearchNameOnConnector(apiUri, selectedConnector.name, searchBoxValue)
.then((mangas: IManga[]) => {
setSearchResults(mangas);
})
.finally(()=>setLoading(false));
}else{
SearchFunctions.SearchUrl(apiUri, searchBoxValue)
.then((manga: IManga) => {
setSearchResults([manga]);
})
.finally(()=>setLoading(false));
}
}
const changeSelectedLanguage : ChangeEventHandler<HTMLSelectElement> = (event) => setSelectedLanguage(event.target.value);
let [selectedLibrary, setSelectedLibrary] = useState<ILocalLibrary | null>(null);
let [libraries, setLibraries] = useState<ILocalLibrary[] | null>(null);
let [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
LocalLibraryFunctions.GetLibraries(apiUri).then(setLibraries);
}, []);
useEffect(() => {
if(libraries === null || libraries.length < 1)
setSelectedLibrary(null);
else
setSelectedLibrary(libraries[0]);
}, [libraries]);
const selectedLibraryChanged : ChangeEventHandler<HTMLSelectElement> = (event) => {
event.preventDefault();
if(libraries === null)
return;
const selectedLibrary = libraries.find((lib:ILocalLibrary) => lib.localLibraryId == event.target.value);
if(selectedLibrary === undefined)
return;
setSelectedLibrary(selectedLibrary);
}
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} disabled={loading} />
<select id="Searchbox-Connector" value={selectedConnector === undefined ? "" : selectedConnector.name} onChange={selectedConnectorChanged} disabled={loading}>
{mangaConnectors === undefined ? <option value="Loading">Loading</option> : <option value="" disabled hidden>Select</option>}
{mangaConnectors === undefined
? null
: mangaConnectors.map(con => <option value={con.name} key={con.name}>{con.name}</option>)}
</select>
<select id="Searchbox-language" onChange={changeSelectedLanguage} value={selectedLanguage === null ? "" : selectedLanguage} disabled={loading}>
{mangaConnectors === undefined ? <option value="Loading">Loading</option> : <option value="" disabled hidden>Select Connector</option>}
{selectedConnector === undefined
? null
: selectedConnector.supportedLanguages.map(language => <option value={language} key={language}>{language}</option>)}
</select>
<button id="Searchbox-button" type="submit" onClick={ExecuteSearch} disabled={loading}>Search</button>
<Loader loading={loading} style={{width:"40px", height:"40px", zIndex: 50}}/>
</div>
<img alt="Close Search" id="closeSearch" src="../media/close-x.svg" onClick={closeSearch} />
<div id="SearchResults">
{searchResults === undefined
? null
: searchResults.map(result => {
return <MangaItem apiUri={apiUri} mangaId={result.mangaId} >
<select defaultValue={selectedLibrary === null ? "" : selectedLibrary.localLibraryId} onChange={selectedLibraryChanged}>
{selectedLibrary === null || libraries === null ? <option value="">Loading</option>
: libraries.map(library => <option key={library.localLibraryId} value={library.localLibraryId}>{library.libraryName} ({library.basePath})</option>)}
</select>
<button className="Manga-AddButton" onClick={() => {
JobFunctions.CreateDownloadAvailableChaptersJob(apiUri, result.mangaId, {recurrenceTimeMs: new Date(jobInterval).getTime(), localLibraryId: selectedLibrary!.localLibraryId});
}}>Monitor</button>
</MangaItem>
})
}
</div>
</div>);
}

View File

@ -0,0 +1,46 @@
import IManga from "./interfaces/IManga";
import {postData} from "../App";
export default class SearchFunctions {
static async SearchName(apiUri: string, name: string) : Promise<IManga[]> {
if(name === undefined || name === null || name.length < 1) {
console.error(`name was not provided`);
return Promise.reject();
}
return postData(`${apiUri}/v2/Search/Name`, name)
.then((json) => {
const ret = json as IManga[];
return (ret);
});
}
static async SearchNameOnConnector(apiUri: string, connectorName: string, name: string) : Promise<IManga[]> {
if(connectorName === undefined || connectorName === null || connectorName.length < 1) {
console.error(`connectorName was not provided`);
return Promise.reject();
}
if(name === undefined || name === null || name.length < 1) {
console.error(`name was not provided`);
return Promise.reject();
}
return postData(`${apiUri}/v2/Search/${connectorName}`, name)
.then((json) => {
const ret = json as IManga[];
return (ret);
});
}
static async SearchUrl(apiUri: string, url: string) : Promise<IManga> {
if(url === undefined || url === null || url.length < 1) {
console.error(`name was not provided`);
return Promise.reject();
}
return postData(`${apiUri}/v2/Search/Url`, url)
.then((json) => {
const ret = json as IManga;
return (ret);
});
}
}

View File

@ -0,0 +1,173 @@
import IFrontendSettings from "./interfaces/IFrontendSettings";
import '../styles/settings.css';
import '../styles/react-toggle.css';
import React, {useEffect, useRef, useState} from "react";
import INotificationConnector, {NotificationConnectorItem} from "./interfaces/INotificationConnector";
import NotificationConnectorFunctions from "./NotificationConnectorFunctions";
import ILocalLibrary, {LocalLibraryItem} from "./interfaces/ILocalLibrary";
import LocalLibraryFunctions from "./LocalLibraryFunctions";
import IBackendSettings from "./interfaces/IBackendSettings";
import BackendSettings from "./BackendSettingsFunctions";
import Toggle from "react-toggle";
import Loader from "./Loader";
import {RequestType} from "./interfaces/IRequestLimits";
export default function Settings({ backendConnected, apiUri, frontendSettings, setFrontendSettings } : {
backendConnected: boolean,
apiUri: string,
frontendSettings: IFrontendSettings,
setFrontendSettings: (settings: IFrontendSettings) => void
}) {
const [showSettings, setShowSettings] = useState<boolean>(false);
const [loadingBackend, setLoadingBackend] = useState(false);
const [backendSettings, setBackendSettings] = useState<IBackendSettings|null>(null);
const [notificationConnectors, setNotificationConnectors] = useState<INotificationConnector[]>([]);
const [localLibraries, setLocalLibraries] = useState<ILocalLibrary[]>([]);
useEffect(() => {
if(!backendConnected)
return;
NotificationConnectorFunctions.GetNotificationConnectors(apiUri).then(setNotificationConnectors);
LocalLibraryFunctions.GetLibraries(apiUri).then(setLocalLibraries);
BackendSettings.GetSettings(apiUri).then(setBackendSettings);
}, [backendConnected, showSettings]);
const dateToStr = (x: Date) => {
const ret = (x.getHours() < 10 ? "0" + x.getHours() : x.getHours())
+ ":" +
(x.getMinutes() < 10 ? "0" + x.getMinutes() : x.getMinutes());
return ret;
}
const ChangeRequestLimit = (requestType: RequestType, limit: number) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateRequestLimit(apiUri, requestType, limit)
.then(() => setBackendSettings({...backendSettings, [requestType]: requestType}))
.finally(() => setLoadingBackend(false));
}
const ref : React.LegacyRef<HTMLInputElement> | undefined = useRef<HTMLInputElement>(null);
return (
<div id="Settings">
<div onClick={() => setShowSettings(true)}>
<img id="Settings-Cogwheel" src="../../media/settings-cogwheel.svg" alt="settings-cogwheel" />
</div>
{showSettings
? <div className="popup" id="SettingsPopUp">
<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">
<Loader loading={loadingBackend} style={{width: "64px", height: "64px", margin: "25vh calc(sin(70)*(50% - 40px))", zIndex: 100, padding: 0, borderRadius: "50%", border: 0, minWidth: "initial", maxWidth: "initial"}}/>
<div className="settings-apiuri">
<h3>ApiUri</h3>
<input type="url" defaultValue={frontendSettings.apiUri} onChange={(e) => setFrontendSettings({...frontendSettings, apiUri:e.currentTarget.value})} id="ApiUri" />
</div>
<div className="settings-jobinterval">
<h3>Default Job-Interval</h3>
<input type="time" min="00:30" max="23:59" defaultValue={dateToStr(new Date(frontendSettings.jobInterval))} onChange={(e) => setFrontendSettings({...frontendSettings, jobInterval: new Date(e.currentTarget.valueAsNumber-60*60*1000) ?? frontendSettings.jobInterval})}/>
</div>
<div className="settings-bwimages">
<h3>B/W Images</h3>
<Toggle defaultChecked={backendSettings ? backendSettings.bwImages : false} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateBWImageToggle(apiUri, e.target.checked)
.then(() => setBackendSettings({...backendSettings, bwImages: e.target.checked}))
.finally(() => setLoadingBackend(false));
}} />
</div>
<div className="settings-aprilfools">
<h3>April Fools Mode</h3>
<Toggle defaultChecked={backendSettings ? backendSettings.aprilFoolsMode : false} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateAprilFoolsToggle(apiUri, e.target.checked)
.then(() => setBackendSettings({...backendSettings, aprilFoolsMode: e.target.checked}))
.finally(() => setLoadingBackend(false));
}} />
</div>
<div className="settings-imagecompression">
<h3>Image Compression</h3>
<Toggle defaultChecked={backendSettings ? backendSettings.compression < 100 : false} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateImageCompressionValue(apiUri, e.target.checked ? 40 : 100)
.then(() => setBackendSettings({...backendSettings, compression: e.target.checked ? 40 : 100}))
.then(() => {
if(ref.current != null){
ref.current.value = e.target.checked ? "40" : "100";
ref.current.disabled = !e.target.checked;
}
})
.finally(() => setLoadingBackend(false));
}} />
<input ref={ref} type="number" min={0} max={100} defaultValue={backendSettings ? backendSettings.compression : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateImageCompressionValue(apiUri, e.currentTarget.valueAsNumber)
.then(() => setBackendSettings({...backendSettings, compression: e.currentTarget.valueAsNumber}))
.finally(() => setLoadingBackend(false));
}} />
</div>
<div className="settings-useragent">
<h3>User Agent</h3>
<input type="text" defaultValue={backendSettings ? backendSettings.userAgent : ""}
onChange={(e) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateUserAgent(apiUri, e.currentTarget.value)
.then(() => setBackendSettings({...backendSettings, userAgent: e.currentTarget.value}))
.finally(() => setLoadingBackend(false));
}} />
</div>
<div className="settings-requestLimits">
<h3>Request Limits:</h3>
<label htmlFor="Default">Default</label>
<input id="Default" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.Default : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.Default, e.currentTarget.valueAsNumber)} />
<label htmlFor="MangaInfo">MangaInfo</label>
<input id="MangaInfo" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.MangaInfo : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.MangaInfo, e.currentTarget.valueAsNumber)} />
<label htmlFor="MangaDexFeed">MangaDexFeed</label>
<input id="MangaDexFeed" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.MangaDexFeed : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.MangaDexFeed, e.currentTarget.valueAsNumber)} />
<label htmlFor="MangaDexImage">MangaDexImage</label>
<input id="MangaDexImage" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.MangaDexImage : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.MangaDexImage, e.currentTarget.valueAsNumber)} />
<label htmlFor="MangaImage">MangaImage</label>
<input id="MangaImage" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.MangaImage : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.MangaImage, e.currentTarget.valueAsNumber)} />
<label htmlFor="MangaCover">MangaCover</label>
<input id="MangaCover" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.MangaCover : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.MangaCover, e.currentTarget.valueAsNumber)} />
</div>
<div>
<h3>Notification Connectors:</h3>
{notificationConnectors.map(c => <NotificationConnectorItem apiUri={apiUri} notificationConnector={c} key={c.name} />)}
<NotificationConnectorItem apiUri={apiUri} notificationConnector={null} key="New Notification Connector" />
</div>
<div>
<h3>Local Libraries:</h3>
{localLibraries.map(l => <LocalLibraryItem apiUri={apiUri} library={l} key={l.localLibraryId} />)}
<LocalLibraryItem apiUri={apiUri} library={null} key="New Local Library" />
</div>
</div>
</div>
: null
}
</div>
);
}

View File

@ -0,0 +1,23 @@
import React, {ReactElement, useEffect} from "react";
import {getData} from "../../App";
export default interface IAuthor {
authorId: string;
authorName: string;
}
export function AuthorElement({apiUri, authorId} : {apiUri: string, authorId: string | null}) : ReactElement{
let [author, setAuthor] = React.useState<IAuthor | null>(null);
useEffect(()=> {
if(authorId === null)
return;
getData(`${apiUri}/v2/Query/Author/${authorId}`)
.then((json) => {
let ret = json as IAuthor;
setAuthor(ret);
});
}, [])
return (<span className="Manga-Author-Name">{author ? author.authorName : authorId}</span>);
}

View File

@ -0,0 +1,17 @@
export default interface IBackendSettings {
downloadLocation: string;
workingDirectory: string;
userAgent: string;
aprilFoolsMode: boolean;
requestLimits: {
Default: number,
MangaInfo: number,
MangaDexFeed: number,
MangaDexImage: number,
MangaImage: number,
MangaCover: number,
};
compression: number;
bwImages: boolean;
startNewJobTimeoutMs: number;
}

View File

@ -0,0 +1,51 @@
import React, {ReactElement, ReactEventHandler, useEffect, useState} from "react";
import MangaFunctions from "../MangaFunctions";
import IManga from "./IManga";
import ChapterFunctions from "../ChapterFunctions";
export default interface IChapter{
chapterId: string;
volumeNumber: number;
chapterNumber: string;
url: string;
title: string | undefined;
archiveFileName: string;
downloaded: boolean;
parentMangaId: string;
}
export function ChapterItem({apiUri, chapterId} : {apiUri: string, chapterId: string}) : ReactElement {
const setCoverItem : ReactEventHandler<HTMLImageElement> = (e) => {
setMangaCoverHtmlImageItem(e.currentTarget);
}
let [chapter, setChapter] = useState<IChapter | null>(null);
let [manga, setManga] = useState<IManga | null>(null);
let [mangaCoverUrl, setMangaCoverUrl] = useState<string>("../../media/blahaj.png");
let [mangaCoverHtmlImageItem, setMangaCoverHtmlImageItem] = useState<HTMLImageElement | null>(null);
useEffect(() => {
ChapterFunctions.GetChapterFromId(apiUri, chapterId).then(setChapter);
}, []);
useEffect(() => {
if(chapter === null)
manga = null;
else
MangaFunctions.GetMangaById(apiUri, chapter.parentMangaId).then(setManga);
}, [chapter]);
useEffect(() => {
if(chapter != null && mangaCoverHtmlImageItem != null)
setMangaCoverUrl(MangaFunctions.GetMangaCoverImageUrl(apiUri, chapter.parentMangaId, mangaCoverHtmlImageItem));
}, [chapter, mangaCoverHtmlImageItem]);
let [clicked, setClicked] = useState<boolean>(false);
return (<div className="ChapterItem" key={chapterId} is-clicked={clicked ? "clicked" : "not-clicked"} onClick={() => setClicked(!clicked)}>
<img className="ChapterItem-Cover" src={mangaCoverUrl} alt="MangaFunctions Cover" onLoad={setCoverItem} onResize={setCoverItem}></img>
<p className="ChapterItem-MangaName">{manga ? manga.name : "MangaFunctions-Name"}</p>
<p className="ChapterItem-ChapterName">{chapter ? chapter.title : "ChapterFunctions-Title"}</p>
<p className="ChapterItem-Volume">Vol.{chapter ? chapter.volumeNumber : "VolNum"}</p>
<p className="ChapterItem-Chapter">Ch.{chapter ? chapter.chapterNumber : "ChNum"}</p>
<p className="ChapterItem-VolumeChapter">Vol.{chapter ? chapter.volumeNumber : "VolNum"} Ch.{chapter ? chapter.chapterNumber : "ChNum"}</p>
<a className="ChapterItem-Website" href={chapter ? chapter.url : "#"}><img src="../../media/link.svg" alt="Link"/></a>
</div>)
}

View File

@ -0,0 +1,18 @@
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(Date.parse("1970-01-01T03:00:00.000Z"))
: cookies.get('jobInterval'),
apiUri: cookies.get('apiUri') === undefined
? `${window.location.protocol}//${window.location.host}/api`
: cookies.get('apiUri')
}
}

View File

@ -0,0 +1,11 @@
export default interface ILibraryConnector {
libraryConnectorId: string;
libraryType: LibraryType;
baseUrl: string;
auth: string;
}
export enum LibraryType {
Komga = "Komga",
Kavita = "Kavita"
}

View File

@ -0,0 +1,24 @@
import React, {ReactElement, useEffect} from "react";
import {getData} from "../../App";
export default interface ILink {
linkId: string;
linkProvider: string;
linkUrl: string;
}
export function LinkElement({apiUri, linkId} : {apiUri: string, linkId: string | null}) : ReactElement{
let [link, setLink] = React.useState<ILink | null>(null);
useEffect(()=> {
if(linkId === null)
return;
getData(`${apiUri}/v2/Query/Link/${linkId}`)
.then((json) => {
let ret = json as ILink;
setLink(ret);
});
}, [])
return (<a className="Manga-Link-Value" href={link ? link.linkUrl : "#"}>{link ? link.linkProvider : linkId}</a>);
}

View File

@ -0,0 +1,45 @@
import {ReactElement, useState} from "react";
import INewLibraryRecord, {Validate} from "./records/INewLibraryRecord";
import Loader from "../Loader";
import LocalLibraryFunctions from "../LocalLibraryFunctions";
import "../../styles/localLibrary.css";
export default interface ILocalLibrary {
localLibraryId: string;
basePath: string;
libraryName: string;
}
export function LocalLibraryItem({apiUri, library} : {apiUri: string, library: ILocalLibrary | null}) : ReactElement {
const [loading, setLoading] = useState<boolean>(false);
const [record, setRecord] = useState<INewLibraryRecord>({
path: library?.basePath ?? "",
name: library?.libraryName ?? ""
});
return (<div className="LocalLibraryFunctions">
<label htmlFor="LocalLibraryFunctions-Name">Library Name</label>
<input id="LocalLibraryFunctions-Name" className="LocalLibraryFunctions-Name" placeholder="Library Name" defaultValue={library ? library.libraryName : "New Library"}
onChange={(e) => setRecord({...record, name: e.currentTarget.value})}/>
<label htmlFor="LocalLibraryFunctions-Path">Library Path</label>
<input id="LocalLibraryFunctions-Path" className="LocalLibraryFunctions-Path" placeholder="Library Path" defaultValue={library ? library.basePath : ""}
onChange={(e) => setRecord({...record, path: e.currentTarget.value})}/>
{library
? <button className="LocalLibraryFunctions-Action" onClick={() => {
if(record === null || Validate(record) === false)
return;
setLoading(true);
LocalLibraryFunctions.UpdateLibrary(apiUri, library.localLibraryId, record)
.finally(() => setLoading(false));
}}>Edit</button>
: <button className="LocalLibraryFunctions-Action" onClick={() => {
if(record === null || Validate(record) === false)
return;
setLoading(true);
LocalLibraryFunctions.CreateLibrary(apiUri, record)
.finally(() => setLoading(false));
}}>Add</button>
}
<Loader loading={loading} style={{width:"40px",height:"40px"}}/>
</div>);
}

View File

@ -0,0 +1,129 @@
import MangaFunctions from "../MangaFunctions";
import React, {Children, ReactElement, ReactEventHandler, useEffect, useState} from "react";
import Icon from '@mdi/react';
import { mdiTagTextOutline, mdiAccountEdit, mdiLinkVariant } from '@mdi/js';
import MarkdownPreview from '@uiw/react-markdown-preview';
import {AuthorElement} from "./IAuthor";
import {LinkElement} from "./ILink";
import IChapter from "./IChapter";
import Loader from "../Loader";
export default interface IManga{
mangaId: string;
idOnConnectorSite: string;
name: string;
description: string;
websiteUrl: string;
year: number;
originalLanguage: string;
releaseStatus: MangaReleaseStatus;
folderName: string;
ignoreChapterBefore: number;
mangaConnectorId: string;
authorIds: string[];
tags: string[];
linkIds: string[];
altTitleIds: string[];
}
export enum MangaReleaseStatus {
Continuing = "Continuing",
Completed = "Completed",
OnHiatus = "OnHiatus",
Cancelled = "Cancelled",
Unreleased = "Unreleased",
}
export function MangaItem({apiUri, mangaId, children} : {apiUri: string, mangaId: string, children?: (string | ReactElement)[]}) : ReactElement {
const LoadMangaCover : ReactEventHandler<HTMLImageElement> = (e) => {
if(e.currentTarget.src != MangaFunctions.GetMangaCoverImageUrl(apiUri, mangaId, e.currentTarget))
e.currentTarget.src = MangaFunctions.GetMangaCoverImageUrl(apiUri, mangaId, e.currentTarget);
}
let [manga, setManga] = useState<IManga | null>(null);
let [clicked, setClicked] = useState<boolean>(false);
let [latestChapterDownloaded, setLatestChapterDownloaded] = useState<IChapter | null>(null);
let [latestChapterAvailable, setLatestChapterAvailable] = useState<IChapter | null>(null);
let [loadingChapterStats, setLoadingChapterStats] = useState<boolean>(true);
let [settingThreshold, setSettingThreshold] = useState<boolean>(false);
const invalidTargets = ["input", "textarea", "button", "select", "a"];
useEffect(() => {
MangaFunctions.GetMangaById(apiUri, mangaId).then(setManga);
MangaFunctions.GetLatestChapterDownloaded(apiUri, mangaId)
.then(setLatestChapterDownloaded)
.finally(() => {
if(latestChapterDownloaded && latestChapterAvailable)
setLoadingChapterStats(false);
});
MangaFunctions.GetLatestChapterAvailable(apiUri, mangaId)
.then(setLatestChapterAvailable)
.finally(() => {
if(latestChapterDownloaded && latestChapterAvailable)
setLoadingChapterStats(false);
});
}, []);
return (<div className="MangaItem" key={mangaId} is-clicked={clicked ? "clicked" : "not-clicked"} onClick={(e)=> {
e.preventDefault();
const target = e.target as HTMLElement;
if(invalidTargets.find(x => x == target.localName) === undefined )
setClicked(!clicked)
}}>
<img className="MangaItem-Cover" src="../../media/blahaj.png" alt="MangaFunctions Cover" onLoad={LoadMangaCover} onResize={LoadMangaCover}></img>
<div className="MangaItem-Connector">{manga ? manga.mangaConnectorId : "Connector"}</div>
<div className="MangaItem-Status" release-status={manga?.releaseStatus}></div>
<div className="MangaItem-Name">{manga ? manga.name : "Name"}</div>
<a className="MangaItem-Website" href={manga ? manga.websiteUrl : "#"}><img src="../../media/link.svg" alt="Link"/></a>
<div className="MangaItem-Tags">
{manga ? manga.authorIds.map(authorId =>
<div className="MangaItem-Author" key={authorId} >
<Icon path={mdiAccountEdit} size={0.5} />
<AuthorElement apiUri={apiUri} authorId={authorId}></AuthorElement>
</div>)
:
<div className="MangaItem-Author" key="null-Author">
<Icon path={mdiAccountEdit} size={0.5} />
<AuthorElement apiUri={apiUri} authorId={null}></AuthorElement>
</div>}
{manga ? manga.tags.map(tag =>
<div className="MangaItem-Tag" key={tag}>
<Icon path={mdiTagTextOutline} size={0.5}/>
<span className="MangaItem-Tag-Value">{tag}</span>
</div>)
:
<div className="MangaItem-Tag" key="null-Tag">
<Icon path={mdiTagTextOutline} size={0.5}/>
<span className="MangaItem-Tag-Value">Tag</span>
</div>
}
{manga ? manga.linkIds.map(linkId =>
<div className="MangaItem-Link" key={linkId}>
<Icon path={mdiLinkVariant} size={0.5}/>
<LinkElement apiUri={apiUri} linkId={linkId} />
</div>)
:
<div className="MangaItem-Link" key="null-Link">
<Icon path={mdiLinkVariant} size={0.5}/>
<LinkElement apiUri={apiUri} linkId={null} />
</div>}
</div>
<MarkdownPreview className="MangaItem-Description" source={manga ? manga.description : "# Description"} />
<div className="MangaItem-Props">
<div className="MangaItem-Props-Threshold">
Start at Chapter
<input type="text" defaultValue={latestChapterDownloaded ? latestChapterDownloaded.chapterNumber : ""} disabled={settingThreshold} onChange={(e) => {
setSettingThreshold(true);
MangaFunctions.SetIgnoreThreshold(apiUri, mangaId, e.currentTarget.valueAsNumber).finally(()=>setSettingThreshold(false));
}} />
<Loader loading={settingThreshold} style={{margin: "-10px -45px"}}/>
out of <span className="MangaItem-Props-Threshold-Available">{latestChapterAvailable ? latestChapterAvailable.chapterNumber : <Loader loading={loadingChapterStats} style={{margin: "-10px -35px"}} />}</span>
</div>
{children ? children.map(c => {
if(c instanceof Element)
return c as ReactElement;
else
return c
}) : null}
</div>
</div>)
}

View File

@ -0,0 +1,25 @@
import React, {ReactElement, useEffect} from "react";
import {getData} from "../../App";
import IAuthor from "./IAuthor";
export default interface IMangaAltTitle {
altTitleId: string;
language: string;
title: string;
}
export function AltTitleElement({apiUri, altTitleId} : {apiUri: string, altTitleId: string | null}) : ReactElement{
let [altTitle, setAltTitle] = React.useState<IMangaAltTitle | null>(null);
useEffect(()=> {
if(altTitleId === null)
return;
getData(`${apiUri}/v2/Query/AltTitle/${altTitleId}`)
.then((json) => {
let ret = json as IMangaAltTitle;
setAltTitle(ret);
});
}, [])
return (<span className="Manga-AltTitle">{altTitle ? altTitle.title : altTitleId}</span>);
}

View File

@ -0,0 +1,7 @@
export default interface IMangaConnector {
name: string;
supportedLanguages: string[];
iconUrl: string;
baseUris: string[];
enabled: boolean;
}

View File

@ -0,0 +1,106 @@
import {ReactElement, ReactEventHandler, useState} from "react";
import "../../styles/notificationConnector.css";
import Loader from "../Loader";
import NotificationConnectorFunctions from "../NotificationConnectorFunctions";
import {LunaseaItem} from "./records/ILunaseaRecord";
import {GotifyItem} from "./records/IGotifyRecord";
import {NtfyItem} from "./records/INtfyRecord";
export default interface INotificationConnector {
name: string;
url: string;
headers: Record<string, string>[];
httpMethod: string;
body: string;
}
export function NotificationConnectorItem({apiUri, notificationConnector} : {apiUri: string, notificationConnector: INotificationConnector | null}) : ReactElement {
if(notificationConnector != null)
return <DefaultItem apiUri={apiUri} notificationConnector={notificationConnector} />
const [selectedConnectorElement, setSelectedConnectorElement] = useState<ReactElement>(<DefaultItem apiUri={apiUri} notificationConnector={null} />);
return <div>
<div>New Notification Connector</div>
<label>Type</label>
<select defaultValue="default" onChange={(e) => {
switch (e.currentTarget.value){
case "default": setSelectedConnectorElement(<DefaultItem apiUri={apiUri} notificationConnector={null} />); break;
case "gotify": setSelectedConnectorElement(<GotifyItem apiUri={apiUri} />); break;
case "ntfy": setSelectedConnectorElement(<NtfyItem apiUri={apiUri} />); break;
case "lunasea": setSelectedConnectorElement(<LunaseaItem apiUri={apiUri} />); break;
}
}}>
<option value="default">Generic REST</option>
<option value="gotify">Gotify</option>
<option value="ntfy">Ntfy</option>
<option value="lunasea">Lunasea</option>
</select>
{selectedConnectorElement}
</div>;
}
function DefaultItem({apiUri, notificationConnector}:{apiUri: string, notificationConnector: INotificationConnector | null}) : ReactElement {
const AddHeader : ReactEventHandler<HTMLButtonElement> = () => {
let header : Record<string, string> = {};
let x = info;
x.headers = [header, ...x.headers];
setInfo(x);
setHeaderElements([...headerElements, <HeaderElement record={header} />])
}
const [headerElements, setHeaderElements] = useState<ReactElement[]>([]);
const [info, setInfo] = useState<INotificationConnector>({
name: "",
url: "",
headers: [],
httpMethod: "",
body: ""
});
const [loading, setLoading] = useState<boolean>(false);
return <div className="NotificationConnectorItem">
<input className="NotificationConnectorItem-Name" placeholder="Name" defaultValue={notificationConnector ? notificationConnector.name : ""}
disabled={notificationConnector != null} onChange={(e) => setInfo({...info, name: e.currentTarget.value})} />
<div className="NotificationConnectorItem-Url">
<select className="NotificationConnectorItem-RequestMethod" defaultValue={notificationConnector ? notificationConnector.httpMethod : ""}
disabled={notificationConnector != null} onChange={(e)=> setInfo({...info, httpMethod: e.currentTarget.value})} >
<option value="" disabled hidden>Request Method</option>
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
<input type="url" className="NotificationConnectorItem-RequestUrl" placeholder="URL" defaultValue={notificationConnector ? notificationConnector.url : ""}
disabled={notificationConnector != null} onChange={(e) => setInfo({...info, url: e.currentTarget.value})} />
</div>
<textarea className="NotificationConnectorItem-Body" placeholder="Request-Body" defaultValue={notificationConnector ? notificationConnector.body : ""}
disabled={notificationConnector != null} onChange={(e)=> setInfo({...info, body: e.currentTarget.value})} />
{notificationConnector != null ? null :
(
<p className="NotificationConnectorItem-Explanation">Formatting placeholders: "%title" and "%text" can be placed in url, header-values and body and will be replaced when notifications are sent</p>
)}
<div className="NotificationConnectorItem-Headers">
{headerElements}
{notificationConnector
? notificationConnector.headers.map((h: Record<string, string>) =>
(<HeaderElement record={h} disabled={notificationConnector != null}/>)
) :
(
<button className="NotificationConnectorItem-AddHeader" onClick={AddHeader}>Add Header</button>
)
}
</div>
<>
<button className="NotificationConnectorItem-Save" onClick={(e) => {
setLoading(true);
NotificationConnectorFunctions.CreateNotificationConnector(apiUri, info)
.finally(() => setLoading(false));
}}>Add</button>
<Loader loading={loading} style={{width:"40px",height:"40px",margin:"25vh calc(sin(70)*(50% - 40px))"}}/>
</>
</div>
}
function HeaderElement({record, disabled} : {record: Record<string, string>, disabled?: boolean | null}) : ReactElement {
return <div className="NotificationConnectorItem-Header" key={record.name}>
<input type="text" className="NotificationConnectorItem-Header-Key" placeholder="Header-Key" defaultValue={record.name} disabled={disabled?disabled:false} onChange={(e) => record.name = e.currentTarget.value}/>
<input type="text" className="NotificationConnectorItem-Header-Value" placeholder="Header-Value" defaultValue={record.value} disabled={disabled?disabled:false} onChange={(e) => record.value = e.currentTarget.value} />
</div>;
}

View File

@ -0,0 +1,17 @@
export default interface IRequestLimits {
Default: number;
MangaDexFeed: number;
MangaImage: number;
MangaCover: number;
MangaDexImage: number;
MangaInfo: number;
}
export enum RequestType {
Default = "Default",
MangaDexFeed = "MangaDexFeed",
MangaImage = "MangaImage",
MangaCover = "MangaCover",
MangaDexImage = "MangaDexImage",
MangaInfo = "MangaInfo"
}

View File

@ -0,0 +1,5 @@
import IJob from "./IJob";
export default interface IDownloadAvailableChaptersJob extends IJob {
mangaId: string;
}

View File

@ -0,0 +1,5 @@
import IJob from "./IJob";
export default interface IDownloadMangaCoverJob extends IJob {
mangaId: string;
}

View File

@ -0,0 +1,5 @@
import IJob from "./IJob";
export default interface IDownloadSingleChapterJob extends IJob {
chapterId: string;
}

View File

@ -0,0 +1,29 @@
export default interface IJob{
jobId: string;
parentJobId: string;
dependsOnJobIds: string[];
jobType: JobType;
recurrenceMs: number;
lastExecution: Date;
nextExecution: Date;
state: JobState;
enabled: boolean;
}
export enum JobType {
DownloadSingleChapterJob = "DownloadSingleChapterJob",
DownloadAvailableChaptersJob = "DownloadAvailableChaptersJob",
UpdateMetaDataJob = "UpdateMetaDataJob",
MoveFileOrFolderJob = "MoveFileOrFolderJob",
DownloadMangaCoverJob = "DownloadMangaCoverJob",
RetrieveChaptersJob = "RetrieveChaptersJob",
UpdateFilesDownloadedJob = "UpdateFilesDownloadedJob",
MoveMangaLibraryJob = "MoveMangaLibraryJob"
}
export enum JobState {
Waiting = "Waiting",
Running = "Running",
Completed = "Completed",
Failed = "Failed"
}

View File

@ -0,0 +1,6 @@
import IJob from "./IJob";
export default interface IMoveFileOrFolderJob extends IJob {
fromLocation: string;
toLocation: string;
}

View File

@ -0,0 +1,6 @@
import IJob from "./IJob";
export default interface IMoveMangaLibraryJob extends IJob {
MangaId: string;
ToLibraryId: string;
}

View File

@ -0,0 +1,5 @@
import IJob from "./IJob";
export default interface IRetrieveChaptersJob extends IJob {
mangaId: string;
}

View File

@ -0,0 +1,5 @@
import IJob from "./IJob";
export default interface IUpdateFilesDownloadedJob extends IJob {
mangaId: string;
}

View File

@ -0,0 +1,5 @@
import IJob from "./IJob";
export default interface IUpdateMetadataJob extends IJob {
mangaId: string;
}

View File

@ -0,0 +1,4 @@
export default interface IDownloadAvailableJobsRecord {
recurrenceTimeMs: number;
localLibraryId: string;
}

View File

@ -0,0 +1,51 @@
import {ReactElement, useState} from "react";
import NotificationConnectorFunctions from "../../NotificationConnectorFunctions";
import Loader from "../../Loader";
import "../../../styles/notificationConnector.css";
import {isValidUri} from "../../../App";
export default interface IGotifyRecord {
endpoint: string;
appToken: string;
priority: number;
}
function Validate(record: IGotifyRecord) : boolean {
if(!isValidUri(record.endpoint))
return false;
if(record.appToken.length < 1)
return false;
if(record.priority < 1 || record.priority > 5)
return false;
return true;
}
export function GotifyItem ({apiUri} : {apiUri: string}) : ReactElement{
const [record, setRecord] = useState<IGotifyRecord>({
endpoint: "",
appToken: "",
priority: 3
});
const [loading, setLoading] = useState(false);
return <div className="NotificationConnectorItem">
<input className="NotificationConnectorItem-Name" value="Gotify" disabled={true} />
<div className="NotificationConnectorItem-Url">
<input type="text" className="NotificationConnectorItem-RequestUrl" placeholder="URL" onChange={(e) => setRecord({...record, endpoint: e.currentTarget.value})} />
<input type="text" className="NotificationConnectorItem-AppToken" placeholder="Apptoken" onChange={(e) => setRecord({...record, appToken: e.currentTarget.value})} />
</div>
<div className="NotificationConnectorItem-Priority">
<label htmlFor="NotificationConnectorItem-Priority">Priority</label>
<input id="NotificationConnectorItem-Priority-Value" type="number" className="NotificationConnectorItem-Priority-Value" min={1} max={5} defaultValue={3} onChange={(e) => setRecord({...record, priority: e.currentTarget.valueAsNumber})} />
</div>
<>
<button className="NotificationConnectorItem-Save" onClick={(e) => {
if(record === null || Validate(record) === false)
return;
setLoading(true);
NotificationConnectorFunctions.CreateGotify(apiUri, record)
.finally(() => setLoading(false));
}}>Add</button>
<Loader loading={loading} style={{width:"40px",height:"40px"}}/>
</>
</div>;
}

View File

@ -0,0 +1,36 @@
import {ReactElement, useState} from "react";
import NotificationConnectorFunctions from "../../NotificationConnectorFunctions";
import Loader from "../../Loader";
import "../../../styles/notificationConnector.css";
export default interface ILunaseaRecord {
id: string;
}
const regex = new RegExp("(?:device|user)\/[0-9a-zA-Z\-]+");
function Validate(record: ILunaseaRecord) : boolean {
return regex.test(record.id);
}
export function LunaseaItem ({apiUri} : {apiUri: string}) : ReactElement{
const [record, setRecord] = useState<ILunaseaRecord>({
id: ""
});
const [loading, setLoading] = useState(false);
return <div className="NotificationConnectorItem">
<input className="NotificationConnectorItem-Name" value="LunaSea" disabled={true} />
<div className="NotificationConnectorItem-Url">
<input type="text" className="NotificationConnectorItem-RequestUrl" placeholder="device/:device_id or user/:user_id" onChange={(e) => setRecord({...record, id: e.currentTarget.value})} />
</div>
<>
<button className="NotificationConnectorItem-Save" onClick={(e) => {
if(record === null || Validate(record) === false)
return;
setLoading(true);
NotificationConnectorFunctions.CreateLunasea(apiUri, record)
.finally(() => setLoading(false));
}}>Add</button>
<Loader loading={loading} style={{width:"40px",height:"40px",margin:"25vh calc(sin(70)*(50% - 40px))"}}/>
</>
</div>;
}

View File

@ -0,0 +1,4 @@
export default interface IModifyJobRecord {
recurrenceMs: number;
enabled: boolean;
}

View File

@ -0,0 +1,12 @@
export default interface INewLibraryRecord {
path: string;
name: string;
}
export function Validate(record: INewLibraryRecord) : boolean {
if(record.path.length < 1)
return false;
if(record.name.length < 1)
return false;
return true;
}

View File

@ -0,0 +1,62 @@
import {ReactElement, useState} from "react";
import NotificationConnectorFunctions from "../../NotificationConnectorFunctions";
import Loader from "../../Loader";
import "../../../styles/notificationConnector.css";
import {isValidUri} from "../../../App";
export default interface INtfyRecord {
endpoint: string;
username: string;
password: string;
topic: string;
priority: number;
}
function Validate(record: INtfyRecord) : boolean {
if(!isValidUri(record.endpoint))
return false;
if(record.username.length < 1)
return false;
if(record.password.length < 1)
return false;
if(record.topic.length < 1)
return false;
if(record.priority < 1 || record.priority > 5)
return false;
return true;
}
export function NtfyItem ({apiUri} : {apiUri: string}) : ReactElement{
const [info, setInfo] = useState<INtfyRecord>({
endpoint: "",
username: "",
password: "",
topic: "",
priority: 0
});
const [loading, setLoading] = useState(false);
return <div className="NotificationConnectorItem">
<input className="NotificationConnectorItem-Name" value="Ntfy" disabled={true} />
<div className="NotificationConnectorItem-Url">
<input type="text" className="NotificationConnectorItem-RequestUrl" placeholder="URL" onChange={(e) => setInfo({...info, endpoint: e.currentTarget.value})} />
<input type="text" className="NotificationConnectorItem-Topic" placeholder="Topic" onChange={(e) => setInfo({...info, topic: e.currentTarget.value})} />
</div>
<div className="NotificationConnectorItem-Ident">
<input type="text" className="NotificationConnectorItem-Username" placeholder="Username" onChange={(e) => setInfo({...info, username: e.currentTarget.value})} />
<input type="password" className="NotificationConnectorItem-Password" placeholder="***" onChange={(e) => setInfo({...info, password: e.currentTarget.value})} />
</div>
<div className="NotificationConnectorItem-Priority">
<label htmlFor="NotificationConnectorItem-Priority">Priority</label>
<input id="NotificationConnectorItem-Priority-Value" type="number" className="NotificationConnectorItem-Priority-Value" min={1} max={5} defaultValue={3} onChange={(e) => setInfo({...info, priority: e.currentTarget.valueAsNumber})} />
</div><>
<button className="NotificationConnectorItem-Save" onClick={(e) => {
if(info === null || Validate(info) === false)
return;
setLoading(true);
NotificationConnectorFunctions.CreateNtfy(apiUri, info)
.finally(() => setLoading(false));
}}>Add</button>
<Loader loading={loading} style={{width:"40px",height:"40px",margin:"25vh calc(sin(70)*(50% - 40px))"}}/>
</>
</div>;
}

View File

@ -1,960 +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;
}
wrapper {
display: flex;
flex-flow: column;
flex-wrap: nowrap;
height: 100vh;
}
background-placeholder{
background-color: var(--second-background-color);
opacity: 1;
position: absolute;
width: 100%;
height: 100%;
border-radius: 0 0 5px 0;
z-index: -1;
}
topbar {
display: flex;
align-items: center;
height: var(--topbar-height);
background-color: var(--secondary-color);
z-index: 100;
box-shadow: 0 0 20px black;
}
titlebox {
position: relative;
display: flex;
margin: 0 0 0 40px;
height: 100%;
align-items:center;
justify-content:center;
}
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;
}
titlebox img {
height: 100%;
cursor: grab;
}
spacer{
flex-grow: 1;
}
filter-box {
display: none;
align-self: center;
flex-direction: column;
position: relative;
margin: 10px;
background-color: var(--second-background-color);
border-style: solid;
border-color: var(--primary-color);
border-width: 2px;
border-radius: 15px;
min-width: 300px;
width: 50%;
overflow: hidden;
max-height: 50%;
height: 600px;
}
filter-box.animate {
display: flex;
}
filter-box border-bar popup-title{
font-size: 12pt;
}
filter-box border-bar popup-close {
height: 20px;
width: 20px;
font-size: 12pt;
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; /* Standard syntax */
}
border-bar-button.clearFilter{
font-weight: bold;
margin: 0px 10px 10px 10px;
border-color: lightgray;
color: gray;
align-content: center;
justify-content: center;
}
border-bar-button.clearFilter:hover {
background-color: red;
border-color: var(--second-background-color);
color: var(--second-background-color);
}
status-filter {
display: block;
margin: 10px;
/*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;
cursor: pointer;
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; /* Standard syntax */
}
status-filter[release-status="Ongoing"]{
background-color: limegreen;
}
status-filter[release-status="Completed"]{
background-color: blueviolet;
}
status-filter[release-status="On Hiatus"]{
background-color: darkorange;
}
status-filter[release-status="Cancelled"]{
background-color: firebrick;
}
status-filter[release-status="Upcoming"]{
background-color: aqua;
}
status-filter[release-status="Status Unavailable"]{
background-color: gray;
}
searchdiv{
display: flex;
width: 100%;
}
#searchbox {
display: flex;
padding: 3px 5px;
margin: 5px;
border-style: solid;
border-width: 2px;
border-radius: 10px;
font-size: 12pt;
outline: none;
border-color: lightgray;
flex-grow: 1;
flex-shrink: 1;
}
#searchbox:focus {
border-color: var(--secondary-color);
}
.pill {
flex-grow: 0;
height: 14pt;
font-size: 12pt;
border-radius: 9pt;
background-color: var(--primary-color);
padding: 2pt 17px;
color: black;
}
#connectorFilterBox .pill {
margin: 10px;
cursor: pointer;
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; /* Standard syntax */
}
#settingscog {
cursor: pointer;
margin: 0px 30px;
margin-left: 15px;
height: 50%;
filter: invert(100%) sepia(0%) saturate(7465%) hue-rotate(115deg) brightness(116%) contrast(101%);
}
#filterFunnel {
cursor: pointer;
margin: 0px 15px;
height: 50%;
filter: invert(100%) sepia(0%) saturate(7465%) hue-rotate(115deg) brightness(116%) contrast(101%);
}
viewport {
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;
}
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;
}
footer > div {
height: 100%;
margin: 0 30px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
cursor: pointer;
}
footer > div > *{
height: 40%;
margin: 0 5px;
}
#madeWith {
flex-grow: 1;
text-align: right;
margin-right: 20px;
cursor: url("media/blahaj.png"), grab;
}
content {
position: relative;
flex-grow: 1;
border-radius: 5px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: start;
align-content: start;
}
#settingsPopup{
z-index: 300;
}
popup{
display: none;
width: 100%;
min-height: 100%;
top: 0;
left: 0;
position: fixed;
z-index: 2;
flex-direction: column;
}
border-bar {
display: flex;
flex-direction: row;
background-color: var(--primary-color);
color: var(--accent-color);
font-weight: bolder;
padding: 7px 5px;
margin:0;
align-items: center;
position: relative;
width: 100%;
}
popup-title {
font-size: 14pt;
display: flex;
margin-top: 3px;
margin-left: 5px;
color: var(--second-background-color);
}
popup-close {
border: none;
background-color: inherit;
color: var(--second-background-color);;
font-weight: inherit;
font-size: 27px;
font-family: 'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif;
display: flex;
cursor: pointer;
margin-left: auto;
margin-right: 15px;
height: 32px;
width: 32px;
border-radius: 16px;
align-content: center;
justify-content: center;
}
popup-close:hover {
background-color: var(--secondary-color);
}
border-bar > .button-container {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
margin-right: 0;
margin-left: auto;
}
border-bar-button {
border-style: solid;
border-width: 2px;
background-color: inherit;
color: var(--second-background-color);
font-weight: inherit;
font-size: inherit;
font-family: inherit;
display: flex;
cursor: pointer;
margin: 0px 5px;
padding: 5px 20px;
border-radius: 20px;
height: 20px;
align-items: center;
border-color: var(--accent-color);
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; /* Standard syntax */
}
border-bar-button:hover {
border-color: var(--secondary-color);
}
border-bar-button.primary {
background-color: var(--secondary-color);
color: var(--accent-color);
border-color: var(--primary-color);
margin-right: 10px;
}
border-bar-button.primary:hover {
border-color: var(--accent-color);
}
border-bar-button.section {
font-weight: bold;
color: darkgray;
border-color: darkgray;
text-align: center;
padding: 5px;
flex-grow: 1;
justify-content: center;
}
border-bar-button.section:hover {
color: var(--secondary-color);
border-color: var(--secondary-color);
}
popup popup-window {
position: absolute;
z-index: 3;
left: 10%;
top: 10%;
height: 80%;
width: 80%;
display: flex;
flex-direction: column;
background-color: var(--second-background-color);
border-radius: 15px;
overflow: hidden;
}
popup#jobStatusView popup-window {
left: 20%;
top: 20%;
height: 60%;
width: 60%;
}
popup-content{
display: flex;
flex-direction: column;
align-items: left;
height: calc(100% - 60px);
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--secondary-color) var(--second-background-color);
}
popup-content > .popup-section {
margin: 5px;
margin-bottom: 10px;
font-size: 10pt;
font-weight: 100;
display: block;
border-top-style: solid;
border-top-width: 1px;
border-top-color: lightgray;
width: calc(100%-10px);
padding: 10px;
}
.section-content {
display: flex;
flex-direction: row;
width: 100%;
flex-wrap: wrap;
}
.section-item {
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;
}
.section-item.dyn-height {
height: fit-content;
}
.section-item > .title {
font-weight: bold;
vertical-align: bottom;
line-height: 32px;
font-size: 12pt;
width: 100%;
}
a:link {
color: inherit;
text-decoration: none;
}
a:visited {
color: inherit;
text-decoration: none;
}
a:hover {
color: inherit;
text-decoration: underline solid var(--secondary-color) 3px;
}
a:active {
color: inherit;
text-decoration: none;
}
.section-item > .title > img {
width: auto;
height: 32px;
margin: 5px;
vertical-align: middle;
border-radius: 5px;
}
.section-item > .title > connector-configured {
display:block;
height: 10px;
width: 10px;
border-radius: 50%;
margin: 5px;
float: right;
top: 5px;
right: 5px;
}
.section-item > .title > connector-configured::after {
display: block;
content: attr(configuration);
float: right;
width: max-content;
width: -webkit-max-content;
width: -mox-max-content;
width: intrinsic;
visibility: hidden;
/*Text Properties*/
font-size:8pt;
font-weight:bold;
color:white;
text-align: right;
/*Size*/
padding: 0px 8px;
border-radius: 6px;
border: 0px;
background-color: inherit;
}
.section-item > .title > connector-configured:hover::after{
visibility:visible;
}
.section-item > .title > connector-configured[configuration="Active"] {
background-color: limegreen;
}
.section-item > .title > connector-configured[configuration="Not Configured"] {
background-color: gray;
}
.section-item > input {
margin: 2px;
padding: 5px;
height: 20px;
border-radius: 10px;
border-style: solid;
outline: none;
}
.section-item > input:focus {
border-color: var(--secondary-color);
}
.section-item > row {
width: calc(100%-20px);
display: flex;
flex-direction: row;
align-items: center;
margin-left: 5px;
margin-bottom: 5px;
}
.section-item > row > input {
margin-left: auto;
margin-right: 2px;
padding: 5px;
height: 20px;
border-radius: 10px;
border-style: solid;
outline: none;
flex-grow: 0;
text-align: end;
float: right;
width: 200px;
}
.section-item > row > input:focus {
border-color: var(--secondary-color);
}
.section-item > row > select {
margin-left: auto;
margin-right: 2px;
padding: 2px;
height: 30px;
border-radius: 10px;
border-style: solid;
outline: none;
flex-grow: 0;
text-align: end;
float: right;
width: 200px;
}
.section-item > row > select:focus {
border-color: var(--secondary-color);
}
.section-buttons-container {
display: flex;
flex-direction: row;
align-items: center;
position: relative;
margin-left: auto;
margin-top: auto;
margin-bottom: 0;
margin-right: 0;
}
.section-buttons-container > .section-button {
font-size: 12px;
padding: 3px 10px;
margin: 3px;
border-radius: 5px;
border-style: solid;
border-width: 1px;
border-color: lightgray;
font-weight: bold;
color: gray;
cursor: pointer;
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; /* Standard syntax */
}
.section-button#reset:hover {
color: red;
border-color: red;
}
.section-buttons-container > .section-button:hover {
border-color: var(--secondary-color);
color: var(--secondary-color);
}
#newMangaPopup > div {
z-index: 3;
position: relative;
}
#newMangaPopup > #newMangaPopupSelector {
width: 600px;
height: 40px;
margin: 80px auto 0;
}
#newMangaPopup > div > #newMangaConnector, #newMangaTitle, #newMangaTranslatedLanguage {
margin: 0;
display: inline-block;
height: 40px;
}
#newMangaPopup #newMangaConnector {
width: 100px;
padding: 0 0 0 5px;
border-radius: 5px 0 0 5px;
border: 0;
border-right: 1px solid darkgray;
}
#newMangaPopup #newMangaTitle{
width: 445px;
padding: 0 5px 0 5px;
border: 0;
}
#newMangaPopup #newMangaTranslatedLanguage {
width: 45px;
border-radius: 0 5px 5px 0;
border: 0;
border-left: 1px solid darkgray;
margin-left: -5px;
}
#newMangaResult {
display: none;
flex-direction: row;
justify-content: flex-start;
margin: 5px auto 0;
border-radius: 5px;
padding: 5px;
width: min-content;
max-width: 98%;
max-height: 400px;
overflow-x: scroll;
overflow-y: hidden;
}
blur-background {
width: 100%;
height: 100%;
position: absolute;
left: 0;
background: var(--blur-background);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(4.5px);
-webkit-backdrop-filter: blur(4.5px);
}
#publicationViewerPopup{
z-index: 5;
}
publication-viewer{
display: block;
width: 460px;
position: absolute;
top: 200px;
left: 400px;
background-color: var(--accent-color);
border-radius: 5px;
overflow: hidden;
padding: 15px;
}
publication-viewer::after{
content: '';
position: absolute;
left: 0; top: 0;
border-radius: 5px;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
backdrop-filter: blur(3px);
}
publication-viewer img {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
object-fit: cover;
border-radius: 5px;
z-index: 0;
}
publication-viewer publication-details > * {
margin: 5px 0;
}
publication-viewer publication-details publication-name {
width: initial;
overflow-x: scroll;
white-space: nowrap;
scrollbar-width: none;
}
publication-viewer publication-details publication-tags::before {
content: "Tags";
display: block;
font-weight: bolder;
}
publication-viewer publication-details publication-tags {
overflow-x: scroll;
white-space: nowrap;
scrollbar-width: none;
}
publication-viewer publication-details publication-author::before {
content: "Author: ";
font-weight: bolder;
}
publication-viewer publication-details publication-description::before {
content: "Description";
display: block;
font-weight: bolder;
}
publication-viewer publication-details publication-description {
font-size: 12pt;
margin: 5px 0;
height: 145px;
overflow-x: scroll;
}
publication-viewer publication-details publication-interactions {
display: flex;
flex-direction: row;
justify-content: end;
align-items: start;
width: 100%;
}
publication-viewer publication-details publication-interactions > * {
margin: 0 10px;
font-size: 16pt;
cursor: pointer;
}
publication-viewer publication-details publication-interactions publication-starttask {
color: var(--secondary-color);
}
publication-viewer publication-details publication-interactions publication-delete {
color: red;
}
publication-view publication-details publication-interactions publication-canceltask {
color: yellow;
}
publication-viewer publication-details publication-interactions publication-add {
color: limegreen;
}
footer-tag-popup {
display: none;
padding: 2px 4px;
position: fixed;
bottom: 58px;
left: 20px;
background-color: var(--second-background-color);
z-index: 8;
border-radius: 5px;
max-height: 400px;
}
footer-tag-content{
position: relative;
max-height: 400px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
overflow-y: scroll;
}
footer-tag-content > * {
margin: 2px 5px;
}
footer-tag-popup::before{
content: "";
width: 0;
height: 0;
position: absolute;
border-right: 10px solid var(--second-background-color);
border-left: 10px solid transparent;
border-top: 10px solid var(--second-background-color);
border-bottom: 10px solid transparent;
left: 0;
bottom: -17px;
border-radius: 0 0 0 5px;
}
#loaderdiv {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
z-index: 200;
}
#loader {
border: 16px solid transparent;
border-top: 16px solid var(--secondary-color);
border-bottom: 16px solid var(--primary-color);
border-radius: 50%;
width: 120px;
height: 120px;
animation: spin 2s linear infinite;
position: absolute;
left: calc(50% - 60px);
top: calc(50% - 120px);
z-index: 201;
}
#loaderText {
position: relative;
margin: 0 auto;
top: calc(50% + 80px);
z-index: 201;
text-align: center;
color: var(--second-background-color);
font-size: 20pt;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
#jobStatusRunning > .section-item {
flex-direction: row;
height: 150px;
padding: 0;
overflow: hidden;
}
#jobStatusWaiting > .section-item {
flex-direction: row;
height: 150px;
padding: 0;
overflow: hidden;
}
.section-item > .jobImage {
height: 100%;
width: auto;
left: 0;
top: 0;
border-radius: 10px;
}
.jobDetails {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.section-item > .jobDetails > .jobTitle {
margin: 5px;
font-size: 11pt;
font-weight: bold;
text-wrap: wrap;
}
.section-item > .jobDetails > .jobProgressBar {
margin: 5px;
height: 10px;
border-radius: 7px;
}
.section-item > .jobDetails > .jobProgressSpan {
margin: 5px;
margin-left: auto;
margin-right: 5px;
}
.section-item > .jobDetails > .jobCancel {
margin-top: auto;
margin-bottom: 5px;
margin-left: auto;
margin-right: 5px;
font-size: 12pt;
color: var(--secondary-color);
cursor: pointer;
}

View File

@ -1,159 +0,0 @@
#addPublication {
cursor: pointer;
background-color: var(--secondary-color);
width: 180px;
height: 300px;
border-radius: 5px;
margin: 10px 10px;
padding: 15px 20px;
position: relative;
}
#addPublication p{
width: 100%;
text-align: center;
font-size: 150pt;
vertical-align: middle;
line-height: 300px;
margin: 0;
color: var(--accent-color);
}
.pill {
flex-grow: 0;
height: 14pt;
font-size: 12pt;
border-radius: 9pt;
background-color: var(--primary-color);
padding: 2pt 17px;
color: black;
}
publication{
cursor: pointer;
background-color: var(--secondary-color);
width: 180px;
height: 300px;
border-radius: 5px;
margin: 10px 10px;
padding: 15px 19px;
position: relative;
flex-shrink: 0;
}
publication::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));
}
publication-information {
display: flex;
flex-direction: column;
justify-content: start;
}
publication-details {
display: flex;
flex-direction: column;
justify-content: start;
}
publication-information * {
z-index: 1;
color: var(--accent-color);
}
publication-details * {
z-index: 1;
color: var(--accent-color);
}
connector-name{
width: fit-content;
margin: 10px 0;
}
publication-name{
width: fit-content;
font-size: 16pt;
font-weight: bold;
color: white;
}
publication-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;
}
publication-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;
}
publication-status:hover::after{
visibility:visible;
}
publication-status[release-status="Ongoing"]{
background-color: limegreen;
}
publication-status[release-status="Completed"]{
background-color: blueviolet;
}
publication-status[release-status="On Hiatus"]{
background-color: darkorange;
}
publication-status[release-status="Cancelled"]{
background-color: firebrick;
}
publication-status[release-status="Upcoming"]{
background-color: aqua;
}
publication-status[release-status="Status Unavailable"]{
background-color: gray;
}
publication img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
border-radius: 5px;
}

View File

@ -1,172 +0,0 @@
#addPublication {
cursor: pointer;
background-color: var(--secondary-color);
width: 180px;
height: 300px;
border-radius: 5px;
margin: 10px 10px;
padding: 15px 20px;
position: relative;
}
#addPublication p{
width: 100%;
text-align: center;
font-size: 150pt;
vertical-align: middle;
line-height: 300px;
margin: 0;
color: var(--accent-color);
}
.pill {
flex-grow: 0;
height: 14pt;
font-size: 12pt;
border-radius: 9pt;
background-color: var(--primary-color);
padding: 2pt 17px;
color: black;
}
publication{
cursor: pointer;
background-color: var(--secondary-color);
width: 180px;
height: 300px;
border-radius: 5px;
margin: 10px 10px;
padding: 15px 19px;
position: relative;
flex-shrink: 0;
}
publication:hover {
background-color: black;
}
publication:hover::after{
background: linear-gradient(rgba(0,0,0,0.8), rgba(0, 0, 0, 0.7),rgba(0, 0, 0, 0.2));
}
publication:hover > publication-information {
display: flex;
opacity:1;
}
publication::after{
content: '';
position: absolute;
left: 0; top: 0;
border-radius: 5px;
width: 100%; height: 100%;
background: none;
}
publication-information {
display: none;
flex-direction: column;
justify-content: start;
}
publication-information * {
z-index: 1;
color: white;
}
connector-name{
width: fit-content;
margin: 10px 0;
}
publication-name{
width: fit-content;
font-size: 16pt;
font-weight: bold;
}
publication-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;
}
publication-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;
}
publication-status:hover::after{
visibility:visible;
}
publication-status[release-status="Ongoing"]{
background-color: limegreen;
}
publication-status[release-status="Completed"]{
background-color: blueviolet;
}
publication-status[release-status="On Hiatus"]{
background-color: darkorange;
}
publication-status[release-status="Cancelled"]{
background-color: firebrick;
}
publication-status[release-status="Upcoming"]{
background-color: aqua;
}
publication-status[release-status="Status Unavailable"]{
background-color: gray;
}
publication-details {
display: flex;
flex-direction: column;
justify-content: start;
}
publication-details * {
z-index: 1;
color: var(--accent-color);
}
publication img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
border-radius: 5px;
}

42
Website/styles/footer.css Normal file
View File

@ -0,0 +1,42 @@
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;
}

37
Website/styles/header.css Normal file
View File

@ -0,0 +1,37 @@
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;
}
header > * {
height: 100%;
}

45
Website/styles/index.css Normal file
View File

@ -0,0 +1,45 @@
: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;
scrollbar-color: var(--primary-color) var(--second-background-color);
scroll-behavior: smooth;
scrollbar-width: thin;
}
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;
}

81
Website/styles/loader.css Normal file
View File

@ -0,0 +1,81 @@
span[is-loading="done"] {
display: none;
}
span[is-loading="loading"] {
transform: rotateZ(45deg);
perspective: 1000px;
border-radius: 50%;
width: 32px;
height: 32px;
color: var(--second-background-color);
position: fixed;
background-color: var(--secondary-color);
background-blend-mode: lighten;
margin: 25vh calc(sin(70)*(50% - 40px));
}
span[is-loading="loading"]:before,
span[is-loading="loading"]:after {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
width: inherit;
height: inherit;
border-radius: 50%;
transform: rotateX(70deg);
animation: 1s spin linear infinite;
}
span[is-loading="loading"]:after {
color: var(--primary-color);
transform: rotateY(70deg);
animation-delay: .4s;
}
@keyframes rotate {
0% {
transform: translate(-50%, -50%) rotateZ(0deg);
}
100% {
transform: translate(-50%, -50%) rotateZ(360deg);
}
}
@keyframes rotateccw {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(-360deg);
}
}
@keyframes spin {
0%,
100% {
box-shadow: .2em 0px 0 0px currentcolor;
}
12% {
box-shadow: .2em .2em 0 0 currentcolor;
}
25% {
box-shadow: 0 .2em 0 0px currentcolor;
}
37% {
box-shadow: -.2em .2em 0 0 currentcolor;
}
50% {
box-shadow: -.2em 0 0 0 currentcolor;
}
62% {
box-shadow: -.2em -.2em 0 0 currentcolor;
}
75% {
box-shadow: 0px -.2em 0 0 currentcolor;
}
87% {
box-shadow: .2em -.2em 0 0 currentcolor;
}
}

View File

@ -0,0 +1,13 @@
.LocalLibraryFunctions {
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.LocalLibraryFunctions input{
width: min-content;
}
.LocalLibraryFunctions-Action {
margin-left: auto;
}

View File

@ -0,0 +1,253 @@
.MangaItem{
cursor: pointer;
background-color: var(--secondary-color);
width: 200px;
height: 300px;
border-radius: 5px;
overflow: hidden;
position: relative;
flex-shrink: 0;
display: grid;
grid-template-columns: 200px 600px;
grid-template-rows: 80px 190px 30px;
grid-template-areas:
"cover tags"
"cover description"
"cover footer";
margin: 5px;
}
.MangaItem[is-clicked="clicked"]{
width: 800px;
}
.MangaItem::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;
}
.MangaItem > * {
z-index: 2;
position: relative;
}
.startSearchEntry::after{
background: initial !important;
}
.MangaItem-Connector {
grid-area: cover;
top: 10px;
left: 10px;
flex-grow: 0;
height: 14pt;
font-size: 12pt;
border-radius: 9pt;
background-color: var(--primary-color);
padding: 2pt 17px;
color: black;
width: fit-content;
z-index: 1;
}
.MangaItem-Name{
grid-area: cover;
width: fit-content;
font-size: 16pt;
font-weight: bold;
color: white;
top: 50px;
left: 10px;
margin: 0;
max-width: calc(100% - 20px);
z-index: 1;
}
.MangaItem-Website {
grid-area: cover;
display: block;
height: 13px;
width: 13px;
margin: 0px 0px auto auto;
top: 12px;
right: 10px;
z-index: 1;
}
.MangaItem-Status {
grid-area: cover;
display:block;
height: 15px;
width: 15px;
border-radius: 50%;
position: absolute;
top: 14px;
right: 35px;
box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 10px, rgb(51, 51, 51) 0px 0px 10px 3px;
z-index: 1;
}
.MangaItem-Status::after {
content: attr(release-status);
position: absolute;
top: -2.5pt;
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;
}
.MangaItem-Status:hover::after{
visibility:visible;
}
.MangaItem-Status[release-status="Continuing"]{
background-color: limegreen;
}
.MangaItem-Status[release-status="Completed"]{
background-color: blueviolet;
}
.MangaItem-Status[release-status="On Hiatus"]{
background-color: darkorange;
}
.MangaItem-Status[release-status="Cancelled"]{
background-color: firebrick;
}
.MangaItem-Status[release-status="Unreleased"]{
background-color: aqua;
}
.MangaItem-Status[release-status="Status Unavailable"]{
background-color: gray;
}
.MangaItem-Cover {
position: absolute;
top: 0;
left: 0;
grid-area: cover;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 5px;
z-index: 0;
}
.MangaItem[is-clicked="not-clicked"] .MangaItem-Description, .MangaItem[is-clicked="not-clicked"] .MangaItem-Tags, .MangaItem[is-clicked="not-clicked"] .MangaItem-Props{
display: none !important;
}
.MangaItem[is-clicked="clicked"] .MangaItem-Description, .MangaItem[is-clicked="clicked"] .MangaItem-Tags, .MangaItem[is-clicked="clicked"] .MangaItem-Props{
display: block;
width: calc(100% - 6px);
background-color: white;
padding: 0 3px;
overflow-y: auto;
}
.MangaItem-Tags {
grid-area: tags;
display: flex !important;
flex-direction: row;
flex-wrap: wrap;
scrollbar-width: thin;
border-bottom: 1px solid var(--secondary-color);
justify-content: start;
align-content: start;
}
.MangaItem-Tags > * {
display: flex;
flex-direction: row;
width: max-content;
margin: 1px 2px !important;
white-space: nowrap;
color: white;
padding: 3px 2px 4px 2px;
position: relative;
}
.MangaItem-Tags > * > * {
margin: 0 2px !important;
align-items: center;
}
.MangaItem-Tags > * > svg {
margin: auto 2px !important;
}
.MangaItem-Tags a, .MangaItem-Tags a:visited {
color: var(--primary-color);
text-decoration: none;
}
.MangaItem-Author {
background-color: green;
}
.MangaItem-Tag {
background-color: blue;
}
.MangaItem-Link{
background-color: brown;
}
.MangaItem-Description {
grid-area: description;
color: black;
max-height: 40vh;
scrollbar-width: thin;
border-bottom: 1px solid var(--secondary-color);
}
.MangaItem-Props {
grid-area: footer;
display: flex !important;
flex-direction: row;
justify-content: flex-end;
width: 100%;
height: 100%;
}
.MangaItem-Props > * {
margin: 3px;
border: 1px solid var(--primary-color);
border-radius: 3px;
background-color: transparent;
width: fit-content;
}
.MangaItem-Props-Threshold {
color: black;
padding: 0 3px;
}
.MangaItem-Props-Threshold > input {
margin: 0 3px;
width: 50px;
}
.MangaItem-Props-Threshold-Available{
text-decoration: underline;
}

View File

@ -0,0 +1,37 @@
#MonitorMangaList {
position: relative;
display: flex;
flex-flow: row;
flex-wrap: nowrap;
flex-grow: 1;
overflow-y: auto;
margin: 5px 0;
}
.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%);
}

View File

@ -0,0 +1,78 @@
.NotificationConnectorItem{
position: relative;
display: grid;
grid-template-columns: 40% calc(60% - 10px);
grid-template-rows: 30px auto auto 30px;
column-gap: 4px;
row-gap: 4px;
grid-template-areas:
"name name"
"url explanation "
"headers body"
"footer footer";
align-items: center;
}
.NotificationConnectorItem p{
margin: 0;
}
.NotificationConnectorItem-Name{
grid-area: name;
justify-self: flex-start;
width: fit-content;
}
.NotificationConnectorItem-Name::before {
content: "Connector-Name";
position: absolute;
display: block;
}
.NotificationConnectorItem-Url{
grid-area: url;
}
.NotificationConnectorItem-Body{
grid-area: body;
width: calc(100% - 2px);
height: max-content;
min-height: 100%;
padding: 0;
margin: 0;
resize: vertical;
}
.NotificationConnectorItem-Explanation{
grid-area: explanation;
align-self: flex-end;
}
.NotificationConnectorItem-Priority {
grid-area: explanation;
}
.NotificationConnectorItem-Ident {
grid-area: body;
}
.NotificationConnectorItem-Headers{
grid-area: headers;
justify-self: flex-end;
align-self: flex-end;
display: flex;
flex-direction: column;
}
.NotificationConnectorItem-Header {
display: flex;
flex-direction: row;
}
.NotificationConnectorItem-Header > input {
width: 48%;
}
.NotificationConnectorItem-Save{
grid-area: footer;
justify-self: flex-end;
}

47
Website/styles/popup.css Normal file
View File

@ -0,0 +1,47 @@
.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: calc(100% - 30px);
height: calc(100% - 50px);
padding: 5px 15px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}

View File

@ -0,0 +1,82 @@
#QueuePopUp #QueuePopUpBody {
display: flex;
color: black;
}
#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;
}

View File

@ -0,0 +1,143 @@
/* 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;
}

61
Website/styles/search.css Normal file
View File

@ -0,0 +1,61 @@
#Search{
position: relative;
width: 100vw;
margin: auto;
}
#SearchBox{
display: flex;
align-content: center;
justify-content: center;
margin: 10px 0;
}
#SearchResults {
width: 100%;
display: flex;
flex-flow: row wrap;
justify-content: center;
}
#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: 10px;
width: 30px;
height: 30px;
filter: brightness(0) saturate(100%) invert(100%) sepia(100%) saturate(1%) hue-rotate(20deg) brightness(103%) contrast(101%);
}

Some files were not shown because too many files have changed in this diff Show More